test(cli): move tests/ out of tools/, separate marmot vs dm

`tools/` is for libraries (arti-build); shell-based interop harnesses
that drive the `amy` binary belong with the CLI module. New layout:

  cli/tests/
  ├── lib.sh                       # shared logging + result tracking
  ├── headless/helpers.sh          # shared amy_a / amy_json / assertions
  ├── marmot/                      # MLS group-messaging interop (vs whitenoise-rs)
  │   ├── marmot-interop.sh
  │   ├── marmot-interop-headless.sh
  │   ├── setup.sh
  │   ├── tests-create.sh
  │   ├── tests-manage.sh
  │   ├── tests-extras.sh
  │   └── patches/
  └── dm/                          # NIP-17 DM interop (amy ↔ amy)
      ├── dm-interop-headless.sh
      ├── setup.sh
      └── tests-dm.sh

Per-suite changes:
- Each suite owns its own setup.sh; previously the Marmot setup was at
  headless/setup.sh and the DM setup at headless/setup-dm.sh, which
  implied shared infra they don't actually share.
- `cli/tests/lib.sh` and `cli/tests/headless/helpers.sh` are the only
  shared bits across suites.
- The DM harness still reuses Marmot's `start_local_relay` /
  `stop_local_relay` (sourced via `../marmot/setup.sh`) — same relay
  binary, no duplication.
- All sourcing paths updated; REPO_ROOT now climbs three levels (was
  two when the harness lived under `tools/`); patches/ is now a sibling
  of marmot/setup.sh rather than under marmot/headless/.
- `.gitignore` updated to cover both suites' state dirs.

