[PM-28086] Add testharness for Credential Manager and Autofill testing (#6159)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Patrick Honkonen 2025-11-21 14:56:24 -05:00 committed by GitHub
parent 53e358d7b3
commit a395f28eba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 7836 additions and 0 deletions

View File

@ -27,6 +27,7 @@ dependencies {
kover(project(":cxf"))
kover(project(":data"))
kover(project(":network"))
kover(project(":testharness"))
kover(project(":ui"))
}
@ -42,6 +43,7 @@ detekt {
"cxf/src",
"data/src",
"network/src",
"testharness/src",
"ui/src",
)
}

View File

@ -56,5 +56,6 @@ include(
":cxf",
":data",
":network",
":testharness",
":ui",
)

9
testharness/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
/build
*.iml
.gradle
/local.properties
.idea/
.DS_Store
/captures
.externalNativeBuild
.cxx

79
testharness/README.md Normal file
View File

@ -0,0 +1,79 @@
# Credential Manager Test Harness
## Purpose
This standalone application serves as a test client for validating the Bitwarden Android credential provider implementation. It uses the Android CredentialManager APIs to request credential operations and verify that the `:app` module responds correctly as a credential provider.
Future iterations will introduce validation for the Android Autofill Framework.
## Features
- **Password Creation**: Test `CreatePasswordRequest` flow through CredentialManager
- **Password Retrieval**: Test `GetPasswordOption` flow through CredentialManager
- **Passkey Creation**: Test `CreatePublicKeyCredentialRequest` flow through CredentialManager
## Requirements
- Android device or emulator running API 28+
- Bitwarden app (`:app`) installed and configured as a credential provider
- Google Play Services (for API 28-33 compatibility)
## Usage
### Setup
1. Build and install the main Bitwarden app (`:app`)
2. Configure Bitwarden as a credential provider in system settings:
- Settings → Passwords & accounts → Credential Manager
- Enable Bitwarden as a provider
3. Build and install the test harness:
```bash
./gradlew :testharness:installDebug
```
### Running Tests
1. Launch "Credential Manager Test Harness" app
2. Select a credential operation type (Password Create, Password Get, or Passkey Create)
3. Fill in required fields:
- **Password Create**: Username, Password, Origin (optional)
- **Password Get**: No inputs required
- **Passkey Create**: Username, Relying Party ID, Origin
- **Passkey Get**: Relying Party ID, Origin
4. Tap "Execute" button
5. System credential picker should appear with Bitwarden as an option
6. Select Bitwarden and follow the flow
7. Result will be displayed in the app
## Known Limitations
1. **API 28-33 compatibility**: Requires Google Play Services for CredentialManager APIs on older Android versions.
2. Passkey operations must be performed as a Privileged App due to security measures built in `:app`.
## Architecture
This module follows the same architectural patterns as the main Bitwarden app:
- **MVVM + UDF**: `BaseViewModel` with State/Action/Event pattern
- **Hilt DI**: Dependency injection throughout
- **Compose UI**: Modern declarative UI with Material 3
- **Result handling**: No exceptions, sealed result classes
## Testing
Run unit tests:
```bash
./gradlew :testharness:test
```
### Debugging
Check Logcat for detailed error messages:
```bash
adb logcat | grep -E "CredentialManager|Bitwarden|TestHarness"
```
## References
- [Android Credential Manager Documentation](https://developer.android.com/identity/credential-manager)
- [Bitwarden Android Architecture](../docs/ARCHITECTURE.md)
- [Passkey Registration Research](../PASSKEY_REGISTRATION_RESEARCH_REPORT.md)

View File

@ -0,0 +1,117 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "com.bitwarden.testharness"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = "com.bitwarden.testharness"
// API 28 - CredentialManager with Play Services support
minSdk = libs.versions.minSdkBwa.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
applicationIdSuffix = ".dev"
isDebuggable = true
}
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
buildFeatures {
compose = true
buildConfig = true
}
testOptions {
// Required for Android framework classes in unit tests
unitTests.isIncludeAndroidResources = true
unitTests.isReturnDefaultValues = true
}
compileOptions {
sourceCompatibility(libs.versions.jvmTarget.get())
targetCompatibility(libs.versions.jvmTarget.get())
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
}
}
}
dependencies {
// Internal modules
implementation(project(":annotation"))
implementation(project(":core"))
implementation(project(":ui"))
// AndroidX Credentials - PRIMARY DEPENDENCY
implementation(libs.androidx.credentials)
// Hilt DI
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// Compose UI
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.splashscreen)
// Kotlin essentials
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization)
implementation(libs.kotlinx.collections.immutable)
// Testing
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.google.hilt.android.testing)
testImplementation(platform(libs.junit.bom))
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.junit.jupiter)
testImplementation(libs.junit.vintage)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk.mockk)
// Robolectric reserved for future Android framework tests if needed
testImplementation(libs.robolectric.robolectric)
testImplementation(libs.square.turbine)
testImplementation(testFixtures(project(":ui")))
}
tasks {
withType<Test> {
useJUnitPlatform()
maxHeapSize = "2g"
maxParallelForks = Runtime.getRuntime().availableProcessors()
}
}

23
testharness/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,23 @@
# CredentialManager classes
-keep class androidx.credentials.** { *; }
-keep interface androidx.credentials.** { *; }
# Hilt
-dontwarn com.google.errorprone.annotations.**
# Kotlinx Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.bitwarden.testharness.**$$serializer { *; }
-keepclassmembers class com.bitwarden.testharness.** {
*** Companion;
}
-keepclasseswithmembers class com.bitwarden.testharness.** {
kotlinx.serialization.KSerializer serializer(...);
}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CREDENTIAL_MANAGER_SET_ORIGIN" />
<application
android:name=".TestHarnessApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
<!-- Digital Asset Links for Credential Manager (empty for test consumer) -->
<meta-data
android:name="asset_statements"
android:resource="@string/asset_statements" />
<activity
android:name=".MainActivity"
android:configChanges="uiMode"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,58 @@
package com.bitwarden.testharness
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.bitwarden.testharness.ui.platform.feature.rootnav.RootNavScreen
import com.bitwarden.testharness.ui.platform.feature.rootnav.RootNavigationRoute
import com.bitwarden.testharness.ui.platform.feature.rootnav.rootNavDestination
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.platform.util.setupEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.map
/**
* Primary entry point for the Credential Manager test harness application.
*
* Delegates navigation to [RootNavScreen] following the same pattern as @app and @authenticator
* modules. Handles Activity-level concerns like theme and splash screen.
*
* The root navigation is managed by [RootNavScreen] which orchestrates all test screen flows:
* - Landing screen with test category selection (Autofill, Credential Manager)
* - Individual test screens for each Credential Manager API operation
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val mainViewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
setupEdgeToEdge(appThemeFlow = mainViewModel.stateFlow.map { it.theme })
setContent {
val navController = rememberNavController()
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
BitwardenTheme(
theme = state.theme,
) {
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
) {
rootNavDestination { shouldShowSplashScreen = false }
}
}
}
}
}

View File

@ -0,0 +1,55 @@
package com.bitwarden.testharness
import android.os.Parcelable
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
/**
* ViewModel that manages Activity-level state for the test harness.
*
* Handles theme and other cross-cutting concerns at the Activity level.
* This follows the pattern from the main app's MainActivity/MainViewModel.
*/
@HiltViewModel
class MainViewModel @Inject constructor(
// Minimal dependencies for test harness
// Could add SettingsRepository if theme persistence needed
) : BaseViewModel<MainState, MainEvent, MainAction>(
initialState = MainState(
theme = AppTheme.DEFAULT,
),
) {
override fun handleAction(action: MainAction) {
// Minimal actions for test harness
// Could handle theme changes if needed
}
}
/**
* Models state for the [MainActivity].
*
* @property theme The current app theme.
*/
@Parcelize
data class MainState(
val theme: AppTheme,
) : Parcelable
/**
* Models actions for the [MainActivity].
*
* Currently empty but reserved for future Activity-level actions.
*/
sealed class MainAction
/**
* Models events emitted by the [MainActivity].
*
* Currently empty but reserved for future Activity-level events such as
* theme updates or Activity recreation requirements.
*/
sealed class MainEvent

View File

@ -0,0 +1,14 @@
package com.bitwarden.testharness
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
/**
* Application class for the Credential Manager test harness.
*
* This test application validates the credential provider implementation in the main
* Bitwarden app by acting as a client application that requests credential operations
* through the Android CredentialManager API.
*/
@HiltAndroidApp
class TestHarnessApplication : Application()

View File

@ -0,0 +1,80 @@
package com.bitwarden.testharness.data.manager
import com.bitwarden.testharness.data.model.CredentialTestResult
/**
* Manager for testing credential operations through the Android CredentialManager API.
*
* This manager wraps the CredentialManager API to test credential provider implementations.
*/
interface CredentialTestManager {
/**
* Test password creation through CredentialManager.
*
* @param username The username/identifier for the password.
* @param password The password value.
* @param origin Optional origin/domain for the credential (e.g., "https://example.com").
* @return Result indicating success, error, or cancellation.
*/
suspend fun createPassword(
username: String,
password: String,
origin: String?,
): CredentialTestResult
/**
* Test password retrieval through CredentialManager.
*
* @return Result indicating success with credential data, error, or cancellation.
*/
suspend fun getPassword(): CredentialTestResult
/**
* Test passkey creation through CredentialManager.
*
* @param username The username/identifier for the passkey.
* @param rpId The Relying Party ID (domain).
* @param origin Optional origin/domain for privileged app simulation
* (e.g., "https://example.com"). Used to simulate browser or password manager apps making
* requests on behalf of websites. Requires CREDENTIAL_MANAGER_SET_ORIGIN permission.
* @return Result indicating success, error, or cancellation.
*/
suspend fun createPasskey(
username: String,
rpId: String,
origin: String? = null,
): CredentialTestResult
/**
* Test passkey authentication through CredentialManager.
*
* @param rpId The Relying Party ID (domain) for the passkey request.
* @param origin Optional origin/domain for privileged app simulation
* (e.g., "https://example.com"). Used to simulate browser or password manager apps making
* requests on behalf of websites. Requires CREDENTIAL_MANAGER_SET_ORIGIN permission.
* @return Result indicating success with credential data, error, or cancellation.
*/
suspend fun getPasskey(
rpId: String,
origin: String? = null,
): CredentialTestResult
/**
* Test combined password and passkey retrieval through CredentialManager.
*
* This method includes both GetPasswordOption and GetPublicKeyCredentialOption in a single
* GetCredentialRequest, allowing the user to choose between saved passwords and passkeys
* from the system credential picker.
*
* @param rpId The Relying Party ID (domain) for the passkey request.
* @param origin Optional origin/domain for privileged app simulation
* (e.g., "https://example.com"). Used to simulate browser or password manager apps making
* requests on behalf of websites. Requires CREDENTIAL_MANAGER_SET_ORIGIN permission.
* @return Result indicating success with credential data (password or passkey), error, or
* cancellation.
*/
suspend fun getPasswordOrPasskey(
rpId: String,
origin: String? = null,
): CredentialTestResult
}

View File

@ -0,0 +1,262 @@
package com.bitwarden.testharness.data.manager
import android.app.Application
import androidx.credentials.CreatePasswordRequest
import androidx.credentials.CreatePasswordResponse
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetPasswordOption
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.PasswordCredential
import androidx.credentials.PublicKeyCredential
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.testharness.data.util.WebAuthnJsonBuilder
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [CredentialTestManager].
*/
@Singleton
class CredentialTestManagerImpl @Inject constructor(
private val application: Application,
private val credentialManager: CredentialManager,
) : CredentialTestManager {
override suspend fun createPassword(
username: String,
password: String,
origin: String?,
): CredentialTestResult {
val request = CreatePasswordRequest(
id = username,
password = password,
origin = origin,
)
val result = try {
credentialManager.createCredential(
context = application,
request = request,
)
} catch (_: CreateCredentialCancellationException) {
return CredentialTestResult.Cancelled
} catch (e: CreateCredentialException) {
return CredentialTestResult.Error(exception = e)
}
return when (result) {
is CreatePasswordResponse -> {
CredentialTestResult.Success(
data = "Username: $username\nOrigin: ${origin ?: "null"}",
)
}
else -> {
CredentialTestResult.Error(
exception = IllegalStateException(
"Unexpected response type: ${result::class.simpleName}",
),
)
}
}
}
override suspend fun getPassword(): CredentialTestResult {
val request = GetCredentialRequest(
credentialOptions = listOf(GetPasswordOption()),
)
val result = try {
credentialManager.getCredential(
context = application,
request = request,
)
} catch (_: GetCredentialCancellationException) {
return CredentialTestResult.Cancelled
} catch (e: GetCredentialException) {
return CredentialTestResult.Error(exception = e)
}
return when (val credential = result.credential) {
is PasswordCredential -> {
CredentialTestResult.Success(
data = "Username: ${credential.id}\nPassword: ${credential.password}",
)
}
else -> {
CredentialTestResult.Error(
exception = IllegalStateException(
"Unexpected credential type: ${credential::class.simpleName}",
),
)
}
}
}
override suspend fun createPasskey(
username: String,
rpId: String,
origin: String?,
): CredentialTestResult {
// Build minimal passkey creation request JSON
val requestJson = WebAuthnJsonBuilder.buildPasskeyCreationJson(username, rpId)
// Conditionally include origin parameter for privileged app simulation
val request = if (origin.isNullOrBlank()) {
CreatePublicKeyCredentialRequest(
requestJson = requestJson,
)
} else {
CreatePublicKeyCredentialRequest(
requestJson = requestJson,
origin = origin,
)
}
val result = try {
credentialManager.createCredential(
context = application,
request = request,
)
} catch (_: CreateCredentialCancellationException) {
return CredentialTestResult.Cancelled
} catch (e: CreateCredentialException) {
return CredentialTestResult.Error(exception = e)
}
return when (result) {
is CreatePublicKeyCredentialResponse -> {
CredentialTestResult.Success(
data = "RP ID: $rpId\nOrigin: ${origin ?: "null"}\n\n" +
result.registrationResponseJson,
)
}
else -> {
CredentialTestResult.Error(
exception = IllegalStateException(
"Unexpected response type: ${result::class.simpleName}",
),
)
}
}
}
override suspend fun getPasskey(
rpId: String,
origin: String?,
): CredentialTestResult {
// Build minimal passkey authentication request JSON
val requestJson = WebAuthnJsonBuilder.buildPasskeyAuthenticationJson(rpId)
val option = GetPublicKeyCredentialOption(
requestJson = requestJson,
)
val request = GetCredentialRequest(
credentialOptions = listOf(option),
origin = origin,
)
val result = try {
credentialManager.getCredential(
context = application,
request = request,
)
} catch (_: GetCredentialCancellationException) {
return CredentialTestResult.Cancelled
} catch (e: GetCredentialException) {
return CredentialTestResult.Error(exception = e)
}
return when (val credential = result.credential) {
is PublicKeyCredential -> {
CredentialTestResult.Success(
data = "RP ID: $rpId\nOrigin: ${origin ?: "null"}\n\n" +
credential.authenticationResponseJson,
)
}
else -> {
CredentialTestResult.Error(
exception = IllegalStateException(
"Unexpected credential type: ${credential::class.simpleName}",
),
)
}
}
}
override suspend fun getPasswordOrPasskey(
rpId: String,
origin: String?,
): CredentialTestResult {
// Build passkey authentication request JSON
val requestJson = WebAuthnJsonBuilder.buildPasskeyAuthenticationJson(rpId)
// Create request with both password and passkey options
// Conditionally include origin parameter for privileged app simulation
val request = if (origin.isNullOrBlank()) {
GetCredentialRequest(
credentialOptions = listOf(
GetPasswordOption(),
GetPublicKeyCredentialOption(requestJson = requestJson),
),
)
} else {
GetCredentialRequest(
credentialOptions = listOf(
GetPasswordOption(),
GetPublicKeyCredentialOption(requestJson = requestJson),
),
origin = origin,
)
}
val result = try {
credentialManager.getCredential(
context = application,
request = request,
)
} catch (_: GetCredentialCancellationException) {
return CredentialTestResult.Cancelled
} catch (e: GetCredentialException) {
return CredentialTestResult.Error(exception = e)
}
return when (val credential = result.credential) {
is PasswordCredential -> {
CredentialTestResult.Success(
data = "Type: PASSWORD\n" +
"Username: ${credential.id}\n" +
"Password: ${credential.password}\n" +
"Origin: ${origin ?: "null"}",
)
}
is PublicKeyCredential -> {
CredentialTestResult.Success(
data = "Type: PASSKEY\n" +
"Origin: ${origin ?: "null"}\n" +
"Response JSON:\n${credential.authenticationResponseJson}",
)
}
else -> {
CredentialTestResult.Error(
exception = IllegalStateException(
"Unexpected credential type: ${credential::class.simpleName}",
),
)
}
}
}
}

View File

@ -0,0 +1,35 @@
package com.bitwarden.testharness.data.manager.di
import android.app.Application
import androidx.credentials.CredentialManager
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.manager.CredentialTestManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides dependencies for credential test managers.
*/
@Module
@InstallIn(SingletonComponent::class)
object CredentialTestManagerModule {
@Provides
@Singleton
fun provideCredentialManager(
application: Application,
): CredentialManager = CredentialManager.create(application.applicationContext)
@Provides
@Singleton
fun provideCredentialTestManager(
application: Application,
credentialManager: CredentialManager,
): CredentialTestManager = CredentialTestManagerImpl(
application = application,
credentialManager = credentialManager,
)
}

View File

@ -0,0 +1,29 @@
package com.bitwarden.testharness.data.model
/**
* Result of a credential operation test.
*/
sealed class CredentialTestResult {
/**
* Credential operation completed successfully.
*
* @property data Structured credential response data (e.g., username, origin, JSON response).
*/
data class Success(
val data: String? = null,
) : CredentialTestResult()
/**
* Credential operation failed with an error.
*
* @property exception The underlying exception that caused the failure.
*/
data class Error(
val exception: Throwable? = null,
) : CredentialTestResult()
/**
* User cancelled the credential operation.
*/
data object Cancelled : CredentialTestResult()
}

