commit c6cfddfb7c61d27c9541c45bc59d1f9e2ff8a354
parent 76d8a2b9bcc36db05670389930c1df70a841cffd
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 17:23:25 +0000
test: exercise identity bootstrap through the product shell
- Drive generated identity recovery and activation through semantic shell controls.
- Prove profile refresh, sign-out, chooser activation, close, and restart behavior.
- Verify read-only entry remains session-local and returns to welcome after restart.
- Fix recovery disposal and identity-entry state transitions exposed by acceptance.
Diffstat:
8 files changed, 127 insertions(+), 24 deletions(-)
diff --git a/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/IdentityBootstrapAcceptanceTest.kt b/app/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/IdentityBootstrapAcceptanceTest.kt
@@ -13,7 +13,6 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.printToString
import androidx.compose.ui.test.v2.runComposeUiTest
import org.harvestcircle.application.ApplicationClock
@@ -72,8 +71,8 @@ class IdentityBootstrapAcceptanceTest {
}
}
- waitForTag("identities-screen")
- onNodeWithTag("choose-create-identity").performClick()
+ waitForTag("bootstrap-welcome")
+ onNodeWithTag("bootstrap-create").performClick()
onNodeWithTag("generate-key").performClick()
waitForTag("generated-key-backup")
onNodeWithTag("generated-nsec").assertIsDisplayed()
@@ -87,8 +86,11 @@ class IdentityBootstrapAcceptanceTest {
onAllNodesWithText(copiedSecret).assertCountEquals(0)
runtime.seedSelectedProfile("Farm Identity")
- onNodeWithText("Activate").performClick()
- waitForTag("home-screen")
+ onNodeWithText("Activate identity").performClick()
+ waitForTag("foundation-today")
+ onNodeWithTag("sidebar-Network").performClick()
+ waitForTag("network-overview")
+ onNodeWithTag("network-tab-identity").performClick()
onNodeWithTag("refresh-profile").performClick()
waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) {
onAllNodesWithText("Display name: Farm Identity").fetchSemanticsNodes().size == 1
@@ -96,13 +98,9 @@ class IdentityBootstrapAcceptanceTest {
onNodeWithText("Display name: Farm Identity").assertIsDisplayed()
onNodeWithTag("sign-out").performClick()
- waitForTag("identities-screen")
- onNodeWithTag("cancel-identity-entry").performClick()
- onNodeWithTag("choose-import-identity").performClick()
- onNodeWithTag("import-nsec-input").performTextInput(copiedSecret)
- onNodeWithTag("import-key").performClick()
- waitForTag("identities-problem")
- onNodeWithTag("identities-problem").assertIsDisplayed()
+ waitForTag("saved-identity-list")
+ onNodeWithText("Activate identity").performClick()
+ waitForTag("foundation-today")
assertFalse(onRoot(useUnmergedTree = true).printToString().contains(copiedSecret))
closeRequested = true
waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { approvedExits == 1 }
@@ -116,9 +114,9 @@ class IdentityBootstrapAcceptanceTest {
showApplication = true
waitForTag("saved-identity-list")
- onNodeWithText("Activate").performClick()
- waitForTag("home-screen")
- onNodeWithText("Display name: Farm Identity").assertIsDisplayed()
+ onNodeWithText("Activate identity").performClick()
+ waitForTag("foundation-today")
+ onNodeWithText("No active commitments").assertIsDisplayed()
onAllNodesWithText(copiedSecret).assertCountEquals(0)
assertFalse(runtime.currentSnapshot().toString().contains(copiedSecret))
@@ -143,9 +141,76 @@ class IdentityBootstrapAcceptanceTest {
}
}
+ @Test
+ fun readOnlySessionRestartsAtBootstrapWithoutPersistingAuthority() =
+ runComposeUiTest {
+ val dataRoot = Files.createTempDirectory("harvestcircle-read-only-")
+ val runtime = TestBridgeHarvestCircleRuntime.open(dataRoot.toString())
+ var showApplication by mutableStateOf(true)
+ var closeRequested by mutableStateOf(false)
+ var applicationSession by mutableStateOf(0)
+ var approvedExits = 0
+ try {
+ setContent {
+ if (showApplication) {
+ key(applicationSession) {
+ HarvestCircleApplicationWithDependencies(
+ closeRequested = closeRequested,
+ onExitApproved = { approvedExits += 1 },
+ clipboardFactory = { scope ->
+ SecretClipboardController(
+ scope = scope,
+ clipboard = RecordingClipboard(),
+ clearDelayMillis = 60_000,
+ )
+ },
+ ) { scope ->
+ HarvestCirclePresenter(
+ runtime = runtime,
+ scope = scope,
+ clock = ApplicationClock { UnixSeconds(FIXED_TIME_SECONDS) },
+ operationIds = SequentialOperationIds(),
+ )
+ }
+ }
+ }
+ }
+
+ waitForTag("bootstrap-welcome")
+ onNodeWithTag("bootstrap-read-only").performClick()
+ waitForTag("foundation-today")
+ onNodeWithText("Read-only session").assertIsDisplayed()
+ assertTrue(runtime.currentSnapshot().identities.isEmpty())
+
+ closeRequested = true
+ waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { approvedExits == 1 }
+ showApplication = false
+ waitForIdle()
+ runtime.restart()
+ applicationSession += 1
+ closeRequested = false
+ showApplication = true
+
+ waitForTag("bootstrap-welcome")
+ onAllNodesWithTag("foundation-today").assertCountEquals(0)
+ assertTrue(runtime.currentSnapshot().identities.isEmpty())
+ closeRequested = true
+ waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { approvedExits == 2 }
+ showApplication = false
+ waitForIdle()
+ } finally {
+ runtime.close()
+ deleteAcceptanceTree(dataRoot)
+ }
+ }
+
private fun androidx.compose.ui.test.ComposeUiTest.waitForTag(tag: String) {
- waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) {
- onAllNodesWithTag(tag).fetchSemanticsNodes().size == 1
+ try {
+ waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) {
+ onAllNodesWithTag(tag).fetchSemanticsNodes().size == 1
+ }
+ } catch (error: androidx.compose.ui.test.ComposeTimeoutException) {
+ throw AssertionError("Timed out waiting for semantic tag: $tag\n${onRoot().printToString()}", error)
}
}
}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt
@@ -146,6 +146,7 @@ class HarvestCirclePresenter(
),
)
acceptResult(result, operationId)
+ updateState { copy(identityEntryMode = IdentityEntryMode.CHOICE) }
} finally {
releaseRecovery()
}
@@ -174,6 +175,7 @@ class HarvestCirclePresenter(
val command = ApplicationCommand.ImportLocalIdentity(input, requestContext(operationId))
try {
acceptResult(runtime.execute(command), operationId)
+ updateState { copy(identityEntryMode = IdentityEntryMode.CHOICE) }
} finally {
input.clear()
}
@@ -413,8 +415,8 @@ class HarvestCirclePresenter(
private fun releaseRecovery() {
val recovery = pendingRecovery
pendingRecovery = null
- updateState { copy(generatedKeyBackup = null) }
recovery?.backup?.clear()
+ updateState { copy(generatedKeyBackup = null) }
}
private fun requestContext(operationId: OperationId): RequestContext =
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt
@@ -25,7 +25,7 @@ fun GeneratedRecoveryCanvas(
actions: HarvestCircleUiActions,
platformActions: HarvestCirclePlatformActions,
) {
- val backup = requireNotNull(model.generatedKeyBackup)
+ val backup = model.generatedKeyBackup ?: return
CanvasScaffold(
textSize = TextSizePreference.Default,
header = { BasicText("Save your recovery key") },
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt
@@ -27,6 +27,7 @@ data class NetworkRelayModel(
data class FoundationNetworkModel(
val identityState: String,
val identityLabel: String?,
+ val profileLabel: String?,
val relayState: String,
val relays: List<NetworkRelayModel>,
val runtimeState: String,
@@ -48,6 +49,7 @@ fun foundationNetworkModel(state: HarvestCircleShellState): FoundationNetworkMod
return FoundationNetworkModel(
identityState = identityState,
identityLabel = active?.identity?.displayLabel ?: snapshot.identities.firstOrNull()?.displayLabel,
+ profileLabel = active?.profile?.displayName ?: active?.profile?.name,
relayState = active?.relays?.state?.label() ?: "Not yet observed",
relays =
snapshot.configuredRelays.map { relay ->
@@ -69,7 +71,11 @@ fun foundationNetworkModel(state: HarvestCircleShellState): FoundationNetworkMod
}
@Composable
-fun FoundationNetworkScreen(model: FoundationNetworkModel) {
+fun FoundationNetworkScreen(
+ model: FoundationNetworkModel,
+ refreshProfile: () -> Unit = {},
+ signOut: () -> Unit = {},
+) {
val tabs =
listOf(
TemplateTab(TemplateSelectionKey("overview"), "Overview"),
@@ -93,7 +99,7 @@ fun FoundationNetworkScreen(model: FoundationNetworkModel) {
}
}
},
- detail = { selection -> NetworkDetail(selection, model) },
+ detail = { selection -> NetworkDetail(selection, model, refreshProfile, signOut) },
)
}
@@ -101,6 +107,8 @@ fun FoundationNetworkScreen(model: FoundationNetworkModel) {
private fun NetworkDetail(
selection: TemplateSelectionKey,
model: FoundationNetworkModel,
+ refreshProfile: () -> Unit,
+ signOut: () -> Unit,
) {
Column(
Modifier.testTag("network-${selection.value}"),
@@ -118,6 +126,11 @@ private fun NetworkDetail(
"identity" -> {
BasicText(model.identityState, Modifier.testTag("network-identity-state"))
model.identityLabel?.let { BasicText(it, Modifier.testTag("network-identity-label")) }
+ model.profileLabel?.let { BasicText("Display name: $it", Modifier.testTag("network-profile-label")) }
+ if (model.identityState == "Local identity active") {
+ ShellAction("Refresh profile", "Refresh active profile", "refresh-profile", onClick = refreshProfile)
+ ShellAction("Sign out", "Sign out", "sign-out", onClick = signOut)
+ }
}
"public_relays" -> {
BasicText(model.relayState, Modifier.testTag("network-relay-state"))
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt
@@ -90,7 +90,7 @@ private fun HarvestCircleShellContent(
onReadOnly = { dispatch(HarvestCircleShellIntent.EnterReadOnly) },
)
}
- is ShellRoot.Dashboard -> DashboardRoot(state, root, platformActions, dispatch)
+ is ShellRoot.Dashboard -> DashboardRoot(state, root, identityActions, platformActions, dispatch)
}
FoundationOverlayHost(state.overlays) { dispatch(HarvestCircleShellIntent.Overlay(it)) }
}
@@ -147,6 +147,7 @@ private fun BootstrapWelcome(dispatch: (HarvestCircleShellIntent) -> Unit) {
private fun DashboardRoot(
state: HarvestCircleShellState,
root: ShellRoot.Dashboard,
+ identityActions: HarvestCircleUiActions,
platformActions: HarvestCirclePlatformActions,
dispatch: (HarvestCircleShellIntent) -> Unit,
) {
@@ -189,7 +190,12 @@ private fun DashboardRoot(
)
},
)
- AppRoute.Network -> FoundationNetworkScreen(foundationNetworkModel(state))
+ AppRoute.Network ->
+ FoundationNetworkScreen(
+ foundationNetworkModel(state),
+ refreshProfile = identityActions.refreshActiveProfile,
+ signOut = identityActions.signOut,
+ )
is AppRoute.Settings ->
FoundationSettingsScreen(
section = route.section,
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt
@@ -222,11 +222,14 @@ class HarvestCirclePresenterTest {
val presenter = presenter(runtime)
runCurrent()
+ presenter.dispatch(HarvestCircleIntent.ChooseImportIdentity)
+ assertEquals(IdentityEntryMode.IMPORT, presenter.state.value.identityEntryMode)
presenter.dispatch(HarvestCircleIntent.EditImportDraft("nsec1" + "x".repeat(200)))
assertEquals(MAX_IMPORT_SECRET_CHARS, presenter.state.value.importDraft.length)
presenter.dispatch(HarvestCircleIntent.ImportIdentity)
assertEquals("", presenter.state.value.importDraft)
advanceUntilIdle()
+ assertEquals(IdentityEntryMode.CHOICE, presenter.state.value.identityEntryMode)
assertEquals(1, runtime.importedSecrets.size)
assertEquals(MAX_IMPORT_SECRET_CHARS, runtime.importedSecrets.single().length)
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkModelTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkModelTest.kt
@@ -34,6 +34,7 @@ class FoundationNetworkModelTest {
val model = foundationNetworkModel(shellState())
assertEquals("Local identity active", model.identityState)
assertEquals("Grower identity", model.identityLabel)
+ assertEquals(null, model.profileLabel)
assertEquals("Degraded", model.relayState)
assertEquals("Public", model.relays.single().destination)
assertEquals("Read available", model.relays.single().readState)
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt
@@ -25,9 +25,21 @@ class FoundationNetworkScreenTest {
@Test
fun identityRelaysAndRuntimeUseOnlyTheSuppliedFoundationState() =
runComposeUiTest {
- setContent { FoundationNetworkScreen(model()) }
+ var refreshed = 0
+ var signedOut = 0
+ setContent {
+ FoundationNetworkScreen(
+ model(),
+ refreshProfile = { refreshed += 1 },
+ signOut = { signedOut += 1 },
+ )
+ }
onNodeWithTag("network-tab-identity").performClick()
onNodeWithText("Grower identity").assertExists()
+ onNodeWithTag("refresh-profile").performClick()
+ onNodeWithTag("sign-out").performClick()
+ kotlin.test.assertEquals(1, refreshed)
+ kotlin.test.assertEquals(1, signedOut)
onNodeWithTag("network-tab-public_relays").performClick()
onNodeWithText("wss://relay.example").assertExists()
onNodeWithText("Public").assertExists()
@@ -62,6 +74,7 @@ private fun model(
) = FoundationNetworkModel(
identityState = identityState,
identityLabel = "Grower identity",
+ profileLabel = "Farm Identity",
relayState = if (relays.isEmpty()) "Not yet observed" else "Degraded",
relays = relays,
runtimeState = "Degraded",