app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mapp/desktop/src/integrationTest/kotlin/org/harvestcircle/integration/IdentityBootstrapAcceptanceTest.kt | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt | 4+++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt | 2+-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt | 17+++++++++++++++--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 10++++++++--
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt | 3+++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkModelTest.kt | 1+
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt | 15++++++++++++++-
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",