test: run relay-backed tests against geode instead of external relays

Every test that used to open a socket to something outside the repo now
talks to geode, the relay this project ships, either in-process or as the
embedded `amy serve`.

- amethyst: the Android instrumented EventSyncTest dialed vitor.nostr1.com,
  pyramid.fiatjaf.com and the nos.lol / nostr.mom defaults and asserted
  nothing. Replaced by a JVM unit test on geode's InProcessRelays that
  preloads a source relay and asserts what lands on the outbox, inbox and
  DM relays, plus a NIP-42 variant with the source behind FullAuthPolicy
  to cover the RelayAuthenticator wiring. Wires :geode, its testFixtures
  and the JVM SQLite driver into amethyst's unit-test classpath, the same
  way quartz's jvmAndroidTest already does.
- cli/tests: the cache, dm and marmot headless harnesses cloned and
  cargo-built nostr-rs-relay on first run. They now boot `amy serve`
  (geode) from the amy binary they already build, via a shared
  start_local_relay / stop_local_relay in headless/helpers.sh. Rust is no
  longer needed for the cache and dm suites at all.
- cli/tests/marmot/marmot-interop.sh: the interactive harness defaulted
  to relay.damus.io / nos.lol / primal / bitcoiner.social / nostr.mom,
  with `--local-relays` pointing at MDK's docker stack. It now boots the
  embedded relay on 0.0.0.0 by default (the phone reaches it over the
  LAN) and keeps the public set behind an explicit `--public-relays`.
- docs: cli/tests/README.md, CONTRIBUTING.md, cli/DEVELOPMENT.md and
  cli/ROADMAP.md no longer describe a loopback nostr-rs-relay.

