From c011dfca6ec5a04357eff15c47d3437ebc7ab6f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 15:09:57 +0000 Subject: [PATCH] test(cli): headless end-to-end for deletion sync (real amy vs amy serve) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the built `amy` binary against a real `amy serve` relay to prove NIP-77 deletion propagation end-to-end — the running answer to "does it actually work", on top of the in-process geode tests: T1 (up) we deleted a note the relay still has → `amy sync` sends our kind-5 up and the relay drops it (checked by an isolated third account that reads the relay only, so no local tombstone masks the result). T2 (off) `--no-sync-deletions` sends nothing and the relay keeps the note. T3 (down) the relay deleted a note we still hold → `amy sync --up` pulls the relay's kind-5 down and applies it locally; a second sync converges. Each amy account gets its own $HOME (accounts under one $HOME share the file store). Follows the cli/tests/*-headless.sh pattern; state dir gitignored. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- cli/tests/.gitignore | 1 + cli/tests/sync/sync-deletions-headless.sh | 196 ++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100755 cli/tests/sync/sync-deletions-headless.sh diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 3d5dc27cf2..7f43ea55a5 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -3,3 +3,4 @@ marmot/state-headless/ dm/state-dm-headless/ nests/state/ clink/state-clink-headless/ +sync/state-sync-deletions/ diff --git a/cli/tests/sync/sync-deletions-headless.sh b/cli/tests/sync/sync-deletions-headless.sh new file mode 100755 index 0000000000..871649dc5e --- /dev/null +++ b/cli/tests/sync/sync-deletions-headless.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# sync-deletions-headless.sh — drives the real `amy` binary against a real +# `amy serve` relay to prove NIP-77 deletion propagation end-to-end. +# +# `amy sync` converges deletions in a second pass over the reconcile residual +# (see quartz `negentropySettleDeletions`). This exercises both directions plus +# the opt-out: +# +# T1 (up) — we deleted a note the relay still has → `amy sync` sends our +# kind-5 up and the relay drops the note. Verified by an ISOLATED +# third account whose store reads the relay only (no tombstone). +# T2 (off) — same setup with `--no-sync-deletions` → the relay keeps the note +# and nothing is sent. +# T3 (down) — the relay deleted a note we still hold → `amy sync --up` pulls the +# relay's kind-5 down and applies it locally (converges on re-sync). +# +# Each amy account gets its OWN $HOME so their file stores don't share (accounts +# under one $HOME share ~/.amy/shared/events-store). The relay (amy serve) keeps +# a separate store from any client store. +# +# Usage: ./sync-deletions-headless.sh [--port N] [--no-build] +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-sync-deletions" +LOG_DIR="$STATE_DIR/logs" +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" + +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" +RELAY_HOST="127.0.0.1" +RELAY_PORT="${RELAY_PORT:-7790}" +RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" +NO_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;; + --no-build) NO_BUILD=1 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac + shift +done + +# Fresh state every run — stale per-account $HOME dirs from a prior run must not +# leak into this one. +rm -rf "$STATE_DIR" +mkdir -p "$LOG_DIR" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$TESTS_DIR/lib.sh" + +# Leniently-trimmed equality assertion (assert helpers live in the DM-specific +# helpers.sh, which hardcodes its own amy wrappers — so define our own here). +assert_eq() { + local actual="$1" expected="$2" test_id="$3" note="${4:-}" + if [[ "${actual// /}" == "${expected// /}" ]]; then + info "assert: $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 +} + +SERVE_PID="" +RELAY_HOME="" +cleanup() { + [[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null + trap - EXIT INT TERM HUP + print_summary +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +banner "amy sync — NIP-77 deletion propagation headless ($RUN_TS)" + +# ---- build ------------------------------------------------------------------ +if [[ "$NO_BUILD" -eq 0 ]]; then + step "Building amy (installDist)…" + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \ + || { fail_msg "build failed (see $LOG_FILE)"; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary not found at $AMY_BIN"; exit 1; } + +# ---- amy wrappers (one isolated $HOME per account) -------------------------- +strip() { grep -vE "Picked up JAVA_TOOL|DEBUG:|INFO:|MarmotManager|MlsGroup"; } +mk_home() { mktemp -d "$STATE_DIR/home.XXXXXX"; } +# amy_run args... +amy_run() { + local home="$1" acct="$2"; shift 2 + HOME="$home" "$AMY_BIN" --account "$acct" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE" | strip +} + +RELAY_HOME="$(mk_home)" +amy_run "$RELAY_HOME" a init >/dev/null + +step "Starting amy serve on $RELAY_URL…" +HOME="$RELAY_HOME" "$AMY_BIN" --account a --secret-backend plaintext \ + serve --host "$RELAY_HOST" --port "$RELAY_PORT" >>"$LOG_FILE" 2>&1 & +SERVE_PID=$! + +# Wait for the relay to accept connections (poll the serve log). +for _ in $(seq 1 60); do + grep -q "relay up at" "$LOG_FILE" && break + sleep 0.5 +done +grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; } + +# Isolated verifier: its own empty store, reads the relay only (no tombstone). +VERIFY_HOME="$(mk_home)" +amy_run "$VERIFY_HOME" v init >/dev/null +relay_count() { amy_run "$VERIFY_HOME" v fetch --id "$1" --relay "$RELAY_URL" | jq -r '.count // 0'; } + +# ============================================================================= +# T1 — up direction: we deleted it, the relay still has it → sync sends it up. +# ============================================================================= +banner "T1 — amy sync sends our deletion up (relay drops the note)" +NOTE="$(amy_run "$RELAY_HOME" a event --kind 1 --content "delete-me-t1" | jq -c '.event')" +NID="$(echo "$NOTE" | jq -r '.id')" +echo "$NOTE" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null + +before="$(relay_count "$NID")" +assert_eq "$before" "1" T1.setup "relay should hold the note before sync" \ + && record_result T1.setup pass "relay has the note" + +# Delete locally only (no --relay → stored, applied, not sent to the relay). +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID\"]]" --content "" --publish >/dev/null + +SYNC="$(amy_run "$RELAY_HOME" a sync --relay "$RELAY_URL")" +info "sync: $SYNC" +sent="$(echo "$SYNC" | jq -r '.deletions_sent_up // 0')" +assert_eq "$sent" "1" T1.sent_up "sync should report one deletion sent up" \ + && record_result T1.sent_up pass "deletions_sent_up=1" + +sleep 1 +after="$(relay_count "$NID")" +assert_eq "$after" "0" T1.relay_dropped "relay must have removed the note after sync" \ + && record_result T1.relay_dropped pass "relay note count 1 → 0" + +# ============================================================================= +# T2 — opt-out: --no-sync-deletions leaves the relay untouched. +# ============================================================================= +banner "T2 — --no-sync-deletions propagates nothing" +NOTE2="$(amy_run "$RELAY_HOME" a event --kind 1 --content "keep-me-t2" | jq -c '.event')" +NID2="$(echo "$NOTE2" | jq -r '.id')" +echo "$NOTE2" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID2\"]]" --content "" --publish >/dev/null + +SYNC2="$(amy_run "$RELAY_HOME" a sync --relay "$RELAY_URL" --no-sync-deletions)" +info "sync: $SYNC2" +sent2="$(echo "$SYNC2" | jq -r '.deletions_sent_up // 0')" +assert_eq "$sent2" "0" T2.no_send "--no-sync-deletions must send nothing" \ + && record_result T2.no_send pass "deletions_sent_up=0" +sleep 1 +kept="$(relay_count "$NID2")" +assert_eq "$kept" "1" T2.relay_kept "relay must still hold the note" \ + && record_result T2.relay_kept pass "relay note untouched" + +# ============================================================================= +# T3 — down direction: the relay deleted it, we still hold it → sync --up pulls +# the relay's deletion down and applies it locally. +# ============================================================================= +banner "T3 — amy sync --up applies the relay's deletion locally" +BOB_HOME="$(mk_home)" +amy_run "$BOB_HOME" b init >/dev/null +NOTE3="$(amy_run "$RELAY_HOME" a event --kind 1 --content "delete-me-t3" | jq -c '.event')" +NID3="$(echo "$NOTE3" | jq -r '.id')" +echo "$NOTE3" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null +# bob's isolated store learns the note from the relay… +amy_run "$BOB_HOME" b fetch --id "$NID3" --relay "$RELAY_URL" >/dev/null +# …then the relay deletes it (author pushes a kind-5 straight to the relay). +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID3\"]]" --content "" | jq -c '.event' \ + | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null + +SYNC3="$(amy_run "$BOB_HOME" b sync --up --relay "$RELAY_URL")" +info "sync: $SYNC3" +applied="$(echo "$SYNC3" | jq -r '.deletions_applied_down // 0')" +assert_eq "$applied" "1" T3.applied_down "sync --up should apply one relay deletion locally" \ + && record_result T3.applied_down pass "deletions_applied_down=1" + +# Converged: a second --up sync finds nothing left to apply. +SYNC3B="$(amy_run "$BOB_HOME" b sync --up --relay "$RELAY_URL")" +applied2="$(echo "$SYNC3B" | jq -r '.deletions_applied_down // 0')" +assert_eq "$applied2" "0" T3.converged "re-sync applies nothing (converged)" \ + && record_result T3.converged pass "second sync stable" + +# print_summary runs from the cleanup trap; exit non-zero if any test failed. +grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 +exit 0