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