View File

@ -0,0 +1,102 @@
package com.bitwarden.testharness.data.util
import com.bitwarden.annotation.OmitFromCoverage
import java.security.SecureRandom
import java.util.Base64
private const val CHALLENGE_SEED_SIZE = 32
/**
* Builder for WebAuthn JSON structures required by the Credential Manager API.
*
* Generates minimal valid JSON for passkey registration and authentication flows
* following the WebAuthn specification.
*/
@OmitFromCoverage
object WebAuthnJsonBuilder {
/**
* Build a minimal valid WebAuthn registration request JSON.
*
* This follows the WebAuthn specification for PublicKeyCredentialCreationOptions.
*
* **WARNING: TEST HARNESS ONLY** - This implementation uses simplified challenge
* generation suitable for testing. Production implementations should use a secure
* backend service to generate and validate challenges.
*
* @param username The username for the passkey.
* @param rpId The Relying Party ID.
* @return JSON string for passkey creation request.
*/
fun buildPasskeyCreationJson(username: String, rpId: String): String {
// Generate random challenge (base64url encoded)
val challenge = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(SecureRandom().generateSeed(CHALLENGE_SEED_SIZE))
// Generate random user ID (base64url encoded)
val userId = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(username.toByteArray())
return """
{
"challenge": "$challenge",
"rp": {
"name": "Test Harness",
"id": "$rpId"
},
"user": {
"id": "$userId",
"name": "$username",
"displayName": "$username"
},
"pubKeyCredParams": [
{
"type": "public-key",
"alg": -7
},
{
"type": "public-key",
"alg": -257
}
],
"timeout": 60000,
"attestation": "none",
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"residentKey": "required",
"requireResidentKey": true,
"userVerification": "required"
}
}
""".trimIndent()
}
/**
* Build a minimal valid WebAuthn authentication request JSON.
*
* This follows the WebAuthn specification for PublicKeyCredentialRequestOptions.
*
* **WARNING: TEST HARNESS ONLY** - This implementation uses simplified challenge
* generation suitable for testing. Production implementations should use a secure
* backend service to generate and validate challenges.
*
* @param rpId The Relying Party ID.
* @return JSON string for passkey authentication request.
*/
fun buildPasskeyAuthenticationJson(rpId: String): String {
// Generate random challenge (base64url encoded)
val challenge = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(SecureRandom().generateSeed(CHALLENGE_SEED_SIZE))
return """
{
"challenge": "$challenge",
"rpId": "$rpId",
"userVerification": "preferred"
}
""".trimIndent()
}
}

View File

@ -0,0 +1,58 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.autofill
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.navigation
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
import kotlinx.serialization.Serializable
/**
* Autofill graph route - serves as the parent for all Autofill test flows.
*/
@Serializable
data object AutofillGraphRoute
/**
* Autofill test placeholder screen - the start destination of the graph.
*/
@Serializable
data object AutofillPlaceholderRoute
/**
* Add Autofill nav graph to the root nav graph.
*
* This graph contains the placeholder screen and can be expanded with
* additional autofill test screens as needed.
*/
fun NavGraphBuilder.autofillGraph(
onNavigateBack: () -> Unit,
) {
navigation<AutofillGraphRoute>(
startDestination = AutofillPlaceholderRoute,
) {
autofillPlaceholderDestination(
onNavigateBack = onNavigateBack,
)
}
}
private fun NavGraphBuilder.autofillPlaceholderDestination(
onNavigateBack: () -> Unit,
) {
composableWithRootPushTransitions<AutofillPlaceholderRoute> {
AutofillPlaceholderScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Autofill graph.
*/
fun NavController.navigateToAutofillGraph(navOptions: NavOptions? = null) {
navigate(route = AutofillGraphRoute, navOptions = navOptions)
}

View File

@ -0,0 +1,73 @@
package com.bitwarden.testharness.ui.platform.feature.autofill
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.testharness.R
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Placeholder screen for Autofill testing (not yet implemented).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AutofillPlaceholderScreen(
onNavigateBack: () -> Unit,
viewModel: AutofillPlaceholderViewModel = hiltViewModel(),
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
AutofillPlaceholderEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.autofill_testing),
scrollBehavior = scrollBehavior,
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(BitwardenString.back),
onNavigationIconClick = {
viewModel.trySendAction(AutofillPlaceholderAction.BackClick)
},
),
)
},
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(id = R.string.autofill_coming_soon),
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.secondary,
)
}
}
}

View File

@ -0,0 +1,45 @@
package com.bitwarden.testharness.ui.platform.feature.autofill
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* ViewModel for Autofill Placeholder screen.
*/
@HiltViewModel
class AutofillPlaceholderViewModel @Inject constructor() :
BaseViewModel<Unit, AutofillPlaceholderEvent, AutofillPlaceholderAction>(
initialState = Unit,
) {
override fun handleAction(action: AutofillPlaceholderAction) {
when (action) {
AutofillPlaceholderAction.BackClick -> handleBackClick()
}
}
private fun handleBackClick() {
sendEvent(AutofillPlaceholderEvent.NavigateBack)
}
}
/**
* Events for Autofill Placeholder screen.
*/
sealed class AutofillPlaceholderEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : AutofillPlaceholderEvent()
}
/**
* Actions for Autofill Placeholder screen.
*/
sealed class AutofillPlaceholderAction {
/**
* User clicked back button.
*/
data object BackClick : AutofillPlaceholderAction()
}

View File

@ -0,0 +1,36 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.createpasskey
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import kotlinx.serialization.Serializable
/**
* Create passkey test screen.
*/
@Serializable
data object CreatePasskeyRoute
/**
* Add Create Passkey destination to the nav graph.
*/
fun NavGraphBuilder.createPasskeyDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions<CreatePasskeyRoute> {
CreatePasskeyScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Create Passkey test screen.
*/
fun NavController.navigateToCreatePasskey(navOptions: NavOptions? = null) {
navigate(route = CreatePasskeyRoute, navOptions = navOptions)
}

View File

@ -0,0 +1,191 @@
package com.bitwarden.testharness.ui.platform.feature.createpasskey
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.testharness.R
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* Create Passkey test screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePasskeyScreen(
onNavigateBack: () -> Unit,
viewModel: CreatePasskeyViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
CreatePasskeyEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
CreatePasskeyScreenContent(
state = state,
onBackClick = { viewModel.trySendAction(CreatePasskeyAction.BackClick) },
onUsernameChange = { viewModel.trySendAction(CreatePasskeyAction.UsernameChanged(it)) },
onRpIdChange = { viewModel.trySendAction(CreatePasskeyAction.RpIdChanged(it)) },
onOriginChange = { viewModel.trySendAction(CreatePasskeyAction.OriginChanged(it)) },
onExecuteClick = { viewModel.trySendAction(CreatePasskeyAction.ExecuteClick) },
onClearResultClick = { viewModel.trySendAction(CreatePasskeyAction.ClearResultClick) },
scrollBehavior = scrollBehavior,
)
}
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreatePasskeyScreenContent(
state: CreatePasskeyState,
onBackClick: () -> Unit,
onUsernameChange: (String) -> Unit,
onRpIdChange: (String) -> Unit,
onOriginChange: (String) -> Unit,
onExecuteClick: () -> Unit,
onClearResultClick: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
BitwardenScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.create_passkey_title),
scrollBehavior = scrollBehavior,
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(BitwardenString.back),
onNavigationIconClick = onBackClick,
),
)
},
) {
Column(
modifier = Modifier
.verticalScroll(state = rememberScrollState())
.fillMaxSize()
.padding(horizontal = 16.dp)
.imePadding(),
) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(R.string.username),
value = state.username,
onValueChange = onUsernameChange,
cardStyle = null,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.relying_party_id),
value = state.rpId,
onValueChange = onRpIdChange,
placeholder = stringResource(R.string.rp_id_hint),
cardStyle = null,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.origin_optional),
value = state.origin,
onValueChange = onOriginChange,
placeholder = stringResource(R.string.origin_hint),
cardStyle = null,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(R.string.execute),
onClick = onExecuteClick,
isEnabled = !state.isLoading,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(BitwardenString.clear),
onClick = onClearResultClick,
isEnabled = !state.isLoading,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.result),
value = state.resultText,
onValueChange = { },
cardStyle = null,
readOnly = true,
singleLine = false,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Preview
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreatePasskeyScreenPreview() {
CreatePasskeyScreenContent(
state = CreatePasskeyState(
username = "user@bitwarden.com",
rpId = "passkeys.example.com",
origin = "https://passkeys.example.com",
isLoading = false,
resultText = "This is the result of the operation.",
),
onBackClick = {},
onUsernameChange = {},
onRpIdChange = {},
onOriginChange = {},
onExecuteClick = {},
onClearResultClick = {},
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
)
}

View File

@ -0,0 +1,244 @@
package com.bitwarden.testharness.ui.platform.feature.createpasskey
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MAX_STACK_TRACE_LINES = 5
private const val RESULT_SEPARATOR_LENGTH = 40
/**
* ViewModel for Create Passkey test screen.
*/
@HiltViewModel
class CreatePasskeyViewModel @Inject constructor(
private val credentialTestManager: CredentialTestManager,
private val clock: Clock,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<CreatePasskeyState, CreatePasskeyEvent, CreatePasskeyAction>(
initialState = savedStateHandle[KEY_STATE] ?: CreatePasskeyState(),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: CreatePasskeyAction) {
when (action) {
is CreatePasskeyAction.UsernameChanged -> handleUsernameChanged(action)
is CreatePasskeyAction.RpIdChanged -> handleRpIdChanged(action)
is CreatePasskeyAction.OriginChanged -> handleOriginChanged(action)
CreatePasskeyAction.ExecuteClick -> handleExecuteClick()
CreatePasskeyAction.ClearResultClick -> handleClearResultClick()
CreatePasskeyAction.BackClick -> handleBackClick()
is CreatePasskeyAction.Internal -> handleInternalAction(action)
}
}
private fun handleBackClick() {
sendEvent(CreatePasskeyEvent.NavigateBack)
}
private fun handleUsernameChanged(action: CreatePasskeyAction.UsernameChanged) {
mutableStateFlow.update {
it.copy(username = action.username)
}
}
private fun handleRpIdChanged(action: CreatePasskeyAction.RpIdChanged) {
mutableStateFlow.update {
it.copy(rpId = action.rpId)
}
}
private fun handleOriginChanged(action: CreatePasskeyAction.OriginChanged) {
mutableStateFlow.update {
it.copy(origin = action.origin)
}
}
private fun handleExecuteClick() {
val currentState = stateFlow.value
val username = currentState.username
val rpId = currentState.rpId
val origin = currentState.origin.takeIf { it.isNotBlank() }
if (username.isBlank() || rpId.isBlank()) {
val errorMessage = "\n${timestamp()} ⚠️ Validation Error: " +
"Username and Relying Party ID are required\n"
mutableStateFlow.update {
it.copy(
resultText = it.resultText + errorMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
return
}
val startMessage = "\n${timestamp()} ⏳ Creating passkey credential...\n"
mutableStateFlow.update {
it.copy(
isLoading = true,
resultText = it.resultText + startMessage,
)
}
viewModelScope.launch {
val result = credentialTestManager.createPasskey(
username = username,
rpId = rpId,
origin = origin,
)
sendAction(CreatePasskeyAction.Internal.CredentialResultReceived(result))
}
}
private fun handleClearResultClick() {
mutableStateFlow.update {
it.copy(resultText = "Result cleared.\n")
}
}
private fun handleInternalAction(action: CreatePasskeyAction.Internal) {
when (action) {
is CreatePasskeyAction.Internal.CredentialResultReceived -> {
handleCredentialResultReceived(action)
}
}
}
private fun handleCredentialResultReceived(
action: CreatePasskeyAction.Internal.CredentialResultReceived,
) {
val resultMessage = when (val result = action.result) {
is CredentialTestResult.Success -> {
buildString {
append("${timestamp()} ✅ SUCCESS: Passkey created successfully\n")
if (result.data != null) {
append("\n📋 Response Data:\n${result.data}\n")
}
}
}
is CredentialTestResult.Error -> {
buildString {
val errorMessage = result.exception?.message ?: "Unknown error"
append("${timestamp()} ❌ ERROR: Failed to create passkey: $errorMessage\n")
if (result.exception != null) {
append("\n🔍 Exception:\n${result.exception}\n")
append("\nStack trace:\n")
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
append(" at $it\n")
}
}
}
}
CredentialTestResult.Cancelled -> {
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
}
}
mutableStateFlow.update {
it.copy(
isLoading = false,
resultText = it.resultText + resultMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
}
private fun timestamp(): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
return clock.instant()
.atZone(ZoneId.systemDefault())
.format(formatter)
.let { "[$it]" }
}
}
/**
* State for Create Passkey screen.
*/
@Parcelize
data class CreatePasskeyState(
val username: String = "",
val rpId: String = "",
val origin: String = "",
val isLoading: Boolean = false,
val resultText: String = "Ready to create passkey credential.\n\n" +
"Enter username and Relying Party ID, then click Execute.",
) : Parcelable
/**
* Events for Create Passkey screen.
*/
sealed class CreatePasskeyEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CreatePasskeyEvent()
}
/**
* Actions for Create Passkey screen.
*/
sealed class CreatePasskeyAction {
/**
* Username input value changed.
*/
data class UsernameChanged(val username: String) : CreatePasskeyAction()
/**
* Relying Party ID input value changed.
*/
data class RpIdChanged(val rpId: String) : CreatePasskeyAction()
/**
* Origin input value changed.
*/
data class OriginChanged(val origin: String) : CreatePasskeyAction()
/**
* User clicked execute button to create passkey credential.
*/
data object ExecuteClick : CreatePasskeyAction()
/**
* User clicked clear button to reset results.
*/
data object ClearResultClick : CreatePasskeyAction()
/**
* User clicked back button.
*/
data object BackClick : CreatePasskeyAction()
/**
* Internal actions for Create Passkey screen.
*/
sealed class Internal : CreatePasskeyAction() {
/**
* Credential operation result received.
*/
data class CredentialResultReceived(
val result: CredentialTestResult,
) : Internal()
}
}

View File

@ -0,0 +1,36 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.createpassword
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import kotlinx.serialization.Serializable
/**
* Create password test screen.
*/
@Serializable
data object CreatePasswordRoute
/**
* Add Create Password destination to the nav graph.
*/
fun NavGraphBuilder.createPasswordDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions<CreatePasswordRoute> {
CreatePasswordScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Create Password test screen.
*/
fun NavController.navigateToCreatePassword(navOptions: NavOptions? = null) {
navigate(route = CreatePasswordRoute, navOptions = navOptions)
}

View File

@ -0,0 +1,151 @@
package com.bitwarden.testharness.ui.platform.feature.createpassword
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.testharness.R
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* Create Password test screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePasswordScreen(
onNavigateBack: () -> Unit,
viewModel: CreatePasswordViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
CreatePasswordEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.create_password_title),
scrollBehavior = scrollBehavior,
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(BitwardenString.back),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CreatePasswordAction.BackClick) }
},
),
)
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(R.string.username),
value = state.username,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(CreatePasswordAction.UsernameChanged(it)) }
},
cardStyle = null,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.password),
value = state.password,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(CreatePasswordAction.PasswordChanged(it)) }
},
cardStyle = null,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(R.string.execute),
onClick = remember(viewModel) {
{ viewModel.trySendAction(CreatePasswordAction.ExecuteClick) }
},
isEnabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(BitwardenString.clear),
onClick = remember(viewModel) {
{ viewModel.trySendAction(CreatePasswordAction.ClearResultClick) }
},
isEnabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.result),
value = state.resultText,
onValueChange = { },
cardStyle = null,
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View File

