mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
- Pin MDK to 03b1809e, the snapshot White Noise Android ships (its MARMOT_VERSION lockfile). White Noise iOS is on the 0.10.4 release (fcc85edd), an ancestor, so per the harness rule the newer one wins. - macOS: the harnesses check for the 127.0.0.2 lo0 alias and print the sudo command instead of failing deep in the relay log. They move wnd sockets to a short /private/tmp dir when the checkout path is too long (wnd binds inside a staging dir that adds ~30 bytes, so an 85-byte path already broke SUN_LEN, and wnd refuses the /tmp symlink alias). They also rebuild amy incrementally on every run, because a stale install/amy produced fake publish failures. --no-build keeps the old behaviour with a warning. - Test 32 (amy's message after a wn commit reaches wn), previously only on a local branch. - Test 33: a wn member leaves a group amy administers and amy commits the departure. It fails without the SelfRemove commit fix earlier in this branch. - README: macOS setup, the MDK pin, and the stale 'cd tools/marmot-interop'. Run on macOS with no manual workarounds: 28 pass, 2 skip (no QUIC broker), and 12, 27 and 29 fail (addressed separately). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
19 KiB
Markdown
406 lines
19 KiB
Markdown
# amy CLI test harnesses
|
||
|
||
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 # 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 embedded `amy serve`
|
||
├── clink/ # CLINK pointer decode — local-only, no relay
|
||
│ └── clink-headless.sh
|
||
├── dm/ # NIP-17 DM interop (amy ↔ amy) vs embedded `amy serve`
|
||
│ ├── dm-interop-headless.sh
|
||
│ ├── setup.sh # preflight + identities
|
||
│ └── tests-dm.sh
|
||
├── git/ # NIP-34 git collaboration vs embedded `amy serve`
|
||
│ └── git-nip34-headless.sh
|
||
├── marmot/ # Marmot / MLS group-messaging interop
|
||
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
|
||
│ ├── marmot-interop-headless.sh # zero-prompt
|
||
│ ├── setup.sh # preflight + wn + identities
|
||
│ ├── tests-create.sh # tests 01–05
|
||
│ ├── tests-manage.sh # tests 06–08, 11
|
||
│ ├── tests-extras.sh # tests 09, 10, 12, 13
|
||
│ ├── tests-media.sh # tests 20-29 (avatar, edits, deletions,
|
||
│ │ # media v2, retention, disband)
|
||
├── nests/ # Audio-rooms interop (Amethyst ↔ nostrnests.com)
|
||
│ ├── nests-interop.sh # 47-test manual harness
|
||
│ └── README.md # operator brief + per-test matrix
|
||
├── pow/ # NIP-13 primitives (bench/mine/check) — no relay
|
||
│ └── pow-headless.sh
|
||
├── sno/ # DECK-0003 conformance vs the cyberspace project's
|
||
│ ├── sno-conformance.sh # own reference implementations — no relay
|
||
│ └── refdriver.py
|
||
├── relaygroup/ # NIP-29 round-trip vs embedded `amy serve` (geode)
|
||
│ └── relaygroup-headless.sh
|
||
└── sync/ # NIP-77 deletion propagation vs `amy serve`
|
||
└── sync-deletions-headless.sh
|
||
```
|
||
|
||
These shell suites are the *interop* layer. The *contract* net — `Args`
|
||
parsing, the exit-code contract (bad_args → 2, timeout → 124), and `--json`
|
||
shapes — is the JVM unit suite at `cli/src/test/kotlin/` (`ArgsTest`,
|
||
`ExitCodeContractTest`, `JsonContractTest`), which drives `runCli`
|
||
in-process with an isolated `~/.amy` via the `amy.home` seam. Run it with
|
||
`./gradlew :cli:test` — no relay, no Rust, milliseconds.
|
||
|
||
Suite notes:
|
||
|
||
- **`clink/clink-headless.sh`** is local-only (no relay): asserts that
|
||
`amy offer info` / `amy debit info` decode the canonical interop vectors
|
||
(the same fixtures quartz's `ClinkInteropTest` uses) to the right fields,
|
||
plus the argument-error paths. The round-trip verbs (`offer request`,
|
||
`debit pay/budget`) need a live CLINK service and aren't covered here.
|
||
- **`sno/sno-conformance.sh`** is relay-free and diffs `amy sno` against the
|
||
cyberspace project's own references: the deck's `sno-reference.py` for §1.9
|
||
verdicts (the rejection table drives the run, so the fixtures are theirs and
|
||
not ours) and cyberspace-cli's `avatar.py` for the §8.10 work ladder and its
|
||
golden vectors. The two places our reader knowingly differs are asserted as
|
||
divergences rather than skipped, so neither can drift quietly. Reference
|
||
checkouts are cloned into `state/` unless `CYBERSPACE_DIR` /
|
||
`CYBERSPACE_CLI_DIR` already point at them.
|
||
- **`pow/pow-headless.sh`** is also relay-free: `pow bench` sanity,
|
||
`pow mine` hitting its target (and exiting 124 on an impossible one),
|
||
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 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
|
||
emptiness is a relay capability, not a client bug.
|
||
- **`sync/sync-deletions-headless.sh`** proves NIP-77 deletion propagation
|
||
both directions (plus the `--no-sync-deletions` opt-out) against
|
||
`amy serve`, with one `$HOME` per account so stores don't share.
|
||
- **`git/git-nip34-headless.sh`** drives the full NIP-34 collaboration surface
|
||
against `amy serve`: `git init` bootstrapping a repo from the harness's own
|
||
git checkout (announce + state derived via `git`), announce (30617) + state
|
||
(30618) + GRASP list (10317),
|
||
issue (1621), patch (1617), pull request (1618) + update (1619), NIP-22
|
||
comment (1111), NIP-32 label (1985), and status events (1630-1633). It also
|
||
publishes a real `git format-patch` and `git apply`s it back into a scratch
|
||
working tree, and asserts the `issues`/`patches`/`prs`/`thread` reads derive
|
||
the right status (a closed issue reads `closed`, an applied PR reads
|
||
`applied`) and that `--open`/`--closed` filter correctly. Pass `--live` to additionally exercise
|
||
the git smart-HTTP reads (`git browse`/`cat`/`log`) against a real public
|
||
repo (`$LIVE_REPO`, default octocat/Hello-World) — skipped by default since
|
||
it needs a reachable git host.
|
||
|
||
The Marmot harnesses come in two flavours, same scenarios:
|
||
|
||
- **`marmot/marmot-interop.sh`** — interactive. Drives B/C via `wn` and
|
||
**prompts the human** to perform each Amethyst-side step in the mobile UI
|
||
(Identity A). Use this for final UI verification.
|
||
- **`marmot/marmot-interop-headless.sh`** — zero prompts. Drives A via the
|
||
`amy` CLI (`./gradlew :cli:installDist`) and B/C via `wn`. Runs every
|
||
scenario end-to-end and exits with a pass/fail summary. Use this for CI
|
||
and for iterating on the Nostr/Marmot plumbing without needing to touch a
|
||
phone.
|
||
|
||
Most features are covered in BOTH directions — founding (02/03), adding
|
||
(04/05), removal (06/14), leaving (11/15), keypackage rotation (13/16),
|
||
agent streams (18/19), avatar URL (20/21), encrypted media v2 (24/25),
|
||
deletion (23/27), retention (26/28). Three gaps are the reference CLI's,
|
||
not ours, and cannot be closed from here:
|
||
|
||
- **edits wn→amy.** `wn messages` has no `edit` verb, and MDK reserves
|
||
kind 1009 so `messages send-event` refuses to forge one. MDK's runtime
|
||
has `edit_message` and its uniffi surface exposes it; only the CLI
|
||
does not.
|
||
- **setting retention from wn.** `wn groups` has no retention verb and
|
||
`groups create` has no flag for it, so test 28 has amy own the setting
|
||
and wn own the sending — which is the half that was untested anyway,
|
||
since inbound messages are where the epoch-pinning rule lives.
|
||
- **disband wn→amy.** Same shape: `disband_group` exists on MDK's runtime
|
||
and uniffi surface (the apps call it) but has no `wn groups` verb, so
|
||
test 29 runs one way only.
|
||
|
||
**The daemon is not a way around this**, which is worth stating because it
|
||
is the obvious next idea. `wnd`'s socket protocol
|
||
(`crates/cli/src/daemon/protocol.rs`) carries `Ping`, `Status`, `Shutdown`,
|
||
four `*Subscribe` variants, and `Execute { cli: Box<Cli> }` — and that last
|
||
one takes the same clap command tree `wn` parses. The daemon is a persistent
|
||
host for the CLI's verbs, not a richer RPC, so a verb missing from `Cli` is
|
||
unreachable through the socket too. Closing these three needs either a verb
|
||
upstream in MDK or a driver linked against `marmot-uniffi`/`marmot-c`; both
|
||
are out of scope for a harness that deliberately builds MDK unpatched.
|
||
|
||
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 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:
|
||
|
||
- **`blossom/blossom-live.sh`** — drives the full `amy blossom` lifecycle
|
||
(upload → HEAD check → download-and-verify-hash → list → cross-server
|
||
mirror → delete) against a real Blossom server. Server-side write
|
||
rejections (whitelists, rate limits, payment) record as SKIP, not FAIL —
|
||
only a broken client contract (bad descriptor, hash mismatch) fails.
|
||
Defaults to `https://files.sovbit.host`; pass `--mirror-server URL` to
|
||
exercise BUD-04. Example:
|
||
`blossom/blossom-live.sh --server https://files.sovbit.host --mirror-server https://blossom.primal.net`.
|
||
|
||
A fourth harness covers audio rooms (NIP-53 + moq-lite):
|
||
|
||
- **`nests/nests-interop.sh`** — fully manual interop between Amethyst
|
||
Android and the [nostrnests.com](https://nostrnests.com) reference
|
||
web client. 47 tests spanning host/listener flows, audio round-trip,
|
||
hand-raise + role promotion, reactions, in-room chat (kind 1311),
|
||
kick (kind 4312), close-room, schedule, network-drop reconnect,
|
||
10-min JWT refresh, custom moq servers (kind 10112), and PIP /
|
||
background audio. See `nests/README.md` for the full matrix and
|
||
prereqs.
|
||
|
||
Both Marmot harnesses validate Amethyst against **MDK**
|
||
(https://github.com/marmot-protocol/mdk), the reference Rust implementation of
|
||
the Marmot protocol, via its `wn` / `wnd` binaries (the `wn-cli` package).
|
||
Every test records a pass/fail/skip result into a tab-separated log, and the
|
||
summary is printed at the end of the run.
|
||
|
||
The MDK checkout is pinned (`MDK_PIN` in `marmot/setup.sh`) to the commit the
|
||
shipping White Noise apps embed, read from their `MARMOT_VERSION` lockfiles;
|
||
when Android and iOS disagree the newer one wins. As of 2026-09-27 that is
|
||
`03b1809e` (White Noise Android; iOS is on the 0.10.4 release `fcc85edd`, an
|
||
ancestor). Override with `MDK_PIN=<sha>` to test another MDK.
|
||
|
||
> These harnesses previously targeted `marmot-protocol/whitenoise-rs`, which was
|
||
> archived on 2026-08-05 pinned to `mdk-core 0.8.0`. Testing against it meant
|
||
> testing against a frozen MIP-era client. The reference moved into `mdk`, and
|
||
> so did we — see `quartz/plans/2026-09-08-marmot-spec-resync.md` for what that
|
||
> change exposed.
|
||
|
||
## What gets tested
|
||
|
||
| # | Test | Needs 3rd identity |
|
||
|---|---|---|
|
||
| 01 | KeyPackage publish & discovery (MIP-00) | – |
|
||
| 02 | Amethyst creates group, invites wn | – |
|
||
| 03 | wn creates group, invites Amethyst | – |
|
||
| 04 | 3-member group, add-after-create | yes |
|
||
| 05 | wn adds Amethyst to existing group | yes |
|
||
| 06 | Member removal + forward secrecy | yes |
|
||
| 07 | Group metadata rename round-trip (MIP-01) | – |
|
||
| 08 | Admin promote / demote | yes |
|
||
| 09 | Reply / react / unreact (inner event kinds 9, 7) | – |
|
||
| 10 | Concurrent commits race | – |
|
||
| 11 | Leave group | – |
|
||
| 12 | Offline catch-up / replay | – |
|
||
| 13 | KeyPackage rotation | – |
|
||
| 14 | Push notifications (MIP-05) | opt-in via `--transponder` |
|
||
|
||
### DM (amy ↔ amy, NIP-17) — `dm/dm-interop-headless.sh`
|
||
|
||
| # | Test |
|
||
|---|---|
|
||
| dm-01 | Text round-trip A↔D (kind:14) |
|
||
| dm-02 | `dm list` returns prior exchange with `type:text` discriminator |
|
||
| dm-03 | Strict kind:10050 refuses sends to an inboxless recipient |
|
||
| dm-04 | `--allow-fallback` opts into the NIP-65 read / bootstrap chain |
|
||
| dm-05 | File message reference mode round-trip (kind:15 with manual key/nonce) |
|
||
| dm-06 | `dm list --since` filters out older messages (window-slide past the newest event returns 0) |
|
||
|
||
**Relay binding note:** the DM harness binds the loopback relay to
|
||
`127.0.0.2` (not `127.0.0.1`) because Quartz's `RelayTag.parse` rejects
|
||
localhost URLs via `isLocalHost()` — so `ws://127.0.0.1` in a kind:10050
|
||
event is silently stripped during recipient-relay resolution, which
|
||
would make strict-mode DM sends spuriously fail. `127.0.0.2` is still
|
||
pure loopback and isn't matched by that filter. Override with
|
||
`--host 127.0.0.5` etc. if `127.0.0.2` is taken.
|
||
|
||
**macOS:** Linux answers every `127.0.0.0/8` address on `lo`; macOS only
|
||
answers `127.0.0.1` until you add an alias, once per boot:
|
||
|
||
```bash
|
||
sudo ifconfig lo0 alias 127.0.0.2 up
|
||
```
|
||
|
||
The harnesses check for it and stop with that command instead of failing
|
||
later with "Can't assign requested address". Two more macOS traps are handled
|
||
for you: `wnd` sockets are moved to a short `/tmp/wnd.*` directory when the
|
||
checkout path is too long for `sun_path` ("path must be shorter than
|
||
SUN_LEN"), and `amy` is rebuilt incrementally on every run (a stale
|
||
`cli/build/install/amy` from another branch produces misleading publish
|
||
failures), unless you pass `--no-build`. A run killed mid-way can leave
|
||
`amy serve` holding the relay port; `pkill -f "amy.*serve"` frees it.
|
||
|
||
**Note:** dm-05 validates the kind:15 wire format via reference mode
|
||
(caller supplies the URL + AES-GCM key/nonce). The upload-mode variant
|
||
(`dm send-file --file PATH --server URL`) needs a local Blossom server
|
||
and isn't scripted here — the upload classes are unit-tested on desktop
|
||
at `desktopApp/src/jvmTest/kotlin/.../service/upload/`.
|
||
|
||
## Prerequisites
|
||
|
||
On the machine that runs the harness:
|
||
|
||
- **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`
|
||
- 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 harness's embedded relay over the network
|
||
(see below), or the public relays when running with `--public-relays`
|
||
|
||
## Quick start
|
||
|
||
```bash
|
||
cd cli/tests/marmot
|
||
./marmot-interop.sh
|
||
```
|
||
|
||
The script will, in order:
|
||
|
||
1. Verify `jq`, `git`, `cargo` etc. are present.
|
||
2. Clone `mdk` into `state/mdk/` and build `wn`/`wnd`
|
||
(`cargo build --release -p wn-cli`). First build takes ~5 minutes;
|
||
subsequent runs reuse the binaries. MDK pins its own Rust toolchain in
|
||
`rust-toolchain.toml`, so rustup may fetch a toolchain on the first run.
|
||
3. Launch two `wnd` daemons (one for Identity B, one for Identity C).
|
||
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. 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**
|
||
- prints a "DO THIS IN AMETHYST" prompt and waits for you to press `<Enter>`,
|
||
then verifies the Amethyst action via `wn`.
|
||
9. Stop the daemons and print a results table.
|
||
|
||
## Command-line flags
|
||
|
||
```
|
||
--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.
|
||
```
|
||
|
||
Environment overrides:
|
||
|
||
```
|
||
WN_REPO=/some/path/mdk # use an existing checkout
|
||
```
|
||
|
||
## 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
|
||
```
|
||
|
||
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
|
||
|
||
When the script needs you to do something in Amethyst, it prints a yellow
|
||
block like this:
|
||
|
||
```
|
||
---- DO THIS IN AMETHYST ----
|
||
In Amethyst:
|
||
1. Tap + -> Create Group
|
||
2. Name: Interop-02
|
||
3. Add member: npub1abc...
|
||
4. Tap Create / Send Invite
|
||
-----------------------------
|
||
[Press Enter to continue]
|
||
```
|
||
|
||
After you press Enter the script resumes. For UI-only verifications (e.g.
|
||
"does Amethyst show reaction 🌮?"), the script asks:
|
||
|
||
```
|
||
? Does Amethyst show the 🌮 reaction? [p]ass / [f]ail / [s]kip:
|
||
```
|
||
|
||
Pick `p`, `f`, or `s`.
|
||
|
||
## Output
|
||
|
||
- **`state/logs/run-<timestamp>.log`** — every step, assertion, and human
|
||
prompt, with the exact `wn` stdout that was parsed.
|
||
- **`state/results-<timestamp>.tsv`** — tab-separated `test_id \t status \t
|
||
note` lines. Easy to grep.
|
||
- **`state/run.env`** — persistent key/value state (npubs, group ids) so you
|
||
can kill and resume the harness mid-run without losing context.
|
||
- **Final summary table on stdout** — colored per-test status and totals.
|
||
|
||
## State cleanup
|
||
|
||
The harness leaves `state/` on disk so daemons and identities survive across
|
||
runs. To start completely fresh:
|
||
|
||
```bash
|
||
# stops daemons if still running; removes identities, groups, logs
|
||
./marmot-interop.sh --no-build # Ctrl-C when it waits for input, then:
|
||
rm -rf state/
|
||
```
|
||
|
||
Published KeyPackages on public relays will remain until they expire naturally
|
||
or are deleted via `wn keys delete-all --confirm`. Use a throwaway identity
|
||
for B/C if this matters to you.
|
||
|
||
## Known gaps
|
||
|
||
- **Test 10 (concurrent commits)** is inherently human-timing sensitive. It's
|
||
a best-effort race; expect occasional flakes.
|
||
- **Test 14 (push)** only exercises the harness side — full end-to-end
|
||
verification requires a running `transponder` instance and platform
|
||
registrations that are out of scope here.
|
||
- The harness assumes Amethyst exposes UI affordances for add-member,
|
||
remove-member, rename, promote/demote, and leave. If any of those is missing
|
||
from the current build, the corresponding test will fail with a clear note
|
||
rather than crash.
|
||
|
||
## Files
|
||
|
||
- `marmot-interop.sh` — main entry point; orchestrates preflight, daemons,
|
||
identities, relays, and runs the 13 tests in sequence.
|
||
- `lib.sh` — helpers (logging, prompts, polling, jq wrappers, result table).
|
||
- `state/` — runtime directory, gitignored. Contains the `mdk/` source
|
||
checkout, per-daemon data/log dirs, the session `run.env`, logs, and
|
||
results TSVs.
|