diff --git a/tests/e2e_spawn.sh b/tests/e2e_spawn.sh new file mode 100755 index 0000000..43f07a7 --- /dev/null +++ b/tests/e2e_spawn.sh @@ -0,0 +1,617 @@ +#!/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 "$@"