@ -0,0 +1,230 @@
package com.bitwarden.testharness.ui.platform.feature.createpassword
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MAX_STACK_TRACE_LINES = 5
private const val RESULT_SEPARATOR_LENGTH = 40
/**
* ViewModel for Create Password test screen.
*/
@HiltViewModel
class CreatePasswordViewModel @Inject constructor(
private val credentialTestManager: CredentialTestManager,
private val clock: Clock,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<CreatePasswordState, CreatePasswordEvent, CreatePasswordAction>(
initialState = savedStateHandle[KEY_STATE] ?: CreatePasswordState(),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: CreatePasswordAction) {
when (action) {
is CreatePasswordAction.UsernameChanged -> handleUsernameChanged(action)
is CreatePasswordAction.PasswordChanged -> handlePasswordChanged(action)
CreatePasswordAction.ExecuteClick -> handleExecuteClick()
CreatePasswordAction.ClearResultClick -> handleClearResultClick()
CreatePasswordAction.BackClick -> handleBackClick()
is CreatePasswordAction.Internal -> handleInternalAction(action)
}
}
private fun handleBackClick() {
sendEvent(CreatePasswordEvent.NavigateBack)
}
private fun handleUsernameChanged(action: CreatePasswordAction.UsernameChanged) {
mutableStateFlow.update {
it.copy(username = action.username)
}
}
private fun handlePasswordChanged(action: CreatePasswordAction.PasswordChanged) {
mutableStateFlow.update {
it.copy(password = action.password)
}
}
private fun handleExecuteClick() {
val currentState = stateFlow.value
val username = currentState.username
val password = currentState.password
if (username.isBlank() || password.isBlank()) {
val errorMessage = "\n${timestamp()} ⚠️ Validation Error: " +
"Username and password are required\n"
mutableStateFlow.update {
it.copy(
resultText = it.resultText + errorMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
return
}
val startMessage = "\n${timestamp()} ⏳ Creating password credential...\n"
mutableStateFlow.update {
it.copy(
isLoading = true,
resultText = it.resultText + startMessage,
)
}
viewModelScope.launch {
val result = credentialTestManager.createPassword(
username = username,
password = password,
origin = null,
)
sendAction(CreatePasswordAction.Internal.CredentialResultReceived(result))
}
}
private fun handleClearResultClick() {
mutableStateFlow.update {
it.copy(resultText = "Result cleared.\n")
}
}
private fun handleInternalAction(action: CreatePasswordAction.Internal) {
when (action) {
is CreatePasswordAction.Internal.CredentialResultReceived -> {
handleCredentialResultReceived(action)
}
}
}
private fun handleCredentialResultReceived(
action: CreatePasswordAction.Internal.CredentialResultReceived,
) {
val resultMessage = when (val result = action.result) {
is CredentialTestResult.Success -> {
buildString {
append("${timestamp()} ✅ SUCCESS: Password created successfully\n")
if (result.data != null) {
append("\n📋 Response Data:\n${result.data}\n")
}
}
}
is CredentialTestResult.Error -> {
buildString {
val errorMessage = result.exception?.message ?: "Unknown error"
append("${timestamp()} ❌ ERROR: Failed to create password: $errorMessage\n")
if (result.exception != null) {
append("\n🔍 Exception:\n${result.exception}\n")
append("\nStack trace:\n")
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
append(" at $it\n")
}
}
}
}
CredentialTestResult.Cancelled -> {
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
}
}
mutableStateFlow.update {
it.copy(
isLoading = false,
resultText = it.resultText + resultMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
}
private fun timestamp(): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
return clock.instant()
.atZone(ZoneId.systemDefault())
.format(formatter)
.let { "[$it]" }
}
}
/**
* State for Create Password screen.
*/
@Parcelize
data class CreatePasswordState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val resultText: String = "Ready to create password credential.\n\n" +
"Enter username and password, then click Execute.",
) : Parcelable
/**
* Events for Create Password screen.
*/
sealed class CreatePasswordEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CreatePasswordEvent()
}
/**
* Actions for Create Password screen.
*/
sealed class CreatePasswordAction {
/**
* Username input value changed.
*/
data class UsernameChanged(val username: String) : CreatePasswordAction()
/**
* Password input value changed.
*/
data class PasswordChanged(val password: String) : CreatePasswordAction()
/**
* User clicked execute button to create password credential.
*/
data object ExecuteClick : CreatePasswordAction()
/**
* User clicked clear button to reset results.
*/
data object ClearResultClick : CreatePasswordAction()
/**
* User clicked back button.
*/
data object BackClick : CreatePasswordAction()
/**
* Internal actions for Create Password screen.
*/
sealed class Internal : CreatePasswordAction() {
/**
* Credential operation result received.
*/
data class CredentialResultReceived(
val result: CredentialTestResult,
) : Internal()
}
}

View File

@ -0,0 +1,114 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.navigation
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.testharness.ui.platform.feature.createpasskey.createPasskeyDestination
import com.bitwarden.testharness.ui.platform.feature.createpasskey.navigateToCreatePasskey
import com.bitwarden.testharness.ui.platform.feature.createpassword.createPasswordDestination
import com.bitwarden.testharness.ui.platform.feature.createpassword.navigateToCreatePassword
import com.bitwarden.testharness.ui.platform.feature.getpasskey.getPasskeyDestination
import com.bitwarden.testharness.ui.platform.feature.getpasskey.navigateToGetPasskey
import com.bitwarden.testharness.ui.platform.feature.getpassword.getPasswordDestination
import com.bitwarden.testharness.ui.platform.feature.getpassword.navigateToGetPassword
import com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey.getPasswordOrPasskeyDestination
import com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey.navigateToGetPasswordOrPasskey
import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
import kotlinx.serialization.Serializable
/**
* Credential Manager graph route - serves as the parent for all Credential Manager API test flows.
*/
@Serializable
data object CredentialManagerGraphRoute
/**
* Credential Manager test category list screen - the start destination of the graph.
*/
@Serializable
data object CredentialManagerListRoute
/**
* Add Credential Manager nav graph to the root nav graph.
*
* This graph contains the list screen and all nested credential manager API test screens.
*/
fun NavGraphBuilder.credentialManagerGraph(
onNavigateBack: () -> Unit,
navController: NavController,
) {
navigation<CredentialManagerGraphRoute>(
startDestination = CredentialManagerListRoute,
) {
credentialManagerListDestination(
onNavigateBack = onNavigateBack,
onNavigateToGetPassword = {
navController.navigateToGetPassword()
},
onNavigateToCreatePassword = {
navController.navigateToCreatePassword()
},
onNavigateToGetPasskey = {
navController.navigateToGetPasskey()
},
onNavigateToCreatePasskey = {
navController.navigateToCreatePasskey()
},
onNavigateToGetPasswordOrPasskey = {
navController.navigateToGetPasswordOrPasskey()
},
)
getPasswordDestination(
onNavigateBack = { navController.popBackStack() },
)
createPasswordDestination(
onNavigateBack = { navController.popBackStack() },
)
getPasskeyDestination(
onNavigateBack = { navController.popBackStack() },
)
createPasskeyDestination(
onNavigateBack = { navController.popBackStack() },
)
getPasswordOrPasskeyDestination(
onNavigateBack = { navController.popBackStack() },
)
}
}
@Suppress("LongParameterList")
private fun NavGraphBuilder.credentialManagerListDestination(
onNavigateBack: () -> Unit,
onNavigateToGetPassword: () -> Unit,
onNavigateToCreatePassword: () -> Unit,
onNavigateToGetPasskey: () -> Unit,
onNavigateToCreatePasskey: () -> Unit,
onNavigateToGetPasswordOrPasskey: () -> Unit,
) {
composableWithRootPushTransitions<CredentialManagerListRoute> {
CredentialManagerListScreen(
onNavigateBack = onNavigateBack,
onNavigateToGetPassword = { onNavigateToGetPassword() },
onNavigateToCreatePassword = { onNavigateToCreatePassword() },
onNavigateToGetPasskey = { onNavigateToGetPasskey() },
onNavigateToCreatePasskey = { onNavigateToCreatePasskey() },
onNavigateToGetPasswordOrPasskey = { onNavigateToGetPasswordOrPasskey() },
)
}
}
/**
* Navigate to the Credential Manager flow.
*/
fun NavController.navigateToCredentialManagerGraph(navOptions: NavOptions? = null) {
navigate(route = CredentialManagerGraphRoute, navOptions = navOptions)
}

View File

@ -0,0 +1,170 @@
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.testharness.R
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.row.BitwardenPushRow
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* Screen displaying available Credential Manager test flows.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CredentialManagerListScreen(
onNavigateBack: () -> Unit,
onNavigateToGetPassword: () -> Unit,
onNavigateToCreatePassword: () -> Unit,
onNavigateToGetPasskey: () -> Unit,
onNavigateToCreatePasskey: () -> Unit,
onNavigateToGetPasswordOrPasskey: () -> Unit,
viewModel: CredentialManagerListViewModel = hiltViewModel(),
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
CredentialManagerListEvent.NavigateToGetPassword -> {
onNavigateToGetPassword()
}
CredentialManagerListEvent.NavigateToCreatePassword -> {
onNavigateToCreatePassword()
}
CredentialManagerListEvent.NavigateToGetPasskey -> {
onNavigateToGetPasskey()
}
CredentialManagerListEvent.NavigateToCreatePasskey -> {
onNavigateToCreatePasskey()
}
CredentialManagerListEvent.NavigateToGetPasswordOrPasskey -> {
onNavigateToGetPasswordOrPasskey()
}
CredentialManagerListEvent.NavigateBack -> {
onNavigateBack()
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
BitwardenScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.credential_manager),
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(BitwardenString.back),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(CredentialManagerListAction.BackClick) }
},
),
)
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(8.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.credential_manager_flows),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenPushRow(
text = stringResource(id = R.string.get_password),
onClick = remember(viewModel) {
{
viewModel.trySendAction(CredentialManagerListAction.GetPasswordClick)
}
},
cardStyle = CardStyle.Top(),
modifier = Modifier.standardHorizontalMargin(),
)
BitwardenPushRow(
text = stringResource(id = R.string.create_password),
onClick = remember(viewModel) {
{
viewModel.trySendAction(CredentialManagerListAction.CreatePasswordClick)
}
},
cardStyle = CardStyle.Middle(),
modifier = Modifier.standardHorizontalMargin(),
)
BitwardenPushRow(
text = stringResource(id = R.string.get_passkey),
onClick = remember(viewModel) {
{
viewModel.trySendAction(CredentialManagerListAction.GetPasskeyClick)
}
},
cardStyle = CardStyle.Middle(),
modifier = Modifier.standardHorizontalMargin(),
)
BitwardenPushRow(
text = stringResource(id = R.string.create_passkey),
onClick = remember(viewModel) {
{
viewModel.trySendAction(CredentialManagerListAction.CreatePasskeyClick)
}
},
cardStyle = CardStyle.Middle(),
modifier = Modifier.standardHorizontalMargin(),
)
BitwardenPushRow(
text = stringResource(id = R.string.get_password_or_passkey),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
CredentialManagerListAction.GetPasswordOrPasskeyClick,
)
}
},
cardStyle = CardStyle.Bottom,
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(Modifier.height(16.dp))
Spacer(Modifier.navigationBarsPadding())
}
}
}

View File

@ -0,0 +1,123 @@
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* ViewModel for Credential Manager List screen.
* Manages navigation events to different credential manager test flows.
*/
@HiltViewModel
class CredentialManagerListViewModel @Inject constructor() :
BaseViewModel<Unit, CredentialManagerListEvent, CredentialManagerListAction>(
initialState = Unit,
) {
override fun handleAction(action: CredentialManagerListAction) {
when (action) {
CredentialManagerListAction.GetPasswordClick -> handleGetPasswordClick()
CredentialManagerListAction.CreatePasswordClick -> handleCreatePasswordClick()
CredentialManagerListAction.GetPasskeyClick -> handleGetPasskeyClick()
CredentialManagerListAction.CreatePasskeyClick -> handleCreatePasskeyClick()
CredentialManagerListAction.GetPasswordOrPasskeyClick -> {
handleGetPasswordOrPasskeyClick()
}
CredentialManagerListAction.BackClick -> handleBackClick()
}
}
private fun handleGetPasswordClick() {
sendEvent(CredentialManagerListEvent.NavigateToGetPassword)
}
private fun handleCreatePasswordClick() {
sendEvent(CredentialManagerListEvent.NavigateToCreatePassword)
}
private fun handleGetPasskeyClick() {
sendEvent(CredentialManagerListEvent.NavigateToGetPasskey)
}
private fun handleCreatePasskeyClick() {
sendEvent(CredentialManagerListEvent.NavigateToCreatePasskey)
}
private fun handleGetPasswordOrPasskeyClick() {
sendEvent(CredentialManagerListEvent.NavigateToGetPasswordOrPasskey)
}
private fun handleBackClick() {
sendEvent(CredentialManagerListEvent.NavigateBack)
}
}
/**
* Events for Credential Manager List screen.
*/
sealed class CredentialManagerListEvent {
/**
* Navigate to Get Password test screen.
*/
data object NavigateToGetPassword : CredentialManagerListEvent()
/**
* Navigate to Create Password test screen.
*/
data object NavigateToCreatePassword : CredentialManagerListEvent()
/**
* Navigate to Get Passkey test screen.
*/
data object NavigateToGetPasskey : CredentialManagerListEvent()
/**
* Navigate to Create Passkey test screen.
*/
data object NavigateToCreatePasskey : CredentialManagerListEvent()
/**
* Navigate to Get Password or Passkey test screen.
*/
data object NavigateToGetPasswordOrPasskey : CredentialManagerListEvent()
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CredentialManagerListEvent()
}
/**
* Actions for Credential Manager List screen.
*/
sealed class CredentialManagerListAction {
/**
* User clicked Get Password button.
*/
data object GetPasswordClick : CredentialManagerListAction()
/**
* User clicked Create Password button.
*/
data object CreatePasswordClick : CredentialManagerListAction()
/**
* User clicked Get Passkey button.
*/
data object GetPasskeyClick : CredentialManagerListAction()
/**
* User clicked Create Passkey button.
*/
data object CreatePasskeyClick : CredentialManagerListAction()
/**
* User clicked Get Password or Passkey button.
*/
data object GetPasswordOrPasskeyClick : CredentialManagerListAction()
/**
* User clicked back button.
*/
data object BackClick : CredentialManagerListAction()
}

View File

@ -0,0 +1,36 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.getpasskey
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import kotlinx.serialization.Serializable
/**
* Get passkey test screen.
*/
@Serializable
data object GetPasskeyRoute
/**
* Add Get Passkey destination to the nav graph.
*/
fun NavGraphBuilder.getPasskeyDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions<GetPasskeyRoute> {
GetPasskeyScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Get Passkey test screen.
*/
fun NavController.navigateToGetPasskey(navOptions: NavOptions? = null) {
navigate(route = GetPasskeyRoute, navOptions = navOptions)
}

View File

@ -0,0 +1,153 @@
package com.bitwarden.testharness.ui.platform.feature.getpasskey
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.testharness.R
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* Get Passkey test screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GetPasskeyScreen(
onNavigateBack: () -> Unit,
viewModel: GetPasskeyViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
GetPasskeyEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.get_passkey_title),
scrollBehavior = scrollBehavior,
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(BitwardenString.back),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasskeyAction.BackClick) }
},
),
)
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(R.string.relying_party_id),
value = state.rpId,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(GetPasskeyAction.RpIdChanged(it)) }
},
placeholder = stringResource(R.string.rp_id_hint),
cardStyle = null,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.origin_optional),
value = state.origin,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(GetPasskeyAction.OriginChanged(it)) }
},
placeholder = stringResource(R.string.origin_hint),
cardStyle = null,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(R.string.execute),
onClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasskeyAction.ExecuteClick) }
},
isEnabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(R.string.clear),
onClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasskeyAction.ClearResultClick) }
},
isEnabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.result),
value = state.resultText,
onValueChange = { },
cardStyle = null,
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View File

@ -0,0 +1,232 @@
package com.bitwarden.testharness.ui.platform.feature.getpasskey
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MAX_STACK_TRACE_LINES = 5
private const val RESULT_SEPARATOR_LENGTH = 40
/**
* ViewModel for Get Passkey test screen.
*/
@HiltViewModel
class GetPasskeyViewModel @Inject constructor(
private val credentialTestManager: CredentialTestManager,
private val clock: Clock,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<GetPasskeyState, GetPasskeyEvent, GetPasskeyAction>(
initialState = savedStateHandle[KEY_STATE] ?: GetPasskeyState(),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: GetPasskeyAction) {
when (action) {
is GetPasskeyAction.RpIdChanged -> handleRpIdChanged(action)
is GetPasskeyAction.OriginChanged -> handleOriginChanged(action)
GetPasskeyAction.ExecuteClick -> handleExecuteClick()
GetPasskeyAction.ClearResultClick -> handleClearResultClick()
GetPasskeyAction.BackClick -> handleBackClick()
is GetPasskeyAction.Internal -> handleInternalAction(action)
}
}
private fun handleBackClick() {
sendEvent(GetPasskeyEvent.NavigateBack)
}
private fun handleRpIdChanged(action: GetPasskeyAction.RpIdChanged) {
mutableStateFlow.update {
it.copy(rpId = action.rpId)
}
}
private fun handleOriginChanged(action: GetPasskeyAction.OriginChanged) {
mutableStateFlow.update {
it.copy(origin = action.origin)
}
}
private fun handleExecuteClick() {
val currentState = stateFlow.value
val rpId = currentState.rpId
val origin = currentState.origin.takeIf { it.isNotBlank() }
if (rpId.isBlank()) {
val errorMessage = "\n${timestamp()} ⚠️ Validation Error: " +
"Relying Party ID is required\n"
mutableStateFlow.update {
it.copy(
resultText = it.resultText + errorMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
return
}
val startMessage = "\n${timestamp()} ⏳ Starting passkey authentication...\n"
mutableStateFlow.update {
it.copy(
isLoading = true,
resultText = it.resultText + startMessage,
)
}
viewModelScope.launch {
val result = credentialTestManager.getPasskey(
rpId = rpId,
origin = origin,
)
sendAction(GetPasskeyAction.Internal.CredentialResultReceived(result))
}
}
private fun handleClearResultClick() {
mutableStateFlow.update {
it.copy(resultText = "Result cleared.\n")
}
}
private fun handleInternalAction(action: GetPasskeyAction.Internal) {
when (action) {
is GetPasskeyAction.Internal.CredentialResultReceived -> {
handleCredentialResultReceived(action)
}
}
}
private fun handleCredentialResultReceived(
action: GetPasskeyAction.Internal.CredentialResultReceived,
) {
val resultMessage = when (val result = action.result) {
is CredentialTestResult.Success -> {
buildString {
append("${timestamp()} ✅ SUCCESS: Passkey authenticated successfully\n")
if (result.data != null) {
append("\n📋 Response Data:\n${result.data}\n")
}
}
}
is CredentialTestResult.Error -> {
buildString {
val errorMessage = result.exception?.message ?: "Unknown error"
append(
"${timestamp()} ❌ ERROR: Failed to authenticate passkey: " +
"$errorMessage\n",
)
if (result.exception != null) {
append("\n🔍 Exception:\n${result.exception}\n")
append("\nStack trace:\n")
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
append(" at $it\n")
}
}
}
}
CredentialTestResult.Cancelled -> {
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
}
}
mutableStateFlow.update {
it.copy(
isLoading = false,
resultText = it.resultText + resultMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
}
private fun timestamp(): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
return clock.instant()
.atZone(ZoneId.systemDefault())
.format(formatter)
.let { "[$it]" }
}
}
/**
* State for Get Passkey screen.
*/
@Parcelize
data class GetPasskeyState(
val rpId: String = "",
val origin: String = "",
val isLoading: Boolean = false,
val resultText: String = "Ready to authenticate passkey.\n\n" +
"Enter Relying Party ID, then click Execute.",
) : Parcelable
/**
* Events for Get Passkey screen.
*/
sealed class GetPasskeyEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : GetPasskeyEvent()
}
/**
* Actions for Get Passkey screen.
*/
sealed class GetPasskeyAction {
/**
* Relying Party ID input value changed.
*/
data class RpIdChanged(val rpId: String) : GetPasskeyAction()
/**
* Origin input value changed.
*/
data class OriginChanged(val origin: String) : GetPasskeyAction()
/**
* User clicked execute button to authenticate passkey.
*/
data object ExecuteClick : GetPasskeyAction()
/**
* User clicked clear button to reset results.
*/
data object ClearResultClick : GetPasskeyAction()
/**
* User clicked back button.
*/
data object BackClick : GetPasskeyAction()
/**
* Internal actions for Get Passkey screen.
*/
sealed class Internal : GetPasskeyAction() {
/**
* Credential operation result received.
*/
data class CredentialResultReceived(
val result: CredentialTestResult,
) : Internal()
}
}

View File

@ -0,0 +1,36 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.getpassword
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import kotlinx.serialization.Serializable
/**
* Get password test screen.
*/
@Serializable
data object GetPasswordRoute
/**
* Add Get Password destination to the nav graph.
*/
fun NavGraphBuilder.getPasswordDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions<GetPasswordRoute> {
GetPasswordScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Get Password test screen.
*/
fun NavController.navigateToGetPassword(navOptions: NavOptions? = null) {
navigate(route = GetPasswordRoute, navOptions = navOptions)
}

View File

@ -0,0 +1,136 @@
package com.bitwarden.testharness.ui.platform.feature.getpassword
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.testharness.R
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Get Password test screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GetPasswordScreen(
onNavigateBack: () -> Unit,
viewModel: GetPasswordViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
GetPasswordEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.get_password_title),
scrollBehavior = scrollBehavior,
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(BitwardenString.back),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasswordAction.BackClick) }
},
),
)
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.no_inputs_required),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(R.string.execute),
onClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasswordAction.ExecuteClick) }
},
isEnabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(R.string.clear),
onClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasswordAction.ClearResultClick) }
},
isEnabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.result),
value = state.resultText,
onValueChange = { },
cardStyle = null,
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View File

