Three shell E2E tests created scratch files via `mktemp` but never
deleted them on early exit (assertion failure, SIGINT, errexit). Each
CI run leaked ~10-100 KB of /tmp into the runner; over ~200 runs/week
that's 20+ MB of accumulated cruft.
## Files
- **test_chat_attachments_e2e.sh** — was missing both trap and rm;
added per-run TMPDIR_E2E with `trap rm -rf … EXIT INT TERM`.
- **test_notify_attachments_e2e.sh** — had a `cleanup()` for the
workspace but didn't include the TMPF; only an unconditional
`rm -f` at the bottom (line 233) which doesn't fire on early exit.
Extended cleanup() to also rm the scratch + dropped the redundant
trailing rm.
- **test_chat_attachments_multiruntime_e2e.sh** — `round_trip()`
function had per-call `rm -f` only on the success path; failure
paths leaked. Switched to script-level TMPDIR_E2E + trap; per-call
rm dropped (the trap handles every return path including SIGINT).
Pattern: `mktemp -d -t prefix-XXX` for the dir, `mktemp <full-template>`
for files (portable across BSD/macOS + GNU coreutils — `-p` is
GNU-only and breaks Mac local-dev runs).
## Regression gate
New `tests/e2e/lint_cleanup_traps.sh` asserts every `*.sh` that calls
`mktemp` also has a `trap … EXIT` line in the file. Wired into the
existing Shellcheck (E2E scripts) CI step. Verified locally: passes
on the fixed state, fails-loud when one of the 3 fixes is reverted.
## Verification
- shellcheck --severity=warning clean on all 4 touched files
- lint_cleanup_traps.sh passes on the post-fix tree (6 mktemp users,
all have EXIT trap)
- Negative test: revert one fix → lint exits 1 with file:line +
suggested fix pattern in the error message (CI-grokkable
::error file=… annotation)
- Trap fires on SIGTERM mid-run (smoke-tested on macOS BSD mktemp)
- Trap fires on `exit 1` (smoke-tested)
## Bars met (7-axis)
- SSOT: trap pattern documented in lint message (one rule, one fix)
- Cleanup: this IS the cleanup hygiene fix
- 100% coverage: lint catches future regressions across all
`tests/e2e/*.sh` files, not just the 3 fixed today
- File-split: N/A (no files split)
- Plugin / abstract / modular: N/A (test infra, not product code)
Iteration 2 of RFC #2873.
248 lines
10 KiB
Bash
Executable File
248 lines
10 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() {
|
|
# Workspace teardown — best-effort, ignore errors so an unrelated CP
|
|
# outage doesn't shadow a real test failure.
|
|
if [ -n "$WSID" ]; then
|
|
curl -s -X DELETE "$BASE/workspaces/$WSID?confirm=true" > /dev/null || true
|
|
fi
|
|
# /tmp scratch — pre-fix only ran on success path (the unconditional
|
|
# rm at the bottom of the script). Trap-based path lets the file leak
|
|
# whenever the script exits non-zero before reaching the rm. RFC #2873
|
|
# cleanup-hygiene PR.
|
|
if [ -n "${TMPF:-}" ]; then
|
|
rm -f "$TMPF"
|
|
fi
|
|
}
|
|
trap cleanup EXIT INT TERM
|
|
|
|
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
|
|
|
|
# $TMPF cleanup happens via the trap-cleanup function above — covers
|
|
# both the success path and any early exit / SIGINT.
|
|
|
|
echo ""
|
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
|
[ "$FAIL" -eq 0 ]
|