mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-23 02:02:40 +01:00
Update TOTP/Passkey management UX.
This commit is contained in:
committed by
Cody Henthorne
parent
141cd31070
commit
03256a7ceb
+115
-20
@@ -6,12 +6,15 @@
|
||||
package org.signal.appsettings.account
|
||||
|
||||
import android.app.Application
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -26,12 +29,30 @@ import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.appsettings.R
|
||||
import org.signal.appsettings.account.AccountSettingsState.Dialog
|
||||
import org.signal.appsettings.account.AccountSettingsState.LoadState
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class AccountSettingsScreenTest {
|
||||
|
||||
companion object {
|
||||
/** Fixed so the "Added ..." subtitle a row renders is something the test can predict. */
|
||||
private const val CREATED_AT = 1_700_000_000_000L
|
||||
private val ADDED_TIME: String = DateUtils.getRelativeDateTimeString(
|
||||
RuntimeEnvironment.getApplication(),
|
||||
CREATED_AT,
|
||||
DateUtils.DAY_IN_MILLIS,
|
||||
DateUtils.WEEK_IN_MILLIS,
|
||||
0
|
||||
).toString()
|
||||
|
||||
private val METHODS = listOf(
|
||||
TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Bitwarden Authenticator", createdAt = CREATED_AT),
|
||||
TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.PASSKEY, name = "Pixel Phone", createdAt = CREATED_AT)
|
||||
)
|
||||
}
|
||||
|
||||
private val context: Application = RuntimeEnvironment.getApplication()
|
||||
|
||||
@get:Rule
|
||||
@@ -299,12 +320,12 @@ class AccountSettingsScreenTest {
|
||||
setContent(createState())
|
||||
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASignalLogin_whenIClickTheSignalLoginCard_thenIExpectAccountAndRecoveryEvent() {
|
||||
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
|
||||
setContent(createState(signalLogin = signalLogin()))
|
||||
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).performClick()
|
||||
|
||||
@@ -312,40 +333,106 @@ class AccountSettingsScreenTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASignalLogin_whenIClickTotpApp_thenIExpectTotpAppEvent() {
|
||||
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
|
||||
fun givenASignalLogin_whenIPickAuthenticatorAppFromTheSetUpMenu_thenIExpectAddTotpAppEvent() {
|
||||
setContent(createState(signalLogin = signalLogin()))
|
||||
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).performClick()
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_AUTHENTICATOR_APP).performClick()
|
||||
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).performClick()
|
||||
|
||||
assertThat(events).contains(AccountSettingsEvent.TotpAppClicked)
|
||||
assertThat(events).contains(AccountSettingsEvent.AddTotpAppClicked)
|
||||
}
|
||||
|
||||
/** A count we couldn't fetch has to read the same as no count at all, rather than as "0 configured". */
|
||||
/** Passkeys aren't supported yet, so the menu can't offer to set one up. */
|
||||
@Test
|
||||
fun givenAnUnknownTotpAppCount_whenScreenDisplayed_thenTheRowDoesNotClaimACount() {
|
||||
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = null, passkeyCount = 0)))
|
||||
fun givenTheSetUpMenu_whenItIsOpen_thenPasskeyIsNotOffered() {
|
||||
setContent(createState(signalLogin = signalLogin()))
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__one_time_verification_codes)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).performClick()
|
||||
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__passkey)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenConfiguredTotpApps_whenScreenDisplayed_thenTheRowShowsTheCount() {
|
||||
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 2, passkeyCount = 0)))
|
||||
fun givenTwoFactorMethods_whenScreenDisplayed_thenIExpectARowPerMethod() {
|
||||
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
|
||||
|
||||
composeTestRule.onNodeWithText(context.resources.getQuantityString(R.plurals.AccountSettingsFragment__d_configured, 2, 2)).assertIsDisplayed()
|
||||
for (method in METHODS) {
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER).performScrollToNode(hasText(method.name))
|
||||
composeTestRule.onNodeWithText(method.name).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
/** Authenticator apps and passkeys share one list, so a row's subtitle is what says which kind it is. */
|
||||
@Test
|
||||
fun givenTwoFactorMethods_whenScreenDisplayed_thenEachRowSaysWhatKindItIs() {
|
||||
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
|
||||
|
||||
for (kind in listOf(R.string.AccountSettingsFragment__authenticator_app, R.string.AccountSettingsFragment__passkey)) {
|
||||
val label = context.getString(R.string.AccountSettingsFragment__s_added_s, context.getString(kind), ADDED_TIME)
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER).performScrollToNode(hasText(label))
|
||||
composeTestRule.onNodeWithText(label).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASignalLogin_whenIClickPasskeys_thenIExpectPasskeysEvent() {
|
||||
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
|
||||
fun givenTwoFactorMethods_whenIClickRenameInTheMenu_thenIExpectRenameMethodEvent() {
|
||||
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
|
||||
|
||||
scrollTo(AccountSettingsTestTags.ROW_PASSKEYS)
|
||||
scrollTo(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD)
|
||||
composeTestRule.onAllNodesWithTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)[0].performClick()
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_RENAME).performClick()
|
||||
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_PASSKEYS).performClick()
|
||||
assertThat(events).contains(AccountSettingsEvent.RenameMethodClicked(METHODS[0]))
|
||||
}
|
||||
|
||||
assertThat(events).contains(AccountSettingsEvent.PasskeysClicked)
|
||||
@Test
|
||||
fun givenTwoFactorMethods_whenIClickRemoveInTheMenu_thenIExpectRemoveMethodEvent() {
|
||||
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
|
||||
|
||||
scrollTo(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD)
|
||||
composeTestRule.onAllNodesWithTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)[0].performClick()
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_REMOVE).performClick()
|
||||
|
||||
assertThat(events).contains(AccountSettingsEvent.RemoveMethodClicked(METHODS[0]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTheConfirmRemoveDialog_whenIConfirm_thenIExpectRemoveTotpAppConfirmedForThatApp() {
|
||||
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS), dialog = Dialog.ConfirmRemoveTotpApp(METHODS[0].id)))
|
||||
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_CONFIRM_REMOVE_TOTP_APP).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(AccountSettingsEvent.RemoveTotpAppConfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() {
|
||||
setContent(createState(signalLogin = signalLogin(), dialog = Dialog.MaxTotpAppsReached))
|
||||
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_MAX_TOTP_APPS_REACHED).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(AccountSettingsEvent.LearnMoreClicked)
|
||||
assertThat(events).contains(AccountSettingsEvent.DialogDismissed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenTheTwoFactorListHasntArrived_thenIExpectASpinnerRatherThanAnEmptyList() {
|
||||
setContent(createState(signalLogin = signalLogin(loadState = LoadState.LOADING)))
|
||||
|
||||
scrollTo(AccountSettingsTestTags.TWO_FACTOR_LOADING)
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOADING).assertIsDisplayed()
|
||||
}
|
||||
|
||||
/** An account we couldn't ask about is not an account with no second factors. */
|
||||
@Test
|
||||
fun givenTheTwoFactorListCouldntBeLoaded_whenScreenDisplayed_thenIExpectTheFailureMessage() {
|
||||
setContent(createState(signalLogin = signalLogin(loadState = LoadState.NETWORK_FAILURE)))
|
||||
|
||||
scrollTo(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE)
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOADING).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -374,6 +461,14 @@ class AccountSettingsScreenTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun signalLogin(
|
||||
twoFactorMethods: List<TwoFactorMethod> = emptyList(),
|
||||
loadState: LoadState = LoadState.LOADED,
|
||||
maxTotpApps: Int = 2
|
||||
): AccountSettingsState.SignalLogin {
|
||||
return AccountSettingsState.SignalLogin(twoFactorMethods = twoFactorMethods, loadState = loadState, maxTotpApps = maxTotpApps)
|
||||
}
|
||||
|
||||
private fun scrollTo(testTag: String) {
|
||||
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER)
|
||||
.performScrollToNode(hasTestTag(testTag))
|
||||
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.appsettings.passkeys
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class PasskeysScreenTest {
|
||||
|
||||
companion object {
|
||||
private val PASSKEYS = listOf(
|
||||
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
|
||||
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val events = mutableListOf<PasskeysEvent>()
|
||||
|
||||
@Test
|
||||
fun givenNoPasskeys_whenIClickSetUpAPasskey_thenIExpectSetUpPasskeyEvent() {
|
||||
setContent(PasskeysState())
|
||||
|
||||
composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP)
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPasskeys_whenIDisplayScreen_thenIExpectARowPerPasskey() {
|
||||
setContent(PasskeysState(passkeys = PASSKEYS))
|
||||
|
||||
for (passkey in PASSKEYS) {
|
||||
composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER).performScrollToNode(hasText(passkey.name))
|
||||
composeTestRule.onNodeWithText(passkey.name).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPasskeys_whenIClickAddANewPasskey_thenIExpectSetUpPasskeyEvent() {
|
||||
setContent(PasskeysState(passkeys = PASSKEYS))
|
||||
|
||||
composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP)
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPasskeys_whenIClickRenameInAPasskeysMenu_thenIExpectRenameEvent() {
|
||||
setContent(PasskeysState(passkeys = PASSKEYS))
|
||||
|
||||
scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU)
|
||||
|
||||
composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick()
|
||||
composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_RENAME).performClick()
|
||||
|
||||
assertThat(events).contains(PasskeysEvent.RenamePasskeyClicked(passkeyId = PASSKEYS[0].id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPasskeys_whenIClickRemoveInAPasskeysMenu_thenIExpectRemoveEvent() {
|
||||
setContent(PasskeysState(passkeys = PASSKEYS))
|
||||
|
||||
scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU)
|
||||
|
||||
composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick()
|
||||
composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_REMOVE).performClick()
|
||||
|
||||
assertThat(events).contains(PasskeysEvent.RemovePasskeyClicked(passkeyId = PASSKEYS[0].id))
|
||||
}
|
||||
|
||||
private fun setContent(state: PasskeysState) {
|
||||
composeTestRule.setContent {
|
||||
PasskeysScreen(
|
||||
state = state,
|
||||
onEvent = { events += it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollTo(testTag: String) {
|
||||
composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER)
|
||||
.performScrollToNode(hasTestTag(testTag))
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.appsettings.passkeys
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PasskeysViewModelTest {
|
||||
|
||||
companion object {
|
||||
private val PASSKEYS = listOf(
|
||||
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
|
||||
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private val repository = object : PasskeysRepository {
|
||||
override fun getPasskeys(): List<Passkey> = PASSKEYS
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the passkeys are available as soon as the screen opens`() = runTest(testDispatcher) {
|
||||
val viewModel = PasskeysViewModel(repository)
|
||||
|
||||
assertThat(viewModel.state.value.passkeys).isEqualTo(PASSKEYS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SetUpPasskeyClicked launches passkey creation`() = runTest(testDispatcher) {
|
||||
val viewModel = PasskeysViewModel(repository)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(PasskeysEvent.SetUpPasskeyClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(PasskeysAction.LaunchPasskeyCreation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LearnMoreClicked opens the learn more article`() = runTest(testDispatcher) {
|
||||
val viewModel = PasskeysViewModel(repository)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(PasskeysEvent.LearnMoreClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(PasskeysAction.OpenLearnMore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
|
||||
val viewModel = PasskeysViewModel(repository)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(PasskeysEvent.NavigateBackClicked)
|
||||
|
||||
assertThat(actions.last()).isEqualTo(PasskeysAction.NavigateBack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename and remove produce no actions yet`() = runTest(testDispatcher) {
|
||||
val viewModel = PasskeysViewModel(repository)
|
||||
val actions = collectActions(viewModel.actions)
|
||||
|
||||
viewModel.onEvent(PasskeysEvent.RenamePasskeyClicked(passkeyId = 1))
|
||||
viewModel.onEvent(PasskeysEvent.RemovePasskeyClicked(passkeyId = 1))
|
||||
|
||||
assertThat(actions).isEmpty()
|
||||
}
|
||||
|
||||
private fun TestScope.collectActions(actions: Flow<PasskeysAction>): List<PasskeysAction> {
|
||||
val collected = mutableListOf<PasskeysAction>()
|
||||
backgroundScope.launch { actions.toList(collected) }
|
||||
return collected
|
||||
}
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.appsettings.totpapplist
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.appsettings.totpapplist.TotpAppListState.Dialog
|
||||
import org.signal.appsettings.totpapplist.TotpAppListState.LoadState
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class TotpAppListScreenTest {
|
||||
|
||||
companion object {
|
||||
private val APPS = listOf(
|
||||
TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()),
|
||||
TotpApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val events = mutableListOf<TotpAppListEvent>()
|
||||
|
||||
@Test
|
||||
fun givenNoApps_whenIDisplayScreen_thenIExpectTheEmptyMessage() {
|
||||
setContent(TotpAppListState(loadState = LoadState.LOADED))
|
||||
|
||||
scrollTo(TotpAppListTestTags.EMPTY_MESSAGE)
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenApps_whenIDisplayScreen_thenIExpectARowPerApp() {
|
||||
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
|
||||
|
||||
for (app in APPS) {
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.SCROLLER).performScrollToNode(hasText(app.name))
|
||||
composeTestRule.onNodeWithText(app.name).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenIClickAddTotpApp_thenIExpectAddTotpAppClickedEvent() {
|
||||
setContent(TotpAppListState(loadState = LoadState.LOADED))
|
||||
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.BUTTON_ADD)
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
assertThat(events).contains(TotpAppListEvent.AddTotpAppClicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenApps_whenIClickRenameInTheMenu_thenIExpectRenameAppClickedEvent() {
|
||||
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
|
||||
|
||||
scrollTo(TotpAppListTestTags.ROW_APP)
|
||||
composeTestRule.onAllNodesWithTag(TotpAppListTestTags.BUTTON_APP_MENU)[0].performClick()
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.MENU_ITEM_RENAME).performClick()
|
||||
|
||||
assertThat(events).contains(TotpAppListEvent.RenameAppClicked(appId = APPS[0].id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenApps_whenIClickRemoveInTheMenu_thenIExpectRemoveAppClickedEvent() {
|
||||
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
|
||||
|
||||
scrollTo(TotpAppListTestTags.ROW_APP)
|
||||
composeTestRule.onAllNodesWithTag(TotpAppListTestTags.BUTTON_APP_MENU)[0].performClick()
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.MENU_ITEM_REMOVE).performClick()
|
||||
|
||||
assertThat(events).contains(TotpAppListEvent.RemoveAppClicked(appId = APPS[0].id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTheConfirmRemoveDialog_whenIDisplayScreen_thenIExpectTheDialog() {
|
||||
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[0].id)))
|
||||
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.DIALOG_CONFIRM_REMOVE).assertIsDisplayed()
|
||||
}
|
||||
|
||||
/** The event has to carry the id, since the dialog holding it is dismissed before the confirmation is reported. */
|
||||
@Test
|
||||
fun givenTheConfirmRemoveDialog_whenIConfirm_thenIExpectRemoveAppConfirmedForThatApp() {
|
||||
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[1].id)))
|
||||
|
||||
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(TotpAppListEvent.RemoveAppConfirmed(APPS[1].id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTheConfirmRemoveDialog_whenICancel_thenIExpectDialogDismissedEvent() {
|
||||
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[0].id)))
|
||||
|
||||
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(TotpAppListEvent.DialogDismissed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTheMaxAppsDialog_whenIDisplayScreen_thenIExpectTheDialog() {
|
||||
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.MaxAppsReached))
|
||||
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.DIALOG_MAX_APPS_REACHED).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() {
|
||||
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.MaxAppsReached))
|
||||
|
||||
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
|
||||
|
||||
assertThat(events).contains(TotpAppListEvent.LearnMoreClicked)
|
||||
assertThat(events).contains(TotpAppListEvent.DialogDismissed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenTheListHasntArrived_thenIExpectASpinnerRatherThanNoTotpApps() {
|
||||
setContent(TotpAppListState(loadState = LoadState.LOADING))
|
||||
|
||||
scrollTo(TotpAppListTestTags.LOADING)
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOADING).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** An account we couldn't ask about is not an account with no authenticator apps. */
|
||||
@Test
|
||||
fun givenTheListCouldntBeLoaded_whenIDisplayScreen_thenIExpectTheFailureMessageRatherThanTheEmptyOne() {
|
||||
setContent(TotpAppListState(loadState = LoadState.NETWORK_FAILURE))
|
||||
|
||||
scrollTo(TotpAppListTestTags.LOAD_FAILED_MESSAGE)
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOAD_FAILED_MESSAGE).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOADING).assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun setContent(state: TotpAppListState) {
|
||||
composeTestRule.setContent {
|
||||
TotpAppListScreen(
|
||||
state = state,
|
||||
onEvent = { events += it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollTo(testTag: String) {
|
||||
composeTestRule.onNodeWithTag(TotpAppListTestTags.SCROLLER)
|
||||
.performScrollToNode(hasTestTag(testTag))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user