@ -0,0 +1,184 @@
package com.bitwarden.testharness.ui.platform.feature.getpassword
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MAX_STACK_TRACE_LINES = 5
private const val RESULT_SEPARATOR_LENGTH = 40
/**
* ViewModel for Get Password test screen.
*/
@HiltViewModel
class GetPasswordViewModel @Inject constructor(
private val credentialTestManager: CredentialTestManager,
private val clock: Clock,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<GetPasswordState, GetPasswordEvent, GetPasswordAction>(
initialState = savedStateHandle[KEY_STATE] ?: GetPasswordState(),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: GetPasswordAction) {
when (action) {
GetPasswordAction.ExecuteClick -> handleExecuteClick()
GetPasswordAction.ClearResultClick -> handleClearResultClick()
GetPasswordAction.BackClick -> handleBackClick()
is GetPasswordAction.Internal -> handleInternalAction(action)
}
}
private fun handleBackClick() {
sendEvent(GetPasswordEvent.NavigateBack)
}
private fun handleExecuteClick() {
val startMessage = "\n${timestamp()} ⏳ Starting password retrieval...\n"
mutableStateFlow.update {
it.copy(
isLoading = true,
resultText = it.resultText + startMessage,
)
}
viewModelScope.launch {
val result = credentialTestManager.getPassword()
sendAction(GetPasswordAction.Internal.CredentialResultReceived(result))
}
}
private fun handleClearResultClick() {
mutableStateFlow.update {
it.copy(resultText = "Result cleared.\n")
}
}
private fun handleInternalAction(action: GetPasswordAction.Internal) {
when (action) {
is GetPasswordAction.Internal.CredentialResultReceived -> {
handleCredentialResultReceived(action)
}
}
}
private fun handleCredentialResultReceived(
action: GetPasswordAction.Internal.CredentialResultReceived,
) {
val resultMessage = when (val result = action.result) {
is CredentialTestResult.Success -> {
buildString {
append("${timestamp()} ✅ SUCCESS: Password retrieved successfully\n")
if (result.data != null) {
append("\n📋 Response Data:\n${result.data}\n")
}
}
}
is CredentialTestResult.Error -> {
buildString {
val errorMessage = result.exception?.message ?: "Unknown error"
append("${timestamp()} ❌ ERROR: Failed to get password: $errorMessage\n")
if (result.exception != null) {
append("\n🔍 Exception:\n${result.exception}\n")
append("\nStack trace:\n")
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
append(" at $it\n")
}
}
}
}
CredentialTestResult.Cancelled -> {
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
}
}
mutableStateFlow.update {
it.copy(
isLoading = false,
resultText = it.resultText + resultMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
}
private fun timestamp(): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
return clock.instant()
.atZone(ZoneId.systemDefault())
.format(formatter)
.let { "[$it]" }
}
}
/**
* State for Get Password screen.
*/
@Parcelize
data class GetPasswordState(
val isLoading: Boolean = false,
val resultText: String = "Ready to retrieve password.\n\n" +
"Click Execute to open the password picker.",
) : Parcelable
/**
* Events for Get Password screen.
*/
sealed class GetPasswordEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : GetPasswordEvent()
}
/**
* Actions for Get Password screen.
*/
sealed class GetPasswordAction {
/**
* User clicked execute button to retrieve password.
*/
data object ExecuteClick : GetPasswordAction()
/**
* User clicked clear button to reset results.
*/
data object ClearResultClick : GetPasswordAction()
/**
* User clicked back button.
*/
data object BackClick : GetPasswordAction()
/**
* Internal actions for Get Password screen.
*/
sealed class Internal : GetPasswordAction() {
/**
* Credential operation result received.
*/
data class CredentialResultReceived(
val result: CredentialTestResult,
) : Internal()
}
}

View File

@ -0,0 +1,36 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import kotlinx.serialization.Serializable
/**
* Get Password or Passkey test screen.
*/
@Serializable
data object GetPasswordOrPasskeyRoute
/**
* Add Get Password or Passkey destination to the nav graph.
*/
fun NavGraphBuilder.getPasswordOrPasskeyDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions<GetPasswordOrPasskeyRoute> {
GetPasswordOrPasskeyScreen(
onNavigateBack = onNavigateBack,
)
}
}
/**
* Navigate to the Get Password or Passkey test screen.
*/
fun NavController.navigateToGetPasswordOrPasskey(navOptions: NavOptions? = null) {
navigate(route = GetPasswordOrPasskeyRoute, navOptions = navOptions)
}

View File

@ -0,0 +1,158 @@
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.testharness.R
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* Get Password or Passkey test screen.
*
* This screen tests the combined credential retrieval use case where both GetPasswordOption
* and GetPublicKeyCredentialOption are included in a single GetCredentialRequest. The system
* credential picker will show both passwords and passkeys, allowing the user to select either
* credential type.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GetPasswordOrPasskeyScreen(
onNavigateBack: () -> Unit,
viewModel: GetPasswordOrPasskeyViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
GetPasswordOrPasskeyEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.get_password_or_passkey_title),
scrollBehavior = scrollBehavior,
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(BitwardenString.back),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.BackClick) }
},
),
)
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(state = rememberScrollState()),
) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField(
label = stringResource(R.string.relying_party_id),
value = state.rpId,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged(it)) }
},
placeholder = stringResource(R.string.rp_id_hint),
cardStyle = null,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.origin_optional),
value = state.origin,
onValueChange = remember(viewModel) {
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged(it)) }
},
placeholder = stringResource(R.string.origin_hint),
cardStyle = null,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(R.string.execute),
onClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick) }
},
isEnabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(R.string.clear),
onClick = remember(viewModel) {
{ viewModel.trySendAction(GetPasswordOrPasskeyAction.ClearResultClick) }
},
isEnabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenTextField(
label = stringResource(R.string.result),
value = state.resultText,
onValueChange = { },
cardStyle = null,
readOnly = true,
singleLine = false,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}

View File

@ -0,0 +1,243 @@
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MAX_STACK_TRACE_LINES = 5
private const val RESULT_SEPARATOR_LENGTH = 40
/**
* ViewModel for Get Password or Passkey test screen.
*
* This ViewModel handles the combined credential retrieval test where both password and
* passkey options are included in a single GetCredentialRequest.
*/
@HiltViewModel
class GetPasswordOrPasskeyViewModel @Inject constructor(
private val credentialTestManager: CredentialTestManager,
private val clock: Clock,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<GetPasswordOrPasskeyState, GetPasswordOrPasskeyEvent, GetPasswordOrPasskeyAction>(
initialState = savedStateHandle[KEY_STATE] ?: GetPasswordOrPasskeyState(),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
}
override fun handleAction(action: GetPasswordOrPasskeyAction) {
when (action) {
is GetPasswordOrPasskeyAction.RpIdChanged -> handleRpIdChanged(action)
is GetPasswordOrPasskeyAction.OriginChanged -> handleOriginChanged(action)
GetPasswordOrPasskeyAction.ExecuteClick -> handleExecuteClick()
GetPasswordOrPasskeyAction.ClearResultClick -> handleClearResultClick()
GetPasswordOrPasskeyAction.BackClick -> handleBackClick()
is GetPasswordOrPasskeyAction.Internal -> handleInternalAction(action)
}
}
private fun handleBackClick() {
sendEvent(GetPasswordOrPasskeyEvent.NavigateBack)
}
private fun handleRpIdChanged(action: GetPasswordOrPasskeyAction.RpIdChanged) {
mutableStateFlow.update {
it.copy(rpId = action.rpId)
}
}
private fun handleOriginChanged(action: GetPasswordOrPasskeyAction.OriginChanged) {
mutableStateFlow.update {
it.copy(origin = action.origin)
}
}
private fun handleExecuteClick() {
val currentState = stateFlow.value
val rpId = currentState.rpId
val origin = currentState.origin.takeIf { it.isNotBlank() }
if (rpId.isBlank()) {
val errorMessage = "\n${timestamp()} ⚠️ Validation Error: " +
"Relying Party ID is required\n"
mutableStateFlow.update {
it.copy(
resultText = it.resultText + errorMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
return
}
val startMessage = "\n${timestamp()} ⏳ Starting credential retrieval...\n"
mutableStateFlow.update {
it.copy(
isLoading = true,
resultText = it.resultText + startMessage,
)
}
viewModelScope.launch {
val result = credentialTestManager.getPasswordOrPasskey(
rpId = rpId,
origin = origin,
)
sendAction(GetPasswordOrPasskeyAction.Internal.CredentialResultReceived(result))
}
}
private fun handleClearResultClick() {
mutableStateFlow.update {
it.copy(resultText = "Result cleared.\n")
}
}
private fun handleInternalAction(action: GetPasswordOrPasskeyAction.Internal) {
when (action) {
is GetPasswordOrPasskeyAction.Internal.CredentialResultReceived -> {
handleCredentialResultReceived(action)
}
}
}
private fun handleCredentialResultReceived(
action: GetPasswordOrPasskeyAction.Internal.CredentialResultReceived,
) {
val resultMessage = when (val result = action.result) {
is CredentialTestResult.Success -> {
buildString {
// Determine credential type from data to construct appropriate message
val successMessage = when {
result.data?.startsWith("Type: PASSWORD") == true ->
"Password retrieved successfully"
result.data?.startsWith("Type: PASSKEY") == true ->
"Passkey authenticated successfully"
else -> "Credential retrieved successfully"
}
append("${timestamp()} ✅ SUCCESS: $successMessage\n")
if (result.data != null) {
append("\n📋 Response Data:\n${result.data}\n")
}
}
}
is CredentialTestResult.Error -> {
buildString {
val errorMessage = result.exception?.message ?: "Unknown error"
append("${timestamp()} ❌ ERROR: Failed to get credential: $errorMessage\n")
if (result.exception != null) {
append("\n🔍 Exception:\n${result.exception}\n")
append("\nStack trace:\n")
result.exception.stackTrace.take(MAX_STACK_TRACE_LINES).forEach {
append(" at $it\n")
}
}
}
}
CredentialTestResult.Cancelled -> {
"${timestamp()} 🚫 CANCELLED: User cancelled the operation\n"
}
}
mutableStateFlow.update {
it.copy(
isLoading = false,
resultText = it.resultText + resultMessage + "\n" +
"".repeat(RESULT_SEPARATOR_LENGTH) + "\n",
)
}
}
private fun timestamp(): String {
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
return clock.instant()
.atZone(ZoneId.systemDefault())
.format(formatter)
.let { "[$it]" }
}
}
/**
* State for Get Password or Passkey screen.
*/
@Parcelize
data class GetPasswordOrPasskeyState(
val rpId: String = "",
val origin: String = "",
val isLoading: Boolean = false,
val resultText: String = "Ready to retrieve password or passkey.\n\n" +
"Enter Relying Party ID, then click Execute.\n" +
"System picker will show both passwords and passkeys.",
) : Parcelable
/**
* Events for Get Password or Passkey screen.
*/
sealed class GetPasswordOrPasskeyEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : GetPasswordOrPasskeyEvent()
}
/**
* Actions for Get Password or Passkey screen.
*/
sealed class GetPasswordOrPasskeyAction {
/**
* Relying Party ID input value changed.
*/
data class RpIdChanged(val rpId: String) : GetPasswordOrPasskeyAction()
/**
* Origin input value changed.
*/
data class OriginChanged(val origin: String) : GetPasswordOrPasskeyAction()
/**
* User clicked execute button to retrieve password or passkey.
*/
data object ExecuteClick : GetPasswordOrPasskeyAction()
/**
* User clicked clear button to reset results.
*/
data object ClearResultClick : GetPasswordOrPasskeyAction()
/**
* User clicked back button.
*/
data object BackClick : GetPasswordOrPasskeyAction()
/**
* Internal actions for Get Password or Passkey screen.
*/
sealed class Internal : GetPasswordOrPasskeyAction() {
/**
* Credential operation result received.
*/
data class CredentialResultReceived(
val result: CredentialTestResult,
) : Internal()
}
}

View File

@ -0,0 +1,29 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.landing
import androidx.navigation.NavGraphBuilder
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
import kotlinx.serialization.Serializable
/**
* Landing screen - main entry point showing test category options.
*/
@Serializable
data object LandingRoute
/**
* Add Landing destination to the nav graph.
*/
fun NavGraphBuilder.landingDestination(
onNavigateToAutofill: () -> Unit,
onNavigateToCredentialManager: () -> Unit,
) {
composableWithRootPushTransitions<LandingRoute> {
LandingScreen(
onNavigateToAutofill = onNavigateToAutofill,
onNavigateToCredentialManager = onNavigateToCredentialManager,
)
}
}

View File

@ -0,0 +1,124 @@
package com.bitwarden.testharness.ui.platform.feature.landing
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.testharness.R
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.row.BitwardenPushRow
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
/**
* Landing screen displaying test category options.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LandingScreen(
onNavigateToAutofill: () -> Unit,
onNavigateToCredentialManager: () -> Unit,
viewModel: LandingViewModel = hiltViewModel(),
) {
EventsEffect(viewModel) { event ->
when (event) {
LandingEvent.NavigateToAutofill -> onNavigateToAutofill()
LandingEvent.NavigateToCredentialManager -> onNavigateToCredentialManager()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
LandingScreenContent(
onAutofillClick = {
viewModel.trySendAction(LandingAction.OnAutofillClick)
},
onCredentialManagerClick = {
viewModel.trySendAction(LandingAction.OnCredentialManagerClick)
},
scrollBehavior = scrollBehavior,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LandingScreenContent(
onAutofillClick: () -> Unit,
onCredentialManagerClick: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
BitwardenScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.app_name),
scrollBehavior = scrollBehavior,
navigationIcon = null,
)
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.test_categories),
modifier = Modifier
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenPushRow(
text = stringResource(id = R.string.autofill),
onClick = onAutofillClick,
cardStyle = CardStyle.Top(),
modifier = Modifier.standardHorizontalMargin(),
)
BitwardenPushRow(
text = stringResource(id = R.string.credential_manager),
onClick = onCredentialManagerClick,
cardStyle = CardStyle.Bottom,
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Preview(showBackground = true)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LandingScreenPreview() {
LandingScreenContent(
onAutofillClick = {},
onCredentialManagerClick = {},
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
)
}

View File

@ -0,0 +1,65 @@
package com.bitwarden.testharness.ui.platform.feature.landing
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* ViewModel for the Landing screen.
*
* Manages navigation events for the test category selection screen following UDF patterns.
*/
@HiltViewModel
class LandingViewModel @Inject constructor() : BaseViewModel<Unit, LandingEvent, LandingAction>(
initialState = Unit,
) {
override fun handleAction(action: LandingAction) {
when (action) {
LandingAction.OnAutofillClick -> {
handleAutofillClicked()
}
LandingAction.OnCredentialManagerClick -> {
handleCredentialManagerClicked()
}
}
}
private fun handleAutofillClicked() {
sendEvent(LandingEvent.NavigateToAutofill)
}
private fun handleCredentialManagerClicked() {
sendEvent(LandingEvent.NavigateToCredentialManager)
}
}
/**
* Models events emitted by the Landing screen.
*/
sealed class LandingEvent {
/**
* Navigates to the Autofill test flow.
*/
data object NavigateToAutofill : LandingEvent()
/**
* Navigates to the Credential Manager test flow.
*/
data object NavigateToCredentialManager : LandingEvent()
}
/**
* Models actions for the Landing screen.
*/
sealed class LandingAction {
/**
* Indicates the user clicked the Autofill test option.
*/
data object OnAutofillClick : LandingAction()
/**
* Indicates the user clicked the Credential Manager test option.
*/
data object OnCredentialManagerClick : LandingAction()
}

View File

@ -0,0 +1,25 @@
@file:OmitFromCoverage
package com.bitwarden.testharness.ui.platform.feature.rootnav
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.bitwarden.annotation.OmitFromCoverage
import kotlinx.serialization.Serializable
/**
* The type-safe route for the root navigation screen.
*/
@Serializable
data object RootNavigationRoute
/**
* Add the root navigation screen to the nav graph.
*/
fun NavGraphBuilder.rootNavDestination(
onSplashScreenRemoved: () -> Unit,
) {
composable<RootNavigationRoute> {
RootNavScreen(onSplashScreenRemoved = onSplashScreenRemoved)
}
}

View File

@ -0,0 +1,58 @@
package com.bitwarden.testharness.ui.platform.feature.rootnav
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.bitwarden.testharness.ui.platform.feature.autofill.autofillGraph
import com.bitwarden.testharness.ui.platform.feature.autofill.navigateToAutofillGraph
import com.bitwarden.testharness.ui.platform.feature.credentialmanager.credentialManagerGraph
import com.bitwarden.testharness.ui.platform.feature.credentialmanager.navigateToCredentialManagerGraph
import com.bitwarden.testharness.ui.platform.feature.landing.LandingRoute
import com.bitwarden.testharness.ui.platform.feature.landing.landingDestination
/**
* Controls the root level [NavHost] for the test harness app.
*/
@Suppress("LongMethod")
@Composable
fun RootNavScreen(
viewModel: RootNavViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
onSplashScreenRemoved: () -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
LaunchedEffect(state) {
if (state != RootNavState.Splash) {
onSplashScreenRemoved()
}
}
NavHost(
navController = navController,
startDestination = LandingRoute,
) {
landingDestination(
onNavigateToAutofill = {
navController.navigateToAutofillGraph()
},
onNavigateToCredentialManager = {
navController.navigateToCredentialManagerGraph()
},
)
autofillGraph(
onNavigateBack = { navController.popBackStack() },
)
credentialManagerGraph(
onNavigateBack = { navController.popBackStack() },
navController = navController,
)
}
}

View File

@ -0,0 +1,48 @@
package com.bitwarden.testharness.ui.platform.feature.rootnav
import android.os.Parcelable
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
/**
* Manages root level navigation state for the test harness application.
*/
@HiltViewModel
class RootNavViewModel @Inject constructor() : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState.Splash,
) {
init {
mutableStateFlow.update { RootNavState.Landing }
}
override fun handleAction(action: RootNavAction) {
// Handle actions if necessary
}
}
/**
* Models root level navigation destinations for the test harness.
*/
sealed class RootNavState : Parcelable {
/**
* App should show splash nav graph.
*/
@Parcelize
data object Splash : RootNavState()
/**
* App should show the main landing screen.
*/
@Parcelize
data object Landing : RootNavState()
}
/**
* Models root navigation actions.
*/
sealed class RootNavAction

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple placeholder: letter "T" for Test Harness -->
<path
android:fillColor="#FF6200EE"
android:pathData="M32,32h44v8h-18v36h-8v-36h-18z"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/white"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/white"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Test harness acts as a credential consumer client, not a provider.
Digital Asset Links references Bitwarden's assetlinks to satisfy Credential Manager lint requirement.
No actual asset verification is performed as testharness doesn't serve as a credential provider.
-->
<string name="asset_statements" translatable="false">
[{
\"include\": \"https://bitwarden.com/.well-known/assetlinks.json\"
}]
</string>
</resources>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Credential Manager Test Harness</string>
<string name="test_categories">Test Categories</string>
<string name="autofill">Autofill</string>
<string name="credential_manager">Credential Manager</string>
<string name="autofill_testing">Autofill Testing</string>
<string name="autofill_coming_soon">Autofill testing functionality coming soon</string>
<string name="credential_manager_flows">Credential Manager Flows</string>
<string name="create_password">Create Password</string>
<string name="get_password">Get Password</string>
<string name="create_passkey">Create Passkey</string>
<string name="get_passkey">Get Passkey</string>
<string name="get_password_or_passkey">Get Password or Passkey</string>
<string name="create_password_title">Create Password</string>
<string name="get_password_title">Get Password</string>
<string name="create_passkey_title">Create Passkey</string>
<string name="get_passkey_title">Get Passkey</string>
<string name="get_password_or_passkey_title">Get Password or Passkey</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="origin_optional">Origin (optional)</string>
<string name="origin_hint">e.g., https://example.com</string>
<string name="relying_party_id">Relying Party ID</string>
<string name="rp_id_hint">e.g., example.com</string>
<!-- Action buttons -->
<string name="execute">Execute</string>
<string name="clear">Clear</string>
<!-- Result area -->
<string name="result">Result</string>
<string name="no_inputs_required">No inputs required. Click Execute to open credential picker.</string>
</resources>

