#!/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 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 "$@"