test(marmot): read the JSON shapes MDK 0.9.x actually emits

The harness was parsing a wire format `wn` no longer speaks, and every
mismatch failed silently as "nothing arrived".

`wn --json groups invites` now answers
`{"ok":true,"result":{"account_id":…,"invites":[…],"npub":…}}`. The
harness iterated `(.result // .) | .[]?`, which walks that object's three
VALUES — two strings and an array — so `jq_group_id` matched nothing on
every poll and the pending count printed 3 forever. Test 02 reported "B
never received invite" for welcomes that had in fact arrived.

Named collections moved the same way (`members`, `admins`, `messages`),
per-entry id fields were renamed (`member_id`, `admin_id`,
`message_id`), the decrypted body is `plaintext` rather than `content`,
the group display name lives in the profile component
(`.group.profile.name`), and `keys check` nests its event id under
`.key_package`.

Adds two helpers so this is fixed in one place rather than at 20 call
sites: `jq_list <name>` peels the envelope and names the collection, and
`jq_member_ids` reads whichever id field the collection uses. `jq_group_id`
now searches `.result.group_id`, `.result.group.group_id` and the bare
element shape, keeping the older serde encodings so a run against an
older `wn` still reports a real mismatch instead of an empty string.

Also fixes test 05, which fed wn's MLS group id to `amy marmot message
send`; amy indexes by the MIP-01 nostr_group_id, which it only learns
from its own `await group`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
Claude
2026-09-09 03:21:11 +00:00
parent 36c73fd654
commit cfd44c2108
4 changed files with 100 additions and 46 deletions
+60 -28
View File
@@ -145,36 +145,66 @@ expect_contains() {
# ------- 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)
# Extract an MLS group id as lowercase hex from `wn --json` output.
#
# MDK 0.9.x settled on one envelope, `{"ok":true,"result":{...}}`, but the
# group id sits at a different place per verb:
# groups create -> .result.group_id
# groups accept / rename -> .result.group.group_id
# groups invites[] -> .group_id (an element, already peeled)
# groups members/admins -> .result.group_id
# Older builds wrapped the id as a serde `{"value":{"vec":[...]}}` struct or a
# bare byte array; both are still decoded so a run against an older `wn` binary
# reports a real mismatch instead of an empty string.
#
# Input: JSON on stdin. Optional 1st arg overrides the field name.
jq_group_id() {
local field="${1:-mls_group_id}"
local field="${1:-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
def as_hex:
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;
[ (.result? // empty), (.result?.group? // empty), (.group? // empty), . ]
| map(select(type == "object") | .[$f]? // empty | as_hex)
| map(select(. != null and . != ""))
| first // empty
' 2>/dev/null || true
}
# Peel MDK 0.9.x's `{"ok":true,"result":{...}}` envelope and hand back the
# named collection as a JSON array. MDK moved every list one level in and gave
# it a name (`invites`, `members`, `admins`, `messages`), so a bare
# `(.result // .) | .[]?` now iterates the RESULT OBJECT'S VALUES — three
# scalars where the harness expected invite objects. That failure is silent:
# every poll simply never matches, and the test reports "never received
# invite" for a welcome that arrived and was accepted.
#
# Input: JSON on stdin, collection name as $1.
jq_list() {
local name="$1"
jq -c --arg n "$name" '
[ (.result?[$n]? // empty), (.[$n]? // empty), (.result? // empty), . ]
| map(select(type == "array"))
| (first // [])
| .[]
' 2>/dev/null || true
}
# npub or hex pubkey of one member/admin entry. MDK names the field per
# collection: members carry `member_id`, admins carry `admin_id`.
jq_member_ids() {
jq -r '.member_id? // .admin_id? // .pubkey? // .public_key? // empty' 2>/dev/null || true
}
# ------- polling helpers -----------------------------------------------------
# Snapshot currently-pending invites on <B|C> as a comma-separated list of
@@ -197,7 +227,7 @@ snapshot_invites() {
local g
g=$(printf '%s' "$one" | jq_group_id)
[[ -n "$g" ]] && gids+=("$g")
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
done < <(printf '%s' "$raw" | jq_list invites)
# 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
@@ -228,9 +258,10 @@ wait_for_invite() {
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.
# MDK 0.9.x returns `{"ok":true,"result":{"invites":[...], …}}`.
# `jq_list` peels the envelope AND names the collection; iterating
# `.result` directly walks the sibling scalars instead and never
# matches.
local raw
raw=$("$wnfn" --json groups invites 2>/dev/null || true)
# Walk every pending invite (not just .[0]) so we skip past stales.
@@ -242,14 +273,14 @@ wait_for_invite() {
printf '%s\n' "$gid"
return 0
fi
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null)
done < <(printf '%s' "$raw" | jq_list invites)
# 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 "?")
pending=$(printf '%s' "$raw" | jq_list invites | wc -l | tr -d ' ')
local recent=""
if [[ -f "$data_dir/logs/stderr.log" ]]; then
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
@@ -277,9 +308,10 @@ wait_for_message() {
else
payload=$(wn_c_json messages list "$gid" --limit 20 2>/dev/null || true)
fi
# MDK 0.9.x: `.result.messages[]`, decrypted body in `plaintext`.
if [[ -n "${payload:-}" ]] && \
printf '%s' "$payload" | jq -e --arg n "$needle" \
'(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \
printf '%s' "$payload" | jq_list messages | jq -e --arg n "$needle" \
'select((.plaintext // .content // .text // "") | contains($n))' \
>/dev/null 2>&1; then
return 0
fi
+10 -3
View File
@@ -184,12 +184,19 @@ test_05_b_adds_a_existing() {
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
# A joins. `gid` is wn's MLS group id; amy indexes by the MIP-01
# nostr_group_id, which only exists locally once A has processed the
# welcome — so take amy's id from its own await, never wn's.
local a_out a_gid
a_out=$(amy_json marmot await group --name "Interop-05" --timeout 30) || {
record_result "$id" fail "A never received invite to Interop-05"; return
}
a_gid=$(printf '%s' "$a_out" | jq -r '.group_id // empty')
if [[ -z "$a_gid" ]]; then
record_result "$id" fail "A joined Interop-05 but reported no group_id"; return
fi
amy_json marmot message send "$gid" "joined from amethyst" >/dev/null || {
amy_json marmot message send "$a_gid" "joined from amethyst" >/dev/null || {
record_result "$id" fail "amy send failed"; return
}
if wait_for_message B "$gid" "joined from amethyst" 90 \
+19 -10
View File
@@ -17,7 +17,8 @@ test_09_reply_react_unreact() {
# 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)' \
| jq_list members | jq -e --arg p "$A_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return
fi
@@ -26,7 +27,9 @@ test_09_reply_react_unreact() {
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')
| jq_list messages \
| jq -r 'select((.plaintext // .content // .text // "") == "anchor for reactions")
| (.message_id // .id)' | head -n 1)
if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then
record_result "$id" fail "couldn't find anchor message id"; return
fi
@@ -47,7 +50,9 @@ test_09_reply_react_unreact() {
sleep 3
local a_anchor_id
a_anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq -r '[.messages[]? | select((.content // "") == "anchor for reactions")][0].event_id // empty')
| jq_list messages \
| jq -r 'select((.plaintext // .content // "") == "anchor for reactions")
| (.message_id // .event_id)' | head -n 1)
if [[ -z "$a_anchor_id" || "$a_anchor_id" == "null" ]]; then
record_result "$id" fail "amy couldn't find anchor message in local log"; return
fi
@@ -67,7 +72,7 @@ test_09_reply_react_unreact() {
payload=$(wn_b_json messages list "$mls_gid" --limit 50 2>/dev/null || true)
if [[ -n "$payload" ]] && \
printf '%s' "$payload" \
| jq -e '(.result // .) | .[]? | (.reactions.by_emoji // {}) | keys[]?' \
| jq_list messages | jq -e '(.reactions.by_emoji // {}) | keys[]?' \
2>/dev/null \
| grep -qF '"🍕"'; then
saw=1; break
@@ -92,7 +97,8 @@ test_10_concurrent_commits() {
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)' \
| jq_list members | jq -e --arg p "$A_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return
fi
@@ -110,7 +116,8 @@ test_10_concurrent_commits() {
# whitenoise-rs ≥ v0.2.x wraps the group payload one level deeper as
# `{"result": {"group": {…name…}}}`; the older shape was a bare group
# object under `.result`. Accept both.
b_name=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | (.group // .) | .name // empty')
b_name=$(wn_b --json groups show "$mls_gid" 2>/dev/null \
| jq -r '(.result // .) | (.group // .) | (.profile.name // .name) // empty')
local a_name
a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "")
@@ -194,7 +201,8 @@ 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')
before=$(wn_b --json keys check "$A_NPUB" 2>/dev/null \
| jq -r '.result.key_package.key_package_event_id // .result.event_id // empty')
if [[ -z "$before" ]]; then
record_result "$id" fail "no prior KP for A"; return
fi
@@ -205,7 +213,8 @@ test_13_keypackage_rotation() {
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')
after=$(wn_b --json keys check "$A_NPUB" 2>/dev/null \
| jq -r '.result.key_package.key_package_event_id // .result.event_id // empty')
[[ -n "$after" && "$after" != "$before" ]] && break
sleep 3
done
@@ -323,9 +332,9 @@ test_15_wn_member_leaves() {
local show
show=$(amy_json marmot group show "$a_gid" 2>/dev/null) || { sleep 3; continue; }
local c_still
c_still=$(printf '%s' "$show" | jq --arg p "$C_HEX" '[.members[]? | select(.pubkey == $p)] | length')
c_still=$(printf '%s' "$show" | jq --arg p "$C_HEX" '[.members[]? | select((.pubkey // .member_id) == $p)] | length')
local a_still
a_still=$(printf '%s' "$show" | jq --arg p "$A_HEX" '[.members[]? | select(.pubkey == $p)] | length')
a_still=$(printf '%s' "$show" | jq --arg p "$A_HEX" '[.members[]? | select((.pubkey // .member_id) == $p)] | length')
if [[ "$c_still" == "0" && "$a_still" == "1" ]]; then
ok=1; break
fi
+11 -5
View File
@@ -28,7 +28,8 @@ test_06_member_removal() {
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)' \
| jq_list members | jq -e --arg p "$C_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
removed=1; break
fi
@@ -78,7 +79,10 @@ test_07_metadata_rename() {
# `{"result": {"group": {…name…}}}`; older builds returned the bare
# group object under `.result`. Probe both shapes so the test survives
# either schema.
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null | jq -r '(.result // .) | (.group // .) | .name // empty')
# MDK 0.9.x keeps the display name in the profile component, not a
# top-level `name`: `.result.group.profile.name`.
seen=$(wn_b --json groups show "$mls_gid" 2>/dev/null \
| jq -r '(.result // .) | (.group // .) | (.profile.name // .name) // empty')
[[ "$seen" == "Interop-02-renamed" ]] && break
sleep 3
done
@@ -151,7 +155,7 @@ test_08_admin_promote_demote() {
local admins
admins=$(wn_b --json groups admins "$mls_gid" 2>/dev/null \
| jq -r '(.result // .) | .[]?.pubkey // .[]?.public_key // .[]?' | tr '\n' ' ')
| jq_list admins | jq_member_ids | tr '\n' ' ')
if [[ "$admins" == *"$A_HEX"* ]]; then
record_result "$id" fail "A still admin after demote"
else
@@ -177,7 +181,8 @@ test_11_leave_group() {
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)' \
| jq_list admins | jq -e --arg p "$A_HEX" \
'select((.admin_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
gone=1; break
fi
@@ -218,7 +223,8 @@ test_17_group_image_commit() {
# Skip cleanly if A is no longer a member of GROUP_02 (a later test may have removed
# A) — this test only makes sense while A can still commit to the group.
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
| jq_list members | jq -e --arg p "$A_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A not in GROUP_02"; return
fi