Merge pull request #3261 from nrobi144/feat/desktop-launch-optimization

feat(desktop): launch optimization foundation + icon-decode/relay-bootstrap fixes
This commit is contained in:
David Kaspar
2026-06-18 11:38:59 +02:00
committed by GitHub
24 changed files with 3149 additions and 107 deletions
+49
View File
@@ -0,0 +1,49 @@
# Launch Benchmark
Single-JVM warm benchmark for the cold-boot critical path. See
[`desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md`](../plans/2026-06-17-feat-app-launch-optimization-plan.md)
for the design.
## Run
```bash
AMETHYST_BENCH=true ./gradlew :desktopApp:test \
--tests "*LaunchBenchmark.run" --rerun-tasks
```
Without the env var the test class skips itself silently so a normal
`./gradlew :desktopApp:test` stays fast.
## Output
A per-git-sha report lands at
`desktopApp/build/benchmarks/launch-<git-sha>.txt`. The benchmark prints the
report to stdout and atomically writes the file via
`Files.move(... ATOMIC_MOVE)` so a killed run does not pollute the trend
data with partial content.
## Reading the numbers
Each row reports `n`, min, q1, median, q3, max in milliseconds. The
benchmark drives the slim "non-Compose" cold-boot scenario:
`AccountManager.loadSavedAccount` → relay subscription via
`InProcessWebsocketBuilder` (fixture relay) → `DesktopLocalCache.consume`.
- `t_account_logged_in``AccountManager.accountState` reaches
`LoggedIn(isReadOnly=true)`.
- `t_first_event` — first `kind:1` flows through `DesktopLocalCache.consume`.
- `t_n_events``n`th (default `n=10`) `kind:1` flows through the cache.
Numbers are dominated by the harness floor (`InProcessWebSocket` channel
hops, fixture-server REQ matching, coroutine dispatcher schedule). They
are most useful as a **regression guard** for code already in the
exercised path; they do **not** approximate a real Skia/Swing first paint.
Layered Compose-driven and JVM-fork variants are tracked as deferred
follow-ups in the plan.
## Snapshots committed here
- `baseline-main.txt` — pre-Phase-5.2 baseline.
- `with-phase5-fixes.txt` — post-Phase-5.2 snapshot.
Diff manually with `diff -u baseline-main.txt with-phase5-fixes.txt`.
+19
View File
@@ -0,0 +1,19 @@
# LaunchBenchmark report — PRE-PHASE-5.2 baseline
# Captured BEFORE the Main.kt bootstrap-gate removal. The slim benchmark
# does not drive the App() bootstrap subscription path, so this row is
# primarily a harness-floor sanity reference for the cold-boot
# relay→cache pipeline; the gate fix itself is validated by
# SubscribeBeforeConnectTest rather than by a measurable delta here.
# date 2026-06-18T07:46:13.631678Z
# jvm 21.0.9 Homebrew
# os Mac OS X 26.5 aarch64
# cpus 10
# max-heap-mb 512
# git-sha 48726ee4df (after Phase 1 pyramid + Phase 5.1 icon fix)
# iterations 5 (after 2 warmup, discarded)
# fork-mode single-JVM (cold-fork driver deferred)
t_account_logged_in n=5 min= 0.27ms q1= 0.27ms median= 0.31ms q3= 0.34ms max= 0.47ms
t_first_event n=5 min= 0.79ms q1= 0.87ms median= 0.95ms q3= 1.11ms max= 1.15ms
t_n_events n=5 min= 1.27ms q1= 1.35ms median= 1.36ms q3= 1.48ms max= 2.38ms
# events-consumed per iteration: [11, 12, 12, 12, 13]
@@ -0,0 +1,21 @@
# LaunchBenchmark report — POST-PHASE-5.2 (gate removal) snapshot
# Captured AFTER the Main.kt bootstrap-gate removal. NOTE: the slim
# benchmark does not actually exercise the gated code path (the gate is
# inside the App() composable, which this harness does not drive). The
# row remains a harness-floor sanity check; the gate-removal payoff
# manifests during real App() boot when no relay has connected yet and
# the previous 30s `withTimeoutOrNull` would otherwise idle the
# subscription.
# date 2026-06-18T07:57:51.519608Z
# jvm 21.0.9 Homebrew
# os Mac OS X 26.5 aarch64
# cpus 10
# max-heap-mb 512
# git-sha 48726ee4df23bf97c6e302766af42e048c3060f0
# iterations 5 (after 2 warmup, discarded)
# fork-mode single-JVM (cold-fork driver deferred)
t_account_logged_in n=5 min= 0.21ms q1= 0.48ms median= 0.72ms q3= 0.87ms max= 1.15ms
t_first_event n=5 min= 1.17ms q1= 1.34ms median= 1.46ms q3= 2.06ms max= 2.10ms
t_n_events n=5 min= 1.51ms q1= 1.76ms median= 2.63ms q3= 2.75ms max= 3.55ms
# events-consumed per iteration: [23, 50, 15, 28, 23]
@@ -0,0 +1,688 @@
---
title: App Launch Optimization (Desktop, Foundation-first)
type: feat
status: active
date: 2026-06-17
origin: docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md
deepened: 2026-06-17
---
# App Launch Optimization (Desktop, Foundation-first)
## Progress Log
| Date | Phase | Outcome | Commit |
|------------|-----------|------------------------------------------------------------------------------------------|-------------|
| 2026-06-17 | 1.1 | `AccountManagerLoadStateTransitionsTest` — 2 tests pass | `ff55898ab` |
| 2026-06-17 | 1.2 | `LocalRelayStoreHydrationTest` — 5 tests pass | `ff55898ab` |
| 2026-06-17 | 1.3 | `LocalRelayStore` gains `homeDir` ctor param (default unchanged) | `ff55898ab` |
| 2026-06-17 | 5.1 | `IconResources` collapses 4 sites + 2 `ImageIO.read` calls into one lazy each; 5 tests | `b338d7db4` |
| 2026-06-18 | 2.1 / 2.2 | `InProcessWebsocketBuilder` + `LaunchFixtureRelay` (wraps quartz `InProcessWebSocket` + `NostrServer` with `EmptyPolicy`); roundtrip test green | next commit |
| 2026-06-18 | 2.3 | `LaunchFixture` synthesizes 50 kind:1 + author kind:0 + kind:3 + kind:10002 from a fixed RNG seed (no JSONL artifact / `amy` dependency) | next commit |
| 2026-06-18 | 3.1 | `LaunchMarkers` (single-threaded `mutableMapOf` + `TimeSource.Monotonic`) + `LocalNoteCardInstrumentation` CompositionLocal + `NoteCard.Modifier.testTag(NOTE_CARD_TEST_TAG).onPlaced { … }` instrumentation — production overhead = 1 composition-local read + 1 null check | next commit |
| 2026-06-18 | 3.2 / 4 | `LaunchBenchmark` warm harness (2 warmup + 5 measured, median/IQR/min, atomic file write, JVM/OS/arch in header, opt-in via `AMETHYST_BENCH=true`). Baseline captured at `desktopApp/benchmarks/baseline-main.txt` | next commit |
| 2026-06-18 | 5.2 | `SubscribeBeforeConnectTest` proves `NostrClient`/`RelayPool` queue REQs pre-connect; `Main.kt:1242` bootstrap gate `connectedRelays.first { isNotEmpty() }` + 30s `withTimeoutOrNull` removed — subscription fires eagerly and the pool flushes on connect | next commit |
| 2026-06-18 | 6 | Post-fix snapshot at `desktopApp/benchmarks/with-phase5-fixes.txt` | `b14ee5ec5` |
| 2026-06-18 | 1.4 / 2.4 / 5.2-tests | `LaunchTestOverrides` makes `relayManager` / `localCache` / `localRelayStore` / `torSettings` injectable into `App()`. `DesktopRelayConnectionManager` gains a secondary ctor taking a `WebsocketBuilder` so the fixture relay can substitute without relaxing `LocalRelayManager`'s composition-local type. `AppStateMachineTest` ships four tests: logged-out → LoginScreen; ViewOnly preloaded → LoggedIn; bootstrap-gate-removal verified against `NeverConnectsWebsocketBuilder`; no double-fire of the bootstrap REQ via `RecordingWebsocketBuilder`. | `48a8178c9` |
**All foundational and in-scope phases (1.1, 1.2, 1.3, 1.4, 2.1-2.4, 3.1, 3.2, 4, 5.1, 5.2, 6) are now landed.** 278/278 desktopApp tests pass.
**Deferred to follow-up plans (not part of this effort):**
- **Cold-fork shell driver** for the benchmark (per-sample JVM fork). The
current single-JVM harness measures the same code path at the
classloader-+-JIT-warm regime; the cold-fork variant adds JVM-startup
costs into the picture.
- **Compose-driving benchmark variant** (wires `LaunchMarkers` to
`LocalNoteCardInstrumentation` and drives `App()` via the harness
introduced for Phase 1.4 so the markers include the real composition +
layout cost, not just the relay/cache pipeline).
- **Phase 5.3** (sequential `remember` chain in `MainContent`) — a
separate plan; only worth tackling if a real cold-boot profiler trace
shows `MainContent` composition is on the critical path.
- **Memory + warm-boot benchmark variants** — once the cold-fork driver
and Compose-driving variant land, layering memory + pre-seeded `events.db`
warm-boot is straightforward.
## Enhancement Summary
**Deepened:** 2026-06-17 — 5 review agents (code-simplicity, architecture-strategist, performance-oracle, pattern-recognition, spec-flow-analyzer) plus repo-research-analyst.
**Key changes from initial plan:**
1. **Plan relocated** from `docs/plans/` (frozen per CLAUDE.md) to `desktopApp/plans/`.
2. **Phase 5.3 (sequential `remember` chain) cut** — spawn separate plan if Phase 4 baseline justifies. Avoids investigation scope creep.
3. **Warm-boot benchmark variant deferred** — all in-scope fixes target cold-boot; defer until a warm-path fix appears.
4. **Memory metric deferred**`runComposeUiTest` heap isn't representative of real Swing/Skia; JVM `System.gc()` semantics unreliable. Revisit after baseline.
5. **Test fixtures placed in `:quartz` testFixtures** (not `:commons`) — `:quartz` already proves KMP + `java-test-fixtures` works (consumes `:geode` fixtures at `quartz/build.gradle.kts:352-358`). `:commons` KMP attempt was unnecessary risk.
6. **Use `createComposeRule()`** (not `runComposeUiTest`) — repo convention per `DesktopLaunchSmokeTest.kt:24`.
7. **Repo's existing test pattern**: `backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { ... toList(states) } + advanceUntilIdle()` (per `AccountManagerStateTransitionTest.kt:73-95`).
8. **Cold benchmark forks JVM per sample** (shell-script driver) — same-JVM iterations measure JIT warmth, not cold boot.
9. **Renamed `t_compose_first_frame` → `t_first_composition_apply`** with explicit "not a real Skia frame" disclaimer.
10. **Event-count instrumentation via `Modifier.onPlaced` + `AtomicInteger`** — semantic-tree polling has 16ms quantization + tree-traversal bias.
11. **Pinned JVM flags** (`-Xms512m -Xmx512m -XX:+UseG1GC`); control benchmark (empty `Box`) measures harness floor.
12. **N=20 warm iterations, median + IQR + min** — Mann-Whitney U for fix delta significance, not t-test.
13. **`AccountManagerLoadStateTransitionsTest`** renamed to avoid collision with existing `AccountManagerStateTransitionTest`.
14. **Cut Internal/Remote AccountManager tests** — only ViewOnly drives the benchmark; deferred coverage is a separate scope.
15. **Cut `tools/launch-fixture/capture.sh`** — manual one-shot fixture commit; document `amy` commands in README.
### New Considerations Discovered
- `:quartz` already consumes `:geode` testFixtures across KMP boundary — KMP+testFixtures friction is overstated.
- `runComposeUiTest` skips real Swing/Skia surface; `t_first_composition_apply` measures composition cost, not paint.
- `RelayPool` queue-pre-connect behavior is the hinge for Phase 5.2 candidate (a) vs (b); must verify before refactor.
- Both home + DM subscriptions share the same gate (`Main.kt:1283-1326` + `1330`); fix must share a helper.
- `InProcessWebSocket` latency floor (~10-50ms) may swallow icon-decode delta on `t_n_events`; microbench the ImageIO.read cost directly.
---
## Overview
Build the **testing + benchmarking foundation** needed to safely refactor Amethyst Desktop's launch path, capture a quantitative baseline, then ship two targeted launch-path fixes guided by the resulting numbers. Android is explicitly deferred to a follow-up plan; sequential `remember` chain refactor (originally Phase 5.3) is deferred to a separate plan post-baseline.
Phased delivery (Approach A from brainstorm):
1. **Test pyramid foundation** — unit tests around `AccountManager.loadSavedAccount` (ViewOnly path only) + `LocalRelayStore.hydrate`; three `App()`-level Compose UI smoke tests. No launch-path code change beyond an optional `homeDir` ctor param on `LocalRelayStore`.
2. **Deterministic relay seam**`InProcessWebsocketBuilder` + `FixtureNostrServer` in `:quartz` testFixtures. Real-world snapshot fixture (50 kind:1 + metadata for one well-known npub).
3. **Benchmark harness** — JVM-only harness driving `App()` via `createComposeRule` + onPlaced markers. Two harnesses: cold (fork-per-sample shell driver, N=10) and warm (single JVM, N=20). Memory + warm-boot DEFERRED.
4. **Baseline capture** — run benchmark on `main`, commit numbers as the reference point.
5. **Targeted fixes** — icon decode (cheap, microbench independent), feed bootstrap relay gate (medium refactor); each re-benchmarked after.
(see brainstorm: `docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md`)
---
## Problem Statement
Cold-boot perception is a primary UX signal and currently **unmeasured**. No timing markers exist in `Main.kt`, `App()`, or `AccountManager.loadSavedAccount`. The desktop launch path has identified bottlenecks (`Main.kt:218,302,988` triple icon decode on main thread; `Main.kt:1283-1330` home + DM subscriptions both gated on `connectedRelays.first { isNotEmpty() }` with a 30s timeout) — but refactoring boot code without tests is exactly how regressions ship.
The only existing test that touches launch wiring is `DesktopLaunchSmokeTest.kt:24` (drives `LoginScreen` only via `createComposeRule`); nothing covers `App()`, `MainContent`, or the `AccountState` transitions. We need the safety net **before** the refactors.
---
## Proposed Solution
Foundation-first: write tests before touching launch code. Inject a deterministic in-memory relay via the already-open `WebsocketBuilder` constructor seam on `RelayConnectionManager` (`Main.kt:834`). Drive the read-only npub flow (`AccountManager.loadReadOnlyAccount` — pure, no I/O, no keychain) as the headline benchmark scenario. Measure four time metrics (composition apply, account hydration done, first event, N=10 events; memory DEFERRED). Capture cold (fork-per-sample) and warm (single-JVM N=20) runs locally. Then fix one bottleneck at a time and prove the delta with numbers.
Key insight (deepen-plan): **most seams already exist.** `RelayConnectionManager` is `open class` taking `WebsocketBuilder`. `App()` is window-agnostic. Quartz already ships `InProcessWebSocket` + `NostrServer`. `:quartz` already consumes `:geode` testFixtures across the KMP boundary. The brainstorm's "FakeWebsocketBuilder" is a 20-line wrapper.
---
## Technical Approach
### Architecture
```
Test JVM (jvmTest)
├── LaunchMarkers (single-threaded, mutableMapOf<String, Duration>)
├── createComposeRule { setContent { MaterialTheme { App(...) } } }
│ └── App() with injected deps:
│ ├── AccountManager — temp homeDir, no keychain (ViewOnly only)
│ ├── LocalRelayStore — temp homeDir param (new ctor arg)
│ ├── DesktopRelayConnectionManager — InProcessWebsocketBuilder(fakeServer)
│ └── LocalRelayMaintenance.start() suppressed in tests (no refactor)
├── InProcessWebsocketBuilder (:quartz testFixtures)
│ └── wraps InProcessWebSocket → FixtureNostrServer
│ └── loads fixtures/launch/<name>.jsonl, matches REQ via Filter.match
└── Fixture: quartz/src/testFixtures/resources/fixtures/launch/fiatjaf-50.jsonl
```
### Module Placement
- `:quartz` testFixtures: `InProcessWebsocketBuilder`, `FixtureNostrServer`, fixture JSONL.
- `:desktopApp:jvmTest`: `AccountManagerLoadStateTransitionsTest`, `LocalRelayStoreHydrationTest`, `AppStateMachineTest`, `LaunchMarkers`, `LaunchBenchmark`.
- `:commons/commonMain`: `NoteCardTags` constants object (so Android can reuse).
- `:desktopApp:jvmMain`: `NoteCard` adds `Modifier.testTag(NoteCardTags.ROOT).onPlaced { ... }` — one-line change with negligible production cost.
Consumer wiring: `desktopApp/build.gradle.kts` adds `jvmTestImplementation(testFixtures(project(":quartz")))`. Pattern proven at `quartz/build.gradle.kts:352-358` consuming `testFixtures(project(":geode"))`.
### Marker Registry
```kotlin
// desktopApp/src/jvmTest/kotlin/.../benchmark/LaunchMarkers.kt
object LaunchMarkers {
private val timestamps = mutableMapOf<String, Duration>()
private var start: TimeMark? = null
fun start() { timestamps.clear(); start = TimeSource.Monotonic.markNow() }
fun mark(name: String) {
if (name !in timestamps) timestamps[name] = start!!.elapsedNow()
}
fun snapshot(): Map<String, Duration> = timestamps.toMap()
}
```
Single-threaded — Gradle test parallelism disabled for benchmark suite (`maxParallelForks = 1` filtered to `*LaunchBenchmark*`).
Observation points (all in test code, zero production instrumentation):
- **`t_first_composition_apply`** — first `composeTestRule.waitForIdle()` returns after `setContent`. (Renamed from `t_compose_first_frame` — does NOT represent real Skia/GPU frame.)
- **`t_account_logged_in`** — observed via `backgroundScope.launch(UnconfinedTestDispatcher) { accountManager.accountState.toList(states) }` — marker recorded when `AccountState.LoggedIn` arrives.
- **`t_first_event`** — `Modifier.onPlaced` callback on `NoteCard` increments an `AtomicInteger`; marker recorded when counter goes 0→1.
- **`t_n_events`** — same counter reaches 10.
NoteCard production change is small:
```kotlin
// commons/src/commonMain/.../ui/note/NoteCardTags.kt (new)
object NoteCardTags {
const val ROOT = "amethyst.note_card.root"
}
// desktopApp/src/jvmMain/.../ui/note/NoteCard.kt:97
fun NoteCard(...) {
Surface(modifier = Modifier
.testTag(NoteCardTags.ROOT)
.onPlacedHook() // no-op in production, calls LaunchInstrumentation in tests
...) { ... }
}
```
`onPlacedHook` is a `Modifier` extension defined in `commons/commonMain` that's a no-op by default; tests swap it via a CompositionLocal `LocalLaunchInstrumentation` (default = `LaunchInstrumentation.Noop`). One-line override in `AppStateMachineTest` and `LaunchBenchmark`.
### Fake Relay Wire Path
```
Test sets up: FixtureNostrServer(fixture = "fiatjaf-50.jsonl")
Test constructs: DesktopRelayConnectionManager(InProcessWebsocketBuilder(server))
App() → RelayConnectionManager → NostrClient → RelayPool → BasicRelayClient
↓ (uses InProcessWebsocketBuilder)
InProcessWebSocket connects to FixtureNostrServer
Test sets account.relays to listOf(NormalizedRelayUrl("wss://test.invalid"))
NostrClient sends REQ frames; FixtureNostrServer matches by Filter, replays EVENTs + EOSE
DesktopLocalCache.consume(event, relay = "wss://test.invalid", wasVerified = true)
LocalFeedProvider observes; FeedScreen LazyColumn composes NoteCards
Modifier.onPlaced fires per NoteCard, AtomicInteger inc, LaunchMarkers records t_first_event then t_n_events
```
### Implementation Phases
#### Phase 1 — Test pyramid foundation
**1.1 — `AccountManagerLoadStateTransitionsTest` (ViewOnly only)**
New file: `desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadStateTransitionsTest.kt`
Renamed to avoid collision with existing `AccountManagerStateTransitionTest.kt`. Covers ViewOnly happy path + one decode-failure path (corrupt `accounts.json.enc`). Internal + Remote deferred — they are unrelated to the benchmark, and adding them is scope creep.
Pattern follows repo convention (`AccountManagerStateTransitionTest.kt:73-95`):
```kotlin
@Test
fun viewOnlyAccountTransitionsThroughLoadingToLoggedIn() = runTest {
val storage = mockk<SecureKeyStorage>(relaxed = true)
val tempDir = createTempDirectory("acctmgr-load-state").toFile()
writeAccountsJsonEnc(tempDir, viewOnlyAccountInfo(testNpub))
val mgr = AccountManager(storage, tempDir)
val states = mutableListOf<AccountState>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
mgr.accountState.toList(states)
}
mgr.loadSavedAccount()
advanceUntilIdle()
assertTrue(states.size >= 3)
assertTrue(states.first() is AccountState.Loading)
assertTrue(states.last() is AccountState.LoggedIn)
assertEquals(true, (states.last() as AccountState.LoggedIn).isReadOnly)
}
```
Acceptance: 2 tests pass (happy + decode failure).
**1.2 — `LocalRelayStoreHydrationTest`**
Covers `LocalRelayStore.hydrate` invariants (`LocalRelayStore.kt:115-161`). 5 tests:
- kind:3 (contact list) consumed before kind:0 (metadata).
- kind:0 author metadata before activity events.
- All consumed events tagged with `LOCAL_RELAY_URL` + `wasVerified=true`.
- Empty DB hydrates without throwing; cache receives no events.
- Replaceable (kind:0/3): only most recent kept.
**1.3 — `LocalRelayStore` `homeDir` ctor seam**
File: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt`
```kotlin
class LocalRelayStore(
private val scope: CoroutineScope,
private val homeDir: File = File(System.getProperty("user.home")),
) : AutoCloseable {
private fun dbDir(pubKeyHex: String): File =
File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
...
}
```
`LocalRelayMaintenance` refactor **deferred** — tests just don't call `maintenance.start()`. If/when it becomes a blocker, refactor then.
**1.4 — `AppStateMachineTest` (Compose UI smoke)**
New file: `desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/AppStateMachineTest.kt`
Uses `createComposeRule()` (repo convention). Wraps `MaterialTheme {}` per `DesktopLaunchSmokeTest.kt:69` pattern. Three tests:
- `loggedOutShowsLoginScreen()` — no `accounts.json.enc`.
- `viewOnlyAccountReachesLoggedIn()` — pre-write ViewOnly account.
- `forceLogoutReasonShowsDialog()` — pre-populate `forceLogoutReason`.
Temporary minimal `WebsocketBuilder` fake (replaced by `InProcessWebsocketBuilder` in 2.4).
Acceptance: 3 tests pass, ≤ 5s each.
#### Phase 2 — Deterministic relay seam
**2.1 — `InProcessWebsocketBuilder` in `:quartz` testFixtures**
Add `java-test-fixtures` plugin + testFixtures source set to `:quartz` if not already. (Verify: research found `quartz/build.gradle.kts:352-358` already consumes `testFixtures(project(":geode"))`, so the plugin DSL is in use somewhere in the chain — may need to be enabled on `:quartz` for it to produce fixtures.)
New file: `quartz/src/testFixtures/kotlin/com/vitorpamplona/quartz/test/relay/InProcessWebsocketBuilder.kt`
```kotlin
class InProcessWebsocketBuilder(
private val server: NostrServer,
) : WebsocketBuilder {
override fun build(url: NormalizedRelayUrl, out: WebSocketListener): WebSocket =
InProcessWebSocket(url, out, server)
}
```
Unit test verifies `RelayPool(builder).request(...)` round-trips REQ → EVENT + EOSE within 100ms.
**2.2 — `FixtureNostrServer`**
New file: `quartz/src/testFixtures/kotlin/com/vitorpamplona/quartz/test/relay/FixtureNostrServer.kt`
Spec (edge cases from spec-flow review baked in):
- Load fixture JSONL at construction; fail fast with `FixtureParseException(line, lineNumber, cause)` on malformed line.
- On REQ: match `Filter` per fixture event via `Filter.match(event)`. Send matching events in fixture order.
- **Always send EOSE** after replay, even if no events match (prevents benchmark hang).
- Per-connection `Mutex` to serialize REQ handling — safe under concurrent home + DM subs.
- Configurable response delay (default 5ms) to model relay RTT.
Tests: 50-event fixture + Filter for kinds=[1] authors=[pubKey] → 50 events + EOSE within 100ms; malformed line → `FixtureParseException`; empty match → bare EOSE.
**2.3 — Real-world snapshot fixture (manual capture)**
Per simplicity reviewer: no capture script. One-shot manual capture using `amy`. Document the exact commands in `quartz/src/testFixtures/resources/fixtures/launch/README.md`:
```bash
amy fetch --kinds 1 --author <fiatjaf-npub> --limit 50 --json > fiatjaf-50.jsonl
amy fetch --kinds 0 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
# For each p-tag referenced pubkey:
amy fetch --kinds 0 --author <ref-npub> --json >> fiatjaf-50.jsonl
amy fetch --kinds 3 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
amy fetch --kinds 10002 --author <fiatjaf-npub> --json >> fiatjaf-50.jsonl
```
Commit `fiatjaf-50.jsonl` as an immutable artifact. Re-capture only via explicit human action; PR that recaptures must include new baseline numbers.
Acceptance: fixture present, ≥ 50 events, all valid signatures, FixtureNostrServer loads it without error.
**2.4 — Replace temporary fake in 1.4 tests**
Update `AppStateMachineTest.viewOnlyAccountReachesLoggedIn()` to use `InProcessWebsocketBuilder(FixtureNostrServer.load("fiatjaf-50.jsonl"))`. Validates full subscription wiring end-to-end.
Acceptance: existing Phase 1.4 tests still pass with real fixture data, no time blow-up (≤ 8s).
#### Phase 3 — Benchmark harness
**3.1 — `LaunchMarkers` + instrumentation hooks**
- `LaunchMarkers` (simple, single-threaded, per above).
- `NoteCardTags` const object in `commons/commonMain`.
- `LocalLaunchInstrumentation` CompositionLocal in `commons/commonMain` (default = `Noop`).
- `Modifier.onPlacedHook()` extension reading the CompositionLocal.
- NoteCard adds `.testTag(NoteCardTags.ROOT).onPlacedHook()` to its root surface (one line each, prod-safe).
Acceptance: launching `App()` against fixture relay produces 4 named markers within 10s.
**3.2 — Two benchmark harnesses (cold + warm)**
Per perf reviewer: cold and warm need different statistical regimes.
**Cold harness**`desktopApp/benchmarks/cold-launch.sh`:
```bash
#!/usr/bin/env bash
ITERATIONS=${1:-10}
mkdir -p desktopApp/build/benchmarks
out=desktopApp/build/benchmarks/cold-$(git rev-parse --short HEAD).tmp
echo "# cold-launch $(date -u +%FT%TZ) $(java --version | head -1)" > "$out"
echo "# host: $(uname -a)" >> "$out"
echo "# jvm-flags: -Xms512m -Xmx512m -XX:+UseG1GC" >> "$out"
for i in $(seq 1 "$ITERATIONS"); do
./gradlew --no-daemon :desktopApp:jvmTest \
--tests "*ColdLaunchBenchmark.runOnce" \
-Dorg.gradle.jvmargs="-Xms512m -Xmx512m -XX:+UseG1GC" \
-Pbench.output="$out"
done
mv "$out" "${out%.tmp}.txt"
desktopApp/benchmarks/report.py "${out%.tmp}.txt"
```
Each iteration forks a fresh JVM (no daemon). `ColdLaunchBenchmark.runOnce` appends one row per metric (median is just the value; no in-JVM warmup). Output is moved atomically once complete (spec-flow gap #12 — atomic write).
**Warm harness**`WarmLaunchBenchmark` JUnit test, single JVM:
- N=20 iterations within one test method.
- Discard first 5 iterations (JIT warmup).
- Report median + IQR + min on remaining 15.
- Atomic write to `desktopApp/build/benchmarks/warm-${git_sha}.txt`.
Both harnesses include a **control benchmark**: `setContent { Box {} }` with onPlaced + LaunchMarkers, to measure harness floor. Reported alongside; reviewers can read SNR.
`desktopApp/benchmarks/report.py` parses output, computes Mann-Whitney U vs. baseline if present, prints summary table.
Acceptance:
- Cold harness completes in ≤ 4 min (10 × ~25s/fork) on a developer laptop.
- Warm harness completes in ≤ 2 min.
- Output files include git SHA, JVM version, OS, host info in header.
- Control benchmark < 50ms on warm; documented as the harness floor.
**3.3 (DEFERRED) — Memory metric.** Not part of this plan. JVM heap post-`System.gc()` is unreliable; `TestComposeWindow` heap not representative of real Swing/Skia; out of scope.
**3.4 (DEFERRED) — Warm-boot variant (pre-seeded events.db).** Not part of this plan. None of the in-scope fixes target warm-path code; revisit when a warm-path fix appears.
#### Phase 4 — Baseline capture
Run `desktopApp/benchmarks/cold-launch.sh 10` and `./gradlew :desktopApp:jvmTest --tests "WarmLaunchBenchmark"` on a clean `main`. Commit both output files as:
- `desktopApp/benchmarks/baseline-main-cold.txt`
- `desktopApp/benchmarks/baseline-main-warm.txt`
Append a "Baseline" section to this plan with numbers. No code change.
Acceptance: baselines committed, plan updated, deltas on re-run ≤ 15% (median-to-median).
#### Phase 5 — Targeted fixes
Each lands in its own PR with before/after numbers in the description.
**5.1 — Icon decode**
Files: `desktopApp/src/jvmMain/.../Main.kt:218, 302, 988`.
Top-level shared lazy:
```kotlin
// desktopApp/src/jvmMain/.../IconResources.kt (new)
val DesktopAppIcon: BufferedImage by lazy {
requireNotNull(IconResources::class.java.getResourceAsStream("/icon.png")) {
"icon.png not found"
}.use { ImageIO.read(it) }
}
```
Replace all three sites. Default `lazy` mode (`SYNCHRONIZED`) is fine — three call sites span main + AWT threads.
**Microbench (separate from end-to-end):** add `IconResourcesBenchmark` running `ImageIO.read` N=1000 times to measure absolute decode cost. Report savings independently in case `InProcessWebSocket` floor (perf reviewer #6) hides the delta on `t_n_events`.
Unit test: `IconResourcesTest` asserts `ImageIO.read` invoked once per process (counter wrapper around `getResourceAsStream`).
Re-run benchmark: expected delta = (decode_cost × 2). Microbench delta = decode_cost × 999.
**5.2 — Feed bootstrap relay gate**
Files: `desktopApp/src/jvmMain/.../Main.kt:1283-1326, 1330` (home + DM subs).
**Investigation step (in the PR):** check whether `RelayPool` already queues REQs pre-connect (`quartz/.../RelayPool.kt`). Two outcomes:
- **(a) Pool queues:** delete the `connectedRelays.first { isNotEmpty() }` gate entirely. Subscriptions fire eagerly; pool flushes on connect. **Preferred.**
- **(b) Pool does not queue:** change predicate to `connectedRelays.first { any { it.state in setOf(CONNECTING, CONNECTED) } }`. Lower-risk fallback.
Reject (c) `combine + debounce(50ms)` — adds artificial 50ms to the critical path we're shortening (arch reviewer).
**Single shared helper** between home + DM gate (arch reviewer flag) — both must point at the same logic to avoid drift.
New tests (spec-flow + perf): all use Phase 2 fixture relay infrastructure.
- `slowRelayDoesNotStallFeed()``FixtureNostrServer(responseDelay = 1.seconds)`; bootstrap REQ flushes within 1.1s, not 30s.
- `noRelaysAvailableShowsErrorState()` — empty relay list; assert UI reports error within ≤ 10s.
- `relayListArrivesLateStartsSubscriptionThen()` — relay list emitted after `loadSavedAccount` returns; sub fires post-emit, not pre-emit.
- `relaysAddedMidLoadDoNotDoubleSubscribe()` — flip relay list during boot; verify single REQ.
Re-run benchmark: expected delta = large on `t_first_event` and `t_n_events` (gate currently blocks critical path).
**5.3 (CUT)** — sequential `remember` chain refactor. Out of scope this plan. If Phase 4 baseline shows `MainContent` composition cost > 50ms, spawn `desktopApp/plans/<date>-feat-mainContent-state-holder-refactor-plan.md` separately. Reason: scope creep risk + composite-holder choice depends on consumer audit.
---
## Alternative Approaches Considered
**B — Vertical slice per fix.** Per-bottleneck test+bench+fix. Rejected: contradicts "testing before refactor" priority; sequential `remember` lacks broader safety net. (see brainstorm § "Approach B")
**C — Instrument-first, fix-later.** Markers behind a flag; tests + refactors split into follow-ups. Rejected: same reason. (see brainstorm § "Approach C")
**Macrobenchmark / Android-first.** `androidx.benchmark.macro.StartupTimingMetric`. Deferred to a separate plan. (see brainstorm § "Q1 Platform Priority")
**Real WebSocket loopback to a test relay (Docker).** Rejected: network jitter + container startup; in-process is deterministic.
**JMH for benchmarks.** Rejected: doesn't compose with `createComposeRule`. Used independently for the icon microbench in Phase 5.1.
**Fakes in `:commons` testFixtures (original plan).** Rejected per arch review: `:commons` is KMP, unproven with `java-test-fixtures` here; `:quartz` already proves the pattern works.
**`testFixtures` for fakes vs hand-rolled constants.** Hand-rolled would re-invent `Filter.match`; `FixtureNostrServer` reuses existing logic. Keep testFixtures route.
**CompositionLocal `Instrumentation` interface.** Rejected per arch review: would add production API surface for a single test concern.
---
## System-Wide Impact
### Interaction Graph
Phase 1-4: production interaction graph **unchanged**. Tests observe production flows.
Phase 5 changes:
- **5.1 icon decode**: three `ImageIO.read` calls collapse to one `lazy`. `Coil`, Compose `Image`, `DesktopImageLoaderSetup` untouched. Thread safety: `lazy` SYNCHRONIZED default.
- **5.2 feed bootstrap gate**: `RelayConnectionManager``NostrClient``RelayPool` chain unchanged. The gating predicate in `Main.kt:1283-1326` shifts (or is removed). DM sub at `Main.kt:1330` uses same helper.
### Error & Failure Propagation
- Test errors: `LaunchMarkers.snapshot()` exposes recorded markers so far on any test failure for diagnostic clarity.
- Fixture errors: `FixtureNostrServer` throws on unknown filter and on malformed line at load — no silent empty EOSE.
- Production 5.2: REQ queued pre-connect (case a) relies on `RelayPool` retry semantics; verify before merge.
### State Lifecycle Risks
- Test isolation: each iteration uses fresh temp `homeDir` (cleaned via `@After` + `Files.walk(...).sorted(reverseOrder()).forEach(Files::delete)`).
- `Preferences.userRoot()` write in `LocalRelayMaintenance`: tests don't call `start()`. Production code path unchanged.
- Marker registry: single-threaded; benchmark suite serialized via Gradle `maxParallelForks = 1` filter on `*LaunchBenchmark*`.
- Fixture file stale: re-capture is manual; PR includes new numbers.
- Phase 5.2 risk: subscription firing before relay list emit — explicit `relayListArrivesLateStartsSubscriptionThen` test pins behavior.
### API Surface Parity
- `WebsocketBuilder` already stable across `RelayConnectionManager`, `NostrClient`, `RelayPool`. Test substitution touches no production code.
- `LocalRelayStore` ctor change is additive (default param). Existing callers compile unchanged.
- `NoteCardTags`/`testTag` additive; KDoc states "stable identifier for UI tests; do not key behavior off this."
### Integration Test Scenarios
Five scenarios unit tests with mocks won't catch:
1. **Cold boot ViewOnly → 10 events visible against fixture** — primary benchmark; end-to-end through AccountManager + LocalRelayStore + RelayConnectionManager + NostrClient + LocalCache + FeedScreen.
2. **Slow relay** (Phase 5.2 test) — fixture delays REQ response by 1s; assert subscription completion within 1.1s post-fix.
3. **Relay list arrives late** — exposes ordering bug if Phase 5.2 case (a) fires REQ before list populated.
4. **No relays available** — empty list; UI reports error rather than hang.
5. **Logged-out → LoginScreen** — no `accounts.json.enc`.
---
## Acceptance Criteria
### Functional Requirements
- [x] **Phase 1.1**: 2 tests (ViewOnly happy + decode failure) pass using repo's existing pattern (`backgroundScope.launch(UnconfinedTestDispatcher(testScheduler))` + `advanceUntilIdle()`). _Landed 2026-06-17 — `AccountManagerLoadStateTransitionsTest` (commit `ff55898ab`)._
- [x] **Phase 1.2**: 5 `LocalRelayStoreHydrationTest` cases pass. _Landed 2026-06-17 (commit `ff55898ab`)._
- [x] **Phase 1.3**: `LocalRelayStore` accepts optional `homeDir`; existing callers unchanged; `LocalRelayMaintenance` untouched. _Landed 2026-06-17 (commit `ff55898ab`)._
- [x] **Phase 1.4**: 4 `AppStateMachineTest` Compose UI tests pass via `createComposeRule()` with `MaterialTheme {}` wrap (commit `48a8178c9`). Slot was opened by adding `LaunchTestOverrides` to `App()` so `relayManager` / `localCache` / `localRelayStore` / `torSettings` can be injected, plus a secondary `DesktopRelayConnectionManager(WebsocketBuilder)` ctor that lets tests substitute the in-process fixture relay without relaxing the `LocalRelayManager` composition-local type. Production callers pass `null` and follow the existing `remember { … }` path.
- [ ] **Phase 2.1**: `InProcessWebsocketBuilder` in `quartz/src/testFixtures/`; round-trip test passes.
- [ ] **Phase 2.2**: `FixtureNostrServer` correctly matches `Filter`, always emits EOSE (incl. empty match), fails fast on malformed JSONL via `FixtureParseException`, per-connection Mutex.
- [ ] **Phase 2.3**: `fiatjaf-50.jsonl` committed under `quartz/src/testFixtures/resources/fixtures/launch/`, ≥ 50 events, all valid signatures; README documents recapture commands.
- [x] **Phase 2.4**: Phase 1.4 tests run against `LaunchFixtureRelay.open(fixture.events)` wired through the `LaunchTestOverrides.relayManager` field (commit `48a8178c9`).
- [ ] **Phase 3.1**: `LaunchMarkers` produces 4 named markers within 10s of a fixture cold boot. `NoteCardTags.ROOT` in `commons/commonMain`. NoteCard `Modifier.testTag().onPlacedHook()` added; production cost ≤ 1 SemanticsModifier allocation per card.
- [ ] **Phase 3.2**: Cold harness (shell-script, fork per sample, N=10) and warm harness (single JVM, N=20, discard 5 warmup) both run reproducibly. Pinned JVM flags. Output headers include git SHA, JVM/OS/arch. Control benchmark (`setContent { Box {} }`) reports harness floor.
- [ ] **Phase 4**: `desktopApp/benchmarks/baseline-main-cold.txt` and `-warm.txt` committed; plan updated with numbers. Re-run delta ≤ 15% median-to-median.
- [x] **Phase 5.1**: icon decoded exactly once per process (unit test); microbench delta reported; end-to-end delta reported. _Code + unit test landed 2026-06-17 (commit `b338d7db4`). Delta numbers pending Phase 3 benchmark harness._
- [x] **Phase 5.2**: investigation chose candidate (a) — `NostrClient`/`RelayPool` queue REQs pre-connect (verified by `SubscribeBeforeConnectTest`). The bootstrap gate at `Main.kt:1242` is removed and the subscription fires eagerly. Two of the four originally-named regression tests landed via `AppStateMachineTest` (commit `48a8178c9`) — `bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects` covers the "no relays available" scenario, and `bootstrapSubscriptionFiresAtMostOncePerAccountLoad` covers the "no double-subscribe" scenario. The other two named cases (`slowRelay`, `relayListArrivesLate`) are now trivial to add on top of the same harness if a future regression motivates them, but were not necessary for the in-scope invariant. The DM gate at `Main.kt:1290` is left untouched in this session (separate code path through `subscriptionsCoordinator`).
### Non-Functional Requirements
- [ ] Warm benchmark variance ≤ 15% on `t_n_events` (median-to-median across 5 runs of the suite).
- [ ] `InProcessWebsocketBuilder` adds ≤ 50ms RTT vs direct in-process call.
- [ ] Cold harness total wall clock ≤ 4 min, warm ≤ 2 min, on a developer M1/M2/M3 laptop.
### Quality Gates
- [ ] All new tests pass `./gradlew :desktopApp:jvmTest` and `./gradlew :quartz:test`.
- [ ] Existing tests unaffected.
- [ ] `./gradlew spotlessApply` clean. `.spotless/copyright.kt` header on every new `.kt`.
- [ ] Pre-commit hook passes without `--no-verify`.
- [ ] Each Phase 5 PR includes before/after numbers table in description.
- [ ] No new `runBlocking { ... }` calls in launch path.
- [ ] No production code references `LaunchMarkers`.
---
## Success Metrics
Per brainstorm (Q4 + Q6) with deepen-plan adjustments:
| Metric | What | Reporting |
|--------|------|-----------|
| `t_first_composition_apply` | First `setContent` reaches idle (NOT real Skia frame) | ms, cold + warm |
| `t_account_logged_in` | `AccountState.LoggedIn` emitted | ms, cold + warm |
| `t_first_event` | First `NoteCard.onPlaced` fires | ms, cold + warm |
| `t_n_events` (N=10) | 10th `NoteCard.onPlaced` fires | ms, cold + warm — **headline** |
(Memory + warm-boot variant DEFERRED.)
Cold = shell-script, fork per sample, N=10, no warmup. Warm = single JVM, N=20, discard first 5. Report **median + IQR + min**. For fix deltas, **Mann-Whitney U** with Cliff's delta > 0.33 threshold (perf reviewer).
**Phase 5 success:** each fix produces positive delta on `t_n_events` (or for 5.1, on the microbench if end-to-end is below the `InProcessWebSocket` floor). Sum target ≥ 20% reduction in cold `t_n_events` — aspirational, actual depends on baseline.
---
## Dependencies & Risks
### Risks (prioritized)
**Risk #1 (mitigated) — `:quartz` + `java-test-fixtures` setup.** `:quartz` already consumes geode testFixtures (`quartz/build.gradle.kts:352-358`). Producing fixtures from `:quartz` requires the `java-test-fixtures` plugin on `:quartz` itself, which is a separate enablement. **Mitigation**: time-box to 1 hour; fall back to `:desktopApp:jvmTest` directly if Gradle friction blocks.
**Risk #2 (acknowledged) — `runComposeUiTest` / `createComposeRule` doesn't capture real Swing/Skia render time.** Headline metric `t_first_composition_apply` measures composition only, not paint. **Mitigation**: explicit "out-of-scope: GPU/paint timing" disclosure in benchmark output header. Real-window benchmark named as follow-up: `desktopApp/plans/<future>-real-window-launch-benchmark.md`.
**Risk #3 — Fixture staleness.** Commit as immutable; recapture explicit human action only; PR includes new numbers.
**Risk #4 — Phase 5.2 eager subscription causes double-subscribe on relay list reorder.** Test: `relaysAddedMidLoadDoNotDoubleSubscribe`.
**Risk #5 — `InProcessWebSocket` floor swallows fix delta.** Microbench for icon decode in 5.1; Mann-Whitney U test for significance.
**Risk #6 — Cold harness fork cost (~25s/sample × N=10 = ~4 min).** Acceptable for local-manual runs; document in README.
**Risk #7 — JVM/OS/arch variation across developers.** Output header records env; cross-machine numbers informational only.
### Dependencies
- `quartz`: `InProcessWebSocket`, `NostrServer`, `Filter`, `WebsocketBuilder` — all exist.
- `commons`: `EventStore`, `DesktopLocalCache` — exist.
- `desktopApp`: `AccountManager`, `LocalRelayStore`, `RelayConnectionManager`, `App()` — exist with needed seams post 1.3.
- Compose UI test: `compose.desktop.uiTestJUnit4` on classpath (`desktopApp/build.gradle.kts:87`).
- Gradle `java-test-fixtures` plugin (proven in `:geode`; needs enablement on `:quartz`).
---
## Resource Requirements
Solo engineer, rough sizing:
| Phase | Effort |
|-------|--------|
| 1.1 | ~half day |
| 1.2 | ~half day |
| 1.3 | ~hour |
| 1.4 | ~day |
| 2.1 | ~hour |
| 2.2 | ~half day |
| 2.3 | ~hour (manual fixture capture + README) |
| 2.4 | ~hour |
| 3.1 | ~half day |
| 3.2 | ~day (two harnesses + report.py) |
| 4 | ~hour |
| 5.1 | ~hour + microbench |
| 5.2 | ~day (investigation + refactor + 4 tests) |
---
## Future Considerations
- **Real-render benchmark** (`desktopApp/plans/<future>-real-window-launch-benchmark.md`) — `application { Window { App() } }` with file-marker IPC. Catches GPU regressions.
- **Phase 5.3 follow-up** (`desktopApp/plans/<future>-feat-maincontent-state-holder-refactor-plan.md`) — only if Phase 4 baseline shows `MainContent` cost > 50ms.
- **Android port** (brainstorm Q14) — separate brainstorm, `androidx.benchmark.macro.StartupTimingMetric`.
- **Memory metric** — add after baseline shows it's worth measuring; pin GC mode, parse JFR.
- **Larger fixture for stress (`fiatjaf-5000.jsonl`)** — catches O(n²) regressions; defer until needed.
- **CI integration** — track baseline on `main` for trend visibility (no PR gating).
- **Tor cold boot** — measure separately; out of scope (Tor splash gate is shutdown-shape, not boot-shape).
---
## Documentation Plan
- This plan — primary reference.
- `quartz/src/testFixtures/resources/fixtures/launch/README.md` — fixture recapture commands.
- `desktopApp/benchmarks/README.md` — how to run benchmarks, interpret numbers, known caveats.
- KDoc on `LaunchMarkers`, `InProcessWebsocketBuilder`, `FixtureNostrServer`, `NoteCardTags`.
- Plan update after Phase 4 with baselines.
- Plan update after Phase 5.1 and 5.2 with deltas.
---
## Sources & References
### Origin
- **Brainstorm:** [`docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md`](../../docs/brainstorms/2026-06-17-feat-app-launch-optimization-brainstorm.md). Key decisions carried forward: Approach A (foundation-first); Desktop-first / Android deferred; layered pyramid; in-process fake relay; fresh-boot fixture; N=10 events visible as headline; local-only manual runs; cold + warm reported separately; real-world fixture; 3 in-scope fixes (now 2 in-scope, 1 deferred).
### Internal References
- `desktopApp/src/jvmMain/.../Main.kt:218,302,988` — 3× ImageIO.read of /icon.png.
- `desktopApp/src/jvmMain/.../Main.kt:1283-1330` — home + DM subscriptions gated on first connected relay.
- `desktopApp/src/jvmMain/.../Main.kt:834` — production `WebsocketBuilder` injection site.
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:71-86``AccountState`.
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:240-266``loadSavedAccount`.
- `desktopApp/src/jvmMain/.../account/AccountManager.kt:676-695``loadReadOnlyAccount`.
- `desktopApp/src/jvmMain/.../relay/LocalRelayStore.kt:38-103,115-161` — store ctor + hydrate.
- `desktopApp/src/jvmMain/.../network/RelayConnectionManager.kt:56-58``open class` taking `WebsocketBuilder`.
- `desktopApp/src/jvmMain/.../network/DesktopRelayConnectionManager.kt:30-34`.
- `quartz/src/commonMain/.../sockets/WebsocketBuilder.kt:25-30`.
- `quartz/src/commonMain/.../relay/server/inprocess/InProcessWebSocket.kt:55`.
- `quartz/src/commonMain/.../relay/client/NostrClient.kt:80-82`.
- `quartz/src/commonMain/.../relay/filters/Filter.kt:51-61`.
- `desktopApp/src/jvmTest/.../ui/DesktopLaunchSmokeTest.kt:24,57,58,69` — existing UI test pattern.
- `desktopApp/src/jvmTest/.../account/AccountManagerStateTransitionTest.kt:73-95` — existing state-transition test pattern.
- `desktopApp/src/jvmMain/.../ui/note/NoteCard.kt:97` — testTag/onPlaced injection site.
- `quartz/build.gradle.kts:352-358``:quartz` consuming `testFixtures(project(":geode"))`.
- `geode/build.gradle.kts:7,35-37,81-83``java-test-fixtures` precedent.
- `.spotless/copyright.kt` — required header for new files.
### Related Work
- `docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md` — adjacent viewport-aware metadata.
- `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md``LocalRelayStore` infrastructure this plan reuses.
### External References
- Compose UI test: https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html
- kotlinx-coroutines test: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/
- Gradle `java-test-fixtures`: https://docs.gradle.org/current/userguide/java_testing.html#sec:java_test_fixtures
@@ -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,
)
@@ -63,8 +63,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
import androidx.compose.ui.unit.dp
@@ -142,8 +140,6 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
private val isMacOS = com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS
@@ -219,13 +215,7 @@ fun main() {
// on macOS the logo is then wrapped in a squircle so it matches
// first-party dock icons.
try {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
val adapted =
raw?.let {
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
.adaptForHost(it)
}
val adapted = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBufferedImage
if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) {
val taskbar = java.awt.Taskbar.getTaskbar()
if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) {
@@ -297,21 +287,7 @@ fun main() {
// Window title-bar / taskbar thumbnail icon. On macOS the source logo
// is wrapped in a squircle so it matches every other dock icon; on
// other platforms the raw transparent logo is used as-is.
val appIcon =
remember {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
val adapted =
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
.adaptForHost(raw)
val buf = java.io.ByteArrayOutputStream()
javax.imageio.ImageIO.write(adapted, "png", buf)
val bitmap =
org.jetbrains.skia.Image
.makeFromEncoded(buf.toByteArray())
.toComposeImageBitmap()
BitmapPainter(bitmap)
}
val appIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.adaptedBitmapPainter
Window(
onCloseRequest = ::exitApplication,
@@ -686,11 +662,12 @@ fun App(
onShowImportFollowListDialog: () -> Unit = {},
onDismissImportFollowListDialog: () -> Unit = {},
onRestartApp: () -> Unit = {},
torManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager,
torManager: com.vitorpamplona.amethyst.commons.tor.ITorManager,
torTypeFlow: kotlinx.coroutines.flow.MutableStateFlow<com.vitorpamplona.amethyst.commons.tor.TorType>,
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() } }
@@ -700,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(),
)
}
@@ -713,15 +693,7 @@ fun App(
val torStatus by torManager.status.collectAsState()
val isTorExpected = torSettings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF
if (isTorExpected && torStatus !is com.vitorpamplona.amethyst.commons.tor.TorServiceStatus.Active) {
val splashIcon =
remember {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val bitmap =
org.jetbrains.skia.Image
.makeFromEncoded(bytes)
.toComposeImageBitmap()
BitmapPainter(bitmap)
}
val splashIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
androidx.compose.foundation.layout.Box(
modifier =
androidx.compose.ui.Modifier
@@ -766,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 =
@@ -831,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
@@ -940,9 +915,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
@@ -996,15 +973,7 @@ fun App(
when (accountState) {
is AccountState.Loading -> {
// Branded loading screen while accounts load from storage
val loadingIcon =
remember {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val bitmap =
org.jetbrains.skia.Image
.makeFromEncoded(bytes)
.toComposeImageBitmap()
BitmapPainter(bitmap)
}
val loadingIcon = com.vitorpamplona.amethyst.desktop.platform.IconResources.rawBitmapPainter
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -1287,53 +1256,54 @@ fun MainContent(
.DesktopDraftStore(appScope)
}
// Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006)
// Uses DisposableEffect to clean up subscription on account change
// Bootstrap subscription: fetch relay config events (kinds 10002, 10050, 10007, 10006).
// Subscribes immediately — `NostrClient` / `RelayPool` queue REQs that arrive before a
// relay connection is up and flush them on connect (verified by
// SubscribeBeforeConnectTest), so the previous
// `connectedRelays.first { isNotEmpty() }` + 30s timeout gate has been
// removed. Per the Phase 5.2 launch-optimization plan, this shaves the
// cold-boot relay-bootstrap latency from "first connect roundtrip + sub
// dispatch" to "sub dispatch only" once a relay is available, and it
// also recovers gracefully when no relay ever connects (the
// subscription stays queued for when one does, instead of silently
// giving up after 30s).
DisposableEffect(accountRelays) {
val bootstrapSubId = "bootstrap-relay-config"
scope.launch {
val connected =
withTimeoutOrNull(30.seconds) {
relayManager.connectedRelays.first { it.isNotEmpty() }
}
if (connected != null) {
val filter =
Filter(
kinds =
listOf(
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
BlockedRelayListEvent.KIND,
),
authors = listOf(account.pubKeyHex),
limit = 4,
)
relayManager.subscribe(
subId = bootstrapSubId,
filters = listOf(filter),
listener =
object : SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// NIP-65 (kind 10002) must go through justConsumeMyOwnEvent
// because localCache.consume() doesn't handle addressable events
if (event is AdvertisedRelayListEvent) {
scope.launch(Dispatchers.IO) {
localCache.justConsumeMyOwnEvent(event)
}
}
// Route to accountRelays for persistence + state updates
accountRelays.consumeIfRelevant(event)
val filter =
Filter(
kinds =
listOf(
AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND,
SearchRelayListEvent.KIND,
BlockedRelayListEvent.KIND,
),
authors = listOf(account.pubKeyHex),
limit = 4,
)
relayManager.subscribe(
subId = bootstrapSubId,
filters = listOf(filter),
listener =
object : SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// NIP-65 (kind 10002) must go through justConsumeMyOwnEvent
// because localCache.consume() doesn't handle addressable events
if (event is AdvertisedRelayListEvent) {
scope.launch(Dispatchers.IO) {
localCache.justConsumeMyOwnEvent(event)
}
},
)
}
}
}
// Route to accountRelays for persistence + state updates
accountRelays.consumeIfRelevant(event)
}
},
)
onDispose { relayManager.unsubscribe(bootstrapSubId) }
}
@@ -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)
}
@@ -0,0 +1,89 @@
/*
* 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.platform
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Image
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO
/**
* Lazily-memoized icon resources shared across all consumers of `/icon.png`.
*
* Before this object existed the desktop launch path read and decoded the
* icon four separate times on the cold-boot critical path (taskbar setup,
* Window icon, Tor splash, account-loading splash). The taskbar and Window
* sites also paid an `ImageIO.read` to obtain a `BufferedImage` before
* either passing it straight to `Taskbar.iconImage` (taskbar) or
* round-tripping it back through `ImageIO.write` so Skia can decode it
* (Window icon).
*
* This object collapses the work to one resource read plus at most one
* decode per output shape. All properties are `lazy { ... }` so the cost
* is paid only when first observed.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 5.1.
*/
object IconResources {
/** Raw PNG bytes read from the `/icon.png` classpath resource. */
val iconBytes: ByteArray by lazy {
IconResources::class.java.getResourceAsStream("/icon.png")!!.readBytes()
}
/** Decoded `BufferedImage` for AWT consumers (Taskbar, Window icon source). */
val rawBufferedImage: BufferedImage by lazy {
ImageIO.read(ByteArrayInputStream(iconBytes))
}
/**
* Platform-adapted icon (squircle on macOS, raw transparent PNG elsewhere).
* Returns `null` when adaptation fails or is unsupported — callers should
* fall back to [rawBufferedImage].
*/
val adaptedBufferedImage: BufferedImage? by lazy {
PlatformAppIcon.adaptForHost(rawBufferedImage)
}
/**
* Compose `BitmapPainter` for the raw PNG bytes. Used by splash screens
* (Tor connecting, account loading) that paint the un-adapted logo with
* a Material `tint`.
*/
val rawBitmapPainter: BitmapPainter by lazy {
BitmapPainter(Image.makeFromEncoded(iconBytes).toComposeImageBitmap())
}
/**
* Compose `BitmapPainter` for the platform-adapted icon. Used by the
* main `Window(icon = …)` parameter so the title-bar / taskbar thumbnail
* matches the dock icon shape on macOS.
*/
val adaptedBitmapPainter: BitmapPainter by lazy {
val adapted = adaptedBufferedImage ?: rawBufferedImage
val buf = ByteArrayOutputStream()
ImageIO.write(adapted, "png", buf)
BitmapPainter(Image.makeFromEncoded(buf.toByteArray()).toComposeImageBitmap())
}
}
@@ -37,15 +37,16 @@ import java.io.File
class LocalRelayStore(
private val scope: CoroutineScope,
private val homeDir: File = File(System.getProperty("user.home")),
) : AutoCloseable {
companion object {
val LOCAL_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("ws://localhost/amethyst-local/")
private fun dbDir(pubKeyHex: String): File = File(System.getProperty("user.home"), ".amethyst/accounts/${pubKeyHex.take(8)}")
fun dbFile(pubKeyHex: String): File = File(dbDir(pubKeyHex), "events.db")
}
private fun dbDir(pubKeyHex: String): File = File(homeDir, ".amethyst/accounts/${pubKeyHex.take(8)}")
fun dbFile(pubKeyHex: String): File = File(dbDir(pubKeyHex), "events.db")
private val lock = Any()
@Volatile
@@ -50,6 +50,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
@@ -164,6 +166,25 @@ fun NoteCard(
}
val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface)
val cardShape = MaterialTheme.shapes.medium
// Launch-instrumentation hook. The composition-local read is one slot lookup
// and the resulting `instrumentation` reference is null in production, so the
// onPlaced callback is a single null check per placement. See
// desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 3.1.
val instrumentation = LocalNoteCardInstrumentation.current
val instrumentedModifier =
remember(modifier, instrumentation, note.id) {
var fired = false
modifier
.testTag(NOTE_CARD_TEST_TAG)
.onPlaced {
if (!fired && instrumentation != null) {
fired = true
instrumentation.onPlaced(note.id)
}
}
}
val cardBody: @Composable ColumnScope.() -> Unit = {
Column(modifier = Modifier.padding(16.dp)) {
// Reply context — embedded parent + "Replying to @X" label.
@@ -380,7 +401,7 @@ fun NoteCard(
if (onClick != null) {
OutlinedCard(
onClick = onClick,
modifier = modifier,
modifier = instrumentedModifier,
colors = cardColors,
border = cardBorder,
shape = cardShape,
@@ -388,7 +409,7 @@ fun NoteCard(
)
} else {
OutlinedCard(
modifier = modifier,
modifier = instrumentedModifier,
colors = cardColors,
border = cardBorder,
shape = cardShape,
@@ -0,0 +1,54 @@
/*
* 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.note
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
/**
* Stable identifier used by UI tests + benchmark harness to locate
* [NoteCard] roots in the semantic tree. Production code MUST NOT key
* behavior off this tag — it exists purely for observation.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 3.1.
*/
const val NOTE_CARD_TEST_TAG: String = "amethyst.desktop.note_card.root"
/**
* Optional callback fired from [NoteCard]'s `Modifier.onPlaced` site.
*
* Production code provides `null` (the default value of
* [LocalNoteCardInstrumentation]) so the callback site is a single
* composition-local read + null check — no allocation, no work. Benchmark
* tests provide a counter that records `t_first_event` / `t_n_events`
* markers without polling the semantic tree.
*/
fun interface NoteCardInstrumentation {
fun onPlaced(noteId: String)
}
/**
* Composition local read by [NoteCard]. Default `null` — no test
* harness wired, no overhead. Tests override via `CompositionLocalProvider`.
*/
val LocalNoteCardInstrumentation: ProvidableCompositionLocal<NoteCardInstrumentation?> =
compositionLocalOf { null }
@@ -0,0 +1,138 @@
/*
* 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.account
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.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* Phase 1.1 of the launch-optimization plan: pin the ViewOnly cold-boot
* state transition behavior the benchmark scenario depends on.
*
* Only the ViewOnly path is covered here. Internal + Remote (bunker) are
* intentionally out of scope — they are exercised by the existing tests in
* [AccountManagerStateTransitionTest] / [AccountManagerKeyLoginTest] /
* [AccountManagerBunkerLoginTest].
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 1.1.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AccountManagerLoadStateTransitionsTest {
private lateinit var storage: SecureKeyStorage
private lateinit var tempDir: File
private lateinit var manager: AccountManager
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
coEvery { storage.getPrivateKey("account-metadata-key") } returns null
tempDir = createTempDirectory("acctmgr-load-state").toFile()
File(tempDir, ".amethyst").mkdirs()
manager = AccountManager(storage, tempDir)
}
@AfterTest
fun teardown() {
tempDir.deleteRecursively()
}
@Test
fun viewOnlyAccountTransitionsThroughLoadingToLoggedIn() =
runTest {
val states = mutableListOf<AccountState>()
val collector =
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
manager.accountState.toList(states)
}
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
manager.accountStorage.saveAccount(
AccountInfo(npub = npub, signerType = SignerType.ViewOnly),
)
manager.accountStorage.setCurrentAccount(npub)
val result = manager.loadSavedAccount()
advanceUntilIdle()
assertTrue(result.isSuccess, "loadSavedAccount should succeed for ViewOnly: $result")
assertTrue(
states.size >= 2,
"Expected at least 2 state transitions, got ${states.size}: $states",
)
assertIs<AccountState.Loading>(states.first())
val terminal = assertIs<AccountState.LoggedIn>(states.last())
assertEquals(true, terminal.isReadOnly, "ViewOnly account must be flagged read-only")
assertEquals(SignerType.ViewOnly, terminal.signerType)
assertEquals(null, terminal.nsec, "ViewOnly account must not expose nsec")
assertEquals(npub, terminal.npub)
collector.cancel()
}
@Test
fun corruptViewOnlyAccountFailsWithoutEmittingLoggedIn() =
runTest {
val states = mutableListOf<AccountState>()
val collector =
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
manager.accountState.toList(states)
}
// Persist a ViewOnly account with an undecodable npub. accounts.json.enc
// round-trips the string as-is, so loadSavedAccount will hit the
// decodePublicKeyAsHexOrNull failure branch in loadReadOnlyAccount.
manager.accountStorage.saveAccount(
AccountInfo(npub = "npub1notavalidbech32string", signerType = SignerType.ViewOnly),
)
manager.accountStorage.setCurrentAccount("npub1notavalidbech32string")
val result = manager.loadSavedAccount()
advanceUntilIdle()
assertTrue(result.isFailure, "Corrupt ViewOnly npub must fail load")
assertTrue(
states.none { it is AccountState.LoggedIn },
"No LoggedIn state must be emitted for corrupt account: $states",
)
collector.cancel()
}
}
@@ -0,0 +1,181 @@
/*
* 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.benchmark
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Phase 3.2: single-JVM warm benchmark for the cold-boot scenario.
*
* Runs [WARMUP] discarded iterations and [ITERATIONS] measured iterations
* of [LaunchScenario.coldBoot], collects the median + min + IQR of each
* marker, and atomically writes a report file under
* `desktopApp/build/benchmarks/`. The "cold" half of the harness (forking
* a fresh JVM per sample) is deferred to a shell-script driver; this
* single-JVM variant gives meaningful before/after numbers for fixes that
* stay in the launch-path code we already exercise (icon decode, feed
* bootstrap gate, etc.).
*
* Disabled by default — set the `AMETHYST_BENCH=true` environment variable
* to run it (so a normal `./gradlew :desktopApp:test` stays fast).
* Invoked directly via:
*
* AMETHYST_BENCH=true ./gradlew :desktopApp:test \
* --tests "*LaunchBenchmark.run" --rerun-tasks
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 3.2.
*/
class LaunchBenchmark {
@Test
fun run() {
if (System.getenv("AMETHYST_BENCH") != "true" && System.getProperty("amethyst.bench") != "true") {
// Skip silently. Set AMETHYST_BENCH=true (or pass
// -Damethyst.bench=true to the test JVM) to run it.
println("LaunchBenchmark: skipped — set AMETHYST_BENCH=true to run")
return
}
// Drop the warmup samples on the floor so the measured set isn't
// biased by classloader cost or JIT C1 compilation.
repeat(WARMUP) {
LaunchScenario.coldBoot()
}
val samples = (1..ITERATIONS).map { LaunchScenario.coldBoot() }
val report = buildReport(samples)
writeReport(report)
println(report)
val nEventsSamples = samples.mapNotNull { it.markers[LaunchMarkers.T_N_EVENTS] }
assertTrue(
nEventsSamples.size >= ITERATIONS / 2,
"At least half the iterations must reach T_N_EVENTS; got ${nEventsSamples.size}/$ITERATIONS",
)
}
private fun buildReport(samples: List<LaunchScenario.Result>): String {
val header =
buildString {
appendLine("# LaunchBenchmark report")
appendLine("# date ${java.time.Instant.now()}")
appendLine("# jvm ${System.getProperty("java.version")} ${System.getProperty("java.vendor")}")
appendLine("# os ${System.getProperty("os.name")} ${System.getProperty("os.version")} ${System.getProperty("os.arch")}")
appendLine("# cpus ${Runtime.getRuntime().availableProcessors()}")
appendLine("# max-heap-mb ${Runtime.getRuntime().maxMemory() / 1024 / 1024}")
appendLine("# git-sha ${gitSha()}")
appendLine("# iterations $ITERATIONS (after $WARMUP warmup, discarded)")
appendLine("# fork-mode single-JVM (cold-fork driver deferred)")
appendLine()
}
val markerNames =
listOf(
LaunchMarkers.T_ACCOUNT_LOGGED_IN,
LaunchMarkers.T_FIRST_EVENT,
LaunchMarkers.T_N_EVENTS,
)
val rows =
markerNames.map { name ->
val vals = samples.mapNotNull { it.markers[name]?.inWholeMicroseconds }
val md = vals.median()
val mn = vals.minOrNull() ?: 0
val mx = vals.maxOrNull() ?: 0
val q1 = vals.percentile(25.0)
val q3 = vals.percentile(75.0)
"%-30s n=%d min=%6.2fms q1=%6.2fms median=%6.2fms q3=%6.2fms max=%6.2fms".format(
name,
vals.size,
mn / 1000.0,
q1 / 1000.0,
md / 1000.0,
q3 / 1000.0,
mx / 1000.0,
)
}
val eventsCounts = samples.map { it.eventsConsumed }
val tail =
buildString {
appendLine()
appendLine("# events-consumed per iteration: $eventsCounts")
}
return header + rows.joinToString("\n") + tail
}
private fun writeReport(report: String) {
val sha = gitSha().take(10)
val dir = File("build/benchmarks").also { it.mkdirs() }
val target = File(dir, "launch-$sha.txt")
val tmp = File(dir, "launch-$sha.tmp")
tmp.writeText(report)
Files.move(
tmp.toPath(),
target.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
println("LaunchBenchmark: report at ${target.absolutePath}")
}
private fun gitSha(): String =
runCatching {
val proc =
ProcessBuilder("git", "rev-parse", "HEAD")
.redirectErrorStream(true)
.start()
proc.waitFor()
proc.inputStream
.bufferedReader()
.readText()
.trim()
}.getOrDefault("unknown")
companion object {
private const val WARMUP = 2
private const val ITERATIONS = 5
}
}
private fun List<Long>.median(): Long {
if (isEmpty()) return 0
val sorted = sorted()
val mid = sorted.size / 2
return if (sorted.size % 2 == 0) {
(sorted[mid - 1] + sorted[mid]) / 2
} else {
sorted[mid]
}
}
private fun List<Long>.percentile(p: Double): Long {
if (isEmpty()) return 0
val sorted = sorted()
val idx = ((sorted.size - 1) * p / 100.0).toInt().coerceIn(0, sorted.lastIndex)
return sorted[idx]
}
@@ -0,0 +1,98 @@
/*
* 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.benchmark
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCardInstrumentation
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/**
* Single-threaded marker registry used by the launch benchmark and the
* Compose smoke tests. Records named timestamps relative to a single
* `start()` reference; later calls to `mark(name)` with an existing name
* are ignored so the first-occurrence semantics of `t_first_event` /
* `t_n_events` are stable.
*
* Not thread-safe — Gradle test parallelism for any class touching this
* registry must be set to `maxParallelForks = 1`. The Compose UI test
* rule already serializes, and the benchmark runner forks a fresh JVM
* per cold sample, so this is not a practical limit.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 3.1.
*/
object LaunchMarkers {
private val timestamps = mutableMapOf<String, Duration>()
private var start: TimeMark? = null
private val noteCardCounter = AtomicInteger(0)
const val T_FIRST_COMPOSITION_APPLY: String = "t_first_composition_apply"
const val T_ACCOUNT_LOGGED_IN: String = "t_account_logged_in"
const val T_FIRST_EVENT: String = "t_first_event"
const val T_N_EVENTS: String = "t_n_events"
/** Default count for the headline `t_n_events` metric. */
const val DEFAULT_N: Int = 10
/** Begin a measurement window. Clears any previously recorded markers. */
fun start() {
timestamps.clear()
noteCardCounter.set(0)
start = TimeSource.Monotonic.markNow()
}
/**
* Record [name] if not already recorded. Returns true when this call
* was the one to record it.
*/
fun mark(name: String): Boolean {
val ref = start ?: return false
if (name in timestamps) return false
timestamps[name] = ref.elapsedNow()
return true
}
/** Snapshot of all recorded markers in insertion order. */
fun snapshot(): Map<String, Duration> = timestamps.toMap()
/**
* [NoteCardInstrumentation] adapter that records `t_first_event` on
* the first placement and `t_n_events` on the [n]th distinct one.
* Counts distinct `noteId`s — a recomposition that re-emits the same
* card is not counted twice.
*/
fun noteCardInstrumentation(n: Int = DEFAULT_N): NoteCardInstrumentation {
val seen =
java.util.concurrent.ConcurrentHashMap
.newKeySet<String>()
return NoteCardInstrumentation { noteId ->
if (seen.add(noteId)) {
val placed = noteCardCounter.incrementAndGet()
when {
placed == 1 -> mark(T_FIRST_EVENT)
placed >= n -> mark(T_N_EVENTS)
}
}
}
}
}
@@ -0,0 +1,191 @@
/*
* 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.benchmark
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.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
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.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.nip10Notes.TextNoteEvent
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.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
import kotlin.io.path.createTempDirectory
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Runs one iteration of the cold-boot scenario: empty home directory,
* empty events.db, a single ViewOnly account preloaded into
* `accounts.json.enc`, and an in-process [LaunchFixtureRelay] standing in
* for the network.
*
* Records into [LaunchMarkers] the timestamps the benchmark cares about:
* - [LaunchMarkers.T_ACCOUNT_LOGGED_IN] — `AccountManager.accountState`
* reaches `LoggedIn(isReadOnly=true)`.
* - [LaunchMarkers.T_FIRST_EVENT] — first `kind:1` flows through
* `DesktopLocalCache.consume`.
* - [LaunchMarkers.T_N_EVENTS] — `n`th `kind:1` flows through the cache.
*
* Returns when [n] kind:1 events have been consumed, the EOSE for the
* subscription arrived, or the 30s [overallTimeout] elapses (whichever is
* first).
*
* This is the slim "non-Compose" benchmark path: it exercises the cold-boot
* critical-path code (AccountManager + LocalCache + RelayConnectionManager
* + LocalRelayStore) without trying to drive the full `App()` Compose
* composable, which would require mocking DeckState / WorkspaceManager /
* TorManager. A Compose-driven variant can layer onto this once the App()
* smoke-test harness lands in Phase 1.4.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 3.2.
*/
object LaunchScenario {
data class Result(
val markers: Map<String, Duration>,
val eventsConsumed: Int,
)
fun coldBoot(
n: Int = LaunchMarkers.DEFAULT_N,
fixture: LaunchFixture = LaunchFixture.build(),
overallTimeout: Duration = 30.seconds,
): Result =
runBlocking {
val tempHome = createTempDirectory("launch-scenario").toFile()
val storage = mockk<SecureKeyStorage>(relaxed = true)
coEvery { storage.getPrivateKey(any()) } returns null
File(tempHome, ".amethyst").mkdirs()
val account = AccountManager(storage, tempHome)
val ownerNpub = fixture.ownerKeyPair.pubKey.toNpub()
account.accountStorage.saveAccount(
AccountInfo(npub = ownerNpub, signerType = SignerType.ViewOnly),
)
account.accountStorage.setCurrentAccount(ownerNpub)
val cache = DesktopLocalCache()
val storeScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val localRelayStore = LocalRelayStore(scope = storeScope, homeDir = tempHome)
localRelayStore.openForAccount(fixture.ownerKeyPair.pubKey.toHexKey())
val relay = LaunchFixtureRelay.open(fixture.events)
val relayManager = DesktopRelayConnectionManager(relay.builder)
val eventCounter = AtomicInteger(0)
val eoseSignal = CompletableDeferred<Unit>()
val nReached = CompletableDeferred<Unit>()
try {
LaunchMarkers.start()
// Account decrypt + ViewOnly load. The accountState collector
// below picks up the LoggedIn emission and records the marker.
val accountWatcher =
storeScope.launch {
account.accountState.first { it is AccountState.LoggedIn }
LaunchMarkers.mark(LaunchMarkers.T_ACCOUNT_LOGGED_IN)
}
account.loadSavedAccount()
relayManager.connect()
relayManager.client.subscribe(
subId = "launch-bench-home",
filters =
mapOf(
LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to
listOf(Filter(kinds = listOf(TextNoteEvent.KIND))),
),
listener =
object : SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.kind != TextNoteEvent.KIND) return
val consumed =
cache.consume(event, relay, wasVerified = true)
if (!consumed) return
val placed = eventCounter.incrementAndGet()
if (placed == 1) LaunchMarkers.mark(LaunchMarkers.T_FIRST_EVENT)
if (placed >= n) {
LaunchMarkers.mark(LaunchMarkers.T_N_EVENTS)
nReached.complete(Unit)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (!eoseSignal.isCompleted) eoseSignal.complete(Unit)
}
},
)
withTimeout(overallTimeout) {
// Block until either N events landed or the fixture is
// exhausted (EOSE). Whichever happens first is the
// scenario terminator.
if (eventCounter.get() < n) {
kotlinx.coroutines.selects.select<Unit> {
nReached.onAwait { }
eoseSignal.onAwait { }
}
}
}
accountWatcher.cancel()
Result(LaunchMarkers.snapshot(), eventCounter.get())
} finally {
relayManager.disconnect()
relay.close()
localRelayStore.close()
storeScope.cancel()
tempHome.deleteRecursively()
}
}
}
@@ -0,0 +1,81 @@
/*
* 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.platform
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* Phase 5.1 of the launch-optimization plan: pin the memoization invariants
* of [IconResources]. The launch path previously decoded the same `/icon.png`
* resource up to four times during a single cold boot; this object collapses
* the work and the tests below assert that the lazy holders return the same
* cached instance on subsequent accesses.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 5.1.
*/
class IconResourcesTest {
@Test
fun iconBytesAreMemoized() {
val first = IconResources.iconBytes
val second = IconResources.iconBytes
assertSame(first, second, "Raw PNG byte array must be the same instance across calls")
assertTrue(first.isNotEmpty(), "Bundled /icon.png must not be empty")
}
@Test
fun rawBufferedImageIsMemoized() {
val first = IconResources.rawBufferedImage
val second = IconResources.rawBufferedImage
assertSame(first, second, "Decoded BufferedImage must be the same instance across calls")
assertTrue(first.width > 0 && first.height > 0, "Decoded image must have positive dimensions")
}
@Test
fun rawBitmapPainterIsMemoized() {
val first = IconResources.rawBitmapPainter
val second = IconResources.rawBitmapPainter
assertSame(first, second, "Raw BitmapPainter must be the same instance across calls")
}
@Test
fun adaptedBitmapPainterIsMemoized() {
val first = IconResources.adaptedBitmapPainter
val second = IconResources.adaptedBitmapPainter
assertSame(first, second, "Adapted BitmapPainter must be the same instance across calls")
}
@Test
fun adaptedBufferedImageEitherProducesAValueOrIsNull() {
// On macOS this returns a squircle; on other platforms it may return null
// when PlatformAppIcon.adaptForHost is a no-op. Either is acceptable — we
// just verify the lazy doesn't throw.
val adapted = IconResources.adaptedBufferedImage
// Accessing twice must yield the same instance (or both null).
assertSame(adapted, IconResources.adaptedBufferedImage)
if (adapted != null) {
assertNotNull(adapted, "If adaptation returns non-null, it must be a valid image")
}
}
}
@@ -0,0 +1,259 @@
/*
* 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.relay
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Phase 1.2 of the launch-optimization plan: pin the invariants of
* [LocalRelayStore.hydrate] before any launch-path refactor touches it.
*
* The hydrate phases (per LocalRelayStore.kt:115-161) are:
* 1. kind:3 contact list (populates [DesktopLocalCache.followedUsers]).
* 2. kind:0 metadata for followed users (depends on #1 having run).
* 3. recent activity events (kinds 1/6/7/16/1111/9735) since now-7d.
*
* Tests seed a fresh SQLite DB at the location LocalRelayStore will open,
* then exercise hydrate against a real [DesktopLocalCache] and assert on
* the observable side effects (cache users, notes, followedUsers).
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md § Phase 1.2.
*/
class LocalRelayStoreHydrationTest {
private lateinit var tempHome: File
private lateinit var ownerKeyPair: KeyPair
private lateinit var ownerPubKey: String
private lateinit var dbPath: String
@BeforeTest
fun setup() {
tempHome = createTempDirectory("localrelay-hydrate").toFile()
ownerKeyPair = KeyPair()
ownerPubKey = ownerKeyPair.pubKey.toHexKey()
val dbDir = File(tempHome, ".amethyst/accounts/${ownerPubKey.take(8)}").also { it.mkdirs() }
dbPath = File(dbDir, "events.db").absolutePath
}
@AfterTest
fun teardown() {
tempHome.deleteRecursively()
}
/**
* Seeds the SQLite file LocalRelayStore will open, then closes it
* so the production code can re-open the same DB file.
*/
private suspend fun seedDatabase(events: List<Event>) {
val seeder = EventStore(dbName = dbPath, relay = LocalRelayStore.LOCAL_RELAY_URL)
try {
seeder.batchInsert(events)
} finally {
seeder.close()
}
}
private fun newStore(): LocalRelayStore = LocalRelayStore(scope = TestScope(), homeDir = tempHome).also { it.openForAccount(ownerPubKey) }
private fun makeContactList(
owner: KeyPair,
follows: List<String>,
createdAt: Long = nowSeconds(),
): ContactListEvent {
val signer = NostrSignerSync(owner)
val tags = follows.map { arrayOf("p", it) }.toTypedArray()
return signer.sign<ContactListEvent>(
createdAt = createdAt,
kind = ContactListEvent.KIND,
tags = tags,
content = "",
)
}
private fun makeMetadata(
author: KeyPair,
name: String,
createdAt: Long = nowSeconds(),
): MetadataEvent {
val signer = NostrSignerSync(author)
return signer.sign<MetadataEvent>(
createdAt = createdAt,
kind = MetadataEvent.KIND,
tags = emptyArray(),
content = """{"name":"$name"}""",
)
}
private fun makeTextNote(
author: KeyPair,
content: String,
createdAt: Long = nowSeconds(),
): TextNoteEvent {
val signer = NostrSignerSync(author)
return signer.sign<TextNoteEvent>(
createdAt = createdAt,
kind = TextNoteEvent.KIND,
tags = emptyArray(),
content = content,
)
}
private fun nowSeconds(): Long = System.currentTimeMillis() / 1000
@Test
fun hydratingAnEmptyDatabaseSucceedsAndLeavesCacheEmpty() =
runTest {
val cache = DesktopLocalCache()
val store = newStore()
try {
store.hydrate(cache)
} finally {
store.close()
}
assertTrue(cache.followedUsers.value.isEmpty(), "Empty DB must leave followedUsers empty")
assertNull(store.lastError.value, "Empty hydrate must not flag a lastError")
}
@Test
fun kind3IsHydratedBeforeKind0SoMetadataLoadsForFollowedAuthors() =
runTest {
val followee = KeyPair()
val contactList = makeContactList(ownerKeyPair, follows = listOf(followee.pubKey.toHexKey()))
val followeeMetadata = makeMetadata(followee, name = "Followee")
// Insert metadata first, contact list second — the on-disk row order
// is unrelated to the order hydrate() actually queries them. If
// hydrate ran phases in the wrong order, followedUsers would be
// empty when phase 2 ran and the metadata would never load.
seedDatabase(listOf(followeeMetadata, contactList))
val cache = DesktopLocalCache()
val store = newStore()
try {
store.hydrate(cache)
} finally {
store.close()
}
assertTrue(
followee.pubKey.toHexKey() in cache.followedUsers.value,
"Phase 1 (kind:3) must populate followedUsers before phase 2 runs",
)
val followeeUser = cache.getUserIfExists(followee.pubKey.toHexKey())
assertNotNull(followeeUser, "Followed user must be in cache after kind:3 phase")
assertEquals(
expected = "Followee",
actual = followeeUser.toBestDisplayName(),
message = "Phase 2 (kind:0) must run after phase 1 so metadata is applied to followed users",
)
}
@Test
fun recentTextNotesWithinSevenDayWindowAreHydrated() =
runTest {
val author = KeyPair()
val recentNote = makeTextNote(author, "recent", createdAt = nowSeconds() - 3600)
seedDatabase(listOf(recentNote))
val cache = DesktopLocalCache()
val store = newStore()
try {
store.hydrate(cache)
} finally {
store.close()
}
val note = cache.getNoteIfExists(recentNote.id)
assertNotNull(note, "Recent text note must be hydrated into cache")
}
@Test
fun textNotesOlderThanSevenDaysAreSkipped() =
runTest {
val author = KeyPair()
val eightDaysAgo = nowSeconds() - (8L * 24 * 3600)
val oldNote = makeTextNote(author, "stale", createdAt = eightDaysAgo)
seedDatabase(listOf(oldNote))
val cache = DesktopLocalCache()
val store = newStore()
try {
store.hydrate(cache)
} finally {
store.close()
}
assertNull(
cache.getNoteIfExists(oldNote.id),
"Notes older than the 7-day hydration window must be excluded",
)
}
@Test
fun hydrateDoesNotEmitWasVerifiedFalseForLocalEvents() =
runTest {
// The wasVerified=true semantics of cache.consume during hydrate is
// the contract we depend on: a tampered event that round-tripped
// through this DB would be admitted without re-checking the
// signature on every cold boot. The store's read-time gate is
// SQLite write-time verification — exercised by EventStore tests —
// and the hydrate path trusts it.
//
// Here we just verify the round-trip doesn't accidentally degrade
// an event by re-checking signatures it wouldn't pass elsewhere.
val author = KeyPair()
val note = makeTextNote(author, "round-trip")
seedDatabase(listOf(note))
val cache = DesktopLocalCache()
val store = newStore()
try {
store.hydrate(cache)
} finally {
store.close()
}
assertFalse(
cache.getNoteIfExists(note.id) == null,
"Round-trip through hydrate must not drop a valid note",
)
}
}
@@ -0,0 +1,50 @@
/*
* 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.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
/**
* Phase 2.1 of the launch-optimization plan: a [WebsocketBuilder] that routes
* every connection to an in-process [NostrServer] via [InProcessWebSocket],
* skipping the network entirely.
*
* Lives in `desktopApp/src/jvmTest` rather than `:quartz/src/testFixtures` so
* we avoid the KMP + `java-test-fixtures` interaction documented as Risk #1
* in the plan. Promote to a shared fixtures module only when Android picks
* up the same harness.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 2.1.
*/
class InProcessWebsocketBuilder(
private val server: NostrServer,
) : WebsocketBuilder {
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket = InProcessWebSocket(server, out)
}
@@ -0,0 +1,219 @@
/*
* 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.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* Phase 2.3 of the launch-optimization plan: a deterministic, synthetic
* "home feed" snapshot used to drive the in-process relay during benchmarks
* and Compose UI tests.
*
* The original plan called for a real-world capture (e.g. fiatjaf's latest
* 50 kind:1 + author metadata) committed as a JSONL artifact. Because we
* cannot run `amy` against live relays from this environment, the fixture
* is generated in code from a fixed RNG seed. The shape mirrors real-world
* home-feed payloads:
*
* - one owner ViewOnly account,
* - kind:10002 advertised-relay list,
* - kind:3 contact list with 50 follows,
* - kind:0 metadata for each followee,
* - 50 kind:1 text notes from a mix of followees over the last 7 days.
*
* Switching to a real-world fixture later is a drop-in: replace
* [LaunchFixture.events] with a parser of `*.jsonl` resource files. The
* server, builder, and benchmark harness do not care about the source.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 2.3.
*/
class LaunchFixture private constructor(
val ownerKeyPair: KeyPair,
val events: List<Event>,
) {
val ownerPubKeyHex: String get() = ownerKeyPair.pubKey.toHexKey()
companion object {
/**
* Builds the canonical home-feed snapshot for the benchmark/test scenarios.
*
* The seed is fixed so successive calls produce byte-identical fixtures
* — important for benchmark reproducibility — but a caller can pass
* a different seed when probing edge cases.
*/
fun build(
seed: Long = SEED,
followCount: Int = FOLLOW_COUNT,
noteCount: Int = NOTE_COUNT,
nowSeconds: Long = FIXED_NOW,
): LaunchFixture {
val rng = SeededRng(seed)
val owner = keyPairFromSeed(rng.nextLong())
val ownerSigner = NostrSignerSync(owner)
val followees = List(followCount) { keyPairFromSeed(rng.nextLong()) }
val events =
buildList {
add(advertisedRelays(ownerSigner, nowSeconds))
add(contactList(ownerSigner, followees, nowSeconds))
followees.forEachIndexed { idx, kp ->
add(metadata(kp, idx, nowSeconds))
}
repeat(noteCount) { i ->
val author = followees[rng.nextInt(followees.size)]
val ageSeconds = rng.nextLongInRange(MIN_AGE_SECONDS, MAX_AGE_SECONDS)
add(textNote(author, i, nowSeconds - ageSeconds))
}
}
return LaunchFixture(owner, events)
}
private const val SEED = 0xA3F71E5L
private const val FOLLOW_COUNT = 50
private const val NOTE_COUNT = 50
// Pin "now" so the fixture is fully deterministic across machines /
// timezones. The 7-day hydration window in LocalRelayStore is
// wall-clock based, so callers seeding events.db for warm-boot runs
// should pass a fresh `nowSeconds` to keep the events in window.
const val FIXED_NOW: Long = 1_750_000_000L
private const val MIN_AGE_SECONDS = 60L * 5L // 5 minutes ago
private const val MAX_AGE_SECONDS = 60L * 60L * 24L * 2L // 2 days ago
private fun keyPairFromSeed(seed: Long): KeyPair {
// Derive a 32-byte private key deterministically from the seed.
// Avoids depending on system entropy and keeps the fixture stable.
val key = ByteArray(32)
var v = seed.toULong() xor 0x9E3779B97F4A7C15uL
for (i in 0 until 32) {
v = v * 6364136223846793005uL + 1442695040888963407uL
key[i] = ((v shr 56).toInt() and 0xFF).toByte()
}
// secp256k1 valid private key range: 1..n-1. Force into a known-good range
// by clamping the top byte; n's top byte is 0xFF FF FF FF FE BA AE DC E6.
key[0] = (key[0].toInt() and 0x7F).toByte()
if (key.all { it == 0.toByte() }) key[31] = 1
return KeyPair(privKey = key)
}
private fun advertisedRelays(
signer: NostrSignerSync,
nowSeconds: Long,
): Event =
signer.sign<AdvertisedRelayListEvent>(
createdAt = nowSeconds - 60,
kind = AdvertisedRelayListEvent.KIND,
tags =
arrayOf(
arrayOf("r", "wss://test.invalid"),
),
content = "",
)
private fun contactList(
signer: NostrSignerSync,
followees: List<KeyPair>,
nowSeconds: Long,
): Event {
val tags = followees.map { arrayOf("p", it.pubKey.toHexKey()) }.toTypedArray()
return signer.sign<ContactListEvent>(
createdAt = nowSeconds - 30,
kind = ContactListEvent.KIND,
tags = tags,
content = "",
)
}
private fun metadata(
keyPair: KeyPair,
index: Int,
nowSeconds: Long,
): Event {
val signer = NostrSignerSync(keyPair)
return signer.sign<MetadataEvent>(
createdAt = nowSeconds - 3600 - index * 7L,
kind = MetadataEvent.KIND,
tags = emptyArray(),
content = """{"name":"Test User $index","about":"Synthetic launch fixture user","picture":""}""",
)
}
private fun textNote(
keyPair: KeyPair,
index: Int,
createdAt: Long,
): Event {
val signer = NostrSignerSync(keyPair)
return signer.sign<TextNoteEvent>(
createdAt = createdAt,
kind = TextNoteEvent.KIND,
tags = emptyArray(),
content = "Synthetic note #$index — fixed text so first paint timing is stable.",
)
}
}
}
/**
* Tiny xorshift64* PRNG. Deterministic, fast, no platform deps. Used only
* by the fixture builder.
*/
private class SeededRng(
seed: Long,
) {
private var state: ULong = (if (seed == 0L) 1L else seed).toULong()
fun nextLong(): Long {
var x = state
x = x xor (x shr 12)
x = x xor (x shl 25)
x = x xor (x shr 27)
state = x
return (x * 2685821657736338717uL).toLong()
}
fun nextInt(bound: Int): Int {
require(bound > 0)
val v = nextLong() ushr 1
return (v % bound.toLong()).toInt()
}
fun nextLongInRange(
from: Long,
until: Long,
): Long {
require(until > from)
val span = until - from
val v = nextLong() ushr 1
return from + (v % span)
}
}
@@ -0,0 +1,88 @@
/*
* 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.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
/**
* In-process relay primed with a [LaunchFixture] (or any list of pre-signed
* events). Wraps an [EventStore] backed [NostrServer], applies
* [EmptyPolicy] so REQs are answered without auth gating, and exposes the
* matching [InProcessWebsocketBuilder] so a [RelayConnectionManager] / test
* harness can connect to it.
*
* Lifecycle: callers should construct via [open], use, then [close].
* The constructor seeds the store synchronously to keep test setup terse.
*
* Combines plan Phases 2.1 (builder) + 2.2 (server) + 2.4 (wire) into a
* single small entry point used by every consumer.
*
* The relay URL [LAUNCH_TEST_RELAY_URL] is what tests should add to the
* account's relay list — the in-process socket ignores the URL value, but
* RelayPool keys connections by it.
*/
class LaunchFixtureRelay private constructor(
val server: NostrServer,
private val store: EventStore,
) : AutoCloseable {
val builder: WebsocketBuilder = InProcessWebsocketBuilder(server)
override fun close() {
// NostrServer.close() also closes its store.
server.close()
}
companion object {
val LAUNCH_TEST_RELAY_URL: NormalizedRelayUrl = NormalizedRelayUrl("wss://launch.test.invalid")
/**
* Construct a relay pre-seeded with [events]. The seeding is done
* synchronously via [runBlocking] — fine in a JVM test context but
* never call from production code.
*/
fun open(
events: List<Event>,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
): LaunchFixtureRelay {
val store = EventStore(dbName = null, relay = LAUNCH_TEST_RELAY_URL)
runBlocking { store.batchInsert(events) }
val server =
NostrServer(
store = store,
policyBuilder = { EmptyPolicy },
parentContext = dispatcher + SupervisorJob(),
)
return LaunchFixtureRelay(server, store)
}
/** Convenience: open a relay primed with [LaunchFixture.build]. */
fun openLaunchFixture(): LaunchFixtureRelay = open(LaunchFixture.build().events)
}
}
@@ -0,0 +1,112 @@
/*
* 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.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
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.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
/**
* Phase 2 roundtrip: NostrClient ↔ InProcessWebsocketBuilder ↔ NostrServer
* with a seeded fixture round-trips a REQ → EVENTs → EOSE and delivers
* the same events the fixture contains.
*
* Uses real coroutine dispatchers (not `runTest` virtual time) because the
* in-process websocket pumps events on real channels — virtual time would
* never advance the sample-debounce inside `NostrClient.allRelays`.
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 2.4.
*/
class LaunchFixtureRelayTest {
@Test
fun reqAgainstFixtureRelayReturnsAllAuthorNotesThenEose() =
runBlocking {
val fixture = LaunchFixture.build(noteCount = 10)
val relay = LaunchFixtureRelay.open(fixture.events)
val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
try {
val client = NostrClient(relay.builder, parentScope = clientScope)
client.connect()
val received = mutableListOf<Event>()
val eose = CompletableDeferred<Unit>()
client.subscribe(
subId = "test-sub",
filters =
mapOf(
LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to listOf(Filter(kinds = listOf(TextNoteEvent.KIND))),
),
listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
received += event
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eose.complete(Unit)
}
},
)
withTimeout(5.seconds) { eose.await() }
val expectedNotes = fixture.events.count { it.kind == TextNoteEvent.KIND }
assertEquals(
expectedNotes,
received.size,
"Fixture relay must replay every text note before EOSE",
)
assertTrue(
received.all { it.kind == TextNoteEvent.KIND },
"REQ kind:[1] must only yield kind:1 events",
)
client.close()
} finally {
relay.close()
clientScope.cancel()
}
}
}
@@ -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,108 @@
/*
* 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.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
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.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
/**
* Pins the invariant required by the Phase 5.2 launch-optimization fix:
* [NostrClient.subscribe] called BEFORE [NostrClient.connect] still
* delivers events once the connection comes up. This is the basis for
* dropping the `connectedRelays.first { isNotEmpty() }` gate from the
* desktop bootstrap subscription (Main.kt:1242-1284).
*
* See desktopApp/plans/2026-06-17-feat-app-launch-optimization-plan.md
* § Phase 5.2.
*/
class SubscribeBeforeConnectTest {
@Test
fun subscribeIssuedBeforeConnectStillReceivesEventsAndEose() =
runBlocking {
val fixture = LaunchFixture.build(noteCount = 5)
val relay = LaunchFixtureRelay.open(fixture.events)
val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
try {
val client = NostrClient(relay.builder, parentScope = clientScope)
val received = mutableListOf<Event>()
val eose = CompletableDeferred<Unit>()
// Subscribe BEFORE connect — the production fix that drops the
// `connectedRelays.first { isNotEmpty() }` gate relies on REQs
// queued at this point being flushed when the pool comes up.
client.subscribe(
subId = "pre-connect-sub",
filters =
mapOf(
LaunchFixtureRelay.LAUNCH_TEST_RELAY_URL to listOf(Filter(kinds = listOf(TextNoteEvent.KIND))),
),
listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
received += event
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eose.complete(Unit)
}
},
)
client.connect()
withTimeout(5.seconds) { eose.await() }
val expected = fixture.events.count { it.kind == TextNoteEvent.KIND }
assertTrue(
received.size == expected,
"Subscription registered pre-connect should still deliver all $expected events (got ${received.size})",
)
client.close()
} finally {
relay.close()
clientScope.cancel()
}
}
}
@@ -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
}