Add basic TOTP support with mocked creation.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:32 -03:00
committed by Alex Hart
parent 6a032e4f27
commit 62897a309c
163 changed files with 6688 additions and 5119 deletions
@@ -13,6 +13,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
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
@@ -21,7 +22,9 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.signal.appsettings.R
import org.signal.appsettings.account.AccountSettingsState.Dialog
import org.signal.core.ui.compose.Dialogs
@@ -29,6 +32,8 @@ import org.signal.core.ui.compose.Dialogs
@Config(application = Application::class)
class AccountSettingsScreenTest {
private val context: Application = RuntimeEnvironment.getApplication()
@get:Rule
val composeTestRule = createComposeRule()
@@ -294,12 +299,12 @@ class AccountSettingsScreenTest {
setContent(createState())
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertDoesNotExist()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_AUTHENTICATOR_APP).assertDoesNotExist()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).assertDoesNotExist()
}
@Test
fun givenASignalLogin_whenIClickTheSignalLoginCard_thenIExpectAccountAndRecoveryEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(authenticatorAppCount = 0, passkeyCount = 0)))
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).performClick()
@@ -307,19 +312,34 @@ class AccountSettingsScreenTest {
}
@Test
fun givenASignalLogin_whenIClickAuthenticatorApp_thenIExpectAuthenticatorAppEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(authenticatorAppCount = 0, passkeyCount = 0)))
fun givenASignalLogin_whenIClickTotpApp_thenIExpectTotpAppEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertIsDisplayed()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_AUTHENTICATOR_APP).performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).performClick()
assertThat(events).contains(AccountSettingsEvent.AuthenticatorAppClicked)
assertThat(events).contains(AccountSettingsEvent.TotpAppClicked)
}
/** A count we couldn't fetch has to read the same as no count at all, rather than as "0 configured". */
@Test
fun givenAnUnknownTotpAppCount_whenScreenDisplayed_thenTheRowDoesNotClaimACount() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = null, passkeyCount = 0)))
composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__one_time_verification_codes)).assertIsDisplayed()
}
@Test
fun givenConfiguredTotpApps_whenScreenDisplayed_thenTheRowShowsTheCount() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 2, passkeyCount = 0)))
composeTestRule.onNodeWithText(context.resources.getQuantityString(R.plurals.AccountSettingsFragment__d_configured, 2, 2)).assertIsDisplayed()
}
@Test
fun givenASignalLogin_whenIClickPasskeys_thenIExpectPasskeysEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(authenticatorAppCount = 0, passkeyCount = 0)))
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
scrollTo(AccountSettingsTestTags.ROW_PASSKEYS)
@@ -1,141 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.authenticatorapps
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.authenticatorapps.AuthenticatorAppsState.Dialog
import org.signal.core.ui.compose.Dialogs
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AuthenticatorAppsScreenTest {
companion object {
private val APPS = listOf(
AuthenticatorApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()),
AuthenticatorApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis())
)
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<AuthenticatorAppsEvent>()
@Test
fun givenNoApps_whenIDisplayScreen_thenIExpectTheEmptyMessage() {
setContent(AuthenticatorAppsState())
scrollTo(AuthenticatorAppsTestTags.EMPTY_MESSAGE)
composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.EMPTY_MESSAGE).assertIsDisplayed()
}
@Test
fun givenApps_whenIDisplayScreen_thenIExpectARowPerApp() {
setContent(AuthenticatorAppsState(apps = APPS))
for (app in APPS) {
composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.SCROLLER).performScrollToNode(hasText(app.name))
composeTestRule.onNodeWithText(app.name).assertIsDisplayed()
}
}
@Test
fun whenIClickAddAuthenticatorApp_thenIExpectAddAuthenticatorAppClickedEvent() {
setContent(AuthenticatorAppsState())
composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.BUTTON_ADD)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(AuthenticatorAppsEvent.AddAuthenticatorAppClicked)
}
@Test
fun givenApps_whenIClickRenameInTheMenu_thenIExpectRenameAppClickedEvent() {
setContent(AuthenticatorAppsState(apps = APPS))
scrollTo(AuthenticatorAppsTestTags.ROW_APP)
composeTestRule.onAllNodesWithTag(AuthenticatorAppsTestTags.BUTTON_APP_MENU)[0].performClick()
composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.MENU_ITEM_RENAME).performClick()
assertThat(events).contains(AuthenticatorAppsEvent.RenameAppClicked(appId = APPS[0].id))
}
@Test
fun givenApps_whenIClickRemoveInTheMenu_thenIExpectRemoveAppClickedEvent() {
setContent(AuthenticatorAppsState(apps = APPS))
scrollTo(AuthenticatorAppsTestTags.ROW_APP)
composeTestRule.onAllNodesWithTag(AuthenticatorAppsTestTags.BUTTON_APP_MENU)[0].performClick()
composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.MENU_ITEM_REMOVE).performClick()
assertThat(events).contains(AuthenticatorAppsEvent.RemoveAppClicked(appId = APPS[0].id))
}
@Test
fun givenTheConfirmRemoveDialog_whenIDisplayScreen_thenIExpectTheDialog() {
setContent(AuthenticatorAppsState(apps = APPS, dialog = Dialog.ConfirmRemove(APPS[0].id)))
composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.DIALOG_CONFIRM_REMOVE).assertIsDisplayed()
}
@Test
fun givenTheConfirmRemoveDialog_whenICancel_thenIExpectDialogDismissedEvent() {
setContent(AuthenticatorAppsState(apps = APPS, dialog = Dialog.ConfirmRemove(APPS[0].id)))
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(AuthenticatorAppsEvent.DialogDismissed)
}
@Test
fun givenTheMaxAppsDialog_whenIDisplayScreen_thenIExpectTheDialog() {
setContent(AuthenticatorAppsState(apps = APPS, dialog = Dialog.MaxAppsReached))
composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.DIALOG_MAX_APPS_REACHED).assertIsDisplayed()
}
@Test
fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() {
setContent(AuthenticatorAppsState(apps = APPS, dialog = Dialog.MaxAppsReached))
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(AuthenticatorAppsEvent.LearnMoreClicked)
assertThat(events).contains(AuthenticatorAppsEvent.DialogDismissed)
}
private fun setContent(state: AuthenticatorAppsState) {
composeTestRule.setContent {
AuthenticatorAppsScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
}
@@ -1,82 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.authenticatorsetup
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
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 AuthenticatorSetupScreenTest {
companion object {
private const val SETUP_KEY = "KVZ7WL3FDDWJZMTOB7PLZPKVRFD4LYSX"
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<AuthenticatorSetupEvent>()
@Test
fun whenIClickOpen_thenIExpectOpenAuthenticatorAppEvent() {
setContent()
scrollTo(AuthenticatorSetupTestTags.BUTTON_OPEN)
composeTestRule.onNodeWithTag(AuthenticatorSetupTestTags.BUTTON_OPEN).performClick()
assertThat(events).contains(AuthenticatorSetupEvent.OpenAuthenticatorAppClicked)
}
@Test
fun whenIClickCopy_thenIExpectCopyKeyEvent() {
setContent()
scrollTo(AuthenticatorSetupTestTags.BUTTON_COPY)
composeTestRule.onNodeWithTag(AuthenticatorSetupTestTags.BUTTON_COPY).performClick()
assertThat(events).contains(AuthenticatorSetupEvent.CopyKeyClicked)
}
@Test
fun whenIClickContinue_thenIExpectContinueEvent() {
setContent()
composeTestRule.onNodeWithTag(AuthenticatorSetupTestTags.BUTTON_CONTINUE)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(AuthenticatorSetupEvent.ContinueClicked)
}
private fun setContent() {
composeTestRule.setContent {
AuthenticatorSetupScreen(
state = AuthenticatorSetupState(setupKey = SETUP_KEY),
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(AuthenticatorSetupTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
}
@@ -0,0 +1,172 @@
/*
* 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))
}
}
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.authenticatorcodeentry
package org.signal.appsettings.totpcodeentry
import android.app.Application
import androidx.compose.ui.test.assertIsEnabled
@@ -22,43 +22,43 @@ import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AuthenticatorCodeEntryScreenTest {
class TotpCodeEntryScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<AuthenticatorCodeEntryEvent>()
private val events = mutableListOf<TotpCodeEntryEvent>()
@Test
fun givenAPartialCode_whenScreenDisplayed_thenDoneIsDisabled() {
setContent(AuthenticatorCodeEntryState(code = "123"))
setContent(TotpCodeEntryState(code = "123"))
composeTestRule.onNodeWithTag(AuthenticatorCodeEntryTestTags.BUTTON_DONE).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_DONE).assertIsNotEnabled()
}
@Test
fun givenAFullCode_whenIClickDone_thenIExpectDoneEvent() {
setContent(AuthenticatorCodeEntryState(code = "123456"))
setContent(TotpCodeEntryState(code = "123456"))
composeTestRule.onNodeWithTag(AuthenticatorCodeEntryTestTags.BUTTON_DONE)
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.BUTTON_DONE)
.assertIsEnabled()
.performClick()
assertThat(events).contains(AuthenticatorCodeEntryEvent.DoneClicked)
assertThat(events).contains(TotpCodeEntryEvent.DoneClicked)
}
@Test
fun whenITypeInTheCodeField_thenIExpectCodeChangedEvent() {
setContent(AuthenticatorCodeEntryState())
setContent(TotpCodeEntryState())
composeTestRule.onNodeWithTag(AuthenticatorCodeEntryTestTags.CODE_INPUT).performTextInput("123456")
composeTestRule.onNodeWithTag(TotpCodeEntryTestTags.CODE_INPUT).performTextInput("123456")
assertThat(events).contains(AuthenticatorCodeEntryEvent.CodeChanged("123456"))
assertThat(events).contains(TotpCodeEntryEvent.CodeChanged("123456"))
}
private fun setContent(state: AuthenticatorCodeEntryState) {
private fun setContent(state: TotpCodeEntryState) {
composeTestRule.setContent {
AuthenticatorCodeEntryScreen(
TotpCodeEntryScreen(
state = state,
onEvent = { events += it }
)
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.authenticatorname
package org.signal.appsettings.totpnameentry
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
@@ -23,52 +23,52 @@ import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AuthenticatorNameScreenTest {
class TotpNameEntryScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<AuthenticatorNameEvent>()
private val events = mutableListOf<TotpNameEntryEvent>()
@Test
fun whenITypeAName_thenIExpectNameChangedEvent() {
setContent(AuthenticatorNameState())
setContent(TotpNameEntryState())
composeTestRule.onNodeWithTag(AuthenticatorNameTestTags.NAME_INPUT)
composeTestRule.onNodeWithTag(TotpNameEntryTestTags.NAME_INPUT)
.assertIsDisplayed()
.performTextReplacement("Bitwarden Authenticator")
assertThat(events).contains(AuthenticatorNameEvent.NameChanged("Bitwarden Authenticator"))
assertThat(events).contains(TotpNameEntryEvent.NameChanged("Bitwarden Authenticator"))
}
@Test
fun givenABlankName_whenIDisplayScreen_thenIExpectNextDisabled() {
setContent(AuthenticatorNameState())
setContent(TotpNameEntryState())
composeTestRule.onNodeWithTag(AuthenticatorNameTestTags.BUTTON_NEXT).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpNameEntryTestTags.BUTTON_NEXT).assertIsNotEnabled()
}
@Test
fun givenASubmittingState_whenIDisplayScreen_thenIExpectNextDisabled() {
setContent(AuthenticatorNameState(name = "Twilio Authy", submitting = true))
setContent(TotpNameEntryState(name = "Twilio Authy", submitting = true))
composeTestRule.onNodeWithTag(AuthenticatorNameTestTags.BUTTON_NEXT).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpNameEntryTestTags.BUTTON_NEXT).assertIsNotEnabled()
}
@Test
fun givenAName_whenIClickNext_thenIExpectNextClickedEvent() {
setContent(AuthenticatorNameState(name = "Twilio Authy"))
setContent(TotpNameEntryState(name = "Twilio Authy"))
composeTestRule.onNodeWithTag(AuthenticatorNameTestTags.BUTTON_NEXT)
composeTestRule.onNodeWithTag(TotpNameEntryTestTags.BUTTON_NEXT)
.assertIsEnabled()
.performClick()
assertThat(events).contains(AuthenticatorNameEvent.NextClicked)
assertThat(events).contains(TotpNameEntryEvent.NextClicked)
}
private fun setContent(state: AuthenticatorNameState) {
private fun setContent(state: TotpNameEntryState) {
composeTestRule.setContent {
AuthenticatorNameScreen(
TotpNameEntryScreen(
state = state,
onEvent = { events += it }
)
@@ -0,0 +1,118 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.totpsetup
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createComposeRule
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 assertk.assertions.isEmpty
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 TotpSetupScreenTest {
companion object {
private const val SETUP_KEY = "KVZ7WL3FDDWJZMTOB7PLZPKVRFD4LYSX"
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<TotpSetupEvent>()
@Test
fun whenIClickOpen_thenIExpectOpenTotpAppEvent() {
setContent()
scrollTo(TotpSetupTestTags.BUTTON_OPEN)
composeTestRule.onNodeWithTag(TotpSetupTestTags.BUTTON_OPEN).performClick()
assertThat(events).contains(TotpSetupEvent.OpenTotpAppClicked)
}
@Test
fun whenIClickCopy_thenIExpectCopyKeyEvent() {
setContent()
scrollTo(TotpSetupTestTags.BUTTON_COPY)
composeTestRule.onNodeWithTag(TotpSetupTestTags.BUTTON_COPY).performClick()
assertThat(events).contains(TotpSetupEvent.CopyKeyClicked)
}
@Test
fun whenIClickContinue_thenIExpectContinueEvent() {
setContent()
composeTestRule.onNodeWithTag(TotpSetupTestTags.BUTTON_CONTINUE)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(TotpSetupEvent.ContinueClicked)
}
@Test
fun whenTheKeyHasntArrived_thenIExpectASpinnerAndNothingToClick() {
setContent(TotpSetupState(loading = true))
scrollTo(TotpSetupTestTags.SETUP_KEY_SPINNER)
composeTestRule.onNodeWithTag(TotpSetupTestTags.SETUP_KEY_SPINNER).assertIsDisplayed()
composeTestRule.onNodeWithTag(TotpSetupTestTags.BUTTON_COPY).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpSetupTestTags.BUTTON_CONTINUE).assertIsNotEnabled()
composeTestRule.onNodeWithTag(TotpSetupTestTags.BUTTON_CONTINUE).performClick()
assertThat(events).isEmpty()
}
@Test
fun whenTheKeyHasArrived_thenIExpectItInPlaceOfTheSpinner() {
setContent()
scrollTo(TotpSetupTestTags.SETUP_KEY)
composeTestRule.onNodeWithTag(TotpSetupTestTags.SETUP_KEY).assertIsDisplayed()
}
@Test
fun whenIDismissAFailureDialog_thenIExpectDialogDismissedEvent() {
setContent(TotpSetupState(loading = false, dialog = TotpSetupState.Dialog.NetworkFailure))
composeTestRule.onNodeWithText("OK").performClick()
assertThat(events).contains(TotpSetupEvent.DialogDismissed)
}
private fun setContent(state: TotpSetupState = TotpSetupState(setupKey = SETUP_KEY, loading = false)) {
composeTestRule.setContent {
TotpSetupScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(TotpSetupTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
}