The quartz prodbench probes (NegentropyStallRepro, CursorTerminationProbe,
NegentropyMultiRelayLiveTest, ProductionReceiverBenchmark, ...) are left
as they are: they are opt-in diagnostics of production relay behaviour,
gated behind PROD_RELAY_BENCH / NEG_MULTI / NEG_STALL_REPRO, and would
measure nothing against a local relay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
This commit is contained in:
Claude
2026-09-12 16:21:51 +00:00
parent 08a3bab605
commit 32f0c462bb
16 changed files with 487 additions and 342 deletions
+5 -3
View File
@@ -265,9 +265,11 @@ front:
sequentially: `for peer in aioquic picoquic quic-go quinn; do
quic/interop/run-matrix.sh -s $peer; done`. Plan at
`quic/interop/plans/2026-05-06-interop-runner.md`.
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless
variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive
Marmot variant prompts a human to drive the Android UI.
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every
relay-backed suite boots the embedded `amy serve` relay (geode) — no
external relay binary; only the Marmot suites additionally need `cargo`
for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to
drive the Android UI.
If a change is documentation-only, UI-only, build-script-only, or otherwise
cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip
+9
View File
@@ -598,6 +598,15 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
// In-process Nostr relay (geode) so unit tests that drive a real
// NostrClient talk to an embedded relay instead of a public one. Same
// wiring quartz uses for its jvmAndroidTest source set: the engine, its
// testFixtures (RelayClientTest base, preload/publish helpers) and the
// JVM SQLite driver the in-memory EventStore needs on a host JVM.
testImplementation(project(":geode"))
testImplementation(testFixtures(project(":geode")))
testImplementation(libs.androidx.sqlite.bundled.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit.ktx)
@@ -1,112 +0,0 @@
/*
* 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.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
@Test
fun testSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = {
listOf(Constants.mom, Constants.nos)
},
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
NostrClient(socketBuilder, appScope)
},
scope = appScope,
)
sync.runSync()
}
@Test
fun testFiatjafSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = { listOf(fiatjaf) },
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
val newClient = NostrClient(socketBuilder, appScope)
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
val signer = NostrSignerInternal(KeyPair())
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { _, authTemplate, _ ->
listOf(signer.sign(authTemplate))
},
)
newClient
},
scope = appScope,
)
sync.runSync()
}
}
@@ -0,0 +1,221 @@
/*
* 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.ui.screen.loggedIn.relays.eventsync
import com.vitorpamplona.geode.InProcessRelays
import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.geode.testing.preload
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
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 com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Drives [EventSync] end to end against geode's in-process relays: one
* "source" relay that already holds the account's history and three empty
* destination relays (outbox / inbox / DM). No network, no public relay —
* every relay is a [RelayEngine] inside this JVM, so the assertions are on
* what actually landed in each destination store, not on "it didn't crash".
*
* The second scenario gates the source relay behind NIP-42 ([FullAuthPolicy])
* to cover the [RelayAuthenticator] wiring the sync screen relies on when a
* user's relay demands AUTH before serving REQs.
*/
class EventSyncTest : RelayClientTest() {
private val account = NostrSignerSync(KeyPair())
private val other = NostrSignerSync(KeyPair())
private val source: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://source.relay/")
private val outbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://outbox.relay/")
private val inbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://inbox.relay/")
private val dm: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://dm.relay/")
/** Separate hub so only the source relay demands AUTH; destinations stay open. */
private val authHub = InProcessRelays(defaultPolicy = { FullAuthPolicy(source) })
@After
fun tearDownAuthHub() {
authHub.close()
}
private fun note(
author: NostrSignerSync,
content: String,
tagged: HexKey? = null,
): Event = author.sign(eventTemplate<Event>(1, content) { tagged?.let { pTag(it) } })
private fun legacyDm(
author: NostrSignerSync,
recipient: HexKey,
): Event = author.sign(eventTemplate<Event>(4, "ciphertext") { pTag(recipient) })
private val mine = List(3) { note(account, "mine $it") }
private val mentions = List(2) { note(other, "hey $it", tagged = account.pubKey) }
private val dmToMe = legacyDm(other, account.pubKey)
private val noise = note(other, "unrelated")
private fun corpus(): List<Event> = mine + mentions + dmToMe + noise
private fun eventSync(builder: WebsocketBuilder): EventSync =
EventSync(
accountPubKey = account.pubKey,
relayDb = { listOf(source) },
outboxTargets = { setOf(outbox) },
inboxTargets = { setOf(inbox) },
dmTargets = { setOf(dm) },
clientBuilder = { NostrClient(builder, scope) },
scope = scope,
)
/**
* Publishes are fire-and-forget on the client side, so the destination
* store can lag `runSync` returning by a few ticks. Poll instead of
* asserting a snapshot.
*/
private suspend fun RelayEngine.awaitCount(
filter: Filter,
expected: Int,
): Int =
withTimeoutOrNull(10_000) {
while (store.count(filter) < expected) delay(25)
store.count(filter)
} ?: store.count(filter)
private suspend fun assertRouted(hubOfTargets: InProcessRelays) {
val outboxRelay = hubOfTargets.getOrCreate(outbox)
val inboxRelay = hubOfTargets.getOrCreate(inbox)
val dmRelay = hubOfTargets.getOrCreate(dm)
assertEquals(
"every event authored by the account lands on the outbox relay",
mine.size,
outboxRelay.awaitCount(Filter(authors = listOf(account.pubKey)), mine.size),
)
assertEquals(
"non-DM mentions land on the inbox relay",
mentions.size,
inboxRelay.awaitCount(Filter(tags = mapOf("p" to listOf(account.pubKey))), mentions.size),
)
assertEquals(
"the kind-4 DM lands on the DM relay",
1,
dmRelay.awaitCount(Filter(kinds = listOf(4)), 1),
)
// Routing is exclusive per rule: nothing leaks across destinations and the
// unrelated note never leaves the source.
assertEquals("outbox holds only the account's events", mine.size, outboxRelay.store.count(Filter()))
assertEquals("inbox holds only the mentions", mentions.size, inboxRelay.store.count(Filter()))
assertEquals("dm relay holds only the DM", 1, dmRelay.store.count(Filter()))
assertEquals("noise stays on the source", 0, outboxRelay.store.count(Filter(ids = listOf(noise.id))))
}
@Test
fun syncRoutesEventsFromSourceToOutboxInboxAndDmRelays() =
runBlocking {
hub.getOrCreate(source).preload(corpus())
val sync = eventSync(hub)
withTimeout(30_000) { sync.runSync() }
val done = sync.syncState.value
assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done)
done as EventSync.SyncState.Done
assertEquals(
"mine + mentions + dm match a routing rule; noise does not",
mine.size + mentions.size + 1,
done.totalEventsReceived,
)
assertRouted(hub)
}
@Test
fun syncReadsFromAuthRequiredSourceOnceAuthenticated() =
runBlocking {
authHub.getOrCreate(source).preload(corpus())
// Source demands NIP-42 before serving REQs; destinations are the open hub.
val router =
object : WebsocketBuilder {
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket = if (url == source) authHub.build(url, out) else hub.build(url, out)
}
val authSigner = NostrSignerSync(KeyPair())
var authenticator: RelayAuthenticator? = null
val sync =
EventSync(
accountPubKey = account.pubKey,
relayDb = { listOf(source) },
outboxTargets = { setOf(outbox) },
inboxTargets = { setOf(inbox) },
dmTargets = { setOf(dm) },
clientBuilder = {
val client = NostrClient(router, scope)
authenticator =
RelayAuthenticator(client = client, scope = scope) { _, template, _ ->
listOf(authSigner.sign(template))
}
client
},
scope = scope,
)
try {
withTimeout(30_000) { sync.runSync() }
} finally {
authenticator?.destroy()
}
val done = sync.syncState.value
assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done)
assertEquals(
"the auth-gated source still yields every routed event",
mine.size + mentions.size + 1,
(done as EventSync.SyncState.Done).totalEventsReceived,
)
assertRouted(hub)
}
}
+1 -1
View File
@@ -348,7 +348,7 @@ Amy-specific layer still needs its own coverage:
| Error / exit-code contract (bad args → 2, timeout → 124, `rejected` → 1) | `ExitCodeContractTest` — table-driven tests invoking `runCli(argv)` with captured stdout/stderr. |
| JSON output shape (keys and types under `--json`) | `JsonContractTest` — runs commands under `--json` and asserts on the parsed object. The default text render has no shape contract and isn't asserted on. |
| File layout on disk (`identity.json`, `shared/events.db`, `marmot/groups/*.mls`, …) | Structural assertions after a command sequence. |
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). |
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up the embedded `amy serve` relay (geode) and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). |
The JVM suite drives `runCli` **in-process** through the shared
`amy(vararg argv)` harness in `CliResult.kt`: it captures stdout/stderr,
+4 -4
View File
@@ -180,11 +180,11 @@ move anything, re-audit — you're probably duplicating logic.
9. **Test suite** — largely in place, two layers:
- **Shell harnesses** under `cli/tests/` — ten suites: `blossom`
(live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`),
`marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`,
`marmot` (vs MDK), `nests` (manual audio-rooms matrix), `pow`,
`relaygroup`, `sync`, plus the shared `headless/` helpers. See
`cli/tests/README.md`.
None run in CI yet (the relay-backed ones need Rust + a ~3 min
cold `nostr-rs-relay` build).
`cli/tests/README.md`. Every relay-backed suite runs against the
embedded `amy serve` relay (geode) — no external relay binary.
None run in CI yet (the Marmot ones need Rust for MDK's `wn`).
- **JVM unit suite** at `cli/src/test/kotlin/``Args` parsing,
exit-code contract, and `--json` shape tests driving `runCli`
in-process via the `amy.home` isolation seam.
+1
View File
@@ -1,6 +1,7 @@
marmot/state/
marmot/state-headless/
dm/state-dm-headless/
cache/state-cache-headless/
nests/state/
clink/state-clink-headless/
relaygroup/state-relaygroup-headless/
+46 -39
View File
@@ -1,21 +1,24 @@
# amy CLI test harnesses
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a
loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers,
or no relay at all, depending on the suite. Eleven directories:
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against an
embedded relay (`amy serve`, i.e. **geode**, the relay this repo ships), live
public servers, or no relay at all, depending on the suite. No suite depends on
an external relay binary or a Rust toolchain for its relay: every relay-backed
harness boots geode from the `amy` binary it already built, so the relay under
test is the same server code that runs in production. Eleven directories:
```
cli/tests/
├── lib.sh # shared logging, results, assertions
├── headless/ # shared bits used by every harness
│ └── helpers.sh
│ └── helpers.sh # amy wrappers, assertions, embedded relay boot
├── blossom/ # Blossom blob lifecycle vs LIVE public servers
│ └── blossom-live.sh
├── cache/ # local-store-as-cache semantics (profile show
│ └── cache-headless.sh # cache/refresh, store stat) vs nostr-rs-relay
│ └── cache-headless.sh # cache/refresh, store stat) vs embedded `amy serve`
├── clink/ # CLINK pointer decode — local-only, no relay
│ └── clink-headless.sh
├── dm/ # NIP-17 DM interop (amy ↔ amy)
├── dm/ # NIP-17 DM interop (amy ↔ amy) vs embedded `amy serve`
│ ├── dm-interop-headless.sh
│ ├── setup.sh # preflight + identities
│ └── tests-dm.sh
@@ -24,7 +27,7 @@ cli/tests/
├── marmot/ # Marmot / MLS group-messaging interop
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
│ ├── marmot-interop-headless.sh # zero-prompt
│ ├── setup.sh # preflight + wn + relay + identities
│ ├── setup.sh # preflight + wn + identities
│ ├── tests-create.sh # tests 0105
│ ├── tests-manage.sh # tests 0608, 11
│ ├── tests-extras.sh # tests 09, 10, 12, 13
@@ -60,7 +63,7 @@ Suite notes:
and mined-nonce round-trips through `pow check`.
- **`cache/cache-headless.sh`** proves the local store is the source of
truth for reads: `profile show` served from cache vs `--refresh`, and
`store stat` reporting the right histogram, vs a loopback nostr-rs-relay.
`store stat` reporting the right histogram, vs the embedded `amy serve` relay.
- **`relaygroup/relaygroup-headless.sh`** runs NIP-29 create/message/join/
list/browse against an embedded relay (`amy serve`, which boots geode) —
no external relay binary. geode doesn't sign 39000-39003, so browse/info
@@ -124,10 +127,8 @@ The Marmot harnesses come in two flavours, same scenarios:
A third, slimmer harness covers the NIP-17 DM surface:
- **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and
Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay.
No MDK required — only `amy` and the relay binary (which
is shared with the Marmot harness's checkout at
`marmot/state-headless/nostr-rs-relay/`).
Identity D) exchange NIP-17 DMs through the embedded `amy serve` relay.
No MDK, no Rust — only `amy`.
A harness covers Blossom blob storage (BUD-01/02/04/09) against **live**
public servers rather than a loopback relay:
@@ -212,15 +213,18 @@ at `desktopApp/src/jvmTest/kotlin/.../service/upload/`.
On the machine that runs the harness:
- **Rust 1.90+** — install via https://rustup.rs
- **Rust 1.90+** — install via https://rustup.rs (for MDK's `wn`/`wnd` only;
the relay is `amy serve`, no Rust needed for it)
- **git**, **curl**, **jq** — package manager
- **~5 GB disk** for the first-run build of `wn` + `wnd`
- Public internet access (for the default relay set and fetching crates)
- Internet access for fetching crates on the first build. Test traffic
stays on the machine unless you pass `--public-relays`.
On the Android side:
- Amethyst installed on an **emulator** or a **physical device**
- The device must reach the same relays the harness uses (see below)
- The device must reach the harness's embedded relay over the network
(see below), or the public relays when running with `--public-relays`
## Quick start
@@ -240,9 +244,11 @@ The script will, in order:
4. Create Nostr identities for B and C, persist their npubs in `state/run.env`.
5. Ask you to paste **your Amethyst account npub** (Identity A). This is
cached for subsequent runs.
6. Add the default public relays to both daemons and run a sanity check
(publish a KP from B, fetch it from C).
7. Print an **Amethyst setup checklist** — add the same relays to Amethyst,
6. Boot the embedded relay (`amy serve`, i.e. geode, on `0.0.0.0:8080`),
add it to both daemons and run a sanity check (publish a KP from B,
fetch it from C). With `--public-relays` the default public set is used
instead and the relay is not started.
7. Print an **Amethyst setup checklist** — add the same relay to Amethyst,
publish a KP, verify you are logged in with A.
8. Run all 13 tests sequentially. Each test either:
- runs `wn` commands fully automatically and asserts on JSON output, **or**
@@ -253,9 +259,10 @@ The script will, in order:
## Command-line flags
```
--local-relays Use ws://localhost:8080 instead of the default public relays.
Required if the public relays reject kinds 444/445/30443.
Run 'just docker-up' inside the mdk checkout first.
--public-relays Use the public relay set below instead of the embedded relay.
The only mode whose test traffic leaves the machine; the
public relays may reject kinds 444/445/30443.
--port N Port for the embedded relay (default 8080).
--transponder Run Test 14 (push notifications via the transponder service).
--no-build Fail instead of rebuilding wn/wnd. Useful when iterating.
-h, --help Show help.
@@ -267,31 +274,31 @@ Environment overrides:
WN_REPO=/some/path/mdk # use an existing checkout
```
## Default relays
## Relays
By default the harness owns the only relay: `amy serve` (geode) bound to
`0.0.0.0:8080`. The `wn` daemons reach it on loopback; Amethyst reaches it
over the network:
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays,
Settings → Key Package Relays and Settings → DM Inbox Relays.
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
With `--public-relays` the daemons are bootstrapped on
```
wss://relay.damus.io
wss://nos.lol
wss://relay.primal.net
wss://nostr.bitcoiner.social
wss://nostr.mom
```
These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable
events). If the **sanity check fails** — meaning C cannot read the KeyPackage
that B just published — the harness warns you and continues. In that case
re-run with `--local-relays` after starting the Docker stack:
```bash
cd state/mdk
just docker-up
cd ../..
./marmot-interop.sh --local-relays
```
For Amethyst with `--local-relays`:
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays and
Settings → Key Package Relays.
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
instead and Amethyst is left on its own relay set, so the run surfaces
real-world discovery failures (A's inbox behind NIP-42, whitelists, kinds the
public relays drop). If the **sanity check fails** in that mode — meaning C
cannot read the KeyPackage that B just published — the harness warns you and
continues; re-run without `--public-relays` to rule the relays out.
## How human interaction works
+9 -11
View File
@@ -3,8 +3,8 @@
# cache-headless.sh — verifies the file-backed event store is the
# source of truth for `amy` reads.
#
# Two amy identities (A and B) talk to a local nostr-rs-relay. We
# assert that:
# Two amy identities (A and B) talk to a local embedded relay
# (`amy serve`, i.e. geode). We assert that:
#
# 1. After A runs `amy create`, A's local store contains the bootstrap
# events (kind:0 / 3 / 10002 / 10050 / 10051 …).
@@ -39,10 +39,11 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Reuse the relay binary the marmot harness builds.
# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
# start_local_relay in headless/helpers.sh. 127.0.0.2 rather than
# 127.0.0.1 so Quartz's isLocalHost() filter doesn't strip it out of the
# published relay lists (see the DM harness for the full note).
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}"
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8092}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
@@ -72,14 +73,11 @@ mkdir -p "$STATE_DIR" "$LOG_DIR"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
# shellcheck source=../marmot/setup.sh — provides start_local_relay / stop_local_relay
source "$TESTS_DIR/marmot/setup.sh"
# shellcheck source=../headless/helpers.sh
# shellcheck source=../headless/helpers.sh — amy wrappers + start_local_relay / stop_local_relay
source "$TESTS_DIR/headless/helpers.sh"
# Keep the dm setup's preflight (just checks for amy + the relay) but
# define our own identity bootstrap so we don't pull in DM-specific
# wiring.
# Keep the dm setup's preflight (just checks for amy) but define our own
# identity bootstrap so we don't pull in DM-specific wiring.
# shellcheck source=../dm/setup.sh
source "$TESTS_DIR/dm/setup.sh"
+9 -13
View File
@@ -3,8 +3,9 @@
# dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness.
#
# Two `amy` processes (Identity A and Identity D) talk to each other
# through a local nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT. No
# whitenoise-rs, no Marmot, no public internet traffic.
# through a local embedded relay (`amy serve`, i.e. geode) on
# ws://127.0.0.2:$RELAY_PORT. No MDK, no Marmot, no Rust toolchain, no
# public internet traffic.
#
# Usage: ./dm-interop-headless.sh [--port N] [--no-build]
#
@@ -26,16 +27,14 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Share the nostr-rs-relay checkout with the Marmot harness to avoid
# rebuilding it twice. Override RELAY_REPO / RELAY_DATA if you want full
# isolation between runs.
# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
# start_local_relay in headless/helpers.sh. Override RELAY_DATA if you
# want full isolation between runs.
# Bind the loopback relay to 127.0.0.2 rather than 127.0.0.1 so Quartz's
# `isLocalHost()` filter doesn't silently strip it out of the kind:10050
# inbox events during recipient-relay resolution. 127.0.0.2 is still pure
# loopback — no network traffic, no config needed.
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}"
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8090}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
@@ -68,12 +67,9 @@ mkdir -p "$STATE_DIR" "$LOG_DIR"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
# Reuse start_local_relay / stop_local_relay from the Marmot harness's
# setup.sh — the relay lifecycle is identical. preflight() there also
# builds whitenoise-rs, which we don't need; setup.sh in this dir
# defines a slimmer preflight_dm().
# shellcheck source=../marmot/setup.sh
source "$TESTS_DIR/marmot/setup.sh"
# setup.sh in this dir defines the slim preflight_dm() (amy only, no
# MDK); the relay lifecycle (start_local_relay / stop_local_relay, the
# embedded `amy serve`) comes from the shared headless helpers.
# shellcheck source=setup.sh
source "$SCRIPT_DIR/setup.sh"
# shellcheck source=../headless/helpers.sh
+7 -29
View File
@@ -3,20 +3,21 @@
# setup.sh — amy-only preflight + identity bootstrap for the
# NIP-17 DM interop harness. Much slimmer than the Marmot setup:
#
# - Builds `amy` (same retry-on-503 logic as setup.sh).
# - Builds nostr-rs-relay if missing.
# - Builds `amy` (same retry-on-503 logic as setup.sh). The loopback
# relay is `amy serve` (geode), so amy is the only binary needed.
# - Bootstraps two fresh amy identities (A and D), each with its own
# `--data-dir`, both pointed at the loopback relay.
# - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict
# recipient-inbox routing has something to resolve to.
#
# The heavy `start_local_relay` / `stop_local_relay` helpers live in
# the Marmot harness's setup.sh and are sourced by the top-level harness.
# The `start_local_relay` / `stop_local_relay` helpers (embedded
# `amy serve`) live in ../headless/helpers.sh and are sourced by the
# top-level harness.
# --- preflight (amy + relay only, no wn / Marmot patches) -------------------
# --- preflight (amy only, no wn / Marmot patches, no Rust) -------------------
preflight_dm() {
banner "Preflight (DM harness)"
for cmd in jq git cargo; do
for cmd in jq git curl; do
if ! command -v "$cmd" >/dev/null 2>&1; then
fail_msg "missing required tool: $cmd"
exit 1
@@ -43,29 +44,6 @@ preflight_dm() {
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
info "amy: $AMY_BIN"
# nostr-rs-relay (same build path as the Marmot harness).
if [[ ! -x "$RELAY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
fi
if [[ ! -d "$RELAY_REPO/.git" ]]; then
step "cloning nostr-rs-relay into $RELAY_REPO"
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
local attempt max=4
for attempt in $(seq 1 $max); do
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
2>&1 | tee -a "$LOG_FILE"
[[ -x "$RELAY_BIN" ]] && break
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed — retrying"
done
[[ -x "$RELAY_BIN" ]] || {
fail_msg "nostr-rs-relay still missing after $max attempts"; exit 1
}
fi
info "relay bin: $RELAY_BIN"
}
# --- amy identity wrappers ---------------------------------------------------
+1 -1
View File
@@ -3,7 +3,7 @@
# tests-dm.sh — NIP-17 DM interop tests for two `amy` clients.
#
# Identity A (sender) and Identity D (recipient) each live in their own
# --data-dir and share one loopback nostr-rs-relay. Tests cover:
# --data-dir and share one loopback embedded relay (`amy serve` / geode). Tests cover:
#
# dm-01 text round-trip (both directions)
# dm-02 dm list surfaces prior exchange with type:text discriminator
+86
View File
@@ -66,5 +66,91 @@ assert_eq() {
return 1
}
# --- embedded relay (amy serve → geode) --------------------------------------
# Every relay-backed harness talks to ONE loopback relay, and that relay is
# `amy serve` — i.e. geode, the relay this repo ships — booted from the amy
# binary the harness already built. No Rust toolchain, no clone, no cargo
# build, no external relay binary: the relay under test is part of the
# product, so a harness run exercises the same server code `amy serve`
# and the standalone geode distribution run in production.
#
# Callers set (before sourcing or at least before calling):
# AMY_BIN amy launcher (built via `./gradlew :cli:installDist`)
# RELAY_HOST host clients connect to (most harnesses use 127.0.0.2 —
# see the isLocalHost() note at the top of each script)
# RELAY_BIND optional bind address; defaults to $RELAY_HOST. Set to
# 0.0.0.0 when a device on the LAN must reach the relay.
# RELAY_PORT listen port
# RELAY_URL ws://$RELAY_HOST:$RELAY_PORT
# RELAY_DATA scratch dir for the relay's own $HOME, pid file and logs
#
# The relay process runs as its own amy account ("relay") inside its own
# $HOME under $RELAY_DATA, so its identity and store never mix with the
# test identities. The store is in-memory (amy serve's default): every run
# starts from an empty relay, matching the state wipe the harnesses do.
start_local_relay() {
banner "Starting embedded relay (amy serve / geode) on $RELAY_URL"
local relay_home="$RELAY_DATA/home"
local bind="${RELAY_BIND:-$RELAY_HOST}"
mkdir -p "$relay_home" "$RELAY_DATA/logs"
[[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; }
# Abort early if something else is already bound to the port — failing
# with a clear error beats a mysterious-looking daemon stall later.
# bash's /dev/tcp probe needs no `ss`/`lsof`; a refused connect on
# loopback returns immediately.
if (exec 3<>"/dev/tcp/$RELAY_HOST/$RELAY_PORT") 2>/dev/null; then
fail_msg "port $RELAY_PORT already in use on $RELAY_HOST — pass --port N or free it"
exit 1
fi
# `amy serve` resolves its admin pubkey from the account, so the relay
# needs an identity of its own. Idempotent across --reuse-state runs.
if [[ ! -d "$relay_home/.amy/relay" ]]; then
HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext --json init \
>"$RELAY_DATA/logs/init.log" 2>&1 \
|| { fail_msg "amy init failed for the relay account (see $RELAY_DATA/logs/init.log)"; exit 1; }
fi
nohup env HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext \
serve --host "$bind" --port "$RELAY_PORT" \
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
echo "$!" > "$RELAY_DATA/pid"
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL"
# Readiness = the NIP-11 document answers on the same port. geode serves
# it on a plain GET with `Accept: application/nostr+json` (anything else
# gets a 426 hint, which curl -f would treat as failure).
local deadline=$(( $(date +%s) + 60 ))
while [[ $(date +%s) -lt $deadline ]]; do
if curl -sSf -m 1 -H 'Accept: application/nostr+json' \
"http://$RELAY_HOST:$RELAY_PORT/" >/dev/null 2>&1; then
info "relay up"
return 0
fi
if ! kill -0 "$(cat "$RELAY_DATA/pid")" 2>/dev/null; then
break
fi
sleep 0.5
done
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
exit 1
}
stop_local_relay() {
local pid_file="$RELAY_DATA/pid"
[[ -f "$pid_file" ]] || return 0
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
info "stopping relay pid $pid"
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$pid_file"
}
# --- wn-side pollers (delegates to lib.sh) -----------------------------------
# Both exist in lib.sh already; this file only adds headless-specific niceties.
+10 -11
View File
@@ -3,9 +3,9 @@
# marmot-interop-headless.sh — zero-prompt, zero-internet interop harness.
#
# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and
# Identities B/C via MDK's `wn`/`wnd`. Spins up a local
# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the
# machine. Matches the 13 test scenarios in marmot-interop.sh but without
# Identities B/C via MDK's `wn`/`wnd`. Spins up a local embedded relay
# (`amy serve`, i.e. geode) on ws://127.0.0.2:$RELAY_PORT so nothing ever
# leaves the machine. Matches the 13 test scenarios in marmot-interop.sh but without
# any human prompts — all checks run to completion and the exit code
# reflects pass/fail totals.
#
@@ -37,9 +37,10 @@ WN_BIN="$WN_REPO/target/release/wn"
WND_BIN="$WN_REPO/target/release/wnd"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Local relay wiring — cloned + built during preflight, started on
# $RELAY_PORT. The harness never touches the public internet for test
# traffic; wn/wnd/amy all point at this one loopback endpoint.
# Local relay wiring — the embedded `amy serve` (geode), started on
# $RELAY_PORT by start_local_relay (../headless/helpers.sh). The harness
# never touches the public internet for test traffic; wn/wnd/amy all
# point at this one loopback endpoint.
#
# Bind to 127.0.0.2 rather than 127.0.0.1: Quartz's RelayUrlNormalizer
# strips literal 127.0.0.1 / localhost / 192.168.* out of NIP-17 inbox
@@ -48,8 +49,6 @@ AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Amethyst's public defaults instead of the loopback. 127.0.0.2 is
# still pure loopback (no network traffic) but isn't on the strip list.
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}"
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8080}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
@@ -73,7 +72,7 @@ BLOSSOM_PID=""
NO_BUILD=0
# Every run starts from empty stores. wnd already wipes B's and C's data dirs
# on each start, but A's amy home and the relay's SQLite file used to survive,
# on each start, but A's amy home and the relay's state used to survive,
# and the leftovers are not inert: a KeyPackage A published in an earlier run
# is still on the relay for B to invite with, an old group's kind:445 events
# still arrive and fail to decrypt, and A's cursors still say it has seen them.
@@ -119,8 +118,8 @@ while [[ $# -gt 0 ]]; do
done
if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then
# Keep the relay checkout + its build (minutes to rebuild) and the log and
# results history; drop everything that holds protocol state.
# Keep the log and results history; drop everything that holds protocol
# state (the relay is in-memory, so wiping its dir just drops its identity).
#
# run.env counts as protocol state: it is where tests hand each other group
# ids. Leaving it behind a wipe leaves ids naming groups nobody is in any
+72 -24
View File
@@ -5,12 +5,19 @@
# Sequential, all-or-nothing. Script drives the `wn` side automatically and
# prompts the human operator at each step that requires Amethyst UI action.
#
# Usage: ./marmot-interop.sh [--local-relays] [--transponder] [--no-build]
# Usage: ./marmot-interop.sh [--public-relays] [--port N] [--transponder] [--no-build]
#
# By default the harness boots its own relay — `amy serve`, i.e. geode — bound
# to 0.0.0.0:$RELAY_PORT so the wn daemons reach it on loopback and the phone
# reaches it over the LAN (ws://<laptop-ip>:PORT, or ws://10.0.2.2:PORT from an
# emulator). Pass --public-relays to run the old real-world path against the
# public relay set instead; that is the only mode that touches the internet.
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state"
LOG_DIR="$STATE_DIR/logs"
B_DIR="$STATE_DIR/B"
@@ -27,6 +34,17 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
WN_REPO="${WN_REPO:-$STATE_DIR/mdk}"
WN_BIN=""
WND_BIN=""
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Embedded relay (default mode). Bound on every interface so a device on the
# same network can reach it; the daemons connect over loopback. Loopback
# `ws://` relays are only accepted by MDK behind this explicit opt-in.
RELAY_HOST="127.0.0.1"
RELAY_BIND="0.0.0.0"
RELAY_PORT="${RELAY_PORT:-8080}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
RELAY_DATA="$STATE_DIR/relay"
export WN_ALLOW_LOOPBACK_RELAYS=1
B_NPUB=""
B_HEX=""
C_NPUB=""
@@ -34,6 +52,7 @@ C_HEX=""
A_NPUB=""
A_HEX=""
# Only used with --public-relays.
DEFAULT_RELAYS=(
"wss://relay.damus.io"
"wss://nos.lol"
@@ -41,7 +60,7 @@ DEFAULT_RELAYS=(
"wss://nostr.bitcoiner.social"
"wss://nostr.mom"
)
USE_LOCAL_RELAYS=0
USE_PUBLIC_RELAYS=0
ENABLE_TRANSPONDER=0
NO_BUILD=0
@@ -50,7 +69,9 @@ usage() {
marmot-interop.sh — Amethyst <-> MDK interop harness
Options:
--local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up')
--public-relays Use the public relay set instead of the embedded relay
(amy serve / geode, the default). Only mode that leaves the machine.
--port N Port for the embedded relay (default 8080)
--transponder Run Test 14 (MIP-05 push notifications)
--no-build Don't rebuild wn/wnd if binaries are missing
-h, --help Show this help
@@ -62,8 +83,10 @@ EOF
while [[ $# -gt 0 ]]; do
case "$1" in
--local-relays) USE_LOCAL_RELAYS=1 ;;
--transponder) ENABLE_TRANSPONDER=1 ;;
--public-relays) USE_PUBLIC_RELAYS=1 ;;
--local-relays) printf '%s\n' "note: --local-relays is now the default (embedded amy serve relay); flag ignored" >&2 ;;
--port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
--transponder) ENABLE_TRANSPONDER=1 ;;
--no-build) NO_BUILD=1 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;;
@@ -77,6 +100,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve)
source "$TESTS_DIR/headless/helpers.sh"
# --- preflight ---------------------------------------------------------------
preflight() {
@@ -88,6 +113,26 @@ preflight() {
printf ' %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE"
done
# The embedded relay is `amy serve`, so amy has to exist unless the run
# goes to the public relays. Same transient-503 retry as the headless
# harness: one bad jitpack/dl.google.com roll must not abort the run.
if [[ "$USE_PUBLIC_RELAYS" -ne 1 && ! -x "$AMY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1
fi
local attempt max_attempts=4
for attempt in $(seq 1 $max_attempts); do
step "building :cli:installDist (attempt $attempt/$max_attempts)"
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
&& [[ -x "$AMY_BIN" ]]; then
break
fi
[[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying"
done
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
printf ' amy: %s\n' "$AMY_BIN" >>"$LOG_FILE"
fi
WN_BIN="$WN_REPO/target/release/wn"
WND_BIN="$WN_REPO/target/release/wnd"
@@ -285,7 +330,7 @@ discover_a_relays() {
if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then
info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working"
else
warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${DEFAULT_RELAYS[*]}."
warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${RELAY_LIST[*]}."
warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach."
warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)."
fi
@@ -300,7 +345,7 @@ discover_a_relays() {
if ! command -v sqlite3 >/dev/null 2>&1; then
warn "sqlite3 not installed — skipping wn user_relays cache probe."
warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun with --local-relays."
warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun without --public-relays."
return
fi
@@ -383,12 +428,7 @@ discover_a_relays() {
# --- relays ------------------------------------------------------------------
configure_relays() {
banner "Configuring relays"
local relays=()
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then
relays=( "ws://localhost:8080" )
else
relays=( "${DEFAULT_RELAYS[@]}" )
fi
local relays=( "${RELAY_LIST[@]}" )
# Each relay × 3 types × 2 daemons produces a lot of repetitive "ok"
# lines — the happy path doesn't need any of it on screen. Quiet the
# per-add logging into $LOG_FILE and only surface real failures as
@@ -527,27 +567,28 @@ configure_relays() {
info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)"
else
warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)"
warn "Consider rerunning with --local-relays."
warn "Consider rerunning without --public-relays (the embedded relay accepts every kind)."
fi
# best-effort cleanup so re-runs don't accumulate dead sanity groups
wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true
wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true
else
warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists"
warn "Consider rerunning with --local-relays (requires 'just docker-up' in the mdk checkout)."
warn "Consider rerunning without --public-relays (the embedded relay accepts every kind)."
fi
fi
}
instruct_amethyst_setup() {
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then
# Offline/sandbox path: we own the only relay, so the harness DOES
# need to dictate Amethyst's relay config — nothing is discoverable
# via the public network.
prompt_human "Configure Amethyst to match this --local-relays harness:
if [[ "$USE_PUBLIC_RELAYS" -ne 1 ]]; then
# Offline/sandbox path (default): we own the only relay the embedded
# `amy serve` (geode) on 0.0.0.0:$RELAY_PORT — so the harness DOES need
# to dictate Amethyst's relay config; nothing is discoverable via the
# public network.
prompt_human "Configure Amethyst to use this harness's embedded relay (amy serve / geode):
1. Settings -> Relays: add as READ+WRITE
ws://10.0.2.2:8080 (Android emulator)
ws://<your-LAN-ip>:8080 (physical device on same Wi-Fi)
ws://10.0.2.2:$RELAY_PORT (Android emulator)
ws://<your-LAN-ip>:$RELAY_PORT (physical device on same Wi-Fi)
2. Settings -> Key Package Relays: add the SAME URL
3. Settings -> DM Inbox Relays (NIP-17/kind:10050): add the SAME URL
4. Trigger key-package publish (toggle KP relay on/off if needed)
@@ -555,7 +596,7 @@ instruct_amethyst_setup() {
return
fi
# Public-relay path: the harness should behave like any real Nostr
# --public-relays path: the harness should behave like any real Nostr
# client — discover A's advertised relays via kind:10002 / 10050 /
# 10051 and publish there, rather than forcing A to adopt the
# harness's own relay set. That lets the tests surface real-world
@@ -1301,6 +1342,7 @@ main() {
local rc=$?
trap - EXIT INT TERM HUP
stop_daemons
stop_local_relay
print_summary
exit "$rc"
}
@@ -1311,6 +1353,12 @@ main() {
banner "Amethyst <-> MDK interop harness ($RUN_TS)"
preflight
if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
RELAY_LIST=( "${DEFAULT_RELAYS[@]}" )
else
RELAY_LIST=( "$RELAY_URL" )
start_local_relay
fi
start_daemon B "$B_DIR" "$B_SOCKET"
start_daemon C "$C_DIR" "$C_SOCKET"
ensure_identity B
@@ -1327,7 +1375,7 @@ main() {
# plane, then summarise what wn sees. Surfaces up front the kind of
# failure (A's 10050 unreachable from wn, missing KP list, etc.) that
# would otherwise bite as a silent Test 03 timeout.
if [[ "$USE_LOCAL_RELAYS" -ne 1 ]]; then
if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
discover_a_relays
fi
+6 -94
View File
@@ -147,29 +147,8 @@ preflight() {
info "wn: $WN_BIN ($(git -C "$WN_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown))"
info "wnd: $WND_BIN"
# Clone/build nostr-rs-relay — the harness's single loopback relay.
if [[ ! -x "$RELAY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
fi
if [[ ! -d "$RELAY_REPO/.git" ]]; then
step "cloning nostr-rs-relay into $RELAY_REPO"
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
local attempt max=4
for attempt in $(seq 1 $max); do
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
2>&1 | tee -a "$LOG_FILE"
[[ -x "$RELAY_BIN" ]] && break
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed (likely transient 503 from crates.io) — retrying"
done
[[ -x "$RELAY_BIN" ]] || {
fail_msg "nostr-rs-relay still missing after $max build attempts"; exit 1
}
fi
info "relay bin: $RELAY_BIN"
# The loopback relay is `amy serve` (geode) — see start_local_relay in
# ../headless/helpers.sh. Nothing to clone or build beyond amy itself.
}
# --- local QUIC broker -------------------------------------------------------
@@ -262,75 +241,8 @@ stop_quic_broker() {
}
# --- local relay -------------------------------------------------------------
# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test
# runs against this one loopback endpoint — no external network traffic.
start_local_relay() {
banner "Starting local nostr-rs-relay on $RELAY_URL"
mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs"
# Render a minimal config file each run so port/limits come from the
# harness rather than whatever was left on disk from a previous session.
cat >"$RELAY_DATA/config.toml" <<EOF
[info]
relay_url = "$RELAY_URL"
name = "amethyst-headless-harness"
description = "Loopback relay for marmot-interop-headless.sh — do not use for anything real."
[database]
data_directory = "$RELAY_DATA"
[network]
address = "${RELAY_BIND:-${RELAY_HOST:-127.0.0.1}}"
port = $RELAY_PORT
[options]
reject_future_seconds = 3600
[limits]
# Keep kind:444 / 445 / 1059 / 30443 wide open — the whole point is
# exercising Marmot traffic the public relays reject.
max_event_bytes = 524288
max_ws_message_bytes = 1048576
max_ws_frame_bytes = 1048576
EOF
# Abort early if something else is already bound to the port — failing
# with a clear error beats a mysterious-looking daemon stall later.
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then
fail_msg "port $RELAY_PORT already in use — pass --port N or free it"
exit 1
fi
nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
echo "$!" > "$RELAY_DATA/pid"
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL"
local deadline=$(( $(date +%s) + 20 ))
while [[ $(date +%s) -lt $deadline ]]; do
if curl -sSf -m 1 "http://${RELAY_HOST:-127.0.0.1}:$RELAY_PORT/" >/dev/null 2>&1; then
info "relay up"
return 0
fi
sleep 0.5
done
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
exit 1
}
stop_local_relay() {
local pid_file="$RELAY_DATA/pid"
[[ -f "$pid_file" ]] || return 0
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
info "stopping relay pid $pid"
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$pid_file"
}
# start_local_relay / stop_local_relay live in ../headless/helpers.sh: the
# relay is the embedded `amy serve` (geode), shared by every harness.
# --- daemons -----------------------------------------------------------------
start_daemon() {
@@ -480,9 +392,9 @@ configure_relays() {
step "publishing A's KeyPackage"
amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed"
# Give nostr-rs-relay a breath to fsync the kind:10002 / 10050 / 30443
# Give the relay a breath to ingest the kind:10002 / 10050 / 30443
# writes and push them out on the discovery subscription so that the
# first `wn keys check` that follows actually sees them instead of
# racing the relay's WAL flush.
# racing the relay's ingest queue.
sleep 2
}