Files
amc/tests/e2e_spawn.sh
2026-02-26 17:09:49 -05:00

618 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# E2E test script for the AMC spawn workflow.
# Tests the full flow from API call to Zellij pane creation.
#
# Usage:
# ./tests/e2e_spawn.sh # Safe mode (no actual spawning)
# ./tests/e2e_spawn.sh --spawn # Full test including real agent spawn
SERVER_URL="http://localhost:7400"
TEST_PROJECT="amc" # Must exist in ~/projects/
AUTH_TOKEN=""
SPAWN_MODE=false
PASSED=0
FAILED=0
SKIPPED=0
# Parse args
for arg in "$@"; do
case "$arg" in
--spawn) SPAWN_MODE=true ;;
*) echo "Unknown arg: $arg"; exit 2 ;;
esac
done
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_test() { echo -e "\n${GREEN}[TEST]${NC} $1"; }
log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; ((PASSED++)); }
log_fail() { echo -e "${RED}[FAIL]${NC} $1"; ((FAILED++)); }
log_skip() { echo -e "${YELLOW}[SKIP]${NC} $1"; ((SKIPPED++)); }
# Track spawned panes for cleanup
SPAWNED_PANE_NAMES=()
cleanup() {
if [[ ${#SPAWNED_PANE_NAMES[@]} -gt 0 ]]; then
log_info "Cleaning up spawned panes..."
for pane_name in "${SPAWNED_PANE_NAMES[@]}"; do
# Best-effort: close panes we spawned during tests
zellij --session infra action close-pane --name "$pane_name" 2>/dev/null || true
done
fi
}
trap cleanup EXIT
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
preflight() {
log_test "Pre-flight checks"
# curl available?
if ! command -v curl &>/dev/null; then
log_error "curl not found"
exit 1
fi
# jq available?
if ! command -v jq &>/dev/null; then
log_error "jq not found (required for JSON assertions)"
exit 1
fi
# Server running?
if ! curl -sf "${SERVER_URL}/api/health" >/dev/null 2>&1; then
log_error "Server not running at ${SERVER_URL}"
log_error "Start with: python -m amc_server.server"
exit 1
fi
# Test project exists?
if [[ ! -d "$HOME/projects/${TEST_PROJECT}" ]]; then
log_error "Test project '${TEST_PROJECT}' not found at ~/projects/${TEST_PROJECT}"
exit 1
fi
log_pass "Pre-flight checks passed"
}
# ---------------------------------------------------------------------------
# Extract auth token from dashboard HTML
# ---------------------------------------------------------------------------
extract_auth_token() {
log_test "Extract auth token from dashboard"
local html
html=$(curl -sf "${SERVER_URL}/")
AUTH_TOKEN=$(echo "$html" | grep -o 'AMC_AUTH_TOKEN = "[^"]*"' | cut -d'"' -f2)
if [[ -z "$AUTH_TOKEN" ]]; then
log_error "Could not extract auth token from dashboard HTML"
log_error "Check that index.html contains <!-- AMC_AUTH_TOKEN --> placeholder"
exit 1
fi
log_pass "Auth token extracted (${AUTH_TOKEN:0:8}...)"
}
# ---------------------------------------------------------------------------
# Test: GET /api/health
# ---------------------------------------------------------------------------
test_health_endpoint() {
log_test "GET /api/health"
local response
response=$(curl -sf "${SERVER_URL}/api/health")
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "true" ]]; then
log_fail "Health endpoint returned ok=$ok"
return
fi
# Must include zellij_available and zellij_session fields
local has_zellij_available has_zellij_session
has_zellij_available=$(echo "$response" | jq 'has("zellij_available")')
has_zellij_session=$(echo "$response" | jq 'has("zellij_session")')
if [[ "$has_zellij_available" != "true" || "$has_zellij_session" != "true" ]]; then
log_fail "Health response missing expected fields: $response"
return
fi
local zellij_available
zellij_available=$(echo "$response" | jq -r '.zellij_available')
log_pass "Health OK (zellij_available=$zellij_available)"
}
# ---------------------------------------------------------------------------
# Test: GET /api/projects
# ---------------------------------------------------------------------------
test_projects_endpoint() {
log_test "GET /api/projects"
local response
response=$(curl -sf "${SERVER_URL}/api/projects")
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "true" ]]; then
log_fail "Projects endpoint returned ok=$ok"
return
fi
local project_count
project_count=$(echo "$response" | jq '.projects | length')
if [[ "$project_count" -lt 1 ]]; then
log_fail "No projects returned (expected at least 1)"
return
fi
# Verify test project is in the list
local has_test_project
has_test_project=$(echo "$response" | jq --arg p "$TEST_PROJECT" '[.projects[] | select(. == $p)] | length')
if [[ "$has_test_project" -lt 1 ]]; then
log_fail "Test project '$TEST_PROJECT' not in projects list"
return
fi
log_pass "Projects OK ($project_count projects, '$TEST_PROJECT' present)"
}
# ---------------------------------------------------------------------------
# Test: POST /api/projects/refresh
# ---------------------------------------------------------------------------
test_projects_refresh() {
log_test "POST /api/projects/refresh"
local response
response=$(curl -sf -X POST "${SERVER_URL}/api/projects/refresh")
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "true" ]]; then
log_fail "Projects refresh returned ok=$ok"
return
fi
local project_count
project_count=$(echo "$response" | jq '.projects | length')
log_pass "Projects refresh OK ($project_count projects)"
}
# ---------------------------------------------------------------------------
# Test: Spawn without auth (should return 401)
# ---------------------------------------------------------------------------
test_spawn_no_auth() {
log_test "POST /api/spawn without auth (expect 401)"
local http_code body
body=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-d '{"project":"amc","agent_type":"claude"}')
if [[ "$body" != "401" ]]; then
log_fail "Expected HTTP 401, got $body"
return
fi
# Also verify the JSON error code
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-d '{"project":"amc","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "UNAUTHORIZED" ]]; then
log_fail "Expected code UNAUTHORIZED, got $code"
return
fi
log_pass "Correctly rejected unauthorized request (401/UNAUTHORIZED)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with wrong token (should return 401)
# ---------------------------------------------------------------------------
test_spawn_wrong_token() {
log_test "POST /api/spawn with wrong token (expect 401)"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer totally-wrong-token" \
-d '{"project":"amc","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "UNAUTHORIZED" ]]; then
log_fail "Expected UNAUTHORIZED, got $code"
return
fi
log_pass "Correctly rejected wrong token (UNAUTHORIZED)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with malformed auth (no Bearer prefix)
# ---------------------------------------------------------------------------
test_spawn_malformed_auth() {
log_test "POST /api/spawn with malformed auth header"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Token ${AUTH_TOKEN}" \
-d '{"project":"amc","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "UNAUTHORIZED" ]]; then
log_fail "Expected UNAUTHORIZED for malformed auth, got $code"
return
fi
log_pass "Correctly rejected malformed auth header"
}
# ---------------------------------------------------------------------------
# Test: Spawn with invalid JSON body
# ---------------------------------------------------------------------------
test_spawn_invalid_json() {
log_test "POST /api/spawn with invalid JSON"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d 'not valid json!!!')
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "false" ]]; then
log_fail "Expected ok=false for invalid JSON, got ok=$ok"
return
fi
log_pass "Correctly rejected invalid JSON body"
}
# ---------------------------------------------------------------------------
# Test: Spawn with path traversal (should return 400/INVALID_PROJECT)
# ---------------------------------------------------------------------------
test_spawn_path_traversal() {
log_test "POST /api/spawn with path traversal"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d '{"project":"../etc/passwd","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "INVALID_PROJECT" ]]; then
log_fail "Expected INVALID_PROJECT for path traversal, got $code"
return
fi
log_pass "Correctly rejected path traversal (INVALID_PROJECT)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with nonexistent project
# ---------------------------------------------------------------------------
test_spawn_nonexistent_project() {
log_test "POST /api/spawn with nonexistent project"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d '{"project":"this-project-does-not-exist-xyz","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "PROJECT_NOT_FOUND" ]]; then
log_fail "Expected PROJECT_NOT_FOUND, got $code"
return
fi
log_pass "Correctly rejected nonexistent project (PROJECT_NOT_FOUND)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with invalid agent type
# ---------------------------------------------------------------------------
test_spawn_invalid_agent_type() {
log_test "POST /api/spawn with invalid agent type"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d "{\"project\":\"${TEST_PROJECT}\",\"agent_type\":\"gpt5\"}")
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "INVALID_AGENT_TYPE" ]]; then
log_fail "Expected INVALID_AGENT_TYPE, got $code"
return
fi
log_pass "Correctly rejected invalid agent type (INVALID_AGENT_TYPE)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with missing project field
# ---------------------------------------------------------------------------
test_spawn_missing_project() {
log_test "POST /api/spawn with missing project"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d '{"agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "MISSING_PROJECT" ]]; then
log_fail "Expected MISSING_PROJECT, got $code"
return
fi
log_pass "Correctly rejected missing project field (MISSING_PROJECT)"
}
# ---------------------------------------------------------------------------
# Test: Spawn with backslash in project name
# ---------------------------------------------------------------------------
test_spawn_backslash_project() {
log_test "POST /api/spawn with backslash in project name"
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d '{"project":"foo\\bar","agent_type":"claude"}')
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" != "INVALID_PROJECT" ]]; then
log_fail "Expected INVALID_PROJECT for backslash, got $code"
return
fi
log_pass "Correctly rejected backslash in project name (INVALID_PROJECT)"
}
# ---------------------------------------------------------------------------
# Test: CORS preflight for /api/spawn
# ---------------------------------------------------------------------------
test_cors_preflight() {
log_test "OPTIONS /api/spawn (CORS preflight)"
local http_code headers
headers=$(curl -sI -X OPTIONS "${SERVER_URL}/api/spawn" 2>/dev/null)
http_code=$(echo "$headers" | head -1 | grep -o '[0-9][0-9][0-9]' | head -1)
if [[ "$http_code" != "204" ]]; then
log_fail "Expected HTTP 204 for OPTIONS, got $http_code"
return
fi
if ! echo "$headers" | grep -qi 'Access-Control-Allow-Methods'; then
log_fail "Missing Access-Control-Allow-Methods header"
return
fi
if ! echo "$headers" | grep -qi 'Authorization'; then
log_fail "Authorization not in Access-Control-Allow-Headers"
return
fi
log_pass "CORS preflight OK (204 with correct headers)"
}
# ---------------------------------------------------------------------------
# Test: Actual spawn (only with --spawn flag)
# ---------------------------------------------------------------------------
test_spawn_valid() {
if [[ "$SPAWN_MODE" != "true" ]]; then
log_skip "Actual spawn test (pass --spawn to enable)"
return
fi
log_test "POST /api/spawn with valid project (LIVE)"
# Check Zellij session first
if ! zellij list-sessions 2>/dev/null | grep -q '^infra'; then
log_skip "Zellij session 'infra' not found - cannot test live spawn"
return
fi
# Count session files before
local sessions_dir="$HOME/.local/share/amc/sessions"
local count_before=0
if [[ -d "$sessions_dir" ]]; then
count_before=$(find "$sessions_dir" -name '*.json' -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
fi
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d "{\"project\":\"${TEST_PROJECT}\",\"agent_type\":\"claude\"}")
local ok
ok=$(echo "$response" | jq -r '.ok')
if [[ "$ok" != "true" ]]; then
local error_code
error_code=$(echo "$response" | jq -r '.code // .error')
log_fail "Spawn failed: $error_code"
return
fi
# Verify spawn_id is returned
local spawn_id
spawn_id=$(echo "$response" | jq -r '.spawn_id')
if [[ -z "$spawn_id" || "$spawn_id" == "null" ]]; then
log_fail "No spawn_id in response"
return
fi
# Track for cleanup
SPAWNED_PANE_NAMES+=("claude-${TEST_PROJECT}")
# Verify session_file_found field
local session_found
session_found=$(echo "$response" | jq -r '.session_file_found')
log_info "session_file_found=$session_found, spawn_id=${spawn_id:0:8}..."
log_pass "Spawn successful (spawn_id=${spawn_id:0:8}...)"
}
# ---------------------------------------------------------------------------
# Test: Rate limiting (only with --spawn flag)
# ---------------------------------------------------------------------------
test_rate_limiting() {
if [[ "$SPAWN_MODE" != "true" ]]; then
log_skip "Rate limiting test (pass --spawn to enable)"
return
fi
log_test "Rate limiting on rapid spawn"
# Immediately try to spawn the same project again
local response
response=$(curl -s -X POST "${SERVER_URL}/api/spawn" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-d "{\"project\":\"${TEST_PROJECT}\",\"agent_type\":\"claude\"}")
local code
code=$(echo "$response" | jq -r '.code')
if [[ "$code" == "RATE_LIMITED" ]]; then
log_pass "Rate limiting active (RATE_LIMITED returned)"
else
local ok
ok=$(echo "$response" | jq -r '.ok')
log_warn "Rate limiting not triggered (ok=$ok, code=$code) - cooldown may have expired"
log_pass "Rate limiting test completed (non-deterministic)"
fi
}
# ---------------------------------------------------------------------------
# Test: Dashboard shows agent after spawn (only with --spawn flag)
# ---------------------------------------------------------------------------
test_dashboard_shows_agent() {
if [[ "$SPAWN_MODE" != "true" ]]; then
log_skip "Dashboard agent visibility test (pass --spawn to enable)"
return
fi
log_test "Dashboard /api/state includes spawned agent"
# Give the session a moment to register
sleep 2
local response
response=$(curl -sf "${SERVER_URL}/api/state")
local session_count
session_count=$(echo "$response" | jq '.sessions | length')
if [[ "$session_count" -gt 0 ]]; then
log_pass "Dashboard shows $session_count session(s)"
else
log_warn "No sessions visible yet (agent may still be starting)"
log_pass "Dashboard state endpoint responsive"
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
echo "========================================="
echo " AMC Spawn Workflow E2E Tests"
echo "========================================="
echo ""
if [[ "$SPAWN_MODE" == "true" ]]; then
log_warn "SPAWN MODE: will create real Zellij panes"
else
log_info "Safe mode (no actual spawning). Pass --spawn to test live spawn."
fi
preflight
extract_auth_token
# Read-only endpoint tests
test_health_endpoint
test_projects_endpoint
test_projects_refresh
# Auth / validation tests (no side effects)
test_spawn_no_auth
test_spawn_wrong_token
test_spawn_malformed_auth
test_spawn_invalid_json
test_spawn_path_traversal
test_spawn_nonexistent_project
test_spawn_invalid_agent_type
test_spawn_missing_project
test_spawn_backslash_project
# CORS
test_cors_preflight
# Live spawn tests (only with --spawn)
test_spawn_valid
test_rate_limiting
test_dashboard_shows_agent
# Summary
echo ""
echo "========================================="
echo " Results: ${PASSED} passed, ${FAILED} failed, ${SKIPPED} skipped"
echo "========================================="
if [[ "$FAILED" -gt 0 ]]; then
exit 1
fi
}
main "$@"