Add scaffolding for passkey creation screen.

This commit is contained in:
Greyson Parrelli
2026-08-21 17:36:10 -04:00
committed by Cody Henthorne
parent 91441f6fca
commit 3e35f70bea
24 changed files with 1001 additions and 8 deletions
@@ -308,6 +308,17 @@ class AccountSettingsScreenTest {
assertThat(events).contains(AccountSettingsEvent.AuthenticatorAppClicked)
}
@Test
fun givenASignalLogin_whenIClickPasskeys_thenIExpectPasskeysEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, hasAuthenticatorApp = false)))
scrollTo(AccountSettingsTestTags.ROW_PASSKEYS)
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_PASSKEYS).performClick()
assertThat(events).contains(AccountSettingsEvent.PasskeysClicked)
}
private fun setContent(state: AccountSettingsState) {
composeTestRule.setContent {
AccountSettingsScreen(
@@ -0,0 +1,111 @@
/*
* 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))
}
}
@@ -0,0 +1,104 @@
/*
* 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
}
}