View File

@ -0,0 +1,26 @@
package com.bitwarden.testharness
import app.cash.turbine.test
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class MainViewModelTest {
private lateinit var viewModel: MainViewModel
@BeforeEach
fun setup() {
viewModel = MainViewModel()
}
@Test
fun `initial state has default theme`() = runTest {
viewModel.stateFlow.test {
val state = awaitItem()
assertEquals(AppTheme.DEFAULT, state.theme)
}
}
}

View File

@ -0,0 +1,703 @@
package com.bitwarden.testharness.data.manager
import android.app.Application
import androidx.credentials.CreatePasswordResponse
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CreatePublicKeyCredentialResponse
import androidx.credentials.Credential
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialResponse
import androidx.credentials.PasswordCredential
import androidx.credentials.PublicKeyCredential
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import com.bitwarden.testharness.data.model.CredentialTestResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.unmockkConstructor
import kotlinx.coroutines.test.runTest
import org.json.JSONObject
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class CredentialTestManagerTest {
private lateinit var application: Application
private lateinit var credentialManager: CredentialManager
private lateinit var manager: CredentialTestManagerImpl
@BeforeEach
fun setup() {
application = mockk(relaxed = true)
credentialManager = mockk()
manager = CredentialTestManagerImpl(
application = application,
credentialManager = credentialManager,
)
}
@Nested
inner class CreatePassword {
@Test
fun `success without origin returns Success`() = runTest {
val response = CreatePasswordResponse()
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.createPassword("user@example.com", "SecurePass123!", null)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("user@example.com") == true)
assertTrue(result.data?.contains("Origin: null") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `success with origin returns Success`() = runTest {
val response = CreatePasswordResponse()
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.createPassword(
username = "user@example.com",
password = "SecurePass123!",
origin = "https://example.com",
)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("https://example.com") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `cancellation exception returns Cancelled`() = runTest {
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} throws CreateCredentialCancellationException("User cancelled")
val result = manager.createPassword("user", "pass", null)
assertTrue(result is CredentialTestResult.Cancelled)
}
@Test
fun `create exception returns Error`() = runTest {
val exception = mockk<CreateCredentialException>(relaxed = true) {
every { message } returns "Failed to create"
}
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} throws exception
val result = manager.createPassword("user", "pass", null)
when (result) {
is CredentialTestResult.Error -> {
assertEquals(exception, result.exception)
}
else -> fail("Expected Error but got $result")
}
}
@Test
fun `unexpected response type returns Error`() = runTest {
val unexpectedResponse = mockk<CreatePublicKeyCredentialResponse>()
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} returns unexpectedResponse
val result = manager.createPassword("user", "pass", null)
when (result) {
is CredentialTestResult.Error -> {
assertTrue(result.exception is IllegalStateException)
assertTrue(
result.exception?.message?.contains("Unexpected response type") == true,
)
}
else -> fail("Expected Error but got $result")
}
}
}
@Nested
inner class GetPassword {
@Test
fun `success with PasswordCredential returns Success`() = runTest {
val passwordCredential = mockk<PasswordCredential> {
every { id } returns "user@example.com"
every { password } returns "SecurePass123!"
}
val response = mockk<GetCredentialResponse> {
every { credential } returns passwordCredential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPassword()
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("user@example.com") == true)
assertTrue(result.data?.contains("SecurePass123!") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `cancellation exception returns Cancelled`() = runTest {
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} throws GetCredentialCancellationException("User cancelled")
val result = manager.getPassword()
assertTrue(result is CredentialTestResult.Cancelled)
}
@Test
fun `get exception returns Error`() = runTest {
val exception = mockk<GetCredentialException>(relaxed = true) {
every { message } returns "Failed to get credential"
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} throws exception
val result = manager.getPassword()
when (result) {
is CredentialTestResult.Error -> {
assertEquals(exception, result.exception)
}
else -> fail("Expected Error but got $result")
}
}
@Test
fun `unexpected credential type returns Error`() = runTest {
val unexpectedCredential = mockk<PublicKeyCredential>()
val response = mockk<GetCredentialResponse> {
every { credential } returns unexpectedCredential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPassword()
when (result) {
is CredentialTestResult.Error -> {
assertTrue(result.exception is IllegalStateException)
assertTrue(
result.exception?.message?.contains("Unexpected credential type") == true,
)
}
else -> fail("Expected Error but got $result")
}
}
}
@Nested
inner class CreatePasskey {
@BeforeEach
fun setupConstructorMock() {
// Mock CreatePublicKeyCredentialRequest constructor to bypass JSON validation
mockkConstructor(JSONObject::class)
every { anyConstructed<JSONObject>().getJSONObject(any()) } returns mockk {
every { getString("name") } returns "user"
every { getString("displayName") } returns "user"
every { isNull("displayName") } returns false
}
}
@AfterEach
fun teardownConstructorMock() {
unmockkConstructor(CreatePublicKeyCredentialRequest::class)
}
@Test
fun `success without origin returns Success`() = runTest {
val response = mockk<CreatePublicKeyCredentialResponse> {
every { registrationResponseJson } returns """{"id":"test-credential-id"}"""
}
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.createPasskey(
username = "user@example.com",
rpId = "example.com",
origin = null,
)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("example.com") == true)
assertTrue(result.data?.contains("Origin: null") == true)
assertTrue(result.data?.contains("test-credential-id") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `success with origin returns Success`() = runTest {
val response = mockk<CreatePublicKeyCredentialResponse> {
every { registrationResponseJson } returns """{"id":"test-credential-id"}"""
}
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.createPasskey(
username = "user@example.com",
rpId = "example.com",
origin = "https://example.com",
)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("https://example.com") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `cancellation exception returns Cancelled`() = runTest {
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} throws CreateCredentialCancellationException("User cancelled")
val result = manager.createPasskey("user", "example.com", null)
assertTrue(result is CredentialTestResult.Cancelled)
}
@Test
fun `create exception returns Error`() = runTest {
val exception = mockk<CreateCredentialException>(relaxed = true) {
every { message } returns "Failed to create passkey"
}
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} throws exception
val result = manager.createPasskey("user", "example.com", null)
when (result) {
is CredentialTestResult.Error -> {
assertEquals(exception, result.exception)
}
else -> fail("Expected Error but got $result")
}
}
@Test
fun `unexpected response type returns Error`() = runTest {
val unexpectedResponse = CreatePasswordResponse()
coEvery {
credentialManager.createCredential(
context = any(),
request = any(),
)
} returns unexpectedResponse
val result = manager.createPasskey("user", "example.com", null)
when (result) {
is CredentialTestResult.Error -> {
assertTrue(result.exception is IllegalStateException)
assertTrue(
result.exception?.message?.contains("Unexpected response type") == true,
)
}
else -> fail("Expected Error but got $result")
}
}
}
@Nested
inner class GetPasskey {
@Test
fun `success without origin returns Success`() = runTest {
val credential = mockk<PublicKeyCredential> {
every { authenticationResponseJson } returns """{"id":"auth-response"}"""
}
val response = mockk<GetCredentialResponse> {
every { this@mockk.credential } returns credential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPasskey(rpId = "example.com", origin = null)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("example.com") == true)
assertTrue(result.data?.contains("Origin: null") == true)
assertTrue(result.data?.contains("auth-response") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `success with origin returns Success`() = runTest {
val credential = mockk<PublicKeyCredential> {
every { authenticationResponseJson } returns """{"id":"auth-response"}"""
}
val response = mockk<GetCredentialResponse> {
every { this@mockk.credential } returns credential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPasskey(
rpId = "example.com",
origin = "https://example.com",
)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("https://example.com") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `cancellation exception returns Cancelled`() = runTest {
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} throws GetCredentialCancellationException("User cancelled")
val result = manager.getPasskey("example.com", null)
assertTrue(result is CredentialTestResult.Cancelled)
}
@Test
fun `get exception returns Error`() = runTest {
val exception = mockk<GetCredentialException>(relaxed = true) {
every { message } returns "Failed to authenticate"
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} throws exception
val result = manager.getPasskey("example.com", null)
when (result) {
is CredentialTestResult.Error -> {
assertEquals(exception, result.exception)
}
else -> fail("Expected Error but got $result")
}
}
@Test
fun `unexpected credential type returns Error`() = runTest {
val unexpectedCredential = mockk<PasswordCredential>()
val response = mockk<GetCredentialResponse> {
every { credential } returns unexpectedCredential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPasskey("example.com", null)
when (result) {
is CredentialTestResult.Error -> {
assertTrue(result.exception is IllegalStateException)
assertTrue(
result.exception?.message?.contains("Unexpected credential type") == true,
)
}
else -> fail("Expected Error but got $result")
}
}
}
@Nested
inner class GetPasswordOrPasskey {
@Test
fun `success with PasswordCredential without origin returns Success`() = runTest {
val credential = mockk<PasswordCredential> {
every { id } returns "user@example.com"
every { password } returns "SecurePass123!"
}
val response = mockk<GetCredentialResponse> {
every { this@mockk.credential } returns credential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPasswordOrPasskey(rpId = "example.com", origin = null)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("Type: PASSWORD") == true)
assertTrue(result.data?.contains("user@example.com") == true)
assertTrue(result.data?.contains("SecurePass123!") == true)
assertTrue(result.data?.contains("Origin: null") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `success with PasswordCredential with origin returns Success`() = runTest {
val credential = mockk<PasswordCredential> {
every { id } returns "user@example.com"
every { password } returns "SecurePass123!"
}
val response = mockk<GetCredentialResponse> {
every { this@mockk.credential } returns credential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPasswordOrPasskey(
rpId = "example.com",
origin = "https://example.com",
)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("https://example.com") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `success with PublicKeyCredential without origin returns Success`() = runTest {
val credential = mockk<PublicKeyCredential> {
every { authenticationResponseJson } returns """{"id":"passkey-auth"}"""
}
val response = mockk<GetCredentialResponse> {
every { this@mockk.credential } returns credential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPasswordOrPasskey(rpId = "example.com", origin = null)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("Type: PASSKEY") == true)
assertTrue(result.data?.contains("Origin: null") == true)
assertTrue(result.data?.contains("passkey-auth") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `success with PublicKeyCredential with origin returns Success`() = runTest {
val credential = mockk<PublicKeyCredential> {
every { authenticationResponseJson } returns """{"id":"passkey-auth"}"""
}
val response = mockk<GetCredentialResponse> {
every { this@mockk.credential } returns credential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPasswordOrPasskey(
rpId = "example.com",
origin = "https://example.com",
)
when (result) {
is CredentialTestResult.Success -> {
assertTrue(result.data?.contains("https://example.com") == true)
}
else -> fail("Expected Success but got $result")
}
}
@Test
fun `cancellation exception returns Cancelled`() = runTest {
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} throws GetCredentialCancellationException("User cancelled")
val result = manager.getPasswordOrPasskey("example.com", null)
assertTrue(result is CredentialTestResult.Cancelled)
}
@Test
fun `get exception returns Error`() = runTest {
val exception = mockk<GetCredentialException>(relaxed = true) {
every { message } returns "Failed to get credential"
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} throws exception
val result = manager.getPasswordOrPasskey("example.com", null)
when (result) {
is CredentialTestResult.Error -> {
assertEquals(exception, result.exception)
}
else -> fail("Expected Error but got $result")
}
}
@Test
fun `unexpected credential type returns Error`() = runTest {
val unexpectedCredential = mockk<Credential>()
val response = mockk<GetCredentialResponse> {
every { credential } returns unexpectedCredential
}
coEvery {
credentialManager.getCredential(
context = any(),
request = any(),
)
} returns response
val result = manager.getPasswordOrPasskey("example.com", null)
when (result) {
is CredentialTestResult.Error -> {
assertTrue(result.exception is IllegalStateException)
assertTrue(
result.exception?.message?.contains("Unexpected credential type") == true,
)
}
else -> fail("Expected Error but got $result")
}
}
}
}

View File

@ -0,0 +1,67 @@
package com.bitwarden.testharness.ui.platform.feature.autofill
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests for the [AutofillPlaceholderScreen] composable in the testharness module.
*
* Verifies that the placeholder screen displays correctly and handles back navigation
* following UDF patterns.
*/
class AutofillPlaceholderScreenTest : BaseComposeTest() {
private var haveCalledOnNavigateBack = false
private val mutableEventFlow = MutableSharedFlow<AutofillPlaceholderEvent>(replay = 1)
private val mutableStateFlow = MutableStateFlow(Unit)
private val viewModel = mockk<AutofillPlaceholderViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setTestContent {
AutofillPlaceholderScreen(
onNavigateBack = { haveCalledOnNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `placeholder text is displayed on screen`() {
composeTestRule
.onNodeWithText("Autofill testing functionality coming soon")
.assertIsDisplayed()
}
@Test
fun `back button click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify {
viewModel.trySendAction(AutofillPlaceholderAction.BackClick)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(AutofillPlaceholderEvent.NavigateBack)
assertTrue(haveCalledOnNavigateBack)
}
}

View File

@ -0,0 +1,24 @@
package com.bitwarden.testharness.ui.platform.feature.autofill
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AutofillPlaceholderViewModelTest : BaseViewModelTest() {
@Test
fun `BackClick action emits NavigateBack event`() = runTest {
val viewModel = AutofillPlaceholderViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(AutofillPlaceholderAction.BackClick)
assertEquals(
AutofillPlaceholderEvent.NavigateBack,
awaitItem(),
)
}
}
}

View File

@ -0,0 +1,111 @@
package com.bitwarden.testharness.ui.platform.feature.createpasskey
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests for the [CreatePasskeyScreen] composable in the testharness module.
*
* Verifies that the Create Passkey screen displays input fields, buttons, and result area
* correctly and handles user interactions appropriately following UDF patterns.
*/
class CreatePasskeyScreenTest : BaseComposeTest() {
private var haveCalledOnNavigateBack = false
private val mutableEventFlow = MutableSharedFlow<CreatePasskeyEvent>(replay = 1)
private val mutableStateFlow = MutableStateFlow(
CreatePasskeyState(
username = "",
rpId = "",
origin = "",
isLoading = false,
resultText = "Ready to create passkey credential.\n\n" +
"Enter username and Relying Party ID, then click Execute.",
),
)
private val viewModel = mockk<CreatePasskeyViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setTestContent {
CreatePasskeyScreen(
onNavigateBack = { haveCalledOnNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `username field label is displayed`() {
composeTestRule
.onNodeWithText("Username")
.assertIsDisplayed()
}
@Test
fun `relying party id field label is displayed`() {
composeTestRule
.onNodeWithText("Relying Party ID")
.assertIsDisplayed()
}
@Test
fun `origin optional field label is displayed`() {
composeTestRule
.onNodeWithText("Origin (optional)")
.assertIsDisplayed()
}
@Test
fun `execute button label is displayed`() {
composeTestRule
.onNodeWithText("Execute")
.assertIsDisplayed()
}
@Test
fun `clear button label is displayed`() {
composeTestRule
.onNodeWithText("Clear")
.assertIsDisplayed()
}
@Test
fun `result field label is displayed`() {
composeTestRule
.onNodeWithText("Result")
.assertExists()
}
@Test
fun `back button click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify {
viewModel.trySendAction(CreatePasskeyAction.BackClick)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(CreatePasskeyEvent.NavigateBack)
assertTrue(haveCalledOnNavigateBack)
}
}

View File

@ -0,0 +1,500 @@
package com.bitwarden.testharness.ui.platform.feature.createpasskey
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class CreatePasskeyViewModelTest : BaseViewModelTest() {
private val mockCredentialTestManager = mockk<CredentialTestManager>()
private val savedStateHandle = SavedStateHandle()
private fun createViewModel(): CreatePasskeyViewModel {
return CreatePasskeyViewModel(
credentialTestManager = mockCredentialTestManager,
savedStateHandle = savedStateHandle,
clock = CLOCK,
)
}
@Test
fun `initial state should have default values`() = runTest {
val viewModel = createViewModel()
val state = viewModel.stateFlow.value
assertEquals("", state.username)
assertEquals("", state.rpId)
assertEquals("", state.origin)
assertFalse(state.isLoading)
assertTrue(state.resultText.contains("Ready to create passkey credential"))
}
@Test
fun `UsernameChanged action updates username in state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
val updatedState = viewModel.stateFlow.value
assertEquals("test-user", updatedState.username)
}
@Test
fun `RpIdChanged action updates rpId in state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
val updatedState = viewModel.stateFlow.value
assertEquals("example.com", updatedState.rpId)
}
@Test
fun `OriginChanged action updates origin in state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasskeyAction.OriginChanged("https://example.com"))
val updatedState = viewModel.stateFlow.value
assertEquals("https://example.com", updatedState.origin)
}
@Test
fun `ExecuteClick action with blank username shows validation error`() = runTest {
val viewModel = createViewModel()
// Set rpId but leave username blank
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("Validation Error"))
assertTrue(
resultState.resultText.contains("Username and Relying Party ID are required"),
)
// Verify manager was never called
coVerify(exactly = 0) {
mockCredentialTestManager.createPasskey(any(), any(), any())
}
}
@Test
fun `ExecuteClick action with blank rpId shows validation error`() = runTest {
val viewModel = createViewModel()
// Set username but leave rpId blank
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("Validation Error"))
assertTrue(
resultState.resultText.contains("Username and Relying Party ID are required"),
)
// Verify manager was never called
coVerify(exactly = 0) {
mockCredentialTestManager.createPasskey(any(), any(), any())
}
}
@Test
fun `ExecuteClick action with blank username and rpId shows validation error`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("Validation Error"))
assertTrue(
resultState.resultText.contains("Username and Relying Party ID are required"),
)
// Verify manager was never called
coVerify(exactly = 0) {
mockCredentialTestManager.createPasskey(any(), any(), any())
}
}
@Test
fun `ExecuteClick action triggers passkey creation with required fields`() = runTest {
coEvery {
mockCredentialTestManager.createPasskey(
username = "test-user",
rpId = "example.com",
origin = null,
)
} returns CredentialTestResult.Success(
data = "test-passkey-data",
)
val viewModel = createViewModel()
// Set required fields
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
// Wait for async operation to complete
testScheduler.advanceUntilIdle()
coVerify {
mockCredentialTestManager.createPasskey(
username = "test-user",
rpId = "example.com",
origin = null,
)
}
}
@Test
fun `ExecuteClick action triggers passkey creation with origin when provided`() = runTest {
coEvery {
mockCredentialTestManager.createPasskey(
username = "test-user",
rpId = "example.com",
origin = "https://example.com",
)
} returns CredentialTestResult.Success(
data = "test-passkey-data",
)
val viewModel = createViewModel()
// Set all fields including optional origin
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.OriginChanged("https://example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
// Wait for async operation to complete
testScheduler.advanceUntilIdle()
coVerify {
mockCredentialTestManager.createPasskey(
username = "test-user",
rpId = "example.com",
origin = "https://example.com",
)
}
}
@Test
fun `ExecuteClick action with blank origin passes null to manager`() = runTest {
coEvery {
mockCredentialTestManager.createPasskey(
username = "test-user",
rpId = "example.com",
origin = null,
)
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
// Set required fields and explicitly blank origin
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.OriginChanged(" ")) // Whitespace only
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
// Wait for async operation to complete
testScheduler.advanceUntilIdle()
coVerify {
mockCredentialTestManager.createPasskey(
username = "test-user",
rpId = "example.com",
origin = null,
)
}
}
@Test
fun `ExecuteClick action sets loading state to true`() = runTest {
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} coAnswers {
kotlinx.coroutines.delay(100)
CredentialTestResult.Success()
}
val viewModel = createViewModel()
viewModel.stateFlow.test {
skipItems(1) // Skip initial state
// Set required fields
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
awaitItem() // Consume username change state
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
awaitItem() // Consume rpId change state
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
// Should receive loading state
val loadingState = awaitItem()
assertTrue(loadingState.isLoading)
assertTrue(loadingState.resultText.contains("Creating passkey credential"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `Success result updates state with success message`() = runTest {
val successMessage = "Passkey created successfully"
val testData = "credential_id: ABC123"
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} returns CredentialTestResult.Success(
data = testData,
)
val viewModel = createViewModel()
// Set required fields
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(successMessage))
assertTrue(resultState.resultText.contains(testData))
}
@Test
fun `Error result updates state with error message`() = runTest {
val exception = Exception("Invalid relying party")
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} returns CredentialTestResult.Error(
exception = exception,
)
val viewModel = createViewModel()
// Set required fields
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains("Invalid relying party"))
}
@Test
fun `Cancelled result updates state with cancelled message`() = runTest {
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} returns CredentialTestResult.Cancelled
val viewModel = createViewModel()
// Set required fields
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("CANCELLED"))
assertTrue(resultState.resultText.contains("User cancelled"))
}
@Test
fun `ClearResultClick action resets result text`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasskeyAction.ClearResultClick)
val clearedState = viewModel.stateFlow.value
assertEquals("Result cleared.\n", clearedState.resultText)
}
@Test
fun `ClearResultClick preserves input field values`() = runTest {
val viewModel = createViewModel()
// Set field values
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.OriginChanged("https://example.com"))
viewModel.trySendAction(CreatePasskeyAction.ClearResultClick)
val clearedState = viewModel.stateFlow.value
assertEquals("Result cleared.\n", clearedState.resultText)
assertEquals("test-user", clearedState.username)
assertEquals("example.com", clearedState.rpId)
assertEquals("https://example.com", clearedState.origin)
}
@Test
fun `Error result without exception does not crash`() = runTest {
val errorMessage = "Unknown error"
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} returns CredentialTestResult.Error(
exception = null,
)
val viewModel = createViewModel()
// Set required fields
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains(errorMessage))
}
@Test
fun `Success result without data does not crash`() = runTest {
val successMessage = "Passkey created"
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} returns CredentialTestResult.Success(
data = null,
)
val viewModel = createViewModel()
// Set required fields
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(successMessage))
}
@Test
fun `state is persisted to SavedStateHandle`() = runTest {
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(CreatePasskeyAction.OriginChanged("https://example.com"))
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
// Wait for state to update
testScheduler.advanceUntilIdle()
val persistedState = savedStateHandle.get<CreatePasskeyState>("state")
assertEquals(viewModel.stateFlow.value, persistedState)
}
@Test
fun `multiple field updates are all persisted`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("user1"))
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("user2"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
// Wait for state updates
testScheduler.advanceUntilIdle()
val persistedState = savedStateHandle.get<CreatePasskeyState>("state")
assertEquals("user2", persistedState?.username)
assertEquals("example.com", persistedState?.rpId)
}
@Test
fun `validation error message appends to existing result text`() = runTest {
val viewModel = createViewModel()
val initialText = viewModel.stateFlow.value.resultText
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
val errorState = viewModel.stateFlow.value
assertTrue(errorState.resultText.contains(initialText))
assertTrue(errorState.resultText.contains("Validation Error"))
}
@Test
fun `multiple operations append to result text`() = runTest {
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} returns CredentialTestResult.Success(
data = "First operation",
)
val viewModel = createViewModel()
// Set required fields
viewModel.trySendAction(CreatePasskeyAction.UsernameChanged("test-user"))
viewModel.trySendAction(CreatePasskeyAction.RpIdChanged("example.com"))
// First operation
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
coEvery {
mockCredentialTestManager.createPasskey(any(), any(), any())
} returns CredentialTestResult.Success(
data = "Second operation",
)
// Second operation
viewModel.trySendAction(CreatePasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
val finalState = viewModel.stateFlow.value
assertTrue(finalState.resultText.contains("First operation"))
assertTrue(finalState.resultText.contains("Second operation"))
}
@Test
fun `BackClick action sends NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasskeyAction.BackClick)
viewModel.eventFlow.test {
assertEquals(
CreatePasskeyEvent.NavigateBack,
awaitItem(),
)
}
}
}
private val CLOCK = Clock.fixed(
Instant.parse("2024-10-12T12:00:00Z"),
ZoneOffset.UTC,
)

View File

@ -0,0 +1,103 @@
package com.bitwarden.testharness.ui.platform.feature.createpassword
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests for the [CreatePasswordScreen] composable in the testharness module.
*
* Verifies that the Create Password screen displays all UI elements correctly
* and handles user interactions appropriately following UDF patterns.
*/
class CreatePasswordScreenTest : BaseComposeTest() {
private var haveCalledOnNavigateBack = false
private val mutableEventFlow = MutableSharedFlow<CreatePasswordEvent>(replay = 1)
private val mutableStateFlow = MutableStateFlow(
CreatePasswordState(
username = "",
password = "",
isLoading = false,
resultText = "Ready to create password credential.\n\n" +
"Enter username and password, then click Execute.",
),
)
private val viewModel = mockk<CreatePasswordViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setTestContent {
CreatePasswordScreen(
onNavigateBack = { haveCalledOnNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `username field label is displayed`() {
composeTestRule
.onNodeWithText("Username")
.assertIsDisplayed()
}
@Test
fun `password field label is displayed`() {
composeTestRule
.onNodeWithText("Password")
.assertIsDisplayed()
}
@Test
fun `execute button label is displayed`() {
composeTestRule
.onNodeWithText("Execute")
.assertIsDisplayed()
}
@Test
fun `clear button label is displayed`() {
composeTestRule
.onNodeWithText("Clear")
.assertIsDisplayed()
}
@Test
fun `result field label is displayed`() {
composeTestRule
.onNodeWithText("Result")
.assertIsDisplayed()
}
@Test
fun `back button click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify {
viewModel.trySendAction(CreatePasswordAction.BackClick)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(CreatePasswordEvent.NavigateBack)
assertTrue(haveCalledOnNavigateBack)
}
}

View File

@ -0,0 +1,471 @@
package com.bitwarden.testharness.ui.platform.feature.createpassword
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class CreatePasswordViewModelTest : BaseViewModelTest() {
private val mockCredentialTestManager = mockk<CredentialTestManager>()
@Test
fun `initial state should have default values`() = runTest {
val viewModel = createViewModel()
val state = viewModel.stateFlow.value
assertFalse(state.isLoading)
assertEquals("", state.username)
assertEquals("", state.password)
assertTrue(state.resultText.contains("Ready to create password credential"))
}
@Test
fun `UsernameChanged action updates username state`() = runTest {
val viewModel = createViewModel()
val testUsername = "test@example.com"
viewModel.stateFlow.test {
// Verify the username is initially empty
assertEquals("", awaitItem().username)
viewModel.trySendAction(CreatePasswordAction.UsernameChanged(testUsername))
val updatedState = awaitItem()
assertEquals(testUsername, updatedState.username)
}
}
@Test
fun `PasswordChanged action updates password state`() = runTest {
val viewModel = createViewModel()
val testPassword = "SecurePassword123!"
viewModel.stateFlow.test {
// Verify the password is initially empty
assertEquals("", awaitItem().password)
viewModel.trySendAction(CreatePasswordAction.PasswordChanged(testPassword))
val updatedState = awaitItem()
assertEquals(testPassword, updatedState.password)
}
}
@Test
fun `ExecuteClick with blank username shows validation error`() = runTest {
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = "",
password = "password123",
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(
resultState.resultText.contains(
"Validation Error: Username and password are required",
),
)
}
@Test
fun `ExecuteClick with blank password shows validation error`() = runTest {
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = "test@example.com",
password = "",
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(
resultState.resultText.contains(
"Validation Error: Username and password are required",
),
)
}
@Test
fun `ExecuteClick with blank username and password shows validation error`() = runTest {
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = "",
password = "",
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("Validation Error"))
assertTrue(resultState.resultText.contains("Username and password are required"))
}
@Test
fun `ExecuteClick with valid inputs triggers password creation`() = runTest {
val testUsername = "test@example.com"
val testPassword = "SecurePassword123!"
coEvery {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
} returns CredentialTestResult.Success(
data = "test-credential-data",
)
val viewModel = createViewModel()
// Set username and password
viewModel.trySendAction(CreatePasswordAction.UsernameChanged(testUsername))
viewModel.trySendAction(CreatePasswordAction.PasswordChanged(testPassword))
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
coVerify {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
}
}
@Test
fun `ExecuteClick action sets loading state to true`() = runTest {
val testUsername = "test@example.com"
val testPassword = "SecurePassword123!"
coEvery {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
} returns CredentialTestResult.Success()
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = testUsername,
password = testPassword,
),
)
viewModel.stateFlow.test {
skipItems(1)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
// Should receive loading state
val loadingState = awaitItem()
assertTrue(loadingState.isLoading)
assertTrue(loadingState.resultText.contains("Creating password credential"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `Success result updates state with success message`() = runTest {
val testUsername = "test@example.com"
val testPassword = "SecurePassword123!"
val testData = "credential-id: 12345"
coEvery {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
} returns CredentialTestResult.Success(
data = testData,
)
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = testUsername,
password = testPassword,
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(testData))
}
@Test
fun `Error result updates state with error message`() = runTest {
val testUsername = "test@example.com"
val testPassword = "SecurePassword123!"
val exception = Exception("Network timeout")
coEvery {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
} returns CredentialTestResult.Error(
exception = exception,
)
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = testUsername,
password = testPassword,
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains("Network timeout"))
}
@Test
fun `Cancelled result updates state with cancelled message`() = runTest {
val testUsername = "test@example.com"
val testPassword = "SecurePassword123!"
coEvery {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
} returns CredentialTestResult.Cancelled
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = testUsername,
password = testPassword,
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("CANCELLED"))
assertTrue(resultState.resultText.contains("User cancelled"))
}
@Test
fun `ClearResultClick action resets result text`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasswordAction.ClearResultClick)
val clearedState = viewModel.stateFlow.value
assertEquals("Result cleared.\n", clearedState.resultText)
}
@Test
fun `Error result without exception does not crash`() = runTest {
val testUsername = "test@example.com"
val testPassword = "SecurePassword123!"
coEvery {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
} returns CredentialTestResult.Error(
exception = null,
)
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = testUsername,
password = testPassword,
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
}
@Test
fun `Success result without data does not crash`() = runTest {
val testUsername = "test@example.com"
val testPassword = "SecurePassword123!"
coEvery {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
} returns CredentialTestResult.Success(
data = null,
)
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = testUsername,
password = testPassword,
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
}
@Test
fun `multiple input changes are reflected in state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
skipItems(1) // Skip initial state
// Change username multiple times
viewModel.trySendAction(CreatePasswordAction.UsernameChanged("first"))
assertEquals("first", awaitItem().username)
viewModel.trySendAction(CreatePasswordAction.UsernameChanged("second"))
assertEquals("second", awaitItem().username)
// Change password multiple times
viewModel.trySendAction(CreatePasswordAction.PasswordChanged("pass1"))
assertEquals("pass1", awaitItem().password)
viewModel.trySendAction(CreatePasswordAction.PasswordChanged("pass2"))
assertEquals("pass2", awaitItem().password)
}
}
@Test
fun `ExecuteClick does not trigger operation when validation fails`() = runTest {
val viewModel = createViewModel()
// Leave username and password blank
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
// Verify createPassword was never called
coVerify(exactly = 0) {
mockCredentialTestManager.createPassword(any(), any(), any())
}
}
@Test
fun `username with whitespace only fails validation`() = runTest {
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = " ",
password = "ValidPassword123!",
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("Validation Error"))
}
@Test
fun `password with whitespace only fails validation`() = runTest {
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = "valid@example.com",
password = " ",
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("Validation Error"))
}
@Test
fun `successful operation preserves username and password in state`() = runTest {
val testUsername = "test@example.com"
val testPassword = "SecurePassword123!"
coEvery {
mockCredentialTestManager.createPassword(
username = testUsername,
password = testPassword,
origin = null,
)
} returns CredentialTestResult.Success()
val viewModel = createViewModel(
initialState = CreatePasswordState(
username = testUsername,
password = testPassword,
),
)
viewModel.trySendAction(CreatePasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
// Verify username and password are still in state after operation
assertEquals(testUsername, resultState.username)
assertEquals(testPassword, resultState.password)
}
@Test
fun `BackClick action sends NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CreatePasswordAction.BackClick)
viewModel.eventFlow.test {
assertEquals(
CreatePasswordEvent.NavigateBack,
awaitItem(),
)
}
}
private fun createViewModel(
initialState: CreatePasswordState? = null,
): CreatePasswordViewModel {
return CreatePasswordViewModel(
credentialTestManager = mockCredentialTestManager,
savedStateHandle = SavedStateHandle().apply { this["state"] = initialState },
clock = CLOCK,
)
}
}
private val CLOCK = Clock.fixed(
Instant.parse("2024-10-12T12:00:00Z"),
ZoneOffset.UTC,
)

View File

@ -0,0 +1,198 @@
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests for the [CredentialManagerListScreen] composable in the testharness module.
*
* Verifies that the Credential Manager list screen displays navigation options correctly,
* sends correct actions to the ViewModel when buttons are clicked, and invokes callbacks
* when the ViewModel emits events, following UDF patterns.
*/
class CredentialManagerListScreenTest : BaseComposeTest() {
private var haveCalledOnNavigateBack = false
private var haveCalledOnNavigateToGetPassword = false
private var haveCalledOnNavigateToCreatePassword = false
private var haveCalledOnNavigateToGetPasskey = false
private var haveCalledOnNavigateToCreatePasskey = false
private var haveCalledOnNavigateToGetPasswordOrPasskey = false
private val mutableEventFlow = MutableSharedFlow<CredentialManagerListEvent>(replay = 1)
private val mutableStateFlow = MutableStateFlow(Unit)
private val viewModel = mockk<CredentialManagerListViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setTestContent {
CredentialManagerListScreen(
onNavigateBack = { haveCalledOnNavigateBack = true },
onNavigateToGetPassword = { haveCalledOnNavigateToGetPassword = true },
onNavigateToCreatePassword = { haveCalledOnNavigateToCreatePassword = true },
onNavigateToGetPasskey = { haveCalledOnNavigateToGetPasskey = true },
onNavigateToCreatePasskey = { haveCalledOnNavigateToCreatePasskey = true },
onNavigateToGetPasswordOrPasskey = {
haveCalledOnNavigateToGetPasswordOrPasskey = true
},
viewModel = viewModel,
)
}
}
@Test
fun `get password button is displayed`() {
composeTestRule
.onNodeWithText("Get Password")
.assertIsDisplayed()
}
@Test
fun `create password button is displayed`() {
composeTestRule
.onNodeWithText("Create Password")
.assertIsDisplayed()
}
@Test
fun `get passkey button is displayed`() {
composeTestRule
.onNodeWithText("Get Passkey")
.assertIsDisplayed()
}
@Test
fun `create passkey button is displayed`() {
composeTestRule
.onNodeWithText("Create Passkey")
.assertIsDisplayed()
}
@Test
fun `get password or passkey button is displayed`() {
composeTestRule
.onNodeWithText("Get Password or Passkey")
.assertIsDisplayed()
}
@Test
fun `back button click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify {
viewModel.trySendAction(CredentialManagerListAction.BackClick)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateBack)
assertTrue(haveCalledOnNavigateBack)
}
@Test
fun `get password button click should send GetPasswordClick action`() {
composeTestRule
.onNodeWithText("Get Password")
.performClick()
verify {
viewModel.trySendAction(CredentialManagerListAction.GetPasswordClick)
}
}
@Test
fun `create password button click should send CreatePasswordClick action`() {
composeTestRule
.onNodeWithText("Create Password")
.performClick()
verify {
viewModel.trySendAction(CredentialManagerListAction.CreatePasswordClick)
}
}
@Test
fun `get passkey button click should send GetPasskeyClick action`() {
composeTestRule
.onNodeWithText("Get Passkey")
.performClick()
verify {
viewModel.trySendAction(CredentialManagerListAction.GetPasskeyClick)
}
}
@Test
fun `create passkey button click should send CreatePasskeyClick action`() {
composeTestRule
.onNodeWithText("Create Passkey")
.performClick()
verify {
viewModel.trySendAction(CredentialManagerListAction.CreatePasskeyClick)
}
}
@Test
fun `get password or passkey button click should send GetPasswordOrPasskeyClick action`() {
composeTestRule
.onNodeWithText("Get Password or Passkey")
.performClick()
verify {
viewModel.trySendAction(CredentialManagerListAction.GetPasswordOrPasskeyClick)
}
}
@Test
fun `NavigateToGetPassword event should call onNavigateToGetPassword`() {
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToGetPassword)
assertTrue(haveCalledOnNavigateToGetPassword)
}
@Test
fun `NavigateToCreatePassword event should call onNavigateToCreatePassword`() {
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToCreatePassword)
assertTrue(haveCalledOnNavigateToCreatePassword)
}
@Test
fun `NavigateToGetPasskey event should call onNavigateToGetPasskey`() {
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToGetPasskey)
assertTrue(haveCalledOnNavigateToGetPasskey)
}
@Test
fun `NavigateToCreatePasskey event should call onNavigateToCreatePasskey`() {
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToCreatePasskey)
assertTrue(haveCalledOnNavigateToCreatePasskey)
}
@Test
fun `NavigateToGetPasswordOrPasskey event should call onNavigateToGetPasswordOrPasskey`() {
mutableEventFlow.tryEmit(CredentialManagerListEvent.NavigateToGetPasswordOrPasskey)
assertTrue(haveCalledOnNavigateToGetPasswordOrPasskey)
}
}

View File

@ -0,0 +1,103 @@
package com.bitwarden.testharness.ui.platform.feature.credentialmanager
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class CredentialManagerListViewModelTest : BaseViewModelTest() {
@Test
fun `initial state is Unit`() = runTest {
val viewModel = CredentialManagerListViewModel()
viewModel.stateFlow.test {
assertEquals(Unit, awaitItem())
}
}
@Test
fun `GetPasswordClick action emits NavigateToGetPassword event`() = runTest {
val viewModel = CredentialManagerListViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CredentialManagerListAction.GetPasswordClick)
assertEquals(
CredentialManagerListEvent.NavigateToGetPassword,
awaitItem(),
)
}
}
@Test
fun `CreatePasswordClick action emits NavigateToCreatePassword event`() = runTest {
val viewModel = CredentialManagerListViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CredentialManagerListAction.CreatePasswordClick)
assertEquals(
CredentialManagerListEvent.NavigateToCreatePassword,
awaitItem(),
)
}
}
@Test
fun `GetPasskeyClick action emits NavigateToGetPasskey event`() = runTest {
val viewModel = CredentialManagerListViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CredentialManagerListAction.GetPasskeyClick)
assertEquals(
CredentialManagerListEvent.NavigateToGetPasskey,
awaitItem(),
)
}
}
@Test
fun `CreatePasskeyClick action emits NavigateToCreatePasskey event`() = runTest {
val viewModel = CredentialManagerListViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CredentialManagerListAction.CreatePasskeyClick)
assertEquals(
CredentialManagerListEvent.NavigateToCreatePasskey,
awaitItem(),
)
}
}
@Test
fun `GetPasswordOrPasskeyClick action emits NavigateToGetPasswordOrPasskey event`() = runTest {
val viewModel = CredentialManagerListViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CredentialManagerListAction.GetPasswordOrPasskeyClick)
assertEquals(
CredentialManagerListEvent.NavigateToGetPasswordOrPasskey,
awaitItem(),
)
}
}
@Test
fun `BackClick action emits NavigateBack event`() = runTest {
val viewModel = CredentialManagerListViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CredentialManagerListAction.BackClick)
assertEquals(
CredentialManagerListEvent.NavigateBack,
awaitItem(),
)
}
}
}

