mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
+63
@@ -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
|
||||
|
||||
+15
-3
@@ -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)
|
||||
}
|
||||
|
||||
+2
-13
@@ -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)
|
||||
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
+435
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user