diff --git a/.claude/skills/setup-agent-team/qa.sh b/.claude/skills/setup-agent-team/qa.sh index a52d19aa..eb8b36ff 100644 --- a/.claude/skills/setup-agent-team/qa.sh +++ b/.claude/skills/setup-agent-team/qa.sh @@ -202,6 +202,25 @@ if [[ "${RUN_MODE}" == "fixtures" ]] || [[ "${RUN_MODE}" == "quality" ]] || [[ " fi fi +# --- Load email credentials for matrix report (e2e mode) --- +if [[ "${RUN_MODE}" == "e2e" ]]; then + if [[ -f /etc/spawn-key-server-auth.env ]]; then + while IFS='=' read -r _ekey _eval || [[ -n "${_ekey}" ]]; do + _ekey="${_ekey#"${_ekey%%[! ]*}"}" + _ekey="${_ekey%"${_ekey##*[! ]}"}" + [[ -z "${_ekey}" || "${_ekey}" == \#* ]] && continue + case "${_ekey}" in + RESEND_API_KEY|KEY_REQUEST_EMAIL) + export "${_ekey}=${_eval}" + ;; + esac + done < /etc/spawn-key-server-auth.env + log "Email credentials loaded for matrix report" + else + log "No /etc/spawn-key-server-auth.env found — matrix email will be skipped" + fi +fi + # Launch Claude Code with mode-specific prompt # Enable agent teams (required for team-based workflows) export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 diff --git a/sh/e2e/e2e.sh b/sh/e2e/e2e.sh index 046bf975..d95db7aa 100755 --- a/sh/e2e/e2e.sh +++ b/sh/e2e/e2e.sh @@ -361,6 +361,148 @@ run_agents_for_cloud() { return 0 } +# --------------------------------------------------------------------------- +# send_matrix_email LOG_DIR CLOUDS AGENTS TOTAL_PASS TOTAL_FAIL DURATION_STR +# +# Sends an agent x cloud matrix report via Resend. +# Requires: RESEND_API_KEY, KEY_REQUEST_EMAIL env vars (silently skips if absent). +# --------------------------------------------------------------------------- +send_matrix_email() { + local log_dir="$1" + local clouds="$2" + local agents="$3" + local total_pass="$4" + local total_fail="$5" + local duration_str="$6" + + local resend_key="${RESEND_API_KEY:-}" + local to_email="${KEY_REQUEST_EMAIL:-}" + + if [ -z "${resend_key}" ] || [ -z "${to_email}" ]; then + log_info "Matrix email skipped (RESEND_API_KEY or KEY_REQUEST_EMAIL not set)" + return 0 + fi + + # Build results string: "cloud:agent:result,..." for bun to process + local results="" + for cloud in ${clouds}; do + for agent in ${agents}; do + local result="skip" + local result_file="${log_dir}/${cloud}-${agent}.result" + if [ -f "${result_file}" ]; then + result=$(cat "${result_file}") + fi + if [ -n "${results}" ]; then results="${results},"; fi + results="${results}${cloud}:${agent}:${result}" + done + done + + local ts_file + ts_file=$(mktemp /tmp/e2e-email-XXXXXX.ts) + + cat > "${ts_file}" << 'TS_EOF' +const results = (process.env._E2E_RESULTS ?? "").split(",").filter(Boolean); +const clouds = (process.env._E2E_CLOUDS ?? "").split(" ").filter(Boolean); +const agents = (process.env._E2E_AGENTS ?? "").split(" ").filter(Boolean); +const totalPass = process.env._E2E_TOTAL_PASS ?? "0"; +const totalFail = process.env._E2E_TOTAL_FAIL ?? "0"; +const duration = process.env._E2E_DURATION ?? "?"; +const toEmail = process.env.KEY_REQUEST_EMAIL ?? ""; +const resendKey = process.env.RESEND_API_KEY ?? ""; +const timestamp = new Date().toUTCString(); + +// Build lookup map: "cloud:agent" -> result +const resultMap: Record = {}; +for (const entry of results) { + const parts = entry.split(":"); + resultMap[`${parts[0]}:${parts[1]}`] = parts[2] ?? "skip"; +} + +// Cell styles per result +const cellStyle = (result: string): string => { + if (result === "pass") return "background:#22c55e;color:#fff;font-weight:bold;padding:4px 10px;border-radius:4px;"; + if (result === "fail") return "background:#ef4444;color:#fff;font-weight:bold;padding:4px 10px;border-radius:4px;"; + return "background:#e2e8f0;color:#94a3b8;padding:4px 10px;border-radius:4px;"; +}; + +const headerCells = clouds + .map(c => `${c}`) + .join(""); + +const bodyRows = agents + .map(agent => { + const cells = clouds + .map(cloud => { + const r = resultMap[`${cloud}:${agent}`] ?? "skip"; + return `${r.toUpperCase()}`; + }) + .join(""); + return `${agent}${cells}`; + }) + .join(""); + +const status = totalFail === "0" ? "✅ All Passed" : `❌ ${totalFail} Failed`; + +const html = ` + +

${status} — Spawn E2E Matrix

+

Completed ${timestamp}

+ + + + + ${headerCells} + + + + ${bodyRows} + +
Agent
+

+ Total: ${totalPass} passed, ${totalFail} failed +  ·  + Duration: ${duration} +

+`; + +const subject = totalFail === "0" + ? `✅ E2E Matrix: ${totalPass} passed · ${duration}` + : `❌ E2E Matrix: ${totalFail} failed, ${totalPass} passed · ${duration}`; + +const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${resendKey}`, + }, + body: JSON.stringify({ + from: "Spawn QA ", + to: [toEmail], + subject, + html, + }), +}); + +if (!res.ok) { + const body = await res.text(); + console.error(`Resend API error ${res.status}: ${body}`); + process.exit(1); +} +console.log(`Matrix email sent to ${toEmail}`); +TS_EOF + + log_info "Sending matrix email to ${to_email}..." + _E2E_RESULTS="${results}" \ + _E2E_CLOUDS="${clouds}" \ + _E2E_AGENTS="${agents}" \ + _E2E_TOTAL_PASS="${total_pass}" \ + _E2E_TOTAL_FAIL="${total_fail}" \ + _E2E_DURATION="${duration_str}" \ + bun run "${ts_file}" 2>&1 || log_warn "Failed to send matrix email" + + rm -f "${ts_file}" 2>/dev/null || true +} + # --------------------------------------------------------------------------- # Final cleanup trap # --------------------------------------------------------------------------- @@ -508,6 +650,9 @@ if [ "${total_fail}" -gt 0 ]; then fi printf "\n Duration: %s\n" "${DURATION_STR}" +# Send matrix email report +send_matrix_email "${LOG_DIR}" "${CLOUDS}" "${AGENTS_TO_TEST}" "${total_pass}" "${total_fail}" "${DURATION_STR}" + # Exit with failure if any agent on any cloud failed if [ "${total_fail}" -gt 0 ]; then exit 1