From 7107cecedcb9da116b7182730ec8bad4c7f78a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 13:56:29 +0000 Subject: [PATCH] test: add Marmot interop harness driving whitenoise-rs CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive bash script under tools/marmot-interop/ that validates Amethyst's Marmot/MLS implementation against the whitenoise-rs wn/wnd CLI. Builds wn/wnd from source on first run, launches two daemons for Identities B and C, configures public (or --local-relays) Nostr relays shared with Amethyst, then walks a human operator through 13 scenarios covering MIP-00 KeyPackages, MIP-01 metadata, MIP-02 Welcome, MIP-03 group messages, admin changes, reactions, concurrent-commit race, leave, offline catch-up, and KeyPackage rotation. Push-notification (MIP-05) test opt-in via --transponder. No Amethyst code is modified — black-box testing only. State (daemon sockets, logs, whitenoise-rs checkout, results TSVs) is kept under tools/marmot-interop/state/ and gitignored. https://claude.ai/code/session_01PacmbE1KKWMSRmDw3bnCNo --- tools/marmot-interop/.gitignore | 1 + tools/marmot-interop/README.md | 184 ++++++ tools/marmot-interop/lib.sh | 256 ++++++++ tools/marmot-interop/marmot-interop.sh | 809 +++++++++++++++++++++++++ 4 files changed, 1250 insertions(+) create mode 100644 tools/marmot-interop/.gitignore create mode 100644 tools/marmot-interop/README.md create mode 100644 tools/marmot-interop/lib.sh create mode 100755 tools/marmot-interop/marmot-interop.sh diff --git a/tools/marmot-interop/.gitignore b/tools/marmot-interop/.gitignore new file mode 100644 index 000000000..dc3b76b28 --- /dev/null +++ b/tools/marmot-interop/.gitignore @@ -0,0 +1 @@ +state/ diff --git a/tools/marmot-interop/README.md b/tools/marmot-interop/README.md new file mode 100644 index 000000000..b382e79d2 --- /dev/null +++ b/tools/marmot-interop/README.md @@ -0,0 +1,184 @@ +# Marmot Interop Test Harness + +Interactive harness that validates Amethyst's Marmot/MLS implementation against +**whitenoise-rs** (https://github.com/marmot-protocol/whitenoise-rs), the +reference Rust implementation that powers the White Noise Flutter app. + +The harness drives two `wn` daemons (Identities **B** and **C**) from the +command line and prompts the human operator to perform the Amethyst-side steps +in the mobile UI as **Identity A**. 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 | – | +| 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 ``, + 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://: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-.log`** — every step, assertion, and human + prompt, with the exact `wn` stdout that was parsed. +- **`state/results-.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. diff --git a/tools/marmot-interop/lib.sh b/tools/marmot-interop/lib.sh new file mode 100644 index 000000000..2be815505 --- /dev/null +++ b/tools/marmot-interop/lib.sh @@ -0,0 +1,256 @@ +# 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 [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 . +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 "${ans,,}" 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" "$@"; } +wn_c() { "$WN_BIN" --socket "$C_SOCKET" "$@"; } + +# 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 +} + +# ------- polling helpers ----------------------------------------------------- + +# wait_for_invite +# Echoes the first new group_id that appears in `wn groups invites --json`. +# Returns 1 on timeout. +wait_for_invite() { + local who="$1" timeout="${2:-60}" deadline gid + deadline=$(( $(date +%s) + timeout )) + while [[ $(date +%s) -lt $deadline ]]; do + if [[ "$who" == "B" ]]; then + gid=$(wn_b_json groups invites 2>/dev/null \ + | jq -r '.[0].group_id // .[0].mls_group_id // empty' 2>/dev/null || true) + else + gid=$(wn_c_json groups invites 2>/dev/null \ + | jq -r '.[0].group_id // .[0].mls_group_id // empty' 2>/dev/null || true) + fi + if [[ -n "${gid:-}" ]]; then + printf '%s\n' "$gid" + return 0 + fi + sleep 2 + done + return 1 +} + +# wait_for_message +# 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" \ + '.[]? | select((.content // .text // "") | contains($n))' \ + >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +# wait_for_member +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" \ + '.[]? | select((.pubkey // .public_key // "") == $p)' \ + >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +# ------- npub <-> hex conversion helpers ------------------------------------- +# whitenoise's `wn` accepts both, but we use hex internally for JSON comparisons. +# We rely on `wn debug` (or fall back to a python one-liner) — but to keep zero +# extra deps we do the conversion via wn itself by round-tripping through +# `wn users show ` which prints both forms. +npub_to_hex() { + local npub="$1" + wn_b --json users show "$npub" 2>/dev/null \ + | jq -r '.pubkey // .public_key // empty' 2>/dev/null \ + || 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- +} + +# ------- 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 +} diff --git a/tools/marmot-interop/marmot-interop.sh b/tools/marmot-interop/marmot-interop.sh new file mode 100755 index 000000000..4785643d8 --- /dev/null +++ b/tools/marmot-interop/marmot-interop.sh @@ -0,0 +1,809 @@ +#!/usr/bin/env bash +# +# marmot-interop.sh — interop test harness: Amethyst <-> whitenoise-rs (wn/wnd) +# +# Sequential, all-or-nothing. Script drives the `wn` side automatically and +# prompts the human operator at each step that requires Amethyst UI action. +# +# Usage: ./marmot-interop.sh [--local-relays] [--transponder] [--no-build] +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +STATE_DIR="$SCRIPT_DIR/state" +LOG_DIR="$STATE_DIR/logs" +B_DIR="$STATE_DIR/B" +C_DIR="$STATE_DIR/C" +B_SOCKET="$B_DIR/wnd.sock" +C_SOCKET="$C_DIR/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:-$STATE_DIR/whitenoise-rs}" +WN_BIN="" +WND_BIN="" + +DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" "wss://relay.primal.net" ) +USE_LOCAL_RELAYS=0 +ENABLE_TRANSPONDER=0 +NO_BUILD=0 + +usage() { + cat < whitenoise-rs interop harness + +Options: + --local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up') + --transponder Run Test 14 (MIP-05 push notifications) + --no-build Don't rebuild wn/wnd if binaries are missing + -h, --help Show this help + +Environment: + WN_REPO Path to whitenoise-rs checkout (default: state/whitenoise-rs) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --local-relays) USE_LOCAL_RELAYS=1 ;; + --transponder) ENABLE_TRANSPONDER=1 ;; + --no-build) NO_BUILD=1 ;; + -h|--help) usage; exit 0 ;; + *) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;; + esac + shift +done + +mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs" +: >"$LOG_FILE" +: >"$RESULTS_FILE" + +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +# --- preflight --------------------------------------------------------------- +preflight() { + banner "Preflight" + for cmd in jq git curl 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 + + WN_BIN="$WN_REPO/target/release/wn" + WND_BIN="$WN_REPO/target/release/wnd" + + if [[ ! -x "$WN_BIN" || ! -x "$WND_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "wn/wnd not found and --no-build set: $WN_BIN"; exit 1 + fi + if [[ ! -d "$WN_REPO/.git" ]]; then + 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 + step "building wn + wnd (cargo build --release --features cli) — ~5 min first run" + ( cd "$WN_REPO" && cargo build --release --features cli --bin wn --bin wnd ) \ + 2>&1 | tee -a "$LOG_FILE" + fi + info "wn: $WN_BIN" + info "wnd: $WND_BIN" +} + +# --- 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" + mkdir -p "$data_dir/logs" + nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" --socket "$socket" \ + >"$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..." + 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 + sleep 1 + done + fail_msg "$name daemon failed to start (see $data_dir/logs/stderr.log)" + 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() { + local who="$1" cmd + if [[ "$who" == "B" ]]; then cmd=wn_b; else cmd=wn_c; fi + step "ensuring Identity $who" + local npub + npub=$("$cmd" --json whoami 2>/dev/null | jq -r '.pubkey // .npub // empty' 2>/dev/null || true) + if [[ -z "${npub:-}" || "$npub" == "null" ]]; then + info "creating new identity for $who" + "$cmd" create-identity 2>&1 | tee -a "$LOG_FILE" >&2 + npub=$("$cmd" --json whoami 2>/dev/null | jq -r '.pubkey // .npub // empty' || true) + fi + if [[ -z "${npub:-}" ]]; then + fail_msg "could not determine npub for $who"; exit 1 + fi + local hex + hex=$("$cmd" --json users show "$npub" 2>/dev/null | jq -r '.pubkey // .public_key // .hex // empty' 2>/dev/null || true) + [[ -z "$hex" ]] && hex="$npub" + if [[ "$who" == "B" ]]; then B_NPUB="$npub"; B_HEX="$hex" + else C_NPUB="$npub"; C_HEX="$hex"; fi + save_state "${who}_NPUB" "$npub" + save_state "${who}_HEX" "$hex" + info "$who npub: $npub" +} + +prompt_for_a_npub() { + A_NPUB=$(load_state "A_NPUB" || true) + if [[ -n "${A_NPUB:-}" ]]; then + info "A npub (cached): $A_NPUB" + else + printf '\n%sPaste your Amethyst account npub:%s ' "$C_CYAN" "$C_RESET" >&2 + read -r A_NPUB + save_state "A_NPUB" "$A_NPUB" + fi + A_HEX=$(wn_b --json users show "$A_NPUB" 2>/dev/null \ + | jq -r '.pubkey // .public_key // .hex // empty' 2>/dev/null || true) + [[ -z "$A_HEX" ]] && A_HEX="$A_NPUB" + save_state "A_HEX" "$A_HEX" + info "A hex: $A_HEX" +} + +# --- relays ------------------------------------------------------------------ +configure_relays() { + banner "Configuring relays" + local relays=() + if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then + relays=( "ws://localhost:8080" ) + else + relays=( "${DEFAULT_RELAYS[@]}" ) + fi + for r in "${relays[@]}"; do + step "adding $r to both daemons" + wn_b relays add "$r" >/dev/null 2>&1 || warn "wn_b relays add $r failed (may already exist)" + wn_c relays add "$r" >/dev/null 2>&1 || warn "wn_c relays add $r failed (may already exist)" + done + + step "sanity-check: B publishes KP, C fetches it" + wn_b keys publish >/dev/null 2>&1 || warn "wn_b keys publish returned nonzero" + sleep 4 + if wn_c --json keys check "$B_NPUB" 2>/dev/null | jq -e '.event_id // empty' >/dev/null 2>&1; then + info "sanity OK — relay round-trip works" + else + warn "sanity-check failed — public relays may be rejecting kind 30443." + warn "Consider rerunning with --local-relays (requires 'just docker-up' in whitenoise-rs)." + fi +} + +instruct_amethyst_setup() { + local list + if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then + list=" ws://10.0.2.2:8080 (Android emulator) + ws://:8080 (physical device on same Wi-Fi)" + else + list=$(printf ' %s\n' "${DEFAULT_RELAYS[@]}") + fi + prompt_human "Configure Amethyst to match this harness: + 1. Settings -> Relays: add the following as READ+WRITE +$list + 2. Settings -> Key Package Relays: add the SAME URLs + 3. Trigger key-package publish (toggle KP relay on/off if needed) + 4. Confirm your Amethyst account is logged in with npub: $A_NPUB" +} + +# ==== tests ================================================================== + +test_01_keypackage_discovery() { + banner "Test 01 — KeyPackage publish & discovery (MIP-00)" + + step "B fetches A's KeyPackage from relays" + if wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -e '.event_id // empty' >/dev/null 2>&1; then + pass_msg "B discovered A's KeyPackage" + record_result "01a KeyPackage A->B" pass + else + fail_msg "B cannot find A's KeyPackage. Did Amethyst publish to the same relays?" + record_result "01a KeyPackage A->B" fail "wn keys check returned no event_id" + fi + + prompt_human "In Amethyst: open 'Create Group' (or 'Add Member' on an existing group). + Type or paste: $B_NPUB + Amethyst should resolve the npub and enable 'Add' (KeyPackage discovered)." + if confirm "Does Amethyst resolve $B_NPUB as having a valid KeyPackage?"; then + record_result "01b KeyPackage B->A" pass + else + record_result "01b KeyPackage B->A" fail "Amethyst did not resolve B" + fi +} + +test_02_amethyst_creates_group() { + banner "Test 02 — Amethyst creates group, invites B" + + prompt_human "In Amethyst: + 1. Tap '+' -> Create Group + 2. Name: Interop-02 + 3. Add member: $B_NPUB + 4. Tap Create / Send Invite" + + step "polling B's daemon for invite (60s)" + local gid + if ! gid=$(wait_for_invite B 60); then + fail_msg "no invite arrived at B" + record_result "02 Amethyst->B create+invite" fail "invite never arrived" + return + fi + save_state "GROUP_02" "$gid" + info "group_id: $gid" + + step "B accepts invite" + if ! wn_b groups accept "$gid" >/dev/null 2>&1; then + fail_msg "B failed to accept invite" + record_result "02 Amethyst->B create+invite" fail "accept failed" + return + fi + + step "verifying member list on B" + local members + members=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '.[].pubkey // .[].public_key' 2>/dev/null | sort | tr '\n' ' ') + if ! expect_contains "$members" "$A_HEX" "member list" \ + || ! expect_contains "$members" "$B_HEX" "member list"; then + record_result "02 Amethyst->B create+invite" fail "member list mismatch" + return + fi + + prompt_human "In Amethyst, send this message in group 'Interop-02': + hello from amethyst" + + step "B waits for the message (30s)" + if wait_for_message B "$gid" "hello from amethyst" 30; then + pass_msg "B decrypted message from Amethyst" + else + fail_msg "B never saw 'hello from amethyst'" + record_result "02 Amethyst->B create+invite" fail "inbound message missing" + return + fi + + step "B sends reply" + wn_b messages send "$gid" "hello from wn" >/dev/null 2>&1 || warn "send returned nonzero" + + if confirm "Does Amethyst now show 'hello from wn' in the same group?"; then + record_result "02 Amethyst->B create+invite" pass + else + record_result "02 Amethyst->B create+invite" fail "Amethyst did not display reply" + fi +} + +test_03_wn_creates_group() { + banner "Test 03 — B creates group, invites Amethyst" + + step "B creates group 'Interop-03' with A as sole invitee" + local out gid + out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>&1 | tee -a "$LOG_FILE") + gid=$(printf '%s' "$out" | jq -r '.group_id // .mls_group_id // empty' 2>/dev/null || true) + if [[ -z "$gid" ]]; then + fail_msg "could not parse group_id from 'wn groups create' output" + record_result "03 B->Amethyst create+invite" fail "no group_id" + return + fi + save_state "GROUP_03" "$gid" + info "group_id: $gid" + + prompt_human "In Amethyst, you should see a new pending group invite 'Interop-03' from $B_NPUB. + Accept the invite and open the chat." + if ! confirm "Did Amethyst accept and open group 'Interop-03'?"; then + record_result "03 B->Amethyst create+invite" fail "Amethyst did not accept invite" + return + fi + + step "B sends: 'ping from wn'" + wn_b messages send "$gid" "ping from wn" >/dev/null 2>&1 || true + + if ! confirm "Does Amethyst show 'ping from wn'?"; then + record_result "03 B->Amethyst create+invite" fail "Amethyst did not show wn message" + return + fi + + prompt_human "In Amethyst, send: 'pong from amethyst'" + if wait_for_message B "$gid" "pong from amethyst" 30; then + record_result "03 B->Amethyst create+invite" pass + else + record_result "03 B->Amethyst create+invite" fail "B did not receive pong" + fi +} + +test_04_three_member_group() { + banner "Test 04 — 3-member group, add C after create" + + step "C publishes KeyPackage so A can discover it" + wn_c keys publish >/dev/null 2>&1 || true + sleep 4 + + local gid + gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + warn "GROUP_02 not set (Test 02 likely skipped/failed); creating new group here" + local out + out=$(wn_b --json groups create "Interop-04-bootstrap" "$A_NPUB" 2>&1 | tee -a "$LOG_FILE") + gid=$(printf '%s' "$out" | jq -r '.group_id // .mls_group_id // empty') + save_state GROUP_02 "$gid" + prompt_human "In Amethyst, accept the new group invite 'Interop-04-bootstrap'." + fi + info "using group $gid" + + prompt_human "In Amethyst, open the group from Test 02 (or 'Interop-04-bootstrap'). + Group Info -> Add Member -> paste: $C_NPUB + Confirm the invite is sent." + + step "polling C for invite (60s)" + local c_gid + if ! c_gid=$(wait_for_invite C 60); then + fail_msg "C never received invite" + record_result "04 3-member add-after-create" fail "invite to C missing" + return + fi + wn_c groups accept "$c_gid" >/dev/null 2>&1 || true + info "C accepted invite ($c_gid)" + + step "verifying all three members on all three clients" + local members_b + members_b=$(wn_b --json groups members "$gid" 2>/dev/null | jq -r '.[].pubkey // .[].public_key' | tr '\n' ' ') + expect_contains "$members_b" "$A_HEX" "B's member list" || : + expect_contains "$members_b" "$B_HEX" "B's member list" || : + expect_contains "$members_b" "$C_HEX" "B's member list" || : + + prompt_human "In Amethyst, send: 'hello three-member world'" + + if wait_for_message B "$gid" "hello three-member world" 30 \ + && wait_for_message C "$c_gid" "hello three-member world" 30; then + pass_msg "both B and C decrypted Amethyst's message after Add" + record_result "04 3-member add-after-create" pass + else + fail_msg "B or C did not see Amethyst's post-Add message" + record_result "04 3-member add-after-create" fail "message not received after Add" + fi +} + +test_05_wn_adds_amethyst_existing() { + banner "Test 05 — wn adds Amethyst to existing B+C group" + + step "B creates group with only C, then adds A" + local out gid + out=$(wn_b --json groups create "Interop-05" "$C_NPUB" 2>&1 | tee -a "$LOG_FILE") + gid=$(printf '%s' "$out" | jq -r '.group_id // .mls_group_id // empty') + if [[ -z "$gid" ]]; then + fail_msg "could not create Interop-05" + record_result "05 wn adds Amethyst existing" fail "create failed" + return + fi + save_state GROUP_05 "$gid" + if wait_for_invite C 30 >/dev/null; then + wn_c groups accept "$gid" >/dev/null 2>&1 || true + fi + + step "B adds A to the group" + wn_b groups add-members "$gid" "$A_NPUB" >/dev/null 2>&1 || { + fail_msg "wn groups add-members failed" + record_result "05 wn adds Amethyst existing" fail "add-members failed" + return + } + + prompt_human "In Amethyst, you should see a new invite 'Interop-05'. + Accept and open the chat." + + prompt_human "In Amethyst, send: 'joined from amethyst'" + if wait_for_message B "$gid" "joined from amethyst" 30 \ + && wait_for_message C "$gid" "joined from amethyst" 30; then + record_result "05 wn adds Amethyst existing" pass + else + record_result "05 wn adds Amethyst existing" fail "message not seen by B or C" + fi +} + +test_06_member_removal() { + banner "Test 06 — Member removal, forward secrecy at next epoch" + + local gid + gid=$(load_state GROUP_05 || true) + if [[ -z "${gid:-}" ]]; then + skip_msg "Test 06 requires Test 05 state"; record_result "06 member removal" skip "no GROUP_05"; return + fi + + prompt_human "In Amethyst, open group 'Interop-05' -> Group Info -> Members. + Tap C ($C_NPUB) -> Remove. + Confirm the removal." + + step "verifying C is removed (60s poll)" + local deadline=$(( $(date +%s) + 60 )) removed=0 + while [[ $(date +%s) -lt $deadline ]]; do + if ! wn_c --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$C_HEX" '.[]? | 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 to be a member; proceeding anyway" + fi + + prompt_human "In Amethyst, send: 'after removing C'" + if wait_for_message B "$gid" "after removing C" 30; then + info "B still receives messages (expected)" + else + fail_msg "B stopped receiving messages (unexpected)" + record_result "06 member removal" fail "B lost access"; return + fi + + # C should NOT be able to decrypt the new message + sleep 5 + if wait_for_message C "$gid" "after removing C" 10; then + fail_msg "C still decrypted a post-removal message — forward secrecy broken" + record_result "06 member removal" fail "forward secrecy broken" + else + pass_msg "C correctly cannot read post-removal messages" + record_result "06 member removal" pass + fi +} + +test_07_metadata_rename() { + banner "Test 07 — Group metadata rename round-trip (MIP-01)" + + local gid + gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + skip_msg "no GROUP_02"; record_result "07 metadata rename" skip "no group state"; return + fi + + prompt_human "In Amethyst, open group 'Interop-02' -> Group Info -> Edit. + Rename to: Interop-02-renamed + Save." + + step "polling B for name change (60s)" + local deadline=$(( $(date +%s) + 60 )) name="" + while [[ $(date +%s) -lt $deadline ]]; do + name=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.name // empty') + [[ "$name" == "Interop-02-renamed" ]] && break + sleep 3 + done + if [[ "$name" == "Interop-02-renamed" ]]; then + pass_msg "B sees renamed group" + else + fail_msg "B still sees name: $name" + record_result "07 metadata rename" fail "rename did not propagate to B"; return + fi + + step "B renames back" + wn_b groups rename "$gid" "Interop-02-reverse" >/dev/null 2>&1 || true + + if confirm "Does Amethyst now show the group name as 'Interop-02-reverse'?"; then + record_result "07 metadata rename" pass + else + record_result "07 metadata rename" fail "Amethyst did not pick up rename" + fi +} + +test_08_admin_promote_demote() { + banner "Test 08 — Admin promote/demote" + + local gid + gid=$(load_state GROUP_03 || true) + if [[ -z "${gid:-}" ]]; then + skip_msg "no GROUP_03"; record_result "08 admin promote/demote" skip "no group"; return + fi + + step "B (creator+admin) adds C so we have 3 members" + wn_c keys publish >/dev/null 2>&1 || true + sleep 3 + wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || warn "add-members C returned nonzero" + if wait_for_invite C 30 >/dev/null; then + wn_c groups accept "$gid" >/dev/null 2>&1 || true + fi + + step "B promotes A to admin" + if ! wn_b groups promote "$gid" "$A_NPUB" >/dev/null 2>&1; then + fail_msg "promote failed" + record_result "08 admin promote/demote" fail "promote returned nonzero"; return + fi + + step "verifying admin list contains both B and A" + local admins + admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '.[].pubkey // .[].public_key // .[]' | tr '\n' ' ') + if expect_contains "$admins" "$A_HEX" "admin list" && expect_contains "$admins" "$B_HEX" "admin list"; then + pass_msg "admin promote worked" + else + record_result "08 admin promote/demote" fail "admin list missing A or B"; return + fi + + step "B demotes A" + wn_b groups demote "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero" + sleep 5 + admins=$(wn_b --json groups admins "$gid" 2>/dev/null | jq -r '.[].pubkey // .[].public_key // .[]' | tr '\n' ' ') + if [[ "$admins" != *"$A_HEX"* ]]; then + pass_msg "demote removed A" + record_result "08 admin promote/demote" pass + else + record_result "08 admin promote/demote" fail "A still admin after demote" + fi +} + +test_09_reply_react_unreact() { + banner "Test 09 — Reply / react / unreact (inner event kinds 9, 7)" + + local gid + gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + skip_msg "no GROUP_02"; record_result "09 reply/react" skip "no group"; return + fi + + step "B sends anchor message" + wn_b messages send "$gid" "anchor for reactions" >/dev/null 2>&1 || true + sleep 3 + local msg_id + msg_id=$(wn_b --json messages list "$gid" --limit 10 2>/dev/null \ + | jq -r '[.[]? | select((.content // .text // "") == "anchor for reactions")][0].id // .[0].event_id // empty' 2>/dev/null || true) + if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then + fail_msg "could not find anchor message id" + record_result "09 reply/react" fail "anchor id missing"; return + fi + info "anchor id: $msg_id" + + step "B reacts with 🌮 and then unreacts" + wn_b messages react "$gid" "$msg_id" "🌮" >/dev/null 2>&1 || true + sleep 3 + if ! confirm "Does Amethyst show 🌮 reaction on 'anchor for reactions'?"; then + record_result "09 reply/react" fail "reaction not visible in Amethyst"; return + fi + wn_b messages unreact "$gid" "$msg_id" >/dev/null 2>&1 || true + sleep 3 + if ! confirm "Did Amethyst remove the 🌮 reaction?"; then + record_result "09 reply/react" fail "unreact not visible"; return + fi + + step "B replies to the anchor" + wn_b messages send "$gid" "replying via wn" --reply_to "$msg_id" >/dev/null 2>&1 || true + sleep 3 + if confirm "Does Amethyst show 'replying via wn' as a threaded reply to the anchor?"; then + record_result "09 reply/react" pass + else + record_result "09 reply/react" fail "reply not threaded" + fi +} + +test_10_concurrent_commits() { + banner "Test 10 — Concurrent commits (race)" + + local gid + gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + skip_msg "no GROUP_02"; record_result "10 concurrent commits" skip "no group"; return + fi + + prompt_human "Get ready to trigger two conflicting commits SIMULTANEOUSLY. + In Amethyst: open group 'Interop-02' -> Group Info -> Edit name. + Type a new name (e.g. 'race-from-amethyst') but DO NOT SAVE YET. + Press here; the script will immediately issue a conflicting rename from B. + Then within 1 second tap SAVE in Amethyst." + + ( sleep 0; wn_b groups rename "$gid" "race-from-wn" >/dev/null 2>&1 ) & + local pid=$! + info "issued wn rename (pid $pid). Tap SAVE in Amethyst NOW." + wait "$pid" 2>/dev/null || true + + step "sleeping 10s for both sides to converge" + sleep 10 + + step "verifying B's view is consistent (name + epoch)" + local b_view + b_view=$(wn_b --json groups show "$gid" 2>/dev/null | jq '{name, epoch}' 2>/dev/null || echo "{}") + info "B state: $b_view" + + prompt_human "Read the group name now shown in Amethyst." + if confirm "Do Amethyst and wn (B view above) agree on the same group name?"; then + step "post-race message exchange" + wn_b messages send "$gid" "post-race ping" >/dev/null 2>&1 || true + if confirm "Does Amethyst show 'post-race ping'? (proves encryption still works)"; then + record_result "10 concurrent commits" pass + else + record_result "10 concurrent commits" fail "encryption broken after race" + fi + else + record_result "10 concurrent commits" fail "diverged state after race" + fi +} + +test_11_leave_group() { + banner "Test 11 — Leave group" + + local gid + gid=$(load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + skip_msg "no GROUP_02"; record_result "11 leave group" skip "no group"; return + fi + + prompt_human "In Amethyst, open group 'Interop-02' -> Group Info -> Leave Group. + Confirm the action." + + step "verifying B's member list no longer contains A (60s poll)" + local deadline=$(( $(date +%s) + 60 )) gone=0 + while [[ $(date +%s) -lt $deadline ]]; do + if ! wn_b --json groups members "$gid" 2>/dev/null \ + | jq -e --arg p "$A_HEX" '.[]? | select((.pubkey // .public_key) == $p)' >/dev/null 2>&1; then + gone=1; break + fi + sleep 3 + done + if [[ "$gone" -eq 1 ]]; then + pass_msg "A removed from B's view of the group" + record_result "11 leave group" pass + else + record_result "11 leave group" fail "A still in member list after leave" + fi +} + +test_12_offline_catchup() { + banner "Test 12 — Offline catch-up (replay multiple commits)" + + # Use a fresh group for cleanliness + wn_c keys publish >/dev/null 2>&1 || true + sleep 3 + local out gid + out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>&1 | tee -a "$LOG_FILE") + gid=$(printf '%s' "$out" | jq -r '.group_id // .mls_group_id // empty') + if [[ -z "$gid" ]]; then + fail_msg "could not create Interop-12"; record_result "12 offline catchup" fail "create failed"; return + fi + save_state GROUP_12 "$gid" + + prompt_human "In Amethyst, accept the new invite 'Interop-12' and confirm you see the group." + + prompt_human "Now force-Amethyst offline: + - Turn on Airplane Mode, OR + - Force-stop the app from Android settings, OR + - On emulator: adb shell svc wifi disable + Press when Amethyst is offline." + + step "B sends 5 messages" + for i in 1 2 3 4 5; do + wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true + sleep 1 + done + + step "B adds C, then sends 3 more messages" + wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true + if wait_for_invite C 30 >/dev/null; then + wn_c groups accept "$gid" >/dev/null 2>&1 || true + fi + for i in 6 7 8; do + wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true + sleep 1 + done + + step "B renames the group" + wn_b groups rename "$gid" "Interop-12-renamed" >/dev/null 2>&1 || true + + prompt_human "Now bring Amethyst back online and open group 'Interop-12-renamed'. + Wait ~10 seconds for replay." + + if confirm "Does Amethyst show all 8 messages (offline-msg-1..8) AND the new name 'Interop-12-renamed' AND C as a member?"; then + record_result "12 offline catchup" pass + else + record_result "12 offline catchup" fail "replay incomplete" + fi +} + +test_13_keypackage_rotation() { + banner "Test 13 — KeyPackage rotation" + + step "B records A's current KP event id" + local before + before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null | jq -r '.event_id // empty') + info "A KP before rotation: $before" + if [[ -z "$before" ]]; then + fail_msg "no existing KP for A"; record_result "13 keypackage rotation" fail "no KP found"; return + fi + + prompt_human "In Amethyst, trigger a KeyPackage rotation: + Option 1: Settings -> Key Package Relays -> toggle OFF all KP relays, then toggle ON. + Option 2: Restart the Amethyst app (KeyPackageRotationManager runs at startup). + Press when Amethyst has published a new KP." + + step "waiting for a new KP event id at B (60s)" + 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 '.event_id // empty') + [[ -n "$after" && "$after" != "$before" ]] && break + sleep 3 + done + if [[ -n "$after" && "$after" != "$before" ]]; then + pass_msg "A's KP rotated: $before -> $after" + record_result "13 keypackage rotation" pass + else + record_result "13 keypackage rotation" fail "no new KP event_id observed" + fi +} + +test_14_push_notifications() { + banner "Test 14 — Push notifications (MIP-05)" + + if [[ "$ENABLE_TRANSPONDER" -ne 1 ]]; then + skip_msg "use --transponder to enable" + record_result "14 push notifications" skip "not requested" + return + fi + + local gid + gid=$(load_state GROUP_12 || load_state GROUP_02 || true) + if [[ -z "${gid:-}" ]]; then + skip_msg "no group state available"; record_result "14 push notifications" skip "no group"; return + fi + + step "B subscribes to notifications" + wn_b notifications subscribe "$gid" >/dev/null 2>&1 || warn "notifications subscribe returned nonzero" + + prompt_human "In Amethyst, send: 'ping for push'" + sleep 5 + if confirm "Did B's transponder instance receive a push notification log entry (check $B_DIR/logs/stderr.log)?"; then + record_result "14 push notifications" pass + else + record_result "14 push notifications" fail "no push observed" + fi +} + +# ==== main =================================================================== + +main() { + trap 'stop_daemons; print_summary' EXIT + banner "Amethyst <-> whitenoise-rs interop harness ($RUN_TS)" + + preflight + start_daemon B "$B_DIR" "$B_SOCKET" + start_daemon C "$C_DIR" "$C_SOCKET" + ensure_identity B + ensure_identity C + prompt_for_a_npub + configure_relays + instruct_amethyst_setup + + test_01_keypackage_discovery + test_02_amethyst_creates_group + test_03_wn_creates_group + test_04_three_member_group + test_05_wn_adds_amethyst_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_push_notifications +} + +main "$@"