cli/ROADMAP.md and cli/tests/README.md updated to point at the new paths.
No behavioural change — every test runs the same code; only the file
layout and source paths moved.
This commit is contained in:
Claude
2026-04-23 20:37:22 +00:00
parent 112801ba82
commit b0698e0a66
19 changed files with 87 additions and 59 deletions
-2
View File
@@ -1,2 +0,0 @@
state/
state-headless/
-216
View File
@@ -1,216 +0,0 @@
# Interop Test Harnesses
Two flavours of the Marmot harness, same scenarios:
- **`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-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.
A third, slimmer harness covers the NIP-17 DM surface:
- **`dm-interop-headless.sh`** — two `amy` processes (Identity A and
Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay.
No whitenoise-rs required — only `amy` and the relay binary (which
is shared with the Marmot harness's checkout at
`state-headless/nostr-rs-relay/`).
Both Marmot harnesses validate Amethyst against **whitenoise-rs**
(https://github.com/marmot-protocol/whitenoise-rs), the reference Rust
implementation that powers the White Noise Flutter app. Every test records a
pass/fail/skip result into a tab-separated log, and the summary is printed at
the end of the run.
## 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 | |
### DM (amy ↔ amy, NIP-17) — `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 | No-flag `dm list` advances the gift-wrap cursor (second call empty) |
**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/`.
| 14 | Push notifications (MIP-05) | opt-in via `--transponder` |
## Prerequisites
On the machine that runs the harness:
- **Rust 1.90+** — install via https://rustup.rs
- **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)
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)
## Quick start
```bash
cd tools/marmot-interop
./marmot-interop.sh
```
The script will, in order:
1. Verify `jq`, `git`, `cargo` etc. are present.
2. Clone `whitenoise-rs` into `state/whitenoise-rs/` and build `wn`/`wnd`
(release, `--features cli`). First build takes ~5 minutes; subsequent runs
reuse the binaries.
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. 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,
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
```
--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 whitenoise-rs first.
--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/whitenoise-rs # use an existing checkout
```
## Default relays
```
wss://relay.damus.io
wss://nos.lol
wss://relay.primal.net
```
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/whitenoise-rs
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`.
## 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 `whitenoise-rs/` source
checkout, per-daemon data/log dirs, the session `run.env`, logs, and
results TSVs.
@@ -1,98 +0,0 @@
#!/usr/bin/env bash
#
# 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.
#
# Usage: ./dm-interop-headless.sh [--port N] [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-dm-headless"
LOG_DIR="$STATE_DIR/logs"
A_DIR="$STATE_DIR/A"
D_DIR="$STATE_DIR/D"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
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.
RELAY_REPO="${RELAY_REPO:-$SCRIPT_DIR/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://127.0.0.1:$RELAY_PORT"
NO_BUILD=0
A_NPUB=""
A_HEX=""
D_NPUB=""
D_HEX=""
while [[ $# -gt 0 ]]; do
case "$1" in
--port) RELAY_PORT="$2"; RELAY_URL="ws://127.0.0.1:$RELAY_PORT"; shift ;;
--no-build) NO_BUILD=1 ;;
-h|--help)
sed -n '3,12p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$D_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# Reuse the logging + result helpers from the Marmot harness. lib.sh
# also declares wn-specific helpers; they're harmless when unused.
# shellcheck source=lib.sh
source "$SCRIPT_DIR/lib.sh"
# Reuse start_local_relay / stop_local_relay from setup.sh. preflight()
# there also builds whitenoise-rs, which we don't need — setup-dm.sh
# defines a slimmer preflight_dm().
# shellcheck source=headless/setup.sh
source "$SCRIPT_DIR/headless/setup.sh"
# shellcheck source=headless/setup-dm.sh
source "$SCRIPT_DIR/headless/setup-dm.sh"
# shellcheck source=headless/helpers.sh
source "$SCRIPT_DIR/headless/helpers.sh"
# shellcheck source=headless/tests-dm.sh
source "$SCRIPT_DIR/headless/tests-dm.sh"
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP
stop_local_relay
print_summary
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
banner "Amethyst NIP-17 DM headless interop ($RUN_TS)"
preflight_dm
start_local_relay
ensure_identity_for A "$A_DIR"
ensure_identity_for D "$D_DIR"
configure_relays_dm
test_01_dm_text_round_trip
test_02_dm_list_surfaces_history
test_03_dm_send_rejects_no_inbox
test_04_dm_send_allow_fallback
test_05_dm_file_reference_round_trip
test_06_dm_list_cursor_advance
-55
View File
@@ -1,55 +0,0 @@
# shellcheck shell=bash
#
# headless/helpers.sh — thin wrappers that keep the per-test code tight.
# --- amy wrapper -------------------------------------------------------------
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; }
# Run amy, log stderr, surface JSON on stdout, remember last result.
amy_json() {
local out
if ! out=$(amy_a "$@" 2>>"$LOG_FILE"); then
fail_msg "amy $*: exit $? (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE"
return 1
fi
printf '%s' "$out"
}
# Convenience extractors — the CLI emits one JSON object per success so we can
# jq with impunity.
amy_field() {
# usage: amy_field '.group_id' init [args...]
local path="$1"; shift
amy_json "$@" | jq -r "$path"
}
# --- assertion helpers -------------------------------------------------------
# Assert a substring is present in a variable; append a failed result on miss
# and return 1. Positive case just logs.
assert_contains() {
local haystack="$1" needle="$2" test_id="$3" note="${4:-}"
if [[ "$haystack" == *"$needle"* ]]; then
info "assertion hit: $test_id contains \"$needle\""
return 0
fi
fail_msg "$test_id: missing \"$needle\" (${note:-no note})"
info "actual: $haystack"
record_result "$test_id" fail "${note:-missing \"$needle\"}"
return 1
}
# Assert two strings are equal (leniently trimmed).
assert_eq() {
local actual="$1" expected="$2" test_id="$3" note="${4:-}"
if [[ "${actual// /}" == "${expected// /}" ]]; then
info "assertion hit: $test_id \"$actual\" == \"$expected\""
return 0
fi
fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})"
record_result "$test_id" fail "${note:-mismatch}"
return 1
}
# --- wn-side pollers (delegates to lib.sh) -----------------------------------
# Both exist in lib.sh already; this file only adds headless-specific niceties.
@@ -1,24 +0,0 @@
--- a/src/whitenoise/relays.rs
+++ b/src/whitenoise/relays.rs
@@ -98,6 +98,21 @@
}
pub(crate) fn defaults() -> Vec<Relay> {
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
+ // (comma-separated list) when present so newly created accounts only
+ // ever get our loopback relay baked into their NIP-65 / inbox /
+ // key-package lists. Without this override, `create-identity` stamps
+ // the hard-coded public set into the account's relay lists, and every
+ // later activate / publish burns connection budget on unreachable
+ // sockets — enough to break inbox-plane activation and drop kind:1059.
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
+ let parsed: Vec<Relay> = from_env
+ .split(',').map(str::trim).filter(|s| !s.is_empty())
+ .filter_map(|u| RelayUrl::parse(u).ok())
+ .map(|url| Relay::new(&url))
+ .collect();
+ if !parsed.is_empty() { return parsed; }
+ }
let urls: &[&str] = if cfg!(debug_assertions) {
&["ws://localhost:8080", "ws://localhost:7777"]
} else {
@@ -1,23 +0,0 @@
--- a/src/relay_control/discovery.rs
+++ b/src/relay_control/discovery.rs
@@ -87,6 +87,20 @@
/// Initial curated relay set from the planning doc.
pub(crate) fn curated_default_relays() -> Vec<RelayUrl> {
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
+ // (comma-separated list) when present, so the harness can force wnd
+ // to use a loopback relay instead of the baked-in public set.
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
+ let parsed: Vec<RelayUrl> = from_env
+ .split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .filter_map(|u| RelayUrl::parse(u).ok())
+ .collect();
+ if !parsed.is_empty() {
+ return parsed;
+ }
+ }
[
"wss://index.hzrd149.com",
"wss://indexer.coracle.social",
@@ -1,18 +0,0 @@
--- a/src/bin/wnd.rs
+++ b/src/bin/wnd.rs
@@ -22,6 +22,15 @@
let args = Args::parse();
let config = Config::resolve(args.data_dir.as_ref(), args.logs_dir.as_ref());
+ // marmot-interop-headless patch: allow sandboxes/CI without a real
+ // kernel keyring to fall back to the integration-tests mock keyring
+ // by setting $WHITENOISE_MOCK_KEYRING=1. Requires the daemon to be
+ // built with --features cli,integration-tests. No effect otherwise.
+ #[cfg(feature = "integration-tests")]
+ if std::env::var("WHITENOISE_MOCK_KEYRING").is_ok() {
+ Whitenoise::initialize_mock_keyring_store();
+ }
+
let wn_config = WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID);
Whitenoise::initialize_whitenoise(wn_config).await?;
@@ -1,26 +0,0 @@
--- a/src/whitenoise/event_processor/account_event_processor.rs
+++ b/src/whitenoise/event_processor/account_event_processor.rs
@@ -178,7 +178,22 @@
}
Err(e) => {
// Handle retry logic for actual processing errors
- if retry_info.should_retry() {
+ // marmot-interop-headless patch: MLS errors that come from
+ // mdk are ALREADY terminal — mdk doesn't retry internally, so
+ // any Err it returns (Unprocessable, PreviouslyFailed, decrypt
+ // failure, group-not-found, etc.) is provably permanent.
+ // Retrying those 10 times with exponential backoff (total
+ // ~17 min) just blocks later decryptable commits behind a
+ // queue of doomed retries, so every later join / rename /
+ // leave propagation races the test timeout. Treat them all
+ // as one-shot: log once, move on.
+ let is_terminal = matches!(
+ e,
+ WhitenoiseError::MlsMessageUnprocessable(_)
+ | WhitenoiseError::MlsMessagePreviouslyFailed
+ | WhitenoiseError::MdkCoreError(_),
+ );
+ if !is_terminal && retry_info.should_retry() {
self.schedule_retry(event, source, retry_info, e);
} else {
tracing::error!(
-112
View File
@@ -1,112 +0,0 @@
# shellcheck shell=bash
#
# headless/setup-dm.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.
# - 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
# headless/setup.sh and are sourced by the top-level harness.
# --- preflight (amy + relay only, no wn / Marmot patches) -------------------
preflight_dm() {
banner "Preflight (DM harness)"
for cmd in jq git cargo; do
if ! command -v "$cmd" >/dev/null 2>&1; then
fail_msg "missing required tool: $cmd"
exit 1
fi
info "$cmd: $(command -v "$cmd")"
done
if [[ ! -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
# Gradle + jitpack/dl.google.com occasionally return transient 503s;
# retry so a single bad roll doesn't tank the whole harness.
local attempt max=4
for attempt in $(seq 1 $max); do
step "building :cli:installDist (attempt $attempt/$max)"
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
&& [[ -x "$AMY_BIN" ]]; then
break
fi
[[ "$attempt" -lt "$max" ]] && warn "gradle build failed — retrying"
done
fi
[[ -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 ---------------------------------------------------
# Two identities: A (sender) and D (recipient). We reuse A_DIR for parity
# with the existing harness files; D_DIR is new.
amy_a() { "$AMY_BIN" --data-dir "$A_DIR" "$@"; }
amy_d() { "$AMY_BIN" --data-dir "$D_DIR" "$@"; }
# --- identity bootstrap ------------------------------------------------------
ensure_identity_for() {
local who="$1" dir="$2"
step "initialising Identity $who (amy at $dir)"
local out
out=$("$AMY_BIN" --data-dir "$dir" init) || {
fail_msg "amy init failed for $who: $out"; exit 1
}
local npub hex
npub=$(printf '%s' "$out" | jq -r '.npub')
hex=$(printf '%s' "$out" | jq -r '.hex')
case "$who" in
A) A_NPUB="$npub"; A_HEX="$hex" ;;
D) D_NPUB="$npub"; D_HEX="$hex" ;;
esac
info "$who npub: $npub"
info "$who hex: $hex"
}
# --- relay wiring ------------------------------------------------------------
# Point both identities at the loopback relay (all three buckets:
# nip65 / inbox / key_package), then publish each identity's kind:10050
# so the DM strict-relay routing has something to resolve to.
configure_relays_dm() {
banner "Configuring relays → $RELAY_URL"
"$AMY_BIN" --data-dir "$A_DIR" relay add "$RELAY_URL" --type all >/dev/null
"$AMY_BIN" --data-dir "$D_DIR" relay add "$RELAY_URL" --type all >/dev/null
step "publishing A's NIP-65 + kind:10050 lists"
amy_a relay publish-lists >>"$LOG_FILE" 2>&1 \
|| warn "amy_a relay publish-lists failed"
step "publishing D's NIP-65 + kind:10050 lists"
amy_d relay publish-lists >>"$LOG_FILE" 2>&1 \
|| warn "amy_d relay publish-lists failed"
}
-370
View File
@@ -1,370 +0,0 @@
# shellcheck shell=bash
#
# headless/setup.sh — preflight + daemon lifecycle + identity bootstrap.
# Sourced from marmot-interop-headless.sh.
# --- preflight ---------------------------------------------------------------
preflight() {
banner "Preflight"
for cmd in jq git curl cargo protoc patch; do
if ! command -v "$cmd" >/dev/null 2>&1; then
fail_msg "missing required tool: $cmd"
case "$cmd" in
protoc) info "hint: apt-get install protobuf-compiler (or brew install protobuf on macOS)" ;;
patch) info "hint: apt-get install patch" ;;
esac
exit 1
fi
info "$cmd: $(command -v "$cmd")"
done
# Build `amy` via gradle if missing.
#
# jitpack.io and dl.google.com both return transient 503s on a non-trivial
# fraction of cold-cache fetches, and Gradle disables the entire repository
# for the rest of the build the moment a single 503 lands — so one bad
# roll aborts the whole harness. Retry a few times; each attempt resumes
# from Gradle's cache so only the still-missing artifacts get re-fetched.
if [[ ! -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
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
info "amy: $AMY_BIN"
# Clone/build whitenoise-rs if needed (shared between both harnesses).
if [[ ! -d "$WN_REPO/.git" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "whitenoise-rs checkout missing at $WN_REPO and --no-build set"; exit 1
fi
step "cloning whitenoise-rs into $WN_REPO"
git clone --depth 1 https://github.com/marmot-protocol/whitenoise-rs.git "$WN_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
# Four harness-only patches to wnd so it runs fully offline / in
# sandboxes that block outbound + kernel keyring:
# 1. discovery-env: honour $WHITENOISE_DISCOVERY_RELAYS so we can
# point wnd at our loopback relay instead of the baked-in public
# set. Without it wnd exits with NoRelayConnections.
# 2. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the
# integration-tests mock keyring store when the kernel keyutils
# syscalls are blocked (common in containers / CI).
# 3. defaults-env: reuse the same env var so `Relay::defaults()`
# (what `create-identity` stamps into the new account's NIP-65 /
# inbox / key-package lists) points at the loopback relay too.
# Without it every account wnd creates carries damus.io /
# primal.net / nos.lol, and every later activate / publish burns
# connection budget on unreachable sockets — enough to break the
# account-inbox subscription plane and drop kind:1059 delivery.
# 4. skip-unprocessable-retry: when mdk-core returns
# `MlsMessageUnprocessable` (pre-membership commit, too-old epoch)
# the message is provably undecryptable — retrying it ten times
# with exponential backoff (total ~17 min) just blocks later
# decryptable commits behind a queue of doomed retries, which in
# the harness manifests as "A already left" / "name unchanged"
# timeouts. The patch treats that error as terminal.
local -a patches=(
"whitenoise-discovery-env.patch"
"whitenoise-mock-keyring.patch"
"whitenoise-defaults-env.patch"
"whitenoise-skip-unprocessable-retry.patch"
)
# Apply each patch with a real exit-code check. The previous version
# swallowed patch's exit status via `| tee`, which meant a miscounted
# hunk header silently left the marker touched and the binary unpatched
# — the resulting wn retried provably-doomed MLS messages for ~17min
# and every later test flapped or timed out. Fail fast instead.
for name in "${patches[@]}"; do
local marker="$WN_REPO/.headless-patched-${name%.patch}"
if [[ ! -f "$marker" ]]; then
step "patching whitenoise-rs: $name"
if ( cd "$WN_REPO" && patch -p1 --forward --reject-file=- \
<"$SCRIPT_DIR/headless/patches/$name" >>"$LOG_FILE" 2>&1 ); then
touch "$marker"
# Invalidate the previous build so the patched source is picked up.
rm -f "$WN_BIN" "$WND_BIN"
else
fail_msg "patch $name failed — see $LOG_FILE"
tail -n 30 "$LOG_FILE" | sed 's/^/ /' >&2
exit 1
fi
fi
done
# cargo's transitive deps (rustup, crates.io) both return 503 on cold
# caches often enough that a single attempt fails ~30% of the time.
# Retry each cargo build until the binary actually exists or we've
# exhausted the budget — the build is incremental so retries are cheap.
if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "wn/wnd not found and --no-build set"; exit 1
fi
local attempt max=4
for attempt in $(seq 1 $max); do
step "building wn + wnd (attempt $attempt/$max, ~5 min first run)"
( cd "$WN_REPO" && \
cargo build --release --features cli,integration-tests --bin wn --bin wnd ) \
2>&1 | tee -a "$LOG_FILE"
[[ -x "$WN_BIN" && -x "$WND_BIN" ]] && break
[[ "$attempt" -lt "$max" ]] && warn "wn/wnd build failed (likely transient 503 from rustup or crates.io) — retrying"
done
[[ -x "$WN_BIN" && -x "$WND_BIN" ]] || {
fail_msg "wn/wnd still missing after $max build attempts"; exit 1
}
fi
info "wn: $WN_BIN"
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"
}
# --- 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 = "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://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"
}
# --- daemons -----------------------------------------------------------------
start_daemon() {
local name="$1" data_dir="$2" socket="$3"
step "starting $name daemon"
if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then
info "$name daemon already running"; return 0
fi
rm -f "$socket"
# The mock keyring (WHITENOISE_MOCK_KEYRING=1) is in-memory only and
# resets to empty on every wnd restart, but the SQLite databases that
# wnd writes under $data_dir persist across runs and reference keys that
# no longer exist — wnd then bails with KeyringEntryMissingForExistingDatabase
# before it can even open a socket. Wipe the keyring-dependent state on
# each start so the daemon always comes up cold and consistent. Logs
# and the pid file are preserved for post-mortem.
if [[ -d "$data_dir" ]]; then
find "$data_dir" -mindepth 1 -maxdepth 1 \
! -name 'logs' ! -name 'pid' \
-exec rm -rf {} + 2>/dev/null || true
fi
mkdir -p "$data_dir/logs" "$data_dir/release"
# Env vars consumed by the two harness-only wnd patches applied in
# preflight:
# WHITENOISE_DISCOVERY_RELAYS — forces the discovery plane at our
# loopback relay (kills the "can't reach nos.lol" exit path).
# WHITENOISE_MOCK_KEYRING — swaps in the integration-tests mock
# secret store so wnd doesn't fall over when the kernel blocks
# keyutils syscalls.
# Both are harmless on a real host with connectivity + a real keyring.
WHITENOISE_DISCOVERY_RELAYS="$RELAY_URL" \
WHITENOISE_MOCK_KEYRING=1 \
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
>"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" &
local pid=$!
echo "$pid" > "$data_dir/pid"
info "$name pid $pid; waiting for socket at $socket"
local deadline=$(( $(date +%s) + 30 ))
while [[ $(date +%s) -lt $deadline ]]; do
if [[ -S "$socket" ]] && "$WN_BIN" --socket "$socket" whoami >/dev/null 2>&1; then
info "$name ready"; return 0
fi
# Exit early if wnd already crashed — no point waiting the full 30s.
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
sleep 1
done
# Dump the actual failure so the operator doesn't have to chase a path.
if kill -0 "$pid" 2>/dev/null; then
fail_msg "$name daemon still running (pid $pid) but socket $socket never appeared within 30s"
kill "$pid" 2>/dev/null || true
else
fail_msg "$name daemon (pid $pid) exited before creating socket $socket"
fi
if [[ -s "$data_dir/logs/stderr.log" ]]; then
printf ' --- last 40 lines of %s ---\n' "$data_dir/logs/stderr.log" >&2
tail -n 40 "$data_dir/logs/stderr.log" | sed 's/^/ /' >&2
printf ' --- end stderr ---\n' >&2
else
info "stderr log is empty at $data_dir/logs/stderr.log"
fi
if [[ -s "$data_dir/logs/stdout.log" ]]; then
printf ' --- last 20 lines of %s ---\n' "$data_dir/logs/stdout.log" >&2
tail -n 20 "$data_dir/logs/stdout.log" | sed 's/^/ /' >&2
printf ' --- end stdout ---\n' >&2
fi
exit 1
}
stop_daemons() {
for d in "$B_DIR" "$C_DIR"; do
if [[ -f "$d/pid" ]]; then
local pid; pid=$(cat "$d/pid" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
info "stopping daemon pid $pid"
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$d/pid"
fi
done
}
# --- identities --------------------------------------------------------------
ensure_identity_a() {
step "initialising Identity A (amy)"
local out
out=$(amy_a init) || { fail_msg "amy init failed: $out"; exit 1; }
A_NPUB=$(printf '%s' "$out" | jq -r '.npub')
A_HEX=$(printf '%s' "$out" | jq -r '.hex')
info "A npub: $A_NPUB"
info "A hex: $A_HEX"
}
ensure_identity() {
local who="$1" cmd npub=""
if [[ "$who" == "B" ]]; then cmd=wn_b; else cmd=wn_c; fi
step "ensuring Identity $who (wn)"
local raw
raw=$("$cmd" --json whoami 2>/dev/null || true)
npub=$(extract_pubkey "$raw")
if [[ -z "${npub:-}" ]]; then
# create-identity sometimes exits non-zero (e.g. transient "failed to
# connect to any relays") even though the account was created. Probe
# --json whoami afterwards before giving up.
"$cmd" create-identity 2>&1 | tee -a "$LOG_FILE" || true
raw=$("$cmd" --json whoami 2>/dev/null || true)
npub=$(extract_pubkey "$raw")
fi
[[ -n "$npub" ]] || { fail_msg "could not determine $who npub"; exit 1; }
local hex; hex=$(npub_to_hex "$npub")
if [[ "$who" == "B" ]]; then B_NPUB="$npub"; B_HEX="$hex"
else C_NPUB="$npub"; C_HEX="$hex"; fi
info "$who npub: $npub"
[[ "$hex" != "$npub" ]] && info "$who hex: $hex"
}
# --- relays ------------------------------------------------------------------
# Point all three identities at the loopback relay. We never publish any
# test traffic off-box — public relays reject kind:445 anyway and the
# goal here is tight, deterministic iteration.
configure_relays() {
banner "Configuring relays → $RELAY_URL"
amy_a relay add "$RELAY_URL" --type all >/dev/null
for t in nip65 inbox key_package; do
wn_b relays add --type "$t" "$RELAY_URL" 2>/dev/null || true
wn_c relays add --type "$t" "$RELAY_URL" 2>/dev/null || true
done
# A advertises its NIP-65 + DM inbox lists so B/C can discover where to
# deliver gift wraps. With a single shared relay the lookup is trivial
# but we still publish so we catch regressions in the advertise path.
step "publishing A's NIP-65 + kind:10050 lists"
amy_a relay publish-lists >>"$LOG_FILE" 2>&1 || warn "amy relay publish-lists failed"
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
# 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.
sleep 2
}
@@ -1,199 +0,0 @@
# shellcheck shell=bash
#
# headless/tests-create.sh — tests 01..05.
# Focus: KeyPackage discovery, group creation, invites, initial messages.
test_01_keypackage_discovery() {
banner "Test 01 — KeyPackage publish & discovery (MIP-00)"
local id="01 KeyPackage A<->B"
# B finds A's KP
local raw ev
raw=$(wn_b --json keys check "$A_NPUB" 2>>"$LOG_FILE" || true)
ev=$(printf '%s' "$raw" | jq -r '.result.event_id // .event_id // empty')
if [[ -z "$ev" || "$ev" == "null" ]]; then
record_result "$id (B->A)" fail "wn couldn't find A's KP"; return
fi
info "B saw A's KP $ev"
# A finds B's KP (wn publishes automatically via create-identity flow? if not, ask).
wn_b keys publish >>"$LOG_FILE" 2>&1 || warn "wn_b keys publish failed"
sleep 3
local out
out=$(amy_json marmot key-package check "$B_NPUB" 2>/dev/null || true)
if [[ -z "$out" ]]; then
record_result "$id (A->B)" fail "amy couldn't find B's KP"; return
fi
info "A saw B's KP $(printf '%s' "$out" | jq -r .event_id)"
record_result "$id" pass
}
test_02_a_creates_group() {
banner "Test 02 — A creates group, invites B"
local id="02 A->B create+invite"
# amy keys groups by the MIP-01 nostr_group_id (`group_id`), wn (via
# mdk-core) keys by the MLS GroupContext groupId (`mls_group_id`). Both
# are 32 random bytes and they are NOT the same. We need both: amy calls
# use `gid`, wn calls use `mls_gid`.
local out gid mls_gid
out=$(amy_json marmot group create --name "Interop-02") || {
record_result "$id" fail "create returned no group_id"; return
}
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
save_state GROUP_02 "$gid"
save_state GROUP_02_MLS "$mls_gid"
info "created group nostr=$gid mls=$mls_gid"
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || {
record_result "$id" fail "amy group add failed"; return
}
# B waits for invite, accepts.
local b_gid
if ! b_gid=$(wait_for_invite B 60); then
record_result "$id" fail "B never received invite"; return
fi
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
info "B joined $b_gid"
# A -> B message. amy takes the nostr id; wait_for_message calls
# `wn messages list` which needs the MLS id.
amy_json marmot message send "$gid" "hello from amethyst" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
if ! wait_for_message B "$mls_gid" "hello from amethyst" 90; then
record_result "$id" fail "B didn't receive A's message"; return
fi
# B -> A message
wn_b messages send "$mls_gid" "hello from wn" >/dev/null 2>&1 || warn "wn send returned nonzero"
if ! amy_json marmot await message "$gid" --match "hello from wn" --timeout 30 >/dev/null; then
record_result "$id" fail "A didn't receive B's reply"; return
fi
record_result "$id" pass
}
test_03_b_creates_group() {
banner "Test 03 — B creates group, invites A"
local id="03 B->A create+invite"
# `wn groups create` returns the MLS group id (what wn's messages API
# expects). amy indexes by the MIP-01 nostr_group_id, which we only learn
# once A has processed the welcome and we can ask `amy await group` for
# it. Keep them distinct so each CLI gets the id it understands.
local out mls_gid
out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn groups create failed"; return
}
mls_gid=$(printf '%s' "$out" | jq_group_id)
if [[ -z "$mls_gid" ]]; then
record_result "$id" fail "could not parse group_id"; return
fi
save_state GROUP_03_MLS "$mls_gid"
info "mls_group_id: $mls_gid"
# A: poll until it joins, and capture its nostr_group_id.
local a_out a_gid
a_out=$(amy_json marmot await group --name "Interop-03" --timeout 30) || {
record_result "$id" fail "A never joined B's group"; return
}
a_gid=$(printf '%s' "$a_out" | jq -r '.group_id')
save_state GROUP_03 "$a_gid"
info "A joined as nostr_group_id=$a_gid"
# B -> A message
wn_b messages send "$mls_gid" "ping from wn" >/dev/null 2>&1 || true
if ! amy_json marmot await message "$a_gid" --match "ping from wn" --timeout 30 >/dev/null; then
record_result "$id" fail "A didn't see B's ping"; return
fi
# A -> B reply
amy_json marmot message send "$a_gid" "pong from amethyst" >/dev/null || {
record_result "$id" fail "amy send pong failed"; return
}
if wait_for_message B "$mls_gid" "pong from amethyst" 90; then
record_result "$id" pass
else
record_result "$id" fail "B didn't see A's pong"
fi
}
test_04_three_member_group() {
banner "Test 04 — 3-member group, add C after create"
local id="04 3-member add-after-create"
wn_c keys publish >/dev/null 2>&1 || true
sleep 3
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
# A adds C
amy_json marmot group add "$gid" "$C_NPUB" >/dev/null || {
record_result "$id" fail "amy group add C failed"; return
}
local c_gid
if ! c_gid=$(wait_for_invite C 60); then
record_result "$id" fail "C never received invite"; return
fi
wn_c groups accept "$c_gid" >/dev/null 2>&1 || true
amy_json marmot message send "$gid" "hello three-member world" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
# wn's event_processor retries undecryptable pre-membership commits with
# exponential backoff (2+4+8+16=~30s), and kind:445 processing is serial
# per-account; a fresh joiner routinely needs ~60s to burn through that
# retry queue before the add-C commit is applied and "hello three-member
# world" decrypts. The 30s we used for the inline A<->B send/recv
# wouldn't clear that.
if wait_for_message B "$mls_gid" "hello three-member world" 90 \
&& wait_for_message C "$c_gid" "hello three-member world" 90; then
record_result "$id" pass
else
record_result "$id" fail "B or C missed A's post-add message"
fi
}
test_05_b_adds_a_existing() {
banner "Test 05 — B adds A to an existing B+C group"
local id="05 wn adds A existing"
local out gid
out=$(wn_b --json groups create "Interop-05" "$C_NPUB" 2>>"$LOG_FILE") || {
record_result "$id" fail "wn create Interop-05 failed"; return
}
gid=$(printf '%s' "$out" | jq_group_id)
if [[ -z "$gid" ]]; then
record_result "$id" fail "no group_id from create"; return
fi
save_state GROUP_05 "$gid"
wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true
wn_b groups add-members "$gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn add-members A failed"; return
}
# A joins
if ! amy_json marmot await group --name "Interop-05" --timeout 30 >/dev/null; then
record_result "$id" fail "A never received invite to Interop-05"; return
fi
amy_json marmot message send "$gid" "joined from amethyst" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
if wait_for_message B "$gid" "joined from amethyst" 90 \
&& wait_for_message C "$gid" "joined from amethyst" 90; then
record_result "$id" pass
else
record_result "$id" fail "B or C didn't see A's message"
fi
}
-220
View File
@@ -1,220 +0,0 @@
# shellcheck shell=bash
#
# headless/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:
#
# dm-01 text round-trip (both directions)
# dm-02 dm list surfaces prior exchange with type:text discriminator
# dm-03 strict kind:10050 routing refuses sends to inboxless recipient
# dm-04 --allow-fallback opts into the NIP-65 read / bootstrap chain
# dm-05 file message reference mode round-trip (kind:15)
# dm-06 cursor advance (subsequent no-flag `dm list` is empty)
# Wrap amy_json around either data-dir so the per-test code stays tight.
amy_json_for() {
local dir="$1"; shift
local out
if ! out=$("$AMY_BIN" --data-dir "$dir" "$@" 2>>"$LOG_FILE"); then
fail_msg "amy --data-dir $dir $*: exit $? (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE"
return 1
fi
printf '%s' "$out"
}
amy_json_a() { amy_json_for "$A_DIR" "$@"; }
amy_json_d() { amy_json_for "$D_DIR" "$@"; }
test_01_dm_text_round_trip() {
banner "DM-01 — text round-trip A↔D (kind:14)"
local id="dm-01 text round-trip"
# A → D
local out_send
out_send=$(amy_json_a dm send "$D_NPUB" "hello from A") || {
record_result "$id" fail "A send failed"; return
}
local event_id recipients_ok
event_id=$(printf '%s' "$out_send" | jq -r '.event_id')
recipients_ok=$(printf '%s' "$out_send" \
| jq -r '[.recipients[] | select(.relay_source == "kind_10050")] | length')
info "A sent kind:14 event_id=$event_id (to ${recipients_ok} kind:10050 inboxes)"
if [[ -z "$event_id" || "$event_id" == "null" ]]; then
record_result "$id" fail "A send response missing event_id"; return
fi
# D awaits the text
if ! amy_json_d dm await --peer "$A_NPUB" --match "hello from A" --timeout 30 >/dev/null; then
record_result "$id" fail "D never received A's text"; return
fi
info "D received A's text"
# D → A
amy_json_d dm send "$A_NPUB" "reply from D" >/dev/null || {
record_result "$id" fail "D send failed"; return
}
if ! amy_json_a dm await --peer "$D_NPUB" --match "reply from D" --timeout 30 >/dev/null; then
record_result "$id" fail "A never received D's reply"; return
fi
info "A received D's reply"
record_result "$id" pass
}
test_02_dm_list_surfaces_history() {
banner "DM-02 — dm list returns text messages with type discriminator"
local id="dm-02 dm list text history"
local out
out=$(amy_json_a dm list --peer "$D_NPUB" --timeout 5) || {
record_result "$id" fail "amy dm list failed"; return
}
local text_count reply_present
text_count=$(printf '%s' "$out" \
| jq -r '[.messages[] | select(.type == "text")] | length')
reply_present=$(printf '%s' "$out" \
| jq -r '[.messages[] | select(.content == "reply from D")] | length')
info "A sees $text_count text message(s) with D; reply_present=$reply_present"
if [[ "$reply_present" == "1" ]]; then
record_result "$id" pass
else
record_result "$id" fail "D's reply missing from A's dm list"
fi
}
test_03_dm_send_rejects_no_inbox() {
banner "DM-03 — strict kind:10050 refuses sends to inboxless recipient"
local id="dm-03 strict no_dm_relays"
# Generate a throwaway identity but do NOT publish its kind:10050.
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
local ghost_out ghost_npub
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" init) || {
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
}
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
info "ghost: $ghost_npub (no relays advertised)"
# A sends without --allow-fallback; amy should refuse.
local raw rc
raw=$("$AMY_BIN" --data-dir "$A_DIR" dm send "$ghost_npub" "should be rejected" 2>&1)
rc=$?
rm -rf "$tmpdir"
if [[ "$rc" -ne 0 ]] && printf '%s' "$raw" | grep -q '"error":"no_dm_relays"'; then
info "amy refused with no_dm_relays as expected (exit $rc)"
record_result "$id" pass
else
fail_msg "expected no_dm_relays error; got rc=$rc raw=$raw"
record_result "$id" fail "send to inboxless recipient did not refuse"
fi
}
test_04_dm_send_allow_fallback() {
banner "DM-04 — --allow-fallback opts into NIP-65 read / bootstrap chain"
local id="dm-04 allow-fallback"
# Same ghost pattern as dm-03. With --allow-fallback, resolution should
# fall through kind:10050 → NIP-65 read → bootstrap. Our bootstrap set
# always includes the loopback relay via the shared RelayConfig, so the
# publish should succeed even though the ghost has no 10050.
local tmpdir; tmpdir=$(mktemp -d "${STATE_DIR}/ghost.XXXXXX")
local ghost_out ghost_npub
ghost_out=$("$AMY_BIN" --data-dir "$tmpdir" init) || {
record_result "$id" fail "ghost init failed"; rm -rf "$tmpdir"; return
}
ghost_npub=$(printf '%s' "$ghost_out" | jq -r '.npub')
rm -rf "$tmpdir"
local out source
out=$(amy_json_a dm send "$ghost_npub" "hi via fallback" --allow-fallback) || {
record_result "$id" fail "send with --allow-fallback failed"; return
}
# The ghost's own wrap will land in the recipients list; its source
# should be bootstrap (no 10050, no 10002) — which confirms the
# fallback actually fired.
source=$(printf '%s' "$out" \
| jq -r --arg pk "${ghost_npub}" \
'.recipients[] | select(.pubkey != $pk) | .relay_source' \
| head -1)
info "relay_source for ghost wrap: $source"
if printf '%s' "$out" | jq -e '[.recipients[] | .relay_source] | index("bootstrap")' >/dev/null \
|| printf '%s' "$out" | jq -e '[.recipients[] | .relay_source] | index("nip65_read")' >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "no recipient reported a fallback relay_source"
fi
}
test_05_dm_file_reference_round_trip() {
banner "DM-05 — file message reference mode (kind:15) round-trip"
local id="dm-05 file reference round-trip"
# 64 hex chars = 32-byte AES-GCM key; 32 hex chars = 16-byte nonce.
local key="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
local nonce="fedcba9876543210fedcba9876543210"
local url="https://example.test/blob/test-dm-05.bin"
local mime="application/octet-stream"
amy_json_a dm send-file "$D_NPUB" "$url" \
--key "$key" --nonce "$nonce" --mime-type "$mime" --size 1024 >/dev/null || {
record_result "$id" fail "A send-file failed"; return
}
info "A published kind:15 for $url"
# D awaits — `dm await --match` searches URL for kind:15.
if ! amy_json_d dm await --peer "$A_NPUB" --match "$url" --timeout 30 >/dev/null; then
record_result "$id" fail "D never received the file message"; return
fi
# Verify the recovered JSON carries all crypto material.
local raw
raw=$(amy_json_d dm list --peer "$A_NPUB" --timeout 5) || {
record_result "$id" fail "D dm list failed"; return
}
local fields
fields=$(printf '%s' "$raw" | jq -r \
'[.messages[] | select(.type == "file" and .url == "'"$url"'")][0]')
if [[ -z "$fields" || "$fields" == "null" ]]; then
record_result "$id" fail "D did not decode a kind:15 matching the URL"; return
fi
local got_key got_nonce got_mime
got_key=$(printf '%s' "$fields" | jq -r '.decryption_key')
got_nonce=$(printf '%s' "$fields" | jq -r '.decryption_nonce')
got_mime=$(printf '%s' "$fields" | jq -r '.mime_type')
info "D decoded key=${got_key:0:16}… nonce=${got_nonce:0:16}… mime=$got_mime"
assert_eq "$got_key" "$key" "$id" "decryption_key mismatch" || return
assert_eq "$got_nonce" "$nonce" "$id" "decryption_nonce mismatch" || return
assert_eq "$got_mime" "$mime" "$id" "mime_type mismatch" || return
record_result "$id" pass
}
test_06_dm_list_cursor_advance() {
banner "DM-06 — no-flag dm list advances the giftWrapSince cursor"
local id="dm-06 cursor advance"
# First no-flag list: drains everything and advances the cursor to now.
local first_out first_count
first_out=$(amy_json_d dm list --timeout 5) || {
record_result "$id" fail "first dm list failed"; return
}
first_count=$(printf '%s' "$first_out" | jq -r '.messages | length')
info "first dm list returned $first_count message(s)"
# Second no-flag list: nothing new should land.
local second_out second_count
second_out=$(amy_json_d dm list --timeout 5) || {
record_result "$id" fail "second dm list failed"; return
}
second_count=$(printf '%s' "$second_out" | jq -r '.messages | length')
info "second dm list returned $second_count message(s)"
if [[ "$first_count" -ge 1 && "$second_count" -eq 0 ]]; then
record_result "$id" pass
else
record_result "$id" fail "expected first>=1, second==0; got first=$first_count second=$second_count"
fi
}
@@ -1,234 +0,0 @@
# shellcheck shell=bash
#
# headless/tests-extras.sh — tests 09, 10, 12, 13.
# Focus: reactions/replies, concurrent commits, offline catchup, KP rotation.
test_09_reply_react_unreact() {
banner "Test 09 — reply / react / unreact"
local id="09 reply/react"
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
# B anchors. Needs a member to be present — if Test 11 already ran and A left,
# skip cleanly so we don't double-fail.
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return
fi
wn_b messages send "$mls_gid" "anchor for reactions" >/dev/null 2>&1 || true
sleep 3
local msg_id
msg_id=$(wn_b --json messages list "$mls_gid" --limit 10 2>/dev/null \
| jq -r '[(.result // .) | .[]? | select((.content // .text // "") == "anchor for reactions")][0].id // empty')
if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then
record_result "$id" fail "couldn't find anchor message id"; return
fi
wn_b messages react "$mls_gid" "$msg_id" "🌮" >/dev/null 2>&1 || true
sleep 3
# amy reply
amy_json marmot message send "$gid" "replying via amy" >/dev/null || {
record_result "$id" fail "amy send reply failed"; return
}
if wait_for_message B "$mls_gid" "replying via amy" 90; then
record_result "$id" pass
else
record_result "$id" fail "B didn't receive reply"
fi
# NB: react/unreact round-trip verification requires a CLI verb we don't have
# yet (amy marmot message react). Once we add it, expand this test.
}
test_10_concurrent_commits() {
banner "Test 10 — Concurrent commits race"
local id="10 concurrent commits"
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return
fi
# Fire both renames in parallel. One wins — MLS single-commit-per-epoch
# guarantees a deterministic outcome.
( amy_json marmot group rename "$gid" "race-from-amethyst" >/dev/null ) &
local a_pid=$!
( wn_b groups rename "$mls_gid" "race-from-wn" >/dev/null 2>&1 ) &
local b_pid=$!
wait "$a_pid" "$b_pid" 2>/dev/null || true
sleep 10
local b_name
b_name=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | .name // empty')
local a_name
a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "")
if [[ -n "$a_name" && "$a_name" == "$b_name" ]]; then
info "race converged: both sides see \"$a_name\""
# Verify encryption still works.
wn_b messages send "$mls_gid" "post-race ping" >/dev/null 2>&1 || true
if amy_json marmot await message "$gid" --match "post-race ping" --timeout 90 >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "encryption broken after race"
fi
else
record_result "$id" fail "diverged: A sees \"$a_name\", B sees \"$b_name\""
fi
}
test_12_offline_catchup() {
banner "Test 12 — Offline catch-up"
local id="12 offline catchup"
# wn-side keys groups by mls_group_id; amy-side by nostr_group_id. Learn
# the amy side from `amy await group` so later amy calls target the right
# group.
local out mls_gid a_out a_gid
out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE")
mls_gid=$(printf '%s' "$out" | jq_group_id)
[[ -n "$mls_gid" ]] || { record_result "$id" fail "couldn't create Interop-12"; return; }
save_state GROUP_12_MLS "$mls_gid"
# A joins.
a_out=$(amy_json marmot await group --name "Interop-12" --timeout 30) || {
record_result "$id" fail "A never received Interop-12 invite"; return
}
a_gid=$(printf '%s' "$a_out" | jq -r '.group_id')
save_state GROUP_12 "$a_gid"
# "Go offline" == don't invoke amy. Meanwhile B sends 5 messages + adds C + sends 3 more + rename.
for i in 1 2 3 4 5; do
wn_b messages send "$mls_gid" "offline-msg-$i" >/dev/null 2>&1 || true
sleep 1
done
wn_b groups add-members "$mls_gid" "$C_NPUB" >/dev/null 2>&1 || true
wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
for i in 6 7 8; do
wn_b messages send "$mls_gid" "offline-msg-$i" >/dev/null 2>&1 || true
sleep 1
done
wn_b groups rename "$mls_gid" "Interop-12-renamed" >/dev/null 2>&1 || true
sleep 3
# A comes back online — single sync pulls everything.
local show
show=$(amy_json marmot group show "$a_gid") || {
record_result "$id" fail "amy group show failed"; return
}
local name; name=$(printf '%s' "$show" | jq -r '.name')
if [[ "$name" != "Interop-12-renamed" ]]; then
record_result "$id" fail "A replay missed rename (saw \"$name\")"; return
fi
# All 8 messages should be locally stored.
local msgs; msgs=$(amy_json marmot message list "$a_gid" --limit 100)
local missing=0
for i in 1 2 3 4 5 6 7 8; do
if ! printf '%s' "$msgs" | jq -e --arg t "offline-msg-$i" \
'.messages[]? | select((.content // "") == $t)' >/dev/null; then
missing=$((missing+1))
info "missing offline-msg-$i"
fi
done
if [[ "$missing" -eq 0 ]]; then
record_result "$id" pass
else
record_result "$id" fail "A missed $missing of 8 offline messages"
fi
}
test_13_keypackage_rotation() {
banner "Test 13 — KeyPackage rotation"
local id="13 keypackage rotation"
local before
before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty')
if [[ -z "$before" ]]; then
record_result "$id" fail "no prior KP for A"; return
fi
amy_json marmot key-package publish >/dev/null || {
record_result "$id" fail "amy key-package publish failed"; return
}
local deadline=$(( $(date +%s) + 60 )) after=""
while [[ $(date +%s) -lt $deadline ]]; do
after=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.result.event_id // empty')
[[ -n "$after" && "$after" != "$before" ]] && break
sleep 3
done
if [[ -n "$after" && "$after" != "$before" ]]; then
info "KP rotated: ${before:0:8}… → ${after:0:8}"
record_result "$id" pass
else
record_result "$id" fail "no new KP event_id observed"
fi
}
# -- Inverted-role tests ----------------------------------------------------
# Tests 0113 mostly exercise amethyst as the committer and wn as the
# receiver. The scenarios below are complementary: wn drives the state
# change and amy is the receiver that must accept, verify and apply it.
# This widens coverage for the post-fix inbound authenticity checks
# (membership_tag, FramedContentTBS signature, LeafNode lifetime,
# confirmation_tag) that now run on every commit amy processes.
test_14_wn_removes_a() {
banner "Test 14 — wn (admin) removes A; amy processes Remove"
local id="14 wn removes amy"
# Known gap: wn (mdk-core/openmls) emits the filtered direct-path
# form of UpdatePath on Remove commits per RFC 9420 §7.7 — when the
# copath of a direct-path node has an empty resolution (every leaf
# under it is blank) the corresponding UpdatePathNode is omitted.
# Quartz's RatchetTree.applyUpdatePath currently requires the
# unfiltered node count (`pathNodes.size == directPath.size`) so
# every wn->amy Remove triggers
# "UpdatePath node count (N) doesn't match direct path length (N+k)".
# That's a pre-existing quartz conformance bug, out of scope for
# this branch; the harness carries the test so it starts passing
# the moment the filtered-path path is wired up.
record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath"
}
test_15_wn_member_leaves() {
banner "Test 15 — wn_c leaves; amy + wn_b process SelfRemove"
local id="15 wn_c leaves"
# Same filtered_direct_path gap as test 14: when wn_c leaves a
# 3-member group, wn_b folds the SelfRemove into a commit whose
# UpdatePath uses RFC 9420 §7.7 filtering, and amy's strict
# applyUpdatePath rejects it. Skip until quartz handles the
# filtered form on inbound.
record_result "$id" skip "pending filtered_direct_path support in applyUpdatePath"
}
test_16_wn_keypackage_rotation() {
banner "Test 16 — wn rotates KeyPackage; amy discovers new KP"
local id="16 wn keypackage rotation"
# amy's KeyPackageFetcher.fetchKeyPackage calls client.fetchFirst,
# which returns the first matching event a relay sends — nostr-rs-relay
# typically serves kind:443 events in storage order, not created_at
# order, so after a rotation amy may keep seeing the older event_id
# depending on which arrives first. Making this test deterministic
# requires a "fetch latest by created_at" KeyPackage fetcher; until
# then the check flaps. The inverse direction (test 13, amy rotates
# and wn sees via `wn keys check` which is an addressable index) is
# the reliable one.
record_result "$id" skip "pending createdAt-sorted KeyPackage fetch path"
}
@@ -1,187 +0,0 @@
# shellcheck shell=bash
#
# headless/tests-manage.sh — tests 06, 07, 08, 11.
# Focus: removal, metadata rename, admin promote/demote, leave.
test_06_member_removal() {
banner "Test 06 — Member removal + forward secrecy"
local id="06 member removal"
# MIP-03 only admins may commit Remove proposals. In GROUP_05 (wn-created
# by B, A joined later) A is not an admin, so the test used to fail with
# `IllegalStateException: non-admin members may only commit...`. Test on
# GROUP_02 instead, where amy is the creator and therefore sole admin,
# and where test 04 has already added C. amy calls use the nostr id,
# wn calls use the MLS id.
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
amy_json marmot group remove "$gid" "$C_NPUB" >/dev/null || {
record_result "$id" fail "amy remove C failed"; return
}
# C should no longer see the group on its own member view.
local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_c --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$C_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
removed=1; break
fi
sleep 3
done
if [[ "$removed" -ne 1 ]]; then
warn "C still appears as a member — continuing"
fi
amy_json marmot message send "$gid" "after removing C" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
wait_for_message B "$mls_gid" "after removing C" 90 || {
record_result "$id" fail "B lost access after C's removal"; return
}
# Forward secrecy: C must NOT see the post-removal message.
sleep 5
if wait_for_message C "$mls_gid" "after removing C" 10; then
record_result "$id" fail "C still decrypted a post-removal message"
else
record_result "$id" pass
fi
}
test_07_metadata_rename() {
banner "Test 07 — Metadata rename round-trip (MIP-01)"
local id="07 metadata rename"
# amy was the creator of GROUP_02 so its own nostr_group_id is saved as
# GROUP_02; wn keys its copy by the MLS group id saved as GROUP_02_MLS.
# Pass each CLI the id it understands.
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
amy_json marmot group rename "$gid" "Interop-02-renamed" >/dev/null || {
record_result "$id" fail "amy rename failed"; return
}
local deadline=$(( $(date +%s) + 120 )) seen=""
while [[ $(date +%s) -lt $deadline ]]; do
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | .name // empty')
[[ "$seen" == "Interop-02-renamed" ]] && break
sleep 3
done
[[ "$seen" == "Interop-02-renamed" ]] || {
record_result "$id" fail "B saw name=\"$seen\" not \"Interop-02-renamed\""; return
}
# MIP-01: only admins may rename. GROUP_02 was created by amy (sole
# admin), so for B's rename to be accepted by wn's MIP-01 check amy
# must first promote B. amy adds B to admin_pubkeys via GCE, which
# the harness consumes via `marmot group promote` — equivalent to
# `wn groups promote` but issued by the quartz side. Without this
# step B's own wn silently refuses the rename on its MIP-01
# `ensure_account_is_group_admin` check, and the kind:445 is never
# published.
amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "amy promote-B failed"; return
}
# Let wn apply the promote commit before issuing the rename.
sleep 3
# Now B renames back and A should pick it up.
wn_b groups rename "$mls_gid" "Interop-02-reverse" >/dev/null 2>&1 || true
if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 120 >/dev/null; then
record_result "$id" pass
else
record_result "$id" fail "A did not pick up B's rename"
fi
}
test_08_admin_promote_demote() {
banner "Test 08 — Admin promote / demote"
local id="08 admin promote/demote"
# GROUP_03 was created by wn so both sides need different ids:
# GROUP_03 → amy's nostr_group_id (captured in test 03 after
# `amy await group` returned `.group_id`)
# GROUP_03_MLS → wn's mls_group_id (wn's `groups create` output)
local a_gid mls_gid
a_gid=$(load_state GROUP_03 || true)
mls_gid=$(load_state GROUP_03_MLS || true)
if [[ -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_03"; return
fi
# Ensure 3 members (add C if missing).
wn_c keys publish >/dev/null 2>&1 || true
sleep 2
wn_b groups add-members "$mls_gid" "$C_NPUB" >/dev/null 2>&1 || true
wait_for_invite C 30 >/dev/null && wn_c groups accept "$mls_gid" >/dev/null 2>&1 || true
# B promotes A.
wn_b groups promote "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || {
record_result "$id" fail "wn promote failed"; return
}
# A should reflect the new admin set — poll via amy.
if ! amy_json marmot await admin "$a_gid" "$A_NPUB" --timeout 90 >/dev/null; then
record_result "$id" fail "A never saw itself promoted"; return
fi
# A now commits a rename — only possible if we're admin.
amy_json marmot group rename "$a_gid" "Interop-03-by-A" >/dev/null || {
record_result "$id" fail "A (now admin) could not rename"; return
}
# B demotes A.
wn_b groups demote "$mls_gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero"
sleep 5
local admins
admins=$(wn_b --json groups admins "$mls_gid" 2>/dev/null \
| jq -r '(.result // .) | .[]?.pubkey // .[]?.public_key // .[]?' | tr '\n' ' ')
if [[ "$admins" == *"$A_HEX"* ]]; then
record_result "$id" fail "A still admin after demote"
else
record_result "$id" pass
fi
}
test_11_leave_group() {
banner "Test 11 — Leave group"
local id="11 leave group"
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
amy_json marmot group leave "$gid" >/dev/null || {
record_result "$id" fail "amy leave failed"; return
}
local deadline=$(( $(date +%s) + 120 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
gone=1; break
fi
sleep 3
done
if [[ "$gone" -eq 1 ]]; then
record_result "$id" pass
else
record_result "$id" fail "A still in B's member list after leave"
fi
}
-585
View File
@@ -1,585 +0,0 @@
# shellcheck shell=bash
#
# lib.sh — helpers for marmot-interop.sh
#
# All functions assume the parent script has set:
# STATE_DIR absolute path to state/ directory
# LOG_FILE absolute path to current run's log file
# RESULTS_FILE absolute path to results table (tab-separated)
# WN_BIN, WND_BIN absolute paths to wn / wnd binaries
# B_SOCKET, C_SOCKET socket paths for the two daemons
set -u
# ------- colors --------------------------------------------------------------
if [[ -t 1 ]]; then
C_RESET=$'\033[0m'
C_BOLD=$'\033[1m'
C_RED=$'\033[31m'
C_GREEN=$'\033[32m'
C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'
C_CYAN=$'\033[36m'
C_GREY=$'\033[90m'
else
C_RESET=""; C_BOLD=""; C_RED=""; C_GREEN=""
C_YELLOW=""; C_BLUE=""; C_CYAN=""; C_GREY=""
fi
# ------- logging primitives --------------------------------------------------
log() { printf '%s\n' "$*" | tee -a "$LOG_FILE" >&2; }
banner() {
printf '\n%s%s================================================================%s\n' \
"$C_BOLD" "$C_BLUE" "$C_RESET" >&2
printf '%s%s %s%s\n' "$C_BOLD" "$C_BLUE" "$1" "$C_RESET" >&2
printf '%s%s================================================================%s\n\n' \
"$C_BOLD" "$C_BLUE" "$C_RESET" >&2
printf '\n=== %s ===\n' "$1" >>"$LOG_FILE"
}
step() { printf '%s>%s %s\n' "$C_CYAN" "$C_RESET" "$1" >&2; printf '> %s\n' "$1" >>"$LOG_FILE"; }
info() { printf '%s %s%s\n' "$C_GREY" "$1" "$C_RESET" >&2; printf ' %s\n' "$1" >>"$LOG_FILE"; }
warn() { printf '%s!%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; printf '! %s\n' "$1" >>"$LOG_FILE"; }
pass_msg() { printf '%s\u2713 PASS%s %s\n' "$C_GREEN" "$C_RESET" "$1" >&2; printf 'PASS %s\n' "$1" >>"$LOG_FILE"; }
fail_msg() { printf '%s\u2717 FAIL%s %s\n' "$C_RED" "$C_RESET" "$1" >&2; printf 'FAIL %s\n' "$1" >>"$LOG_FILE"; }
skip_msg() { printf '%s- SKIP%s %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2; printf 'SKIP %s\n' "$1" >>"$LOG_FILE"; }
# ------- result tracking ------------------------------------------------------
# Each test calls record_result <test-id> <pass|fail|skip> [note]
record_result() {
local test_id="$1" status="$2" note="${3:-}"
printf '%s\t%s\t%s\n' "$test_id" "$status" "$note" >>"$RESULTS_FILE"
}
print_summary() {
local total=0 passed=0 failed=0 skipped=0
printf '\n%s%s================================================================%s\n' \
"$C_BOLD" "$C_BLUE" "$C_RESET"
printf '%s%s RESULTS%s\n' "$C_BOLD" "$C_BLUE" "$C_RESET"
printf '%s%s================================================================%s\n\n' \
"$C_BOLD" "$C_BLUE" "$C_RESET"
if [[ ! -s "$RESULTS_FILE" ]]; then
printf ' (no tests ran)\n\n'
return
fi
while IFS=$'\t' read -r tid status note; do
total=$((total+1))
case "$status" in
pass) passed=$((passed+1)); col="$C_GREEN"; sym="\u2713" ;;
fail) failed=$((failed+1)); col="$C_RED"; sym="\u2717" ;;
skip) skipped=$((skipped+1)); col="$C_YELLOW"; sym="-" ;;
*) col="$C_GREY"; sym="?" ;;
esac
printf ' %b %s%-44s%s %s\n' "$sym" "$col" "$tid" "$C_RESET" "$note"
done <"$RESULTS_FILE"
printf '\n %s%d passed%s, %s%d failed%s, %s%d skipped%s (of %d)\n\n' \
"$C_GREEN" "$passed" "$C_RESET" \
"$C_RED" "$failed" "$C_RESET" \
"$C_YELLOW" "$skipped" "$C_RESET" \
"$total"
printf ' full log: %s\n\n' "$LOG_FILE"
}
# ------- human interaction ---------------------------------------------------
# Pretty-print multi-line instructions and wait for <Enter>.
prompt_human() {
printf '\n%s%s---- DO THIS IN AMETHYST ----%s\n' "$C_BOLD" "$C_YELLOW" "$C_RESET" >&2
printf '%s%s%s\n' "$C_YELLOW" "$1" "$C_RESET" >&2
printf '%s%s-----------------------------%s\n' "$C_BOLD" "$C_YELLOW" "$C_RESET" >&2
printf '%s[Press Enter to continue]%s ' "$C_CYAN" "$C_RESET" >&2
read -r _
printf '\n' >>"$LOG_FILE"
printf 'HUMAN-PROMPT: %s\n' "$1" >>"$LOG_FILE"
}
# Ask the operator to confirm a UI rendering result.
# Returns 0 on pass, 1 on fail, 2 on skip.
confirm() {
local q="$1" ans
while true; do
printf '%s? %s%s [p]ass / [f]ail / [s]kip: ' "$C_CYAN" "$q" "$C_RESET" >&2
read -r ans
case "$(printf '%s' "$ans" | tr '[:upper:]' '[:lower:]')" in
p|pass) printf 'CONFIRM PASS: %s\n' "$q" >>"$LOG_FILE"; return 0 ;;
f|fail) printf 'CONFIRM FAIL: %s\n' "$q" >>"$LOG_FILE"; return 1 ;;
s|skip) printf 'CONFIRM SKIP: %s\n' "$q" >>"$LOG_FILE"; return 2 ;;
*) printf 'enter p, f, or s\n' >&2 ;;
esac
done
}
# ------- wn wrappers ---------------------------------------------------------
wn_b() { "$WN_BIN" --socket "$B_SOCKET" ${B_NPUB:+--account "$B_NPUB"} "$@"; }
wn_c() { "$WN_BIN" --socket "$C_SOCKET" ${C_NPUB:+--account "$C_NPUB"} "$@"; }
# Run wn with --json and pipe through jq; aborts the test on jq parse failure.
wn_b_json() { wn_b --json "$@"; }
wn_c_json() { wn_c --json "$@"; }
# ------- assertions ----------------------------------------------------------
expect_eq() {
local actual="$1" expected="$2" what="${3:-value}"
if [[ "$actual" == "$expected" ]]; then
info "$what == \"$expected\""
return 0
fi
fail_msg "$what mismatch: expected \"$expected\", got \"$actual\""
return 1
}
expect_contains() {
local haystack="$1" needle="$2" what="${3:-output}"
if [[ "$haystack" == *"$needle"* ]]; then
info "$what contains \"$needle\""
return 0
fi
fail_msg "$what missing \"$needle\""
info "actual: $haystack"
return 1
}
# ------- JSON helpers --------------------------------------------------------
# Extract MLS group ID as lowercase hex from wn JSON output.
# Handles both formats:
# - plain hex string (from `groups list`)
# - {"value":{"vec":[...]}} serde struct (from `groups create`)
# - flat byte array [n, ...] (from some responses)
# Plus the three wrapper shapes wn actually uses:
# - {"result": {"mls_group_id": ...}} (groups create)
# - {"group": {"mls_group_id": ...}, "membership": ...} (groups invites[0])
# - {"mls_group_id": ...} (bare)
# Input: JSON string via stdin; optional 2nd arg = field name (default: mls_group_id)
jq_group_id() {
local field="${1:-mls_group_id}"
jq -r --arg f "$field" '
def byte2hex:
. as $n |
[($n / 16 | floor), ($n % 16)] |
map(if . < 10 then (48 + .) else (87 + .) end) |
implode;
(.group // .result // .) |
(.group // .) |
.[$f] |
if type == "string" then .
elif (type == "object" and (.value.vec != null)) then
[.value.vec[] | byte2hex] | join("")
elif type == "array" then
[.[] | byte2hex] | join("")
else empty end
' 2>/dev/null || true
}
# ------- polling helpers -----------------------------------------------------
# Snapshot currently-pending invites on <B|C> as a comma-separated list of
# gids. Use this BEFORE triggering a publish that should produce a new
# welcome, then pass the result to `wait_for_invite` as the ignore list so
# a stale welcome left over from a previous harness run (wnd persists
# pending invites across restarts) doesn't register as the arrival we
# just triggered.
snapshot_invites() {
local who="$1" wnfn
if [[ "$who" == "B" ]]; then wnfn=wn_b
else wnfn=wn_c; fi
local raw
raw=$("$wnfn" --json groups invites 2>/dev/null || true)
# jq_group_id reads one invite at a time, so iterate the array in shell
# rather than trying to handle all shapes in a single jq expression.
local gids=()
while IFS= read -r one; do
[[ -z "$one" || "$one" == "null" ]] && continue
local g
g=$(printf '%s' "$one" | jq_group_id)
[[ -n "$g" ]] && gids+=("$g")
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
# bash 3.2 (stock macOS) treats "${gids[*]}" on an empty array as an
# unbound reference under `set -u`, so guard the expansion.
if (( ${#gids[@]} > 0 )); then
local IFS=','
printf '%s' "${gids[*]}"
fi
}
# wait_for_invite <B|C> <timeout-seconds> [ignore-csv]
# Echoes the first pending group_id that isn't listed in <ignore-csv>
# (comma-separated hex gids). Use the ignore list to skip welcomes left
# pending by previous harness runs — otherwise `wait_for_invite` can
# fire on a stale invite and the caller ends up driving a group the
# publisher isn't a member of, which then looks like a kind:445 drop.
# Returns 1 on timeout.
#
# Also prints a heartbeat every ~10s with:
# - elapsed/remaining time
# - current count of pending welcomes (total, pre-filter)
# - last relevant line of wnd stderr (grep for welcome/giftwrap/subscribe/error)
# so a stalled poll gives the operator something to forward.
wait_for_invite() {
local who="$1" timeout="${2:-60}" ignore_csv="${3:-}" deadline gid start last_hb
local wnfn data_dir
if [[ "$who" == "B" ]]; then wnfn=wn_b; data_dir="$B_DIR"
else wnfn=wn_c; data_dir="$C_DIR"; fi
start=$(date +%s)
deadline=$(( start + timeout ))
last_hb=$start
while [[ $(date +%s) -lt $deadline ]]; do
# Post-v0.2 `wn --json groups invites` returns `{"result": [...]}`
# (older builds returned the bare array). Peel the wrapper when
# present so a pending invite is actually detected.
local raw
raw=$("$wnfn" --json groups invites 2>/dev/null || true)
# Walk every pending invite (not just .[0]) so we skip past stales.
while IFS= read -r one; do
[[ -z "$one" || "$one" == "null" ]] && continue
gid=$(printf '%s' "$one" | jq_group_id)
[[ -z "$gid" ]] && continue
if [[ ",$ignore_csv," != *",$gid,"* ]]; then
printf '%s\n' "$gid"
return 0
fi
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
# Heartbeat every ~10s.
local now=$(date +%s)
if (( now - last_hb >= 10 )); then
local elapsed=$(( now - start )) remaining=$(( deadline - now ))
local pending
pending=$(printf '%s' "$raw" | jq '(.result // .) | length' 2>/dev/null || echo "?")
local recent=""
if [[ -f "$data_dir/logs/stderr.log" ]]; then
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
| grep -iE 'welcome|giftwrap|gift_wrap|mls|subscribe|1059|444|error|warn' \
| tail -n 1 || true)
fi
info "$who wait_for_invite +${elapsed}s (remaining ${remaining}s) pending=$pending ignoring=${ignore_csv:-<none>} tail: ${recent:-<no relevant wnd log yet>}"
last_hb=$now
fi
sleep 2
done
return 1
}
# wait_for_message <B|C> <group_id> <substring> <timeout>
# Returns 0 if a message containing the substring shows up via wn messages list.
wait_for_message() {
local who="$1" gid="$2" needle="$3" timeout="${4:-30}"
local deadline payload
deadline=$(( $(date +%s) + timeout ))
while [[ $(date +%s) -lt $deadline ]]; do
if [[ "$who" == "B" ]]; then
payload=$(wn_b_json messages list "$gid" --limit 20 2>/dev/null || true)
else
payload=$(wn_c_json messages list "$gid" --limit 20 2>/dev/null || true)
fi
if [[ -n "${payload:-}" ]] && \
printf '%s' "$payload" | jq -e --arg n "$needle" \
'(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \
>/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
# wait_for_member <B|C> <group_id> <pubkey-hex> <timeout>
wait_for_member() {
local who="$1" gid="$2" pubkey="$3" timeout="${4:-30}"
local deadline payload
deadline=$(( $(date +%s) + timeout ))
while [[ $(date +%s) -lt $deadline ]]; do
if [[ "$who" == "B" ]]; then
payload=$(wn_b_json groups members "$gid" 2>/dev/null || true)
else
payload=$(wn_c_json groups members "$gid" 2>/dev/null || true)
fi
if printf '%s' "${payload:-}" | jq -e --arg p "$pubkey" \
'(.result // .) | .[]? | select((.pubkey // .public_key // "") == $p)' \
>/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
# ------- output parsing ------------------------------------------------------
# `wn` prints either JSON (with --json) or a yaml-ish "key: value" pretty form.
# This helper extracts the pubkey (npub) from whichever form we got.
extract_pubkey() {
local raw="$1" v=""
# JSON: single object
v=$(printf '%s' "$raw" | jq -r '.pubkey // .npub // .public_key // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
# JSON: {"result": [ {"pubkey": …}, … ]} — post-v0.2 `wn --json whoami` shape
v=$(printf '%s' "$raw" | jq -r '.result[0].pubkey // .result[0].npub // .result[0].public_key // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
# JSON: array of accounts (whoami may return a list)
v=$(printf '%s' "$raw" | jq -r '.[0].pubkey // .[0].npub // .[0].public_key // empty' 2>/dev/null || true)
if [[ -n "$v" && "$v" != "null" ]]; then printf '%s' "$v"; return; fi
# yaml-ish "pubkey: npub1..."
v=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*pubkey[[:space:]]*:[[:space:]]*//p' | head -n1)
if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi
# yaml-ish "npub: npub1..." (alternative key)
v=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*npub[[:space:]]*:[[:space:]]*//p' | head -n1)
if [[ -n "$v" ]]; then printf '%s' "$v"; return; fi
}
# Pure-awk bech32 decoder for npub1... → 32-byte lowercase hex. Needed because
# `wn users show` only resolves identities the local daemon already knows
# about, so A's npub (the Amethyst account) can't be converted via wn — and a
# failed conversion leaks the raw npub into hex-equality checks like the
# member-list assertion in Test 02, which then fails with
# "member list missing npub1…".
bech32_decode_npub() {
local npub="$1"
[[ "$npub" == npub1* ]] || return 1
local body="${npub#npub1}"
# Need at least 6 chars of checksum + some data.
(( ${#body} > 6 )) || return 1
local hex
hex=$(awk -v data="$body" 'BEGIN {
charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
for (i = 0; i < 32; i++) map[substr(charset, i + 1, 1)] = i
data = substr(data, 1, length(data) - 6)
acc = 0; bits = 0; out = ""
for (i = 1; i <= length(data); i++) {
c = substr(data, i, 1)
if (!(c in map)) exit 1
acc = acc * 32 + map[c]
bits += 5
while (bits >= 8) {
bits -= 8
pow = 1
for (j = 0; j < bits; j++) pow *= 2
byte = int(acc / pow)
acc -= byte * pow
out = out sprintf("%02x", byte)
}
}
print out
}' 2>/dev/null) || return 1
# 32-byte x-only pubkey = 64 hex chars.
[[ ${#hex} -eq 64 ]] || return 1
printf '%s' "$hex"
}
# npub -> hex conversion. Tries `wn users show` first (handy when it works,
# since it also validates the npub against the daemon's view), then falls
# back to a local bech32 decode so unknown npubs still convert. Returns the
# input unchanged only if both paths fail, which should never happen for a
# well-formed npub.
npub_to_hex() {
local npub="$1" raw hex
raw=$(wn_b --json users show "$npub" 2>/dev/null || true)
hex=$(printf '%s' "$raw" \
| jq -r '(.result // .) | (.pubkey // .public_key // .hex // .[0]?.pubkey // .[0]?.public_key // empty)' \
2>/dev/null || true)
if [[ -z "$hex" || "$hex" == "null" ]]; then
# fallback to yaml-ish output
raw=$(wn_b users show "$npub" 2>/dev/null || true)
hex=$(printf '%s\n' "$raw" | sed -n 's/^[[:space:]]*\(public_key\|hex\)[[:space:]]*:[[:space:]]*//p' | head -n1)
fi
if [[ -n "$hex" && "$hex" != "null" ]]; then
printf '%s' "$hex"
return
fi
if hex=$(bech32_decode_npub "$npub"); then
printf '%s' "$hex"
return
fi
printf '%s' "$npub"
}
# ------- state file ----------------------------------------------------------
# Simple key=value store at $STATE_DIR/run.env
STATE_FILE_RUNTIME="${STATE_DIR}/run.env"
save_state() {
local key="$1" value="$2"
: >"${STATE_FILE_RUNTIME}.tmp"
if [[ -f "$STATE_FILE_RUNTIME" ]]; then
grep -v "^${key}=" "$STATE_FILE_RUNTIME" >>"${STATE_FILE_RUNTIME}.tmp" || true
fi
printf '%s=%s\n' "$key" "$value" >>"${STATE_FILE_RUNTIME}.tmp"
mv "${STATE_FILE_RUNTIME}.tmp" "$STATE_FILE_RUNTIME"
}
load_state() {
local key="$1"
[[ -f "$STATE_FILE_RUNTIME" ]] || return 1
grep "^${key}=" "$STATE_FILE_RUNTIME" 2>/dev/null | tail -n1 | cut -d= -f2-
}
# ------- daemon diagnostics --------------------------------------------------
# Dumps everything we know about a daemon's state. Called before polling for
# invites (baseline) and on every invite-poll failure so the operator can
# forward a single log file that explains what the daemon saw (or didn't).
#
# Output is tee'd to both stderr (for live viewing) and $LOG_FILE (for forward).
dump_daemon_diagnostics() {
local who="$1" tag="${2:-diagnostics}" data_dir socket wnfn npub hex
if [[ "$who" == "B" ]]; then
data_dir="$B_DIR"; socket="$B_SOCKET"; wnfn=wn_b
npub="${B_NPUB:-?}"; hex="${B_HEX:-?}"
else
data_dir="$C_DIR"; socket="$C_SOCKET"; wnfn=wn_c
npub="${C_NPUB:-?}"; hex="${C_HEX:-?}"
fi
printf '\n%s%s---- [%s] %s daemon diagnostics ----%s\n' \
"$C_BOLD" "$C_CYAN" "$tag" "$who" "$C_RESET" >&2
printf '\n==== [%s] %s daemon diagnostics ====\n' "$tag" "$who" >>"$LOG_FILE"
info "$who identity npub=$npub"
info "$who identity hex =$hex"
info "$who socket $socket"
# Relay list grouped by type — this is the critical bit for invite
# debugging. whitenoise-rs subscribes to kind:1059 on Inbox relays only,
# so if Amethyst doesn't see $B_NPUB's kind:10050 list pointing at these
# URLs, the gift wrap will never reach B.
step "$who relays (per type):"
for t in nip65 inbox key_package; do
local raw urls
raw=$("$wnfn" --json relays list --type "$t" 2>/dev/null || true)
urls=$(printf '%s' "$raw" \
| jq -r '(.result // .) | .[]? | (.url // .relay.url // empty)' 2>/dev/null \
| paste -sd',' -)
info " $t: ${urls:-<none>}"
printf ' %s %s raw: %s\n' "$who" "$t" "$raw" >>"$LOG_FILE"
done
# Connection status per relay — shows which sockets are actually live.
step "$who relay connection status:"
"$wnfn" --json relays list 2>/dev/null \
| jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \
| tee -a "$LOG_FILE" >&2 || true
# Daemon-level subscription health.
step "$who wn debug health:"
local health
health=$("$wnfn" --json debug health 2>/dev/null || "$wnfn" debug health 2>/dev/null || true)
printf ' %s\n' "${health:-<no output>}" | tee -a "$LOG_FILE" >&2
# Relay-control state (subscriptions, filters, planes). Truncated to keep
# stderr readable; full copy still goes to $LOG_FILE.
step "$who wn debug relay-control-state (truncated to stderr; full in $LOG_FILE):"
local rcs
rcs=$("$wnfn" --json debug relay-control-state 2>/dev/null || "$wnfn" debug relay-control-state 2>/dev/null || true)
printf '%s\n' "${rcs:-<no output>}" | head -n 20 | sed 's/^/ /' >&2
printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-<no output>}" >>"$LOG_FILE"
# Pending welcomes already decrypted by the daemon. Non-empty here while
# wait_for_invite is polling would be a race worth noticing.
step "$who pending invites:"
local inv
inv=$("$wnfn" --json groups invites 2>/dev/null || true)
printf ' %s\n' "${inv:-<no output>}" | tee -a "$LOG_FILE" >&2
# Tail of daemon stderr — contains the live 1059/welcome processing logs.
step "$who wnd stderr (last 80 lines from $data_dir/logs/stderr.log):"
if [[ -f "$data_dir/logs/stderr.log" ]]; then
tail -n 80 "$data_dir/logs/stderr.log" 2>/dev/null | sed 's/^/ /' | tee -a "$LOG_FILE" >&2 || true
else
info " (no stderr log at $data_dir/logs/stderr.log)"
fi
printf '%s%s---- end %s diagnostics ----%s\n\n' \
"$C_BOLD" "$C_CYAN" "$who" "$C_RESET" >&2
printf '==== end [%s] %s diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE"
}
# Asks the operator to capture Amethyst's MarmotDbg logcat so the script log
# ends up with both sides of the pipe in one place.
prompt_amethyst_logcat() {
local note="${1:-paste below}"
prompt_human "On the host running adb, capture Amethyst's Marmot logs:
adb logcat -d -v time | grep -E 'MarmotDbg|Marmot |MlsWelcome|GiftWrap' \\
| tail -n 200 > /tmp/amethyst-marmot.log
Then paste /tmp/amethyst-marmot.log contents here so the failure report has
both the Amethyst-side publish log and the wn-side subscription log ($note)."
}
# Dump everything we know about an outbound kind:445 from a wn daemon, so
# when the operator reports "Amethyst didn't see it" we can tell them which
# side of the wire to investigate:
#
# - `messages list` output (did the daemon persist the message locally,
# i.e. is this a relay-reach issue, a membership issue, or a pure
# Amethyst-receive issue?)
# - `groups show` metadata (which MLS-extension relays is wn using for
# this group — these are the SAME relays Amethyst subscribes to, so if
# the lists don't match that's the bug)
# - live relay connection status
#
# Output is tee'd to stderr (live) and $LOG_FILE (forward).
dump_outbound_diagnostics() {
local who="$1" gid="$2" needle="${3:-}" tag="${4:-post-send}"
local wnfn
if [[ "$who" == "B" ]]; then wnfn=wn_b
else wnfn=wn_c; fi
printf '\n%s%s---- [%s] %s outbound diagnostics (group %.8s…) ----%s\n' \
"$C_BOLD" "$C_CYAN" "$tag" "$who" "$gid" "$C_RESET" >&2
printf '\n==== [%s] %s outbound diagnostics (group %s) ====\n' "$tag" "$who" "$gid" >>"$LOG_FILE"
step "$who local view of latest messages in group"
local list_raw
list_raw=$("$wnfn" --json messages list "$gid" --limit 5 2>/dev/null || true)
printf ' %s\n' "${list_raw:-<no output>}" >>"$LOG_FILE"
printf '%s' "$list_raw" \
| jq -r '(.result // .) | .[]? | " \(.id // .event_id // "?") \((.content // .text // "") | tostring | .[0:80])"' 2>/dev/null \
| tee -a "$LOG_FILE" >&2 || true
if [[ -n "$needle" ]]; then
if printf '%s' "$list_raw" | jq -e --arg n "$needle" \
'(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \
>/dev/null 2>&1; then
info "$who local store contains \"$needle\" — send reached wn's own DB (kind:445 went out)"
else
warn "$who local store does NOT contain \"$needle\" — `messages send` never persisted; check stderr.log"
fi
fi
step "$who MLS group metadata (relays here are what Amethyst subscribes on)"
local meta_raw
meta_raw=$("$wnfn" --json groups show "$gid" 2>/dev/null || true)
printf ' %s\n' "${meta_raw:-<no output>}" >>"$LOG_FILE"
local meta_relays
meta_relays=$(printf '%s' "$meta_raw" \
| jq -r '(.result // .) | (.relays // .group.relays // [])[]? | (. | tostring)' 2>/dev/null \
| paste -sd',' -)
info " group relays (MLS extension): ${meta_relays:-<none — Amethyst will fall back to homeRelays>}"
local meta_name meta_epoch
meta_name=$(printf '%s' "$meta_raw" | jq -r '(.result // .) | (.name // .group.name // "?")' 2>/dev/null)
meta_epoch=$(printf '%s' "$meta_raw" | jq -r '(.result // .) | (.epoch // .group.epoch // "?")' 2>/dev/null)
info " group name: $meta_name epoch: $meta_epoch"
step "$who relay connection status right now"
"$wnfn" --json relays list 2>/dev/null \
| jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \
| tee -a "$LOG_FILE" >&2 || true
printf '%s%s---- end outbound diagnostics ----%s\n\n' \
"$C_BOLD" "$C_CYAN" "$C_RESET" >&2
printf '==== end [%s] %s outbound diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE"
}
# ------- group cleanup -------------------------------------------------------
cleanup_group() {
local gid="$1" who="${2:-B}"
[[ -z "$gid" ]] && return 0
if [[ "$who" == "B" ]]; then
wn_b groups leave "$gid" >/dev/null 2>&1 || true
else
wn_c groups leave "$gid" >/dev/null 2>&1 || true
fi
}
@@ -1,125 +0,0 @@
#!/usr/bin/env bash
#
# 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 whitenoise-rs `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
# any human prompts — all checks run to completion and the exit code
# reflects pass/fail totals.
#
# Usage: ./marmot-interop-headless.sh [--port N] [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-headless"
LOG_DIR="$STATE_DIR/logs"
A_DIR="$STATE_DIR/A"
B_DIR="$STATE_DIR/B"
C_DIR="$STATE_DIR/C"
B_SOCKET="$B_DIR/release/wnd.sock"
C_SOCKET="$C_DIR/release/wnd.sock"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
WN_REPO="${WN_REPO:-$SCRIPT_DIR/state/whitenoise-rs}"
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.
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://127.0.0.1:$RELAY_PORT"
NO_BUILD=0
A_NPUB=""
A_HEX=""
B_NPUB=""
B_HEX=""
C_NPUB=""
C_HEX=""
while [[ $# -gt 0 ]]; do
case "$1" in
--port) RELAY_PORT="$2"; RELAY_URL="ws://127.0.0.1:$RELAY_PORT"; shift ;;
--no-build) NO_BUILD=1 ;;
-h|--help)
sed -n '3,14p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
mkdir -p "$STATE_DIR" "$LOG_DIR" "$A_DIR" "$B_DIR/logs" "$C_DIR/logs"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# Reuse colours / logging / dump_daemon_diagnostics from the interactive harness.
# shellcheck source=lib.sh
source "$SCRIPT_DIR/lib.sh"
# shellcheck source=headless/setup.sh
source "$SCRIPT_DIR/headless/setup.sh"
# shellcheck source=headless/helpers.sh
source "$SCRIPT_DIR/headless/helpers.sh"
# shellcheck source=headless/tests-create.sh
source "$SCRIPT_DIR/headless/tests-create.sh"
# shellcheck source=headless/tests-manage.sh
source "$SCRIPT_DIR/headless/tests-manage.sh"
# shellcheck source=headless/tests-extras.sh
source "$SCRIPT_DIR/headless/tests-extras.sh"
# Make sure Ctrl+C / SIGTERM / SIGHUP all run the full cleanup path —
# otherwise wnd is nohup'd and keeps running after the script dies,
# and the next run's `ss` check then complains the port is in use.
# Trap INT/TERM/HUP forces `exit`, which triggers the EXIT handler once.
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP
stop_daemons
stop_local_relay
print_summary
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
banner "Marmot headless interop harness ($RUN_TS)"
preflight
start_local_relay
start_daemon B "$B_DIR" "$B_SOCKET"
start_daemon C "$C_DIR" "$C_SOCKET"
ensure_identity_a
ensure_identity B
ensure_identity C
configure_relays
test_01_keypackage_discovery
test_02_a_creates_group
test_03_b_creates_group
test_04_three_member_group
test_05_b_adds_a_existing
test_06_member_removal
test_07_metadata_rename
test_08_admin_promote_demote
test_09_reply_react_unreact
test_10_concurrent_commits
test_11_leave_group
test_12_offline_catchup
test_13_keypackage_rotation
test_14_wn_removes_a
test_15_wn_member_leaves
test_16_wn_keypackage_rotation
File diff suppressed because it is too large Load Diff