View File

@ -0,0 +1,103 @@
package com.bitwarden.testharness.ui.platform.feature.getpasskey
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests for the [GetPasskeyScreen] composable in the testharness module.
*
* Verifies that the Get Passkey screen displays input fields, buttons, and result area
* correctly and handles user interactions appropriately following UDF patterns.
*/
class GetPasskeyScreenTest : BaseComposeTest() {
private var haveCalledOnNavigateBack = false
private val mutableEventFlow = MutableSharedFlow<GetPasskeyEvent>(replay = 1)
private val mutableStateFlow = MutableStateFlow(
GetPasskeyState(
rpId = "",
origin = "",
isLoading = false,
resultText = "Ready to authenticate passkey.\n\n" +
"Enter Relying Party ID, then click Execute.",
),
)
private val viewModel = mockk<GetPasskeyViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setTestContent {
GetPasskeyScreen(
onNavigateBack = { haveCalledOnNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `relying party id field label is displayed`() {
composeTestRule
.onNodeWithText("Relying Party ID")
.assertIsDisplayed()
}
@Test
fun `origin optional field label is displayed`() {
composeTestRule
.onNodeWithText("Origin (optional)")
.assertIsDisplayed()
}
@Test
fun `execute button label is displayed`() {
composeTestRule
.onNodeWithText("Execute")
.assertIsDisplayed()
}
@Test
fun `clear button label is displayed`() {
composeTestRule
.onNodeWithText("Clear")
.assertIsDisplayed()
}
@Test
fun `result field label is displayed`() {
composeTestRule
.onNodeWithText("Result")
.assertIsDisplayed()
}
@Test
fun `back button click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify {
viewModel.trySendAction(GetPasskeyAction.BackClick)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(GetPasskeyEvent.NavigateBack)
assertTrue(haveCalledOnNavigateBack)
}
}

View File

@ -0,0 +1,320 @@
package com.bitwarden.testharness.ui.platform.feature.getpasskey
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class GetPasskeyViewModelTest : BaseViewModelTest() {
private val mockCredentialTestManager = mockk<CredentialTestManager>()
private val savedStateHandle = SavedStateHandle()
private fun createViewModel(): GetPasskeyViewModel {
return GetPasskeyViewModel(
credentialTestManager = mockCredentialTestManager,
savedStateHandle = savedStateHandle,
clock = CLOCK,
)
}
@Test
fun `initial state should have default values`() = runTest {
val viewModel = createViewModel()
val state = viewModel.stateFlow.value
assertEquals("", state.rpId)
assertEquals("", state.origin)
assertFalse(state.isLoading)
assertTrue(state.resultText.contains("Ready to authenticate passkey"))
}
@Test
fun `RpIdChanged action updates rpId in state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
val updatedState = viewModel.stateFlow.value
assertEquals("example.com", updatedState.rpId)
}
@Test
fun `OriginChanged action updates origin in state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.OriginChanged("https://example.com"))
val updatedState = viewModel.stateFlow.value
assertEquals("https://example.com", updatedState.origin)
}
@Test
fun `ExecuteClick with blank rpId shows validation error`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
val errorState = viewModel.stateFlow.value
assertFalse(errorState.isLoading)
assertTrue(errorState.resultText.contains("Validation Error"))
assertTrue(errorState.resultText.contains("Relying Party ID is required"))
}
@Test
fun `ExecuteClick with valid rpId triggers passkey authentication`() = runTest {
coEvery {
mockCredentialTestManager.getPasskey(
rpId = "example.com",
origin = null,
)
} returns CredentialTestResult.Success(
data = "test-passkey-data",
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
coVerify {
mockCredentialTestManager.getPasskey(
rpId = "example.com",
origin = null,
)
}
}
@Test
fun `ExecuteClick with rpId and origin passes both parameters`() = runTest {
coEvery {
mockCredentialTestManager.getPasskey(
rpId = "example.com",
origin = "https://example.com",
)
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.OriginChanged("https://example.com"))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
coVerify {
mockCredentialTestManager.getPasskey(
rpId = "example.com",
origin = "https://example.com",
)
}
}
@Test
fun `ExecuteClick with blank origin passes null origin`() = runTest {
coEvery {
mockCredentialTestManager.getPasskey(
rpId = "example.com",
origin = null,
)
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.OriginChanged(""))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
coVerify {
mockCredentialTestManager.getPasskey(
rpId = "example.com",
origin = null,
)
}
}
@Test
fun `ExecuteClick action sets loading state to true`() = runTest {
coEvery {
mockCredentialTestManager.getPasskey(any(), any())
} coAnswers {
kotlinx.coroutines.delay(100)
CredentialTestResult.Success()
}
val viewModel = createViewModel()
viewModel.stateFlow.test {
skipItems(1) // Skip initial state
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
skipItems(1) // Skip rpId update
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
// Should receive loading state
val loadingState = awaitItem()
assertTrue(loadingState.isLoading)
assertTrue(loadingState.resultText.contains("Starting passkey authentication"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `Success result updates state with success message`() = runTest {
val successMessage = "Passkey authenticated successfully"
val testData = "credential-id: test-credential-123"
coEvery {
mockCredentialTestManager.getPasskey(any(), any())
} returns CredentialTestResult.Success(
data = testData,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(successMessage))
assertTrue(resultState.resultText.contains(testData))
}
@Test
fun `Error result updates state with error message`() = runTest {
val exception = Exception("Invalid passkey")
coEvery {
mockCredentialTestManager.getPasskey(any(), any())
} returns CredentialTestResult.Error(
exception = exception,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains("Invalid passkey"))
}
@Test
fun `Cancelled result updates state with cancelled message`() = runTest {
coEvery {
mockCredentialTestManager.getPasskey(any(), any())
} returns CredentialTestResult.Cancelled
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("CANCELLED"))
assertTrue(resultState.resultText.contains("User cancelled"))
}
@Test
fun `ClearResultClick action resets result text`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.ClearResultClick)
val clearedState = viewModel.stateFlow.value
assertEquals("Result cleared.\n", clearedState.resultText)
}
@Test
fun `Error result without exception does not crash`() = runTest {
val errorMessage = "Unknown error"
coEvery {
mockCredentialTestManager.getPasskey(any(), any())
} returns CredentialTestResult.Error(
exception = null,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains(errorMessage))
}
@Test
fun `Success result without data does not crash`() = runTest {
val successMessage = "Passkey authenticated"
coEvery {
mockCredentialTestManager.getPasskey(any(), any())
} returns CredentialTestResult.Success(
data = null,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(successMessage))
}
@Test
fun `state is persisted to SavedStateHandle`() = runTest {
coEvery {
mockCredentialTestManager.getPasskey(any(), any())
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasskeyAction.ExecuteClick)
// Wait for state to update
testScheduler.advanceUntilIdle()
val persistedState = savedStateHandle.get<GetPasskeyState>("state")
assertEquals(viewModel.stateFlow.value, persistedState)
}
@Test
fun `BackClick action sends NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasskeyAction.BackClick)
viewModel.eventFlow.test {
assertEquals(
GetPasskeyEvent.NavigateBack,
awaitItem(),
)
}
}
}
private val CLOCK = Clock.fixed(
Instant.parse("2024-10-12T12:00:00Z"),
ZoneOffset.UTC,
)

