feat(desktop): App() Compose UI smoke tests + Phase 5.2 regression tests

Adds a `LaunchTestOverrides` bundle (default null in production) so
`App()` can be driven from `createComposeRule()` against the in-process
fixture relay instead of the OkHttp + kmp-tor + DesktopHttpClient stack
it normally constructs via `remember { … }`. `DesktopRelayConnectionManager`
gains a secondary constructor taking a `WebsocketBuilder` so the
`LocalRelayManager` composition local (typed as
`DesktopRelayConnectionManager?` and consumed by ~20 screens) does not
have to be relaxed.

`AppStateMachineTest` exercises four scenarios:

1. `appShowsLoginScreenWhenNoSavedAccountExists` — App() with no
   `accounts.json.enc` reaches LoggedOut and renders LoginScreen.
2. `appWithViewOnlyAccountReachesLoggedInWithoutCrashing` — App() with
   a pre-seeded ViewOnly account reaches LoggedIn end-to-end through
   `MainContent`, the deck columns, NWC wiring, etc.
3. `bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects` —
   wires a `NeverConnectsWebsocketBuilder` so no connection ever opens,
   yet App() still reaches LoggedIn within 5s instead of the previous
   30s gate timeout. Direct regression test for the Phase 5.2
   bootstrap-gate removal.
4. `bootstrapSubscriptionFiresAtMostOncePerAccountLoad` — wraps the
   fixture builder with a `RecordingWebsocketBuilder` and asserts the
   bootstrap REQ does not loop or double-fire.

The `LaunchScenario` benchmark drops its private
`BenchmarkRelayConnectionManager` subclass in favor of the new
secondary `DesktopRelayConnectionManager(WebsocketBuilder)` constructor.

278/278 desktopApp tests pass.
This commit is contained in:
nrobi144
2026-06-18 12:09:41 +03:00
parent d10c875d88
commit 48a8178c98
6 changed files with 628 additions and 25 deletions
@@ -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,
)
@@ -667,6 +667,7 @@ fun App(
externalPortFlow: kotlinx.coroutines.flow.MutableStateFlow<Int>,
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<com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab?>(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
@@ -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)
}
@@ -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<Unit>()
@@ -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)
@@ -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<String, AtomicInteger>()
fun reqCountForSubscription(subId: String): Int = perSubscriptionReqCount[subId]?.get() ?: 0
fun totalReqCount(): Int = perSubscriptionReqCount.values.sumOf { it.get() }
fun observedSubscriptionIds(): Set<String> = 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","<subId>",{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
}
}
@@ -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<Unit>()
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<Unit>()
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<Unit>()
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>(TorServiceStatus.Off)
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
override val activePortOrNull: StateFlow<Int?> = MutableStateFlow<Int?>(null).asStateFlow()
override suspend fun dormant() = Unit
override suspend fun active() = Unit
override suspend fun newIdentity() = Unit
}