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 -------------------------------------------------------- # ------- JSON helpers --------------------------------------------------------
# Extract MLS group ID as lowercase hex from wn JSON output. # Extract an MLS group id as lowercase hex from `wn --json` output.
# Handles both formats: #
# - plain hex string (from `groups list`) # MDK 0.9.x settled on one envelope, `{"ok":true,"result":{...}}`, but the
# - {"value":{"vec":[...]}} serde struct (from `groups create`) # group id sits at a different place per verb:
# - flat byte array [n, ...] (from some responses) # groups create -> .result.group_id
# Plus the three wrapper shapes wn actually uses: # groups accept / rename -> .result.group.group_id
# - {"result": {"mls_group_id": ...}} (groups create) # groups invites[] -> .group_id (an element, already peeled)
# - {"group": {"mls_group_id": ...}, "membership": ...} (groups invites[0]) # groups members/admins -> .result.group_id
# - {"mls_group_id": ...} (bare) # Older builds wrapped the id as a serde `{"value":{"vec":[...]}}` struct or a
# Input: JSON string via stdin; optional 2nd arg = field name (default: mls_group_id) # 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() { jq_group_id() {
local field="${1:-mls_group_id}" local field="${1:-group_id}"
jq -r --arg f "$field" ' jq -r --arg f "$field" '
def byte2hex: def byte2hex:
. as $n | . as $n |
[($n / 16 | floor), ($n % 16)] | [($n / 16 | floor), ($n % 16)] |
map(if . < 10 then (48 + .) else (87 + .) end) | map(if . < 10 then (48 + .) else (87 + .) end) |
implode; implode;
(.group // .result // .) | def as_hex:
(.group // .) | if type == "string" then .
.[$f] | elif (type == "object" and (.value.vec != null)) then
if type == "string" then . [.value.vec[] | byte2hex] | join("")
elif (type == "object" and (.value.vec != null)) then elif type == "array" then
[.value.vec[] | byte2hex] | join("") [.[] | byte2hex] | join("")
elif type == "array" then else empty end;
[.[] | byte2hex] | join("") [ (.result? // empty), (.result?.group? // empty), (.group? // empty), . ]
else empty end | map(select(type == "object") | .[$f]? // empty | as_hex)
| map(select(. != null and . != ""))
| first // empty
' 2>/dev/null || true ' 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 ----------------------------------------------------- # ------- polling helpers -----------------------------------------------------
# Snapshot currently-pending invites on <B|C> as a comma-separated list of # Snapshot currently-pending invites on <B|C> as a comma-separated list of
@@ -197,7 +227,7 @@ snapshot_invites() {
local g local g
g=$(printf '%s' "$one" | jq_group_id) g=$(printf '%s' "$one" | jq_group_id)
[[ -n "$g" ]] && gids+=("$g") [[ -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 # bash 3.2 (stock macOS) treats "${gids[*]}" on an empty array as an
# unbound reference under `set -u`, so guard the expansion. # unbound reference under `set -u`, so guard the expansion.
if (( ${#gids[@]} > 0 )); then if (( ${#gids[@]} > 0 )); then
@@ -228,9 +258,10 @@ wait_for_invite() {
deadline=$(( start + timeout )) deadline=$(( start + timeout ))
last_hb=$start last_hb=$start
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
# Post-v0.2 `wn --json groups invites` returns `{"result": [...]}` # MDK 0.9.x returns `{"ok":true,"result":{"invites":[...], …}}`.
# (older builds returned the bare array). Peel the wrapper when # `jq_list` peels the envelope AND names the collection; iterating
# present so a pending invite is actually detected. # `.result` directly walks the sibling scalars instead and never
# matches.
local raw local raw
raw=$("$wnfn" --json groups invites 2>/dev/null || true) raw=$("$wnfn" --json groups invites 2>/dev/null || true)
# Walk every pending invite (not just .[0]) so we skip past stales. # Walk every pending invite (not just .[0]) so we skip past stales.
@@ -242,14 +273,14 @@ wait_for_invite() {
printf '%s\n' "$gid" printf '%s\n' "$gid"
return 0 return 0
fi fi
done < <(printf '%s' "$raw" | jq -c '(.result // .) | .[]?' 2>/dev/null) done < <(printf '%s' "$raw" | jq_list invites)
# Heartbeat every ~10s. # Heartbeat every ~10s.
local now=$(date +%s) local now=$(date +%s)
if (( now - last_hb >= 10 )); then if (( now - last_hb >= 10 )); then
local elapsed=$(( now - start )) remaining=$(( deadline - now )) local elapsed=$(( now - start )) remaining=$(( deadline - now ))
local pending 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="" local recent=""
if [[ -f "$data_dir/logs/stderr.log" ]]; then if [[ -f "$data_dir/logs/stderr.log" ]]; then
recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \ recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \
@@ -277,9 +308,10 @@ wait_for_message() {
else else
payload=$(wn_c_json messages list "$gid" --limit 20 2>/dev/null || true) payload=$(wn_c_json messages list "$gid" --limit 20 2>/dev/null || true)
fi fi
# MDK 0.9.x: `.result.messages[]`, decrypted body in `plaintext`.
if [[ -n "${payload:-}" ]] && \ if [[ -n "${payload:-}" ]] && \
printf '%s' "$payload" | jq -e --arg n "$needle" \ printf '%s' "$payload" | jq_list messages | jq -e --arg n "$needle" \
'(.result // .) | .[]? | select((.content // .text // "") | contains($n))' \ 'select((.plaintext // .content // .text // "") | contains($n))' \
>/dev/null 2>&1; then >/dev/null 2>&1; then
return 0 return 0
fi 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 record_result "$id" fail "wn add-members A failed"; return
} }
# A joins # A joins. `gid` is wn's MLS group id; amy indexes by the MIP-01
if ! amy_json marmot await group --name "Interop-05" --timeout 30 >/dev/null; then # 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 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 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 record_result "$id" fail "amy send failed"; return
} }
if wait_for_message B "$gid" "joined from amethyst" 90 \ 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, # B anchors. Needs a member to be present — if Test 11 already ran and A left,
# skip cleanly so we don't double-fail. # skip cleanly so we don't double-fail.
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ 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 >/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return record_result "$id" skip "A already left GROUP_02"; return
fi fi
@@ -26,7 +27,9 @@ test_09_reply_react_unreact() {
sleep 3 sleep 3
local msg_id local msg_id
msg_id=$(wn_b --json messages list "$mls_gid" --limit 10 2>/dev/null \ 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 if [[ -z "$msg_id" || "$msg_id" == "null" ]]; then
record_result "$id" fail "couldn't find anchor message id"; return record_result "$id" fail "couldn't find anchor message id"; return
fi fi
@@ -47,7 +50,9 @@ test_09_reply_react_unreact() {
sleep 3 sleep 3
local a_anchor_id local a_anchor_id
a_anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \ 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 if [[ -z "$a_anchor_id" || "$a_anchor_id" == "null" ]]; then
record_result "$id" fail "amy couldn't find anchor message in local log"; return record_result "$id" fail "amy couldn't find anchor message in local log"; return
fi fi
@@ -67,7 +72,7 @@ test_09_reply_react_unreact() {
payload=$(wn_b_json messages list "$mls_gid" --limit 50 2>/dev/null || true) payload=$(wn_b_json messages list "$mls_gid" --limit 50 2>/dev/null || true)
if [[ -n "$payload" ]] && \ if [[ -n "$payload" ]] && \
printf '%s' "$payload" \ printf '%s' "$payload" \
| jq -e '(.result // .) | .[]? | (.reactions.by_emoji // {}) | keys[]?' \ | jq_list messages | jq -e '(.reactions.by_emoji // {}) | keys[]?' \
2>/dev/null \ 2>/dev/null \
| grep -qF '"🍕"'; then | grep -qF '"🍕"'; then
saw=1; break saw=1; break
@@ -92,7 +97,8 @@ test_10_concurrent_commits() {
record_result "$id" skip "no GROUP_02"; return record_result "$id" skip "no GROUP_02"; return
fi fi
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ 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 >/dev/null 2>&1; then
record_result "$id" skip "A already left GROUP_02"; return record_result "$id" skip "A already left GROUP_02"; return
fi fi
@@ -110,7 +116,8 @@ test_10_concurrent_commits() {
# whitenoise-rs ≥ v0.2.x wraps the group payload one level deeper as # whitenoise-rs ≥ v0.2.x wraps the group payload one level deeper as
# `{"result": {"group": {…name…}}}`; the older shape was a bare group # `{"result": {"group": {…name…}}}`; the older shape was a bare group
# object under `.result`. Accept both. # 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 local a_name
a_name=$(amy_field '.name' marmot group show "$gid" 2>/dev/null || echo "") 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 id="13 keypackage rotation"
local before 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 if [[ -z "$before" ]]; then
record_result "$id" fail "no prior KP for A"; return record_result "$id" fail "no prior KP for A"; return
fi fi
@@ -205,7 +213,8 @@ test_13_keypackage_rotation() {
local deadline=$(( $(date +%s) + 60 )) after="" local deadline=$(( $(date +%s) + 60 )) after=""
while [[ $(date +%s) -lt $deadline ]]; do 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 [[ -n "$after" && "$after" != "$before" ]] && break
sleep 3 sleep 3
done done
@@ -323,9 +332,9 @@ test_15_wn_member_leaves() {
local show local show
show=$(amy_json marmot group show "$a_gid" 2>/dev/null) || { sleep 3; continue; } show=$(amy_json marmot group show "$a_gid" 2>/dev/null) || { sleep 3; continue; }
local c_still 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 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 if [[ "$c_still" == "0" && "$a_still" == "1" ]]; then
ok=1; break ok=1; break
fi fi
+11 -5
View File
@@ -28,7 +28,8 @@ test_06_member_removal() {
local deadline=$(( $(date +%s) + 120 )) removed=0 local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_c --json groups members "$mls_gid" 2>/dev/null \ 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 >/dev/null 2>&1; then
removed=1; break removed=1; break
fi fi
@@ -78,7 +79,10 @@ test_07_metadata_rename() {
# `{"result": {"group": {…name…}}}`; older builds returned the bare # `{"result": {"group": {…name…}}}`; older builds returned the bare
# group object under `.result`. Probe both shapes so the test survives # group object under `.result`. Probe both shapes so the test survives
# either schema. # 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 [[ "$seen" == "Interop-02-renamed" ]] && break
sleep 3 sleep 3
done done
@@ -151,7 +155,7 @@ test_08_admin_promote_demote() {
local admins local admins
admins=$(wn_b --json groups admins "$mls_gid" 2>/dev/null \ 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 if [[ "$admins" == *"$A_HEX"* ]]; then
record_result "$id" fail "A still admin after demote" record_result "$id" fail "A still admin after demote"
else else
@@ -177,7 +181,8 @@ test_11_leave_group() {
local deadline=$(( $(date +%s) + 120 )) gone=0 local deadline=$(( $(date +%s) + 120 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ 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 >/dev/null 2>&1; then
gone=1; break gone=1; break
fi 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 # 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. # 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 \ 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 >/dev/null 2>&1; then
record_result "$id" skip "A not in GROUP_02"; return record_result "$id" skip "A not in GROUP_02"; return
fi fi