View File

@ -0,0 +1,94 @@
package com.bitwarden.testharness.ui.platform.feature.getpassword
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests for the [GetPasswordScreen] composable in the testharness module.
*
* Verifies that the Get Password screen displays text and controls correctly
* and handles user interactions appropriately following UDF patterns.
*/
class GetPasswordScreenTest : BaseComposeTest() {
private var haveCalledOnNavigateBack = false
private val mutableEventFlow = MutableSharedFlow<GetPasswordEvent>(replay = 1)
private val mutableStateFlow = MutableStateFlow(
GetPasswordState(
isLoading = false,
resultText = "Ready to retrieve password.\n\n" +
"Click Execute to open the password picker.",
),
)
private val viewModel = mockk<GetPasswordViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setTestContent {
GetPasswordScreen(
onNavigateBack = { haveCalledOnNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `helper text is displayed`() {
composeTestRule
.onNodeWithText("No inputs required. Click Execute to open credential picker.")
.assertIsDisplayed()
}
@Test
fun `execute button is displayed`() {
composeTestRule
.onNodeWithText("Execute")
.assertIsDisplayed()
}
@Test
fun `clear button is displayed`() {
composeTestRule
.onNodeWithText("Clear")
.assertIsDisplayed()
}
@Test
fun `result field is displayed`() {
composeTestRule
.onNodeWithText("Result")
.assertIsDisplayed()
}
@Test
fun `back button click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify {
viewModel.trySendAction(GetPasswordAction.BackClick)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(GetPasswordEvent.NavigateBack)
assertTrue(haveCalledOnNavigateBack)
}
}

View File

@ -0,0 +1,205 @@
package com.bitwarden.testharness.ui.platform.feature.getpassword
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class GetPasswordViewModelTest : BaseViewModelTest() {
private val mockCredentialTestManager = mockk<CredentialTestManager>()
private val savedStateHandle = SavedStateHandle()
private fun createViewModel(): GetPasswordViewModel {
return GetPasswordViewModel(
credentialTestManager = mockCredentialTestManager,
savedStateHandle = savedStateHandle,
clock = CLOCK,
)
}
@Test
fun `initial state should have default values`() = runTest {
val viewModel = createViewModel()
val state = viewModel.stateFlow.value
assertFalse(state.isLoading)
assertTrue(state.resultText.contains("Ready to retrieve password"))
}
@Test
fun `ExecuteClick action triggers password retrieval`() = runTest {
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Success(
data = "test-password-data",
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
coVerify { mockCredentialTestManager.getPassword() }
}
@Test
fun `ExecuteClick action sets loading state to true`() = runTest {
coEvery { mockCredentialTestManager.getPassword() } coAnswers {
kotlinx.coroutines.delay(100)
CredentialTestResult.Success()
}
val viewModel = createViewModel()
viewModel.stateFlow.test {
skipItems(1) // Skip initial state
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
// Should receive loading state
val loadingState = awaitItem()
assertTrue(loadingState.isLoading)
assertTrue(loadingState.resultText.contains("Starting password retrieval"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `Success result updates state with success message`() = runTest {
val successMessage = "Password retrieved successfully"
val testData = "username: test@example.com"
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Success(
data = testData,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(successMessage))
assertTrue(resultState.resultText.contains(testData))
}
@Test
fun `Error result updates state with error message`() = runTest {
val exception = Exception("Network error")
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Error(
exception = exception,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains("Network error"))
}
@Test
fun `Cancelled result updates state with cancelled message`() = runTest {
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Cancelled
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("CANCELLED"))
assertTrue(resultState.resultText.contains("User cancelled"))
}
@Test
fun `ClearResultClick action resets result text`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.ClearResultClick)
val clearedState = viewModel.stateFlow.value
assertEquals("Result cleared.\n", clearedState.resultText)
}
@Test
fun `Error result without exception does not crash`() = runTest {
val errorMessage = "Unknown error"
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Error(
exception = null,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains(errorMessage))
}
@Test
fun `Success result without data does not crash`() = runTest {
val successMessage = "Password retrieved"
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Success(
data = null,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(successMessage))
}
@Test
fun `state is persisted to SavedStateHandle`() = runTest {
coEvery { mockCredentialTestManager.getPassword() } returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.ExecuteClick)
// Wait for state to update
testScheduler.advanceUntilIdle()
val persistedState = savedStateHandle.get<GetPasswordState>("state")
assertEquals(viewModel.stateFlow.value, persistedState)
}
@Test
fun `BackClick action sends NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordAction.BackClick)
viewModel.eventFlow.test {
assertEquals(
GetPasswordEvent.NavigateBack,
awaitItem(),
)
}
}
}
private val CLOCK = Clock.fixed(
Instant.parse("2024-10-12T12:00:00Z"),
ZoneOffset.UTC,
)

View File

@ -0,0 +1,104 @@
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests for the [GetPasswordOrPasskeyScreen] composable in the testharness module.
*
* Verifies that the Get Password or Passkey screen displays UI elements correctly
* and handles user interactions appropriately following UDF patterns.
*/
class GetPasswordOrPasskeyScreenTest : BaseComposeTest() {
private var haveCalledOnNavigateBack = false
private val mutableEventFlow = MutableSharedFlow<GetPasswordOrPasskeyEvent>(replay = 1)
private val mutableStateFlow = MutableStateFlow(
GetPasswordOrPasskeyState(
rpId = "",
origin = "",
isLoading = false,
resultText = "Ready to retrieve password or passkey.\n\n" +
"Enter Relying Party ID, then click Execute.\n" +
"System picker will show both passwords and passkeys.",
),
)
private val viewModel = mockk<GetPasswordOrPasskeyViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setTestContent {
GetPasswordOrPasskeyScreen(
onNavigateBack = { haveCalledOnNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `relying party id field label is displayed`() {
composeTestRule
.onNodeWithText("Relying Party ID")
.assertIsDisplayed()
}
@Test
fun `origin optional field label is displayed`() {
composeTestRule
.onNodeWithText("Origin (optional)")
.assertIsDisplayed()
}
@Test
fun `execute button label is displayed`() {
composeTestRule
.onNodeWithText("Execute")
.assertIsDisplayed()
}
@Test
fun `clear button label is displayed`() {
composeTestRule
.onNodeWithText("Clear")
.assertIsDisplayed()
}
@Test
fun `result field label is displayed`() {
composeTestRule
.onNodeWithText("Result")
.assertIsDisplayed()
}
@Test
fun `back button click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify {
viewModel.trySendAction(GetPasswordOrPasskeyAction.BackClick)
}
}
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(GetPasswordOrPasskeyEvent.NavigateBack)
assertTrue(haveCalledOnNavigateBack)
}
}

View File

@ -0,0 +1,414 @@
package com.bitwarden.testharness.ui.platform.feature.getpasswordorpasskey
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.testharness.data.manager.CredentialTestManager
import com.bitwarden.testharness.data.model.CredentialTestResult
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class GetPasswordOrPasskeyViewModelTest : BaseViewModelTest() {
private val mockCredentialTestManager = mockk<CredentialTestManager>()
private val savedStateHandle = SavedStateHandle()
private fun createViewModel(): GetPasswordOrPasskeyViewModel {
return GetPasswordOrPasskeyViewModel(
credentialTestManager = mockCredentialTestManager,
savedStateHandle = savedStateHandle,
clock = CLOCK,
)
}
@Test
fun `initial state should have default values`() = runTest {
val viewModel = createViewModel()
val state = viewModel.stateFlow.value
assertEquals("", state.rpId)
assertEquals("", state.origin)
assertFalse(state.isLoading)
assertTrue(state.resultText.contains("Ready to retrieve password or passkey"))
}
@Test
fun `RpIdChanged action updates rpId in state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
val state = viewModel.stateFlow.value
assertEquals("example.com", state.rpId)
}
@Test
fun `OriginChanged action updates origin in state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged("https://app.example.com"))
val state = viewModel.stateFlow.value
assertEquals("https://app.example.com", state.origin)
}
@Test
fun `ExecuteClick with blank rpId shows validation error`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
val errorState = viewModel.stateFlow.value
assertFalse(errorState.isLoading)
assertTrue(errorState.resultText.contains("Validation Error"))
assertTrue(errorState.resultText.contains("Relying Party ID is required"))
}
@Test
fun `ExecuteClick with blank rpId does not call credentialTestManager`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
coVerify(exactly = 0) { mockCredentialTestManager.getPasswordOrPasskey(any(), any()) }
}
@Test
fun `ExecuteClick with valid rpId triggers credential retrieval`() = runTest {
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = null,
)
} returns CredentialTestResult.Success(
data = "test-credential-data",
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
coVerify {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = null,
)
}
}
@Test
fun `ExecuteClick with valid rpId and origin passes both parameters`() = runTest {
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = "https://app.example.com",
)
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged("https://app.example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
coVerify {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = "https://app.example.com",
)
}
}
@Test
fun `ExecuteClick with blank origin passes null to credentialTestManager`() = runTest {
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = null,
)
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged(""))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
coVerify {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = null,
)
}
}
@Test
fun `ExecuteClick action sets loading state to true`() = runTest {
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
} coAnswers {
kotlinx.coroutines.delay(100)
CredentialTestResult.Success()
}
val viewModel = createViewModel()
viewModel.stateFlow.test {
skipItems(1) // Skip initial state
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
awaitItem() // Consume rpId change
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
// Should receive loading state
val loadingState = awaitItem()
assertTrue(loadingState.isLoading)
assertTrue(loadingState.resultText.contains("Starting credential retrieval"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `Success result updates state with success message`() = runTest {
val successMessage = "Credential retrieved successfully"
val testData = "type: password, username: test@example.com"
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
} returns CredentialTestResult.Success(
data = testData,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(successMessage))
assertTrue(resultState.resultText.contains(testData))
}
@Test
fun `Error result updates state with error message`() = runTest {
val exception = Exception("Network error")
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
} returns CredentialTestResult.Error(
exception = exception,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains("Network error"))
}
@Test
fun `Cancelled result updates state with cancelled message`() = runTest {
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
} returns CredentialTestResult.Cancelled
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("CANCELLED"))
assertTrue(resultState.resultText.contains("User cancelled"))
}
@Test
fun `ClearResultClick action resets result text`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.ClearResultClick)
val clearedState = viewModel.stateFlow.value
assertEquals("Result cleared.\n", clearedState.resultText)
}
@Test
fun `Error result without exception does not crash`() = runTest {
val errorMessage = "Unknown error"
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
} returns CredentialTestResult.Error(
exception = null,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("ERROR"))
assertTrue(resultState.resultText.contains(errorMessage))
}
@Test
fun `Success result without data does not crash`() = runTest {
val successMessage = "Credential retrieved"
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
} returns CredentialTestResult.Success(
data = null,
)
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
val resultState = viewModel.stateFlow.value
assertFalse(resultState.isLoading)
assertTrue(resultState.resultText.contains("SUCCESS"))
assertTrue(resultState.resultText.contains(successMessage))
}
@Test
fun `state is persisted to SavedStateHandle`() = runTest {
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
// Wait for state to update
testScheduler.advanceUntilIdle()
val persistedState = savedStateHandle.get<GetPasswordOrPasskeyState>("state")
assertEquals(viewModel.stateFlow.value, persistedState)
}
@Test
fun `multiple input changes update state correctly`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged("https://app.example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("newdomain.com"))
val state = viewModel.stateFlow.value
assertEquals("newdomain.com", state.rpId)
assertEquals("https://app.example.com", state.origin)
}
@Test
fun `validation error appends to existing result text`() = runTest {
val viewModel = createViewModel()
// First set some existing result text
viewModel.trySendAction(GetPasswordOrPasskeyAction.ClearResultClick)
// Try to execute without rpId
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
val errorState = viewModel.stateFlow.value
assertTrue(errorState.resultText.contains("Result cleared"))
assertTrue(errorState.resultText.contains("Validation Error"))
}
@Test
fun `successful execution after validation error works correctly`() = runTest {
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(any(), any())
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
// First try without rpId - should fail validation
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
// Now set rpId and try again - should succeed
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
coVerify(exactly = 1) {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = null,
)
}
}
@Test
fun `whitespace-only origin is treated as null`() = runTest {
coEvery {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = null,
)
} returns CredentialTestResult.Success()
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.RpIdChanged("example.com"))
viewModel.trySendAction(GetPasswordOrPasskeyAction.OriginChanged(" "))
viewModel.trySendAction(GetPasswordOrPasskeyAction.ExecuteClick)
testScheduler.advanceUntilIdle()
coVerify {
mockCredentialTestManager.getPasswordOrPasskey(
rpId = "example.com",
origin = null,
)
}
}
@Test
fun `BackClick action sends NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(GetPasswordOrPasskeyAction.BackClick)
viewModel.eventFlow.test {
assertEquals(
GetPasswordOrPasskeyEvent.NavigateBack,
awaitItem(),
)
}
}
}
private val CLOCK = Clock.fixed(
Instant.parse("2024-10-12T12:00:00Z"),
ZoneOffset.UTC,
)

View File

@ -0,0 +1,96 @@
package com.bitwarden.testharness.ui.platform.feature.landing
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests for the [LandingScreen] composable in the testharness module.
*
* Verifies that the Landing screen displays navigation options correctly
* and handles user interactions appropriately following UDF patterns.
*/
class LandingScreenTest : BaseComposeTest() {
private var haveCalledOnNavigateToAutofill = false
private var haveCalledOnNavigateToCredentialManager = false
private val mutableEventFlow = MutableSharedFlow<LandingEvent>(replay = 1)
private val mutableStateFlow = MutableStateFlow(Unit)
private val viewModel = mockk<LandingViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setTestContent {
LandingScreen(
onNavigateToAutofill = { haveCalledOnNavigateToAutofill = true },
onNavigateToCredentialManager = { haveCalledOnNavigateToCredentialManager = true },
viewModel = viewModel,
)
}
}
@Test
fun `test categories header text is displayed`() {
composeTestRule
.onNodeWithText("TEST CATEGORIES")
.assertIsDisplayed()
}
@Test
fun `autofill button text is displayed`() {
composeTestRule
.onNodeWithText("Autofill")
.assertIsDisplayed()
}
@Test
fun `credential manager button text is displayed`() {
composeTestRule
.onNodeWithText("Credential Manager")
.assertIsDisplayed()
}
@Test
fun `autofill button click should send OnAutofillClick action`() {
composeTestRule
.onNodeWithText("Autofill")
.performClick()
verify { viewModel.trySendAction(LandingAction.OnAutofillClick) }
}
@Test
fun `credential manager button click should send OnCredentialManagerClick action`() {
composeTestRule
.onNodeWithText("Credential Manager")
.performClick()
verify { viewModel.trySendAction(LandingAction.OnCredentialManagerClick) }
}
@Test
fun `NavigateToAutofill event should call onNavigateToAutofill`() {
mutableEventFlow.tryEmit(LandingEvent.NavigateToAutofill)
assertTrue(haveCalledOnNavigateToAutofill)
}
@Test
fun `NavigateToCredentialManager event should call onNavigateToCredentialManager`() {
mutableEventFlow.tryEmit(LandingEvent.NavigateToCredentialManager)
assertTrue(haveCalledOnNavigateToCredentialManager)
}
}

View File

@ -0,0 +1,69 @@
package com.bitwarden.testharness.ui.platform.feature.landing
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class LandingViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be Unit`() = runTest {
val viewModel = LandingViewModel()
assertEquals(Unit, viewModel.stateFlow.value)
}
@Test
fun `state flow emits correct Unit state`() = runTest {
val viewModel = LandingViewModel()
viewModel.stateFlow.test {
val state = awaitItem()
assertEquals(Unit, state)
}
}
@Test
fun `OnAutofillClick action emits NavigateToAutofill event`() = runTest {
val viewModel = LandingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.OnAutofillClick)
assertEquals(LandingEvent.NavigateToAutofill, awaitItem())
}
}
@Test
fun `OnCredentialManagerClick action emits NavigateToCredentialManager event`() = runTest {
val viewModel = LandingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.OnCredentialManagerClick)
assertEquals(LandingEvent.NavigateToCredentialManager, awaitItem())
}
}
@Test
fun `event flow emits events correctly when multiple actions sent`() = runTest {
val viewModel = LandingViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LandingAction.OnAutofillClick)
assertEquals(LandingEvent.NavigateToAutofill, awaitItem())
viewModel.actionChannel.trySend(LandingAction.OnCredentialManagerClick)
assertEquals(LandingEvent.NavigateToCredentialManager, awaitItem())
}
}
@Test
fun `event flow remains empty when no actions are sent`() = runTest {
val viewModel = LandingViewModel()
viewModel.eventFlow.test {
expectNoEvents()
}
}
}

View File

@ -0,0 +1,78 @@
package com.bitwarden.testharness.ui.platform.feature.rootnav
import com.bitwarden.ui.platform.base.BaseComposeTest
import com.bitwarden.ui.platform.base.createMockNavHostController
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Before
import org.junit.Test
/**
* Tests for the [RootNavScreen] composable in the testharness module.
*
* Verifies that root-level navigation correctly routes to different test flows
* based on the [RootNavState].
*/
class RootNavScreenTest : BaseComposeTest() {
private val mockNavHostController = createMockNavHostController()
private val rootNavStateFlow = MutableStateFlow<RootNavState>(RootNavState.Splash)
private val viewModel = mockk<RootNavViewModel> {
every { eventFlow } returns emptyFlow()
every { stateFlow } returns rootNavStateFlow
}
private var isSplashScreenRemoved: Boolean = false
@Before
fun setup() {
setTestContent {
RootNavScreen(
viewModel = viewModel,
navController = mockNavHostController,
onSplashScreenRemoved = { isSplashScreenRemoved = true },
)
}
}
@Test
fun `initial state does not invoke splash screen removal`() {
composeTestRule.runOnIdle {
assert(!isSplashScreenRemoved)
}
}
@Test
fun `splash screen callback is called when transitioning from Splash state`() {
composeTestRule.runOnIdle {
assert(!isSplashScreenRemoved)
}
// Transition to Landing state
rootNavStateFlow.value = RootNavState.Landing
composeTestRule.runOnIdle {
assert(isSplashScreenRemoved)
}
}
@Test
fun `nav host displays landing destination as start destination`() {
composeTestRule.runOnIdle {
// The NavHost is configured with LandingRoute as startDestination
assert(true)
}
}
@Test
fun `nav host contains autofill and credential manager graphs`() {
composeTestRule.runOnIdle {
// The NavHost contains:
// - landingDestination()
// - autofillGraph()
// - credentialManagerGraph()
// Navigation to these graphs is triggered by user interaction in LandingScreen
assert(true)
}
}
}

View File

@ -0,0 +1,48 @@
package com.bitwarden.testharness.ui.platform.feature.rootnav
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class RootNavViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should transition from Splash to Landing on init`() = runTest {
val viewModel = RootNavViewModel()
viewModel.stateFlow.test {
// First emission is Landing (after init block updates from Splash)
assertEquals(RootNavState.Landing, awaitItem())
}
}
@Test
fun `state flow emits correct Landing state`() = runTest {
val viewModel = RootNavViewModel()
viewModel.stateFlow.test {
val state = awaitItem()
assertEquals(RootNavState.Landing, state)
}
}
@Test
fun `event flow remains empty when no events are emitted`() = runTest {
val viewModel = RootNavViewModel()
viewModel.eventFlow.test {
// Event flow should not emit anything since no actions produce events
expectNoEvents()
}
}
@Test
fun `state is Landing after ViewModel construction`() = runTest {
val viewModel = RootNavViewModel()
// Verify the current state value directly
assertEquals(RootNavState.Landing, viewModel.stateFlow.value)
}
}