molecule-core/tests/e2e/test_notify_attachments_e2e.sh
Hongming Wang 49fb5fdaf6 test(notify): pre-sweep prior workspaces so interrupted runs don't pile up
User flagged a leftover "Notify E2E" workspace on the canvas — caused by
an earlier debug run getting SIGPIPE'd before the EXIT trap could fire.
Add an idempotent pre-sweep at the top of the script so the next run
cleans up any prior leftover with the same name. Belt-and-suspenders
with the existing trap; both have to fail for a leak to persist.

Verified:
  - Normal run: 14/14 pass, 0 leftovers
  - SIGTERM mid-setup: trap fires, 0 leftovers
  - Re-run after interruption: pre-sweep + new run both clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:55:13 -07:00

238 lines
9.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# E2E test: agent → user notify with attachments (PR #2130).
#
# Exercises the wire contract that workspace/a2a_tools.tool_send_message_to_user
# uses to push files into the canvas chat. Pure platform test — no workspace
# container or LLM key required, so it runs against a bare workspace-server
# in <2s and pins the contract that real runtimes depend on.
#
# What this proves:
# 1. POST /notify (no attachments) persists a chat row that survives reload.
# 2. POST /notify with attachments persists parts[].kind=file in the SAME
# shape extractFilesFromTask reads → chips re-render after reload.
# 3. Per-element attachment validation rejects empty uri/name (regression
# for the bug where gin's binding:"required" on slice elements was a
# no-op without `dive` — see activity.go:299-306).
# 4. Empty `attachments: []` does NOT inject a stray `parts` key (would
# otherwise render a "0 files" header in the canvas).
# 5. Real /chat/uploads → /notify chain round-trips the URI verbatim.
#
# Usage: tests/e2e/test_notify_attachments_e2e.sh
# Prereqs: workspace-server on http://localhost:8080, MOLECULE_ENV != production
set -euo pipefail
source "$(dirname "$0")/_lib.sh"
PASS=0
FAIL=0
WSID=""
cleanup() {
if [ -n "$WSID" ]; then
curl -s -X DELETE "$BASE/workspaces/$WSID?confirm=true" > /dev/null || true
fi
}
trap cleanup EXIT
assert() {
local label="$1"
local actual="$2"
local expected="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS — $label"
PASS=$((PASS+1))
else
echo " FAIL — $label"
echo " expected: $expected"
echo " actual: $actual"
FAIL=$((FAIL+1))
fi
}
assert_contains() {
local label="$1"
local haystack="$2"
local needle="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo " PASS — $label"
PASS=$((PASS+1))
else
echo " FAIL — $label"
echo " haystack: $haystack"
echo " needle: $needle"
FAIL=$((FAIL+1))
fi
}
echo "=== Setup ==="
# Idempotent pre-sweep: a prior run that got SIGPIPE'd or kill -9'd before
# the EXIT trap fired would leave a "Notify E2E" workspace sitting on the
# canvas. Find and delete any with this exact name so the test is safe to
# re-run from any state. Match by name (not tag) so this also catches
# leftovers created by older script versions.
PRIOR=$(curl -s "$BASE/workspaces" | python3 -c '
import json, sys
try:
print(" ".join(w["id"] for w in json.load(sys.stdin) if w.get("name") == "Notify E2E"))
except Exception:
pass
')
for _wid in $PRIOR; do
echo "Sweeping leftover Notify E2E workspace: $_wid"
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
done
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Notify E2E","tier":1}')
WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; }
echo "Created workspace $WSID"
# Mint a bearer token so the wsAuth-grouped endpoints (notify, activity,
# chat/uploads) accept us. Local dev mode skips auth, but CI enforces it
# — so we always send the header to keep the test portable. The
# admin/test-token endpoint is only enabled when MOLECULE_ENV != production.
TOKEN=$(e2e_mint_test_token "$WSID")
[ -n "$TOKEN" ] || { echo "Failed to mint test token"; exit 1; }
AUTH="Authorization: Bearer $TOKEN"
echo ""
echo "=== Test 1: notify without attachments persists row ==="
CODE=$(curl -s -o /tmp/notify1.json -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \
-H "Content-Type: application/json" -H "$AUTH" \
-d '{"message":"Working on it"}')
assert "POST /notify (text only) returns 200" "$CODE" "200"
# Read it back via /activity. The notify handler writes a2a_receive,
# method=notify, response_body.result=<message>.
# Notify writes source_id=NULL → use source=canvas (matches the chat
# panel's history loader filter). source=agent would correctly hide it.
ACT=$(curl -s -H "$AUTH" "$BASE/workspaces/$WSID/activity?source=canvas&limit=10")
ROW=$(echo "$ACT" | python3 -c '
import json, sys
rows = json.load(sys.stdin) or []
for r in rows:
if r.get("method") == "notify":
print(json.dumps(r))
break
')
[ -n "$ROW" ] || { echo " FAIL — could not find notify row in activity"; FAIL=$((FAIL+1)); }
if [ -n "$ROW" ]; then
RESULT=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["result"])')
assert "persisted response_body.result matches message" "$RESULT" "Working on it"
PARTS=$(echo "$ROW" | python3 -c 'import json,sys;b=json.load(sys.stdin)["response_body"];print("parts" in b)')
assert "no stray parts[] when message has no attachments" "$PARTS" "False"
fi
echo ""
echo "=== Test 2: notify with attachments persists parts[].kind=file ==="
CODE=$(curl -s -o /tmp/notify2.json -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \
-H "Content-Type: application/json" -H "$AUTH" \
-d '{
"message": "Done — see attached.",
"attachments": [
{"uri":"workspace:/tmp/build-output.zip","name":"build-output.zip","mimeType":"application/zip","size":12345}
]
}')
assert "POST /notify (with attachment) returns 200" "$CODE" "200"
ACT=$(curl -s -H "$AUTH" "$BASE/workspaces/$WSID/activity?source=canvas&limit=10")
ROW=$(echo "$ACT" | python3 -c '
import json, sys
rows = json.load(sys.stdin) or []
# Most recent matching notify row
for r in rows:
rb = r.get("response_body") or {}
if r.get("method") == "notify" and "parts" in rb:
print(json.dumps(r))
break
')
[ -n "$ROW" ] || { echo " FAIL — could not find notify-with-attachments row"; FAIL=$((FAIL+1)); }
if [ -n "$ROW" ]; then
KIND=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["kind"])')
URI=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["file"]["uri"])')
NAME=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["file"]["name"])')
MIME=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["file"]["mimeType"])')
SIZE=$(echo "$ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin)["response_body"]["parts"][0]["file"]["size"])')
assert "parts[0].kind == file" "$KIND" "file"
assert "parts[0].file.uri preserved" "$URI" "workspace:/tmp/build-output.zip"
assert "parts[0].file.name preserved" "$NAME" "build-output.zip"
assert "parts[0].file.mimeType preserved" "$MIME" "application/zip"
assert "parts[0].file.size preserved" "$SIZE" "12345"
fi
echo ""
echo "=== Test 3: per-element validation rejects empty uri/name ==="
# Critical regression: gin's binding:"required" on slice elements is a no-op
# without `dive`. activity.go:299 explicitly loops and rejects. Keep in lock-
# step here so a future refactor that drops the loop fails this test.
CODE=$(curl -s -o /tmp/notify3.json -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \
-H "Content-Type: application/json" -H "$AUTH" \
-d '{"message":"x","attachments":[{"uri":"","name":""}]}')
assert "empty uri/name attachment is 400" "$CODE" "400"
ERR=$(cat /tmp/notify3.json | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))')
assert_contains "error mentions attachment[0]" "$ERR" "attachment[0]"
CODE=$(curl -s -o /tmp/notify3b.json -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \
-H "Content-Type: application/json" -H "$AUTH" \
-d '{"message":"x","attachments":[{"uri":"workspace:/ok.txt","name":"ok.txt"},{"uri":"","name":"bad"}]}')
assert "second-element empty uri rejects whole call" "$CODE" "400"
ERR=$(cat /tmp/notify3b.json | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))')
assert_contains "error mentions attachment[1]" "$ERR" "attachment[1]"
echo ""
echo "=== Test 4: missing message field rejected ==="
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \
-H "Content-Type: application/json" -H "$AUTH" -d '{}')
assert "POST /notify with no message returns 400" "$CODE" "400"
echo ""
echo "=== Test 5: real /chat/uploads → /notify round-trip ==="
TMPF=$(mktemp -t notify-e2e-XXXX.txt)
echo "round-trip-marker-$(date +%s)" > "$TMPF"
UP=$(curl -s -X POST "$BASE/workspaces/$WSID/chat/uploads" -H "$AUTH" -F "files=@$TMPF")
URI=$(echo "$UP" | python3 -c '
import json,sys
try:
print(json.load(sys.stdin)["files"][0]["uri"])
except Exception:
pass
')
NAME=$(basename "$TMPF")
if [ -z "$URI" ]; then
# /chat/uploads requires a running container in some configs. Skip the
# round-trip if the platform refused — the synthetic-URI tests above
# already pin the wire contract.
echo " SKIP — /chat/uploads not available in this env (response: $UP)"
else
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$WSID/notify" \
-H "Content-Type: application/json" -H "$AUTH" \
-d "{\"message\":\"see file\",\"attachments\":[{\"uri\":\"$URI\",\"name\":\"$NAME\"}]}")
assert "uploaded URI round-trips through notify" "$CODE" "200"
ACT=$(curl -s -H "$AUTH" "$BASE/workspaces/$WSID/activity?source=canvas&limit=10")
STORED_URI=$(echo "$ACT" | python3 -c "
import json, sys
rows = json.load(sys.stdin) or []
for r in rows:
rb = r.get('response_body') or {}
parts = rb.get('parts') or []
for p in parts:
f = p.get('file') or {}
if f.get('name') == '$NAME':
print(f.get('uri',''))
sys.exit(0)
")
assert "stored URI matches uploaded URI" "$STORED_URI" "$URI"
fi
rm -f "$TMPF"
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
[ "$FAIL" -eq 0 ]