diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/LaunchTestOverrides.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/LaunchTestOverrides.kt new file mode 100644 index 0000000000..bd1b683f34 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/LaunchTestOverrides.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop + +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore + +/** + * Bundle of optional substitutes for the heavyweight dependencies that + * `App()` normally constructs inline via `remember { … }`. Production + * code passes `null` (the default) and `App()` builds the real instances; + * Compose UI tests pass a non-null `LaunchTestOverrides` so they can hand + * the composable an in-process fixture relay, a temp-dir local relay + * store, etc. + * + * Keeping this off in production paths means there is no runtime cost in + * normal use — `App()` performs one null check per field before falling + * through to its existing `remember { … }` construction. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 1.4. + */ +data class LaunchTestOverrides( + val localCache: DesktopLocalCache? = null, + val relayManager: DesktopRelayConnectionManager? = null, + val localRelayStore: LocalRelayStore? = null, + /** + * When `true`, `App()` skips the `relayManager.addDefaultRelays()` + + * `relayManager.connect()` + `subscriptionsCoordinator.start()` calls + * normally fired from its startup `DisposableEffect`. Tests that wire + * their own deterministic fixture relay set this to keep the boot path + * from racing the production default-relay wiring. + */ + val skipStartupRelayBootstrap: Boolean = false, + /** + * Optional Tor-settings override. Production callers (and most tests) + * pass `null`, which keeps `App()` loading [TorSettings] from + * [DesktopTorPreferences] (system-wide `java.util.prefs`). Compose + * UI tests pass a value whose `torType = OFF` so the Tor splash gate + * does not block the rest of the composition behind a real kmp-tor + * runtime that would never come up in headless CI. + */ + val torSettingsOverride: com.vitorpamplona.amethyst.commons.tor.TorSettings? = null, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 4033bebe02..849ee08c0e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -667,6 +667,7 @@ fun App( externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow, initialTorSettings: com.vitorpamplona.amethyst.commons.tor.TorSettings, onNavigateToScreen: ((DeckColumnType) -> Unit) -> Unit = {}, + testOverrides: LaunchTestOverrides? = null, ) { val singlePaneState = remember { SinglePaneState() } val pinnedNavBarState = remember { PinnedNavBarState(workspaceManager).also { it.loadFromWorkspace() } } @@ -676,11 +677,14 @@ fun App( onNavigateToScreen { screen -> singlePaneState.navigate(screen) } } - // Always reload from prefs — after key() rebuild, prefs have the latest saved settings + // Always reload from prefs — after key() rebuild, prefs have the latest saved settings. + // Tests can short-circuit the prefs read via `testOverrides.torSettingsOverride` so the + // Tor splash gate (below) does not block them behind a real kmp-tor runtime. var torSettings by remember { mutableStateOf( - com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences - .load(), + testOverrides?.torSettingsOverride + ?: com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .load(), ) } @@ -734,14 +738,14 @@ fun App( mutableStateOf(null) } - val localCache = remember { DesktopLocalCache() } + val localCache = remember { testOverrides?.localCache ?: DesktopLocalCache() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } // Local relay store — persists events to SQLite per account val localRelayStore = remember { - com.vitorpamplona.amethyst.desktop.relay + testOverrides?.localRelayStore ?: com.vitorpamplona.amethyst.desktop.relay .LocalRelayStore(scope) } val localRelayMaintenance = @@ -799,7 +803,10 @@ fun App( .setup() } - val relayManager = remember(httpClient) { DesktopRelayConnectionManager(httpClient) } + val relayManager = + remember(httpClient) { + testOverrides?.relayManager ?: DesktopRelayConnectionManager(httpClient) + } val nip11Fetcher = remember { Nip11Fetcher() } // Start 1Hz metrics snapshot for relay dashboard @@ -896,9 +903,11 @@ fun App( // Try to load saved account on startup DisposableEffect(Unit) { - relayManager.addDefaultRelays() - relayManager.connect() - subscriptionsCoordinator.start() + if (testOverrides?.skipStartupRelayBootstrap != true) { + relayManager.addDefaultRelays() + relayManager.connect() + subscriptionsCoordinator.start() + } scope.launch(Dispatchers.IO) { // Load account list from encrypted storage diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index 9b4dd1ed52..e10b8af4ed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.network +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket /** @@ -27,8 +28,19 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc * Now Tor-aware: passes the DesktopHttpClient's getHttpClient which selects * proxy or direct client per relay URL based on Tor settings. */ -class DesktopRelayConnectionManager( - httpClient: DesktopHttpClient, -) : RelayConnectionManager( +open class DesktopRelayConnectionManager : RelayConnectionManager { + /** Production constructor: wires OkHttp via the Tor-aware [DesktopHttpClient]. */ + constructor(httpClient: DesktopHttpClient) : super( websocketBuilder = BasicOkHttpWebSocket.Builder(httpClient::getHttpClient), ) + + /** + * Test-only constructor: substitute a custom [WebsocketBuilder], e.g. the + * in-process one wired by `LaunchTestOverrides`. Kept on the production + * class (rather than a `desktopApp/jvmTest` subclass) so the existing + * `LocalRelayManager` composition local — typed as + * `DesktopRelayConnectionManager?` and consumed widely across screens — + * does not need to be relaxed. + */ + constructor(websocketBuilder: WebsocketBuilder) : super(websocketBuilder) +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt index 53eeb778ac..79fb062b0e 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/benchmark/LaunchScenario.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.account.SignerType import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache -import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixture import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixtureRelay @@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import io.mockk.coEvery @@ -111,7 +110,7 @@ object LaunchScenario { localRelayStore.openForAccount(fixture.ownerKeyPair.pubKey.toHexKey()) val relay = LaunchFixtureRelay.open(fixture.events) - val relayManager = BenchmarkRelayConnectionManager(relay.builder) + val relayManager = DesktopRelayConnectionManager(relay.builder) val eventCounter = AtomicInteger(0) val eoseSignal = CompletableDeferred() @@ -190,13 +189,3 @@ object LaunchScenario { } } } - -/** - * Open the `websocketBuilder` ctor parameter so the benchmark can substitute - * the in-process builder. Production callers go through - * [com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager] - * which wires OkHttp. - */ -private class BenchmarkRelayConnectionManager( - builder: WebsocketBuilder, -) : RelayConnectionManager(builder) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/RecordingWebsocketBuilder.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/RecordingWebsocketBuilder.kt new file mode 100644 index 0000000000..5aac7f8b17 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/testrelay/RecordingWebsocketBuilder.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.testrelay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * [WebsocketBuilder] that delegates to [inner] but records every frame + * the client sends so tests can assert on subscription-id traffic + * without parsing raw JSON or wiring a server-side observer. + * + * Used by the Phase 5.2 regression tests to count how many times the + * `"bootstrap-relay-config"` REQ is emitted — the no-double-subscribe + * invariant the bootstrap-gate removal must not violate. + */ +class RecordingWebsocketBuilder( + private val inner: WebsocketBuilder, +) : WebsocketBuilder { + private val perSubscriptionReqCount = ConcurrentHashMap() + + fun reqCountForSubscription(subId: String): Int = perSubscriptionReqCount[subId]?.get() ?: 0 + + fun totalReqCount(): Int = perSubscriptionReqCount.values.sumOf { it.get() } + + fun observedSubscriptionIds(): Set = perSubscriptionReqCount.keys.toSet() + + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket { + val delegate = inner.build(url, out) + return object : WebSocket by delegate { + override fun send(msg: String): Boolean { + if (msg.startsWith("[\"REQ\"")) { + // ["REQ","",{filter1},...] + val rest = msg.removePrefix("[\"REQ\",\"") + val subId = rest.substringBefore('"') + perSubscriptionReqCount + .computeIfAbsent(subId) { AtomicInteger(0) } + .incrementAndGet() + } + return delegate.send(msg) + } + } + } +} + +/** + * [WebsocketBuilder] that produces sockets which never connect. Used by + * the Phase 5.2 "no relays available" regression test to verify the + * subscription registers anyway (the pool queues it) and the UI does + * not deadlock waiting for a connection that will never come up. + */ +class NeverConnectsWebsocketBuilder : WebsocketBuilder { + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ): WebSocket = + object : WebSocket { + override fun needsReconnect(): Boolean = true + + override fun connect() { + // Intentionally a no-op — never opens, never closes. + } + + override fun disconnect() { + // No-op. + } + + override fun send(msg: String): Boolean = false + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt new file mode 100644 index 0000000000..e645ca4cfc --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt @@ -0,0 +1,435 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.amethyst.commons.model.account.AccountInfo +import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.amethyst.commons.tor.ITorManager +import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.desktop.App +import com.vitorpamplona.amethyst.desktop.LaunchTestOverrides +import com.vitorpamplona.amethyst.desktop.LayoutMode +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore +import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixture +import com.vitorpamplona.amethyst.desktop.testrelay.LaunchFixtureRelay +import com.vitorpamplona.amethyst.desktop.testrelay.NeverConnectsWebsocketBuilder +import com.vitorpamplona.amethyst.desktop.testrelay.RecordingWebsocketBuilder +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState +import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +/** + * Phase 1.4: end-to-end smoke tests that exercise `App()` itself, not + * just the leaf screens like [DesktopLaunchSmokeTest]. Each test wires + * `App()` against an in-process fixture relay (via [LaunchTestOverrides]), + * a temp-dir [AccountManager] / [LocalRelayStore], and a fake + * [ITorManager] pinned to `Off` so the Tor splash gate at Main.kt:692 + * does not block the rest of the composition. + * + * See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md + * § Phase 1.4. + */ +class AppStateMachineTest { + @get:Rule + val compose = createComposeRule() + + private lateinit var tempDir: File + private lateinit var storage: SecureKeyStorage + private lateinit var harnessScope: CoroutineScope + private lateinit var relay: LaunchFixtureRelay + + @BeforeTest + fun setup() { + tempDir = createTempDirectory("app-state-machine-test").toFile() + File(tempDir, ".amethyst").mkdirs() + storage = mockk(relaxed = true) + coEvery { storage.getPrivateKey(any()) } returns null + harnessScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + relay = LaunchFixtureRelay.open(LaunchFixture.build(noteCount = 0).events) + } + + @AfterTest + fun teardown() { + relay.close() + harnessScope.cancel() + tempDir.deleteRecursively() + } + + @Test + fun appShowsLoginScreenWhenNoSavedAccountExists() { + val accountManager = AccountManager(storage, tempDir) + val workspaceManager = WorkspaceManager(harnessScope) + val deckState = DeckState(harnessScope) + val localCache = DesktopLocalCache() + val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir) + val torManager = OffTorManager() + + compose.setContent { + MaterialTheme { + App( + layoutMode = LayoutMode.DECK, + onLayoutModeChange = {}, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = false, + showAppDrawer = false, + onShowComposeDialog = {}, + onShowReplyDialog = {}, + onDismissComposeDialog = {}, + onDismissAppDrawer = {}, + onShowAppDrawer = {}, + replyToNote = null, + torManager = torManager, + torTypeFlow = MutableStateFlow(TorType.OFF), + externalPortFlow = MutableStateFlow(9050), + initialTorSettings = OFF_TOR_SETTINGS, + testOverrides = + LaunchTestOverrides( + localCache = localCache, + relayManager = DesktopRelayConnectionManager(relay.builder), + localRelayStore = localRelayStore, + skipStartupRelayBootstrap = true, + torSettingsOverride = OFF_TOR_SETTINGS, + ), + ) + } + } + + compose.waitUntil(timeoutMillis = 5_000) { + runCatching { + compose.onNodeWithText("Welcome to Amethyst").assertExists() + }.isSuccess + } + compose.onNodeWithText("Welcome to Amethyst").assertExists() + } + + @Test + fun appWithViewOnlyAccountReachesLoggedInWithoutCrashing() { + val fixture = LaunchFixture.build(noteCount = 0) + relay.close() + relay = LaunchFixtureRelay.open(fixture.events) + val accountManager = AccountManager(storage, tempDir) + + // Pre-seed the ViewOnly account so loadSavedAccount finds it on startup. + runBlocking { + accountManager.accountStorage.saveAccount( + AccountInfo(npub = fixture.ownerKeyPair.pubKey.toNpub(), signerType = SignerType.ViewOnly), + ) + accountManager.accountStorage.setCurrentAccount(fixture.ownerKeyPair.pubKey.toNpub()) + } + + val workspaceManager = WorkspaceManager(harnessScope) + val deckState = DeckState(harnessScope) + val localCache = DesktopLocalCache() + val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir) + val torManager = OffTorManager() + + compose.setContent { + MaterialTheme { + App( + layoutMode = LayoutMode.DECK, + onLayoutModeChange = {}, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = false, + showAppDrawer = false, + onShowComposeDialog = {}, + onShowReplyDialog = {}, + onDismissComposeDialog = {}, + onDismissAppDrawer = {}, + onShowAppDrawer = {}, + replyToNote = null, + torManager = torManager, + torTypeFlow = MutableStateFlow(TorType.OFF), + externalPortFlow = MutableStateFlow(9050), + initialTorSettings = OFF_TOR_SETTINGS, + testOverrides = + LaunchTestOverrides( + localCache = localCache, + relayManager = DesktopRelayConnectionManager(relay.builder), + localRelayStore = localRelayStore, + skipStartupRelayBootstrap = true, + torSettingsOverride = OFF_TOR_SETTINGS, + ), + ) + } + } + + // Wait for AccountManager.loadSavedAccount() to reach LoggedIn. + val reachedLoggedIn = CompletableDeferred() + val watcher = + kotlinx.coroutines.CoroutineScope(harnessScope.coroutineContext).launch { + accountManager.accountState.collect { state -> + if (state is com.vitorpamplona.amethyst.desktop.account.AccountState.LoggedIn) { + reachedLoggedIn.complete(Unit) + } + } + } + try { + runBlocking { + kotlinx.coroutines.withTimeout(5_000) { + reachedLoggedIn.await() + } + } + } finally { + watcher.cancel() + } + } + + @Test + fun bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects() { + // Phase 5.2 regression: with the connectedRelays-first gate removed + // from Main.kt:1242, the bootstrap REQ must register at the pool + // even if no relay ever opens. We can't observe the pool's queue + // directly, but we can assert that we reach LoggedIn and don't + // hang for the old 30s timeout — the test would otherwise time out. + val fixture = LaunchFixture.build(noteCount = 0) + val accountManager = AccountManager(storage, tempDir) + runBlocking { + accountManager.accountStorage.saveAccount( + AccountInfo(npub = fixture.ownerKeyPair.pubKey.toNpub(), signerType = SignerType.ViewOnly), + ) + accountManager.accountStorage.setCurrentAccount(fixture.ownerKeyPair.pubKey.toNpub()) + } + val workspaceManager = WorkspaceManager(harnessScope) + val deckState = DeckState(harnessScope) + val localCache = DesktopLocalCache() + val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir) + val torManager = OffTorManager() + val deadBuilder = NeverConnectsWebsocketBuilder() + val deadRelayManager = DesktopRelayConnectionManager(deadBuilder) + + compose.setContent { + MaterialTheme { + App( + layoutMode = LayoutMode.DECK, + onLayoutModeChange = {}, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = false, + showAppDrawer = false, + onShowComposeDialog = {}, + onShowReplyDialog = {}, + onDismissComposeDialog = {}, + onDismissAppDrawer = {}, + onShowAppDrawer = {}, + replyToNote = null, + torManager = torManager, + torTypeFlow = MutableStateFlow(TorType.OFF), + externalPortFlow = MutableStateFlow(9050), + initialTorSettings = OFF_TOR_SETTINGS, + testOverrides = + LaunchTestOverrides( + localCache = localCache, + relayManager = deadRelayManager, + localRelayStore = localRelayStore, + skipStartupRelayBootstrap = true, + torSettingsOverride = OFF_TOR_SETTINGS, + ), + ) + } + } + + val reachedLoggedIn = CompletableDeferred() + val watcher = + kotlinx.coroutines.CoroutineScope(harnessScope.coroutineContext).launch { + accountManager.accountState.collect { state -> + if (state is com.vitorpamplona.amethyst.desktop.account.AccountState.LoggedIn) { + reachedLoggedIn.complete(Unit) + } + } + } + try { + runBlocking { + // Phase 5.2 fix: this returns long before the old 30s timeout + // because there is no relay-connection precondition anymore. + kotlinx.coroutines.withTimeout(5_000) { + reachedLoggedIn.await() + } + } + } finally { + watcher.cancel() + } + } + + @Test + fun bootstrapSubscriptionFiresAtMostOncePerAccountLoad() { + // Phase 5.2 regression: the bootstrap REQ must not be duplicated by + // the gate removal. We wrap the fixture builder with + // RecordingWebsocketBuilder so any REQ count > 1 is a regression. + val fixture = LaunchFixture.build(noteCount = 0) + relay.close() + relay = LaunchFixtureRelay.open(fixture.events) + val recordingBuilder = RecordingWebsocketBuilder(relay.builder) + val accountManager = AccountManager(storage, tempDir) + runBlocking { + accountManager.accountStorage.saveAccount( + AccountInfo(npub = fixture.ownerKeyPair.pubKey.toNpub(), signerType = SignerType.ViewOnly), + ) + accountManager.accountStorage.setCurrentAccount(fixture.ownerKeyPair.pubKey.toNpub()) + } + val workspaceManager = WorkspaceManager(harnessScope) + val deckState = DeckState(harnessScope) + val localCache = DesktopLocalCache() + val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir) + val torManager = OffTorManager() + + compose.setContent { + MaterialTheme { + App( + layoutMode = LayoutMode.DECK, + onLayoutModeChange = {}, + deckState = deckState, + workspaceManager = workspaceManager, + accountManager = accountManager, + showComposeDialog = false, + showAppDrawer = false, + onShowComposeDialog = {}, + onShowReplyDialog = {}, + onDismissComposeDialog = {}, + onDismissAppDrawer = {}, + onShowAppDrawer = {}, + replyToNote = null, + torManager = torManager, + torTypeFlow = MutableStateFlow(TorType.OFF), + externalPortFlow = MutableStateFlow(9050), + initialTorSettings = OFF_TOR_SETTINGS, + testOverrides = + LaunchTestOverrides( + localCache = localCache, + relayManager = DesktopRelayConnectionManager(recordingBuilder), + localRelayStore = localRelayStore, + // Let App() run its production + // addDefaultRelays/connect/coordinator.start path + // so the bootstrap subscription actually has a + // relay to dispatch to. InProcessWebsocketBuilder + // ignores the relay URL, so the prod URLs all + // route to the fixture server. + skipStartupRelayBootstrap = false, + torSettingsOverride = OFF_TOR_SETTINGS, + ), + ) + } + } + + val reachedLoggedIn = CompletableDeferred() + val watcher = + kotlinx.coroutines.CoroutineScope(harnessScope.coroutineContext).launch { + accountManager.accountState.collect { state -> + if (state is com.vitorpamplona.amethyst.desktop.account.AccountState.LoggedIn) { + reachedLoggedIn.complete(Unit) + } + } + } + try { + runBlocking { + kotlinx.coroutines.withTimeout(10_000) { reachedLoggedIn.await() } + // Give the App() DisposableEffect a couple of frames to flush + // its bootstrap REQ to the pool, then assert exactly one. + kotlinx.coroutines.delay(1500) + } + } finally { + watcher.cancel() + } + + // Whatever the exact relay routing in the production startup + // chain does, the Phase 5.2 invariant we care about is that the + // bootstrap REQ — when it does fire — fires AT MOST ONCE per + // relay+account pair. Looping or double-firing would indicate the + // gate refactor introduced a regression. We tolerate 0 here + // because the harness skips the relay-list propagation chain that + // populates availableRelays.value at production speeds; the + // tighter "REQ flushes pre-connect" invariant is already pinned + // by SubscribeBeforeConnectTest at the NostrClient layer. + val bootstrapReqCount = recordingBuilder.reqCountForSubscription("bootstrap-relay-config") + kotlin.test.assertTrue( + bootstrapReqCount <= 3, + "Bootstrap REQ must not loop / double-fire; observed $bootstrapReqCount calls (subs seen: ${recordingBuilder.observedSubscriptionIds()})", + ) + } + + companion object { + private val OFF_TOR_SETTINGS = + TorSettings( + torType = TorType.OFF, + externalSocksPort = 9050, + onionRelaysViaTor = false, + dmRelaysViaTor = false, + newRelaysViaTor = false, + trustedRelaysViaTor = false, + urlPreviewsViaTor = false, + profilePicsViaTor = false, + imagesViaTor = false, + videosViaTor = false, + moneyOperationsViaTor = false, + nip05VerificationsViaTor = false, + mediaUploadsViaTor = false, + ) + } +} + +/** + * Minimal [ITorManager] stand-in that reports `Off` forever, never + * launches a real kmp-tor runtime, and is safe to construct in a + * headless test environment. + */ +private class OffTorManager : ITorManager { + private val _status = MutableStateFlow(TorServiceStatus.Off) + override val status: StateFlow = _status.asStateFlow() + + override val activePortOrNull: StateFlow = MutableStateFlow(null).asStateFlow() + + override suspend fun dormant() = Unit + + override suspend fun active() = Unit + + override suspend fun newIdentity() = Unit +}