feat(marmot-interop): add zero-prompt headless harness driving amy
Matches the 13 scenarios in marmot-interop.sh but runs A via the new `amy` CLI instead of prompting the human. Identities B and C still go through `wn`/`wnd` from whitenoise-rs. Layout: - marmot-interop-headless.sh — top-level entrypoint - headless/setup.sh — preflight, daemon lifecycle, identities - headless/helpers.sh — amy wrappers + assertion helpers - headless/tests-create.sh — tests 01..05 - headless/tests-manage.sh — tests 06, 07, 08, 11 - headless/tests-extras.sh — tests 09, 10, 12, 13 Reuses lib.sh for colours, record_result, wait_for_invite/message, jq_group_id, and dump_daemon_diagnostics. Keeps the interactive marmot-interop.sh intact for final UI sign-off. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
This commit is contained in:
@@ -1 +1,2 @@
|
|||||||
state/
|
state/
|
||||||
|
state-headless/
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
# Marmot Interop Test Harness
|
# Marmot Interop Test Harness
|
||||||
|
|
||||||
Interactive harness that validates Amethyst's Marmot/MLS implementation against
|
Two flavours, same scenarios:
|
||||||
**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
|
- **`marmot-interop.sh`** — interactive. Drives B/C via `wn` and **prompts the
|
||||||
command line and prompts the human operator to perform the Amethyst-side steps
|
human** to perform each Amethyst-side step in the mobile UI (Identity A).
|
||||||
in the mobile UI as **Identity A**. Every test records a pass/fail/skip result
|
Use this for final UI verification.
|
||||||
into a tab-separated log, and the summary is printed at the end of the run.
|
- **`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.
|
||||||
|
|
||||||
|
Both 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
|
## What gets tested
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# 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; 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
|
||||||
|
|
||||||
|
# Build `amy` via gradle if missing.
|
||||||
|
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
|
||||||
|
step "building :cli:installDist"
|
||||||
|
( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE"
|
||||||
|
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 [[ ! -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
|
||||||
|
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 (~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" "$data_dir/release"
|
||||||
|
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
|
||||||
|
>"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" &
|
||||||
|
echo "$!" > "$data_dir/pid"
|
||||||
|
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_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
|
||||||
|
raw=$("$cmd" create-identity 2>&1 | tee -a "$LOG_FILE")
|
||||||
|
npub=$(extract_pubkey "$raw")
|
||||||
|
[[ -n "$npub" ]] || npub=$(extract_pubkey "$("$cmd" whoami 2>/dev/null || true)")
|
||||||
|
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 ------------------------------------------------------------------
|
||||||
|
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 A/B/C"
|
||||||
|
amy_a relay add "$r" --type all >/dev/null
|
||||||
|
for t in nip65 inbox key_package; do
|
||||||
|
wn_b relays add --type "$t" "$r" 2>/dev/null || true
|
||||||
|
wn_c relays add --type "$t" "$r" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# A advertises its NIP-65 + DM inbox lists so B/C can discover where to
|
||||||
|
# deliver gift wraps. Without this, cross-client welcomes only work
|
||||||
|
# through relay-set overlap — fine for this harness but still worth
|
||||||
|
# publishing 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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# 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"
|
||||||
|
|
||||||
|
local gid
|
||||||
|
gid=$(amy_field '.group_id' marmot group create --name "Interop-02") || {
|
||||||
|
record_result "$id" fail "create returned no group_id"; return
|
||||||
|
}
|
||||||
|
save_state GROUP_02 "$gid"
|
||||||
|
info "created group $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_json marmot message send "$gid" "hello from amethyst" >/dev/null || {
|
||||||
|
record_result "$id" fail "amy send failed"; return
|
||||||
|
}
|
||||||
|
if ! wait_for_message B "$gid" "hello from amethyst" 30; then
|
||||||
|
record_result "$id" fail "B didn't receive A's message"; return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# B -> A message
|
||||||
|
wn_b messages send "$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"
|
||||||
|
|
||||||
|
local out gid
|
||||||
|
out=$(wn_b --json groups create "Interop-03" "$A_NPUB" 2>>"$LOG_FILE") || {
|
||||||
|
record_result "$id" fail "wn groups create failed"; return
|
||||||
|
}
|
||||||
|
gid=$(printf '%s' "$out" | jq_group_id)
|
||||||
|
if [[ -z "$gid" ]]; then
|
||||||
|
record_result "$id" fail "could not parse group_id"; return
|
||||||
|
fi
|
||||||
|
save_state GROUP_03 "$gid"
|
||||||
|
info "group_id: $gid"
|
||||||
|
|
||||||
|
# A: poll until it joins.
|
||||||
|
if ! amy_json marmot await group --name "Interop-03" --timeout 30 >/dev/null; then
|
||||||
|
record_result "$id" fail "A never joined B's group"; return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# B -> A message
|
||||||
|
wn_b messages send "$gid" "ping from wn" >/dev/null 2>&1 || true
|
||||||
|
if ! amy_json marmot await message "$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 "$gid" "pong from amethyst" >/dev/null || {
|
||||||
|
record_result "$id" fail "amy send pong failed"; return
|
||||||
|
}
|
||||||
|
if wait_for_message B "$gid" "pong from amethyst" 30; 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; gid=$(load_state GROUP_02 || 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
|
||||||
|
}
|
||||||
|
if wait_for_message B "$gid" "hello three-member world" 30 \
|
||||||
|
&& wait_for_message C "$c_gid" "hello three-member world" 30; 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" 30 \
|
||||||
|
&& wait_for_message C "$gid" "joined from amethyst" 30; then
|
||||||
|
record_result "$id" pass
|
||||||
|
else
|
||||||
|
record_result "$id" fail "B or C didn't see A's message"
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# 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; gid=$(load_state GROUP_02 || 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 "$gid" 2>/dev/null \
|
||||||
|
| jq -e --arg p "$A_HEX" '.[]? | 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 "$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 // 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 "$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 "$gid" "replying via amy" 30; 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; gid=$(load_state GROUP_02 || true)
|
||||||
|
if [[ -z "${gid:-}" ]]; then
|
||||||
|
record_result "$id" skip "no GROUP_02"; return
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
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 "$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 "$gid" 2>/dev/null | jq -r '.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 "$gid" "post-race ping" >/dev/null 2>&1 || true
|
||||||
|
if amy_json marmot await message "$gid" --match "post-race ping" --timeout 15 >/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"
|
||||||
|
|
||||||
|
# Fresh group so we don't collide with other tests.
|
||||||
|
local out gid
|
||||||
|
out=$(wn_b --json groups create "Interop-12" "$A_NPUB" 2>>"$LOG_FILE")
|
||||||
|
gid=$(printf '%s' "$out" | jq_group_id)
|
||||||
|
[[ -n "$gid" ]] || { record_result "$id" fail "couldn't create Interop-12"; return; }
|
||||||
|
save_state GROUP_12 "$gid"
|
||||||
|
|
||||||
|
# A joins.
|
||||||
|
amy_json marmot await group --name "Interop-12" --timeout 30 >/dev/null || {
|
||||||
|
record_result "$id" fail "A never received Interop-12 invite"; return
|
||||||
|
}
|
||||||
|
|
||||||
|
# "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 "$gid" "offline-msg-$i" >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
wn_b groups add-members "$gid" "$C_NPUB" >/dev/null 2>&1 || true
|
||||||
|
wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true
|
||||||
|
for i in 6 7 8; do
|
||||||
|
wn_b messages send "$gid" "offline-msg-$i" >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
wn_b groups rename "$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 "$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 "$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
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# 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"
|
||||||
|
|
||||||
|
local gid; gid=$(load_state GROUP_05 || true)
|
||||||
|
if [[ -z "${gid:-}" ]]; then
|
||||||
|
record_result "$id" skip "no GROUP_05"; 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) + 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 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 "$gid" "after removing C" 30 || {
|
||||||
|
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 "$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"
|
||||||
|
|
||||||
|
local gid; gid=$(load_state GROUP_02 || true)
|
||||||
|
if [[ -z "${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) + 60 )) seen=""
|
||||||
|
while [[ $(date +%s) -lt $deadline ]]; do
|
||||||
|
seen=$(wn_b --json groups show "$gid" 2>/dev/null | jq -r '.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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Now B renames back and A should pick it up.
|
||||||
|
wn_b groups rename "$gid" "Interop-02-reverse" >/dev/null 2>&1 || true
|
||||||
|
if amy_json marmot await rename "$gid" --name "Interop-02-reverse" --timeout 60 >/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"
|
||||||
|
|
||||||
|
local gid; gid=$(load_state GROUP_03 || true)
|
||||||
|
if [[ -z "${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 "$gid" "$C_NPUB" >/dev/null 2>&1 || true
|
||||||
|
wait_for_invite C 30 >/dev/null && wn_c groups accept "$gid" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# B promotes A.
|
||||||
|
wn_b groups promote "$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 "$gid" "$A_NPUB" --timeout 30 >/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 "$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 "$gid" "$A_NPUB" >/dev/null 2>&1 || warn "demote returned nonzero"
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
local admins
|
||||||
|
admins=$(wn_b --json groups admins "$gid" 2>/dev/null \
|
||||||
|
| jq -r '.[].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; gid=$(load_state GROUP_02 || 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) + 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
|
||||||
|
record_result "$id" pass
|
||||||
|
else
|
||||||
|
record_result "$id" fail "A still in B's member list after leave"
|
||||||
|
fi
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# marmot-interop-headless.sh — zero-prompt interop harness.
|
||||||
|
#
|
||||||
|
# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and
|
||||||
|
# Identities B/C via whitenoise-rs `wn`/`wnd`. 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 [--local-relays] [--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"
|
||||||
|
|
||||||
|
DEFAULT_RELAYS=( "wss://relay.damus.io" "wss://nos.lol" "wss://relay.primal.net" )
|
||||||
|
USE_LOCAL_RELAYS=0
|
||||||
|
NO_BUILD=0
|
||||||
|
|
||||||
|
A_NPUB=""
|
||||||
|
A_HEX=""
|
||||||
|
B_NPUB=""
|
||||||
|
B_HEX=""
|
||||||
|
C_NPUB=""
|
||||||
|
C_HEX=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--local-relays) USE_LOCAL_RELAYS=1 ;;
|
||||||
|
--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" "$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"
|
||||||
|
|
||||||
|
trap 'stop_daemons; print_summary' EXIT
|
||||||
|
|
||||||
|
banner "Marmot headless interop harness ($RUN_TS)"
|
||||||
|
preflight
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user