#!/usr/bin/env bash # Comprehensive E2E test — covers ALL platform API endpoints, workspace lifecycle, # parent-child A2A, peer delegation, secrets, config, bundles, approvals, memories, and more. # # Requires: platform running on :8080, Postgres + Redis up. # Does NOT require running agent containers (tests platform-only behavior). set -euo pipefail source "$(dirname "$0")/_lib.sh" # sets BASE default PASS=0 FAIL=0 SKIP=0 # Phase 30.1: tokens issued at /registry/register must be echoed back on # heartbeat, update-card, discover, and peers calls. PM_TOKEN="" DEV_TOKEN="" e2e_cleanup_all_workspaces check() { local desc="$1" expected="$2" actual="$3" if echo "$actual" | grep -qF "$expected"; then echo " PASS: $desc" PASS=$((PASS + 1)) else echo " FAIL: $desc" echo " expected: $expected" echo " got: ${actual:0:200}" FAIL=$((FAIL + 1)) fi } check_status() { local desc="$1" expected_code="$2" actual_code="$3" if [ "$actual_code" = "$expected_code" ]; then echo " PASS: $desc (HTTP $actual_code)" PASS=$((PASS + 1)) else echo " FAIL: $desc (expected HTTP $expected_code, got $actual_code)" FAIL=$((FAIL + 1)) fi } jq_extract() { python3 -c "import sys,json; print(json.load(sys.stdin)$1)" 2>/dev/null } echo "============================================" echo " Comprehensive Platform E2E Test Suite" echo "============================================" echo "" # ============================================================ # Section 1: Health & Metrics # ============================================================ echo "--- Section 1: Health & Metrics ---" R=$(curl -s "$BASE/health") check "GET /health" '"status":"ok"' "$R" CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/metrics") check_status "GET /metrics returns 200" "200" "$CODE" # ============================================================ # Section 2: Workspace CRUD # ============================================================ echo "" echo "--- Section 2: Workspace CRUD ---" # Create parent workspace (PM) and immediately register to capture its # auth token BEFORE the provisioner's container can spawn and claim it. # Tokens are single-issue on first /registry/register per workspace # (Phase 30.1) — if the container's main.py beats us, our later calls # get no token and bearer-protected endpoints fail. Empirically the # script wins the race 5/5 times when register fires right after # create; sections that depend on container readiness (RT_* in 2b) # still run normally. R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d '{"name":"Test PM","role":"Project Manager","tier":2}') check "Create PM" '"status":"provisioning"' "$R" PM_ID=$(echo "$R" | jq_extract "['id']") echo " PM_ID=$PM_ID" RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$PM_ID\",\"url\":\"http://localhost:9000\",\"agent_card\":{\"name\":\"PM\",\"skills\":[]}}") PM_TOKEN=$(echo "$RR" | e2e_extract_token) # Create child workspace under PM R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\"}") check "Create Dev (child of PM)" '"status":"provisioning"' "$R" DEV_ID=$(echo "$R" | jq_extract "['id']") RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$DEV_ID\",\"url\":\"http://localhost:9001\",\"agent_card\":{\"name\":\"Dev Agent\",\"skills\":[],\"version\":\"1.0.0\"}}") DEV_TOKEN=$(echo "$RR" | e2e_extract_token) # Create sibling R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\"}") check "Create QA (sibling of Dev)" '"status":"provisioning"' "$R" QA_ID=$(echo "$R" | jq_extract "['id']") curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$QA_ID\",\"url\":\"http://localhost:9002\",\"agent_card\":{\"name\":\"QA\",\"skills\":[]}}" > /dev/null # Create unrelated workspace R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d '{"name":"Test Outsider","role":"External","tier":1}') check "Create Outsider (unrelated)" '"status":"provisioning"' "$R" OUTSIDER_ID=$(echo "$R" | jq_extract "['id']") # List workspaces R=$(curl -s "$BASE/workspaces") check "List workspaces (4 total)" "$PM_ID" "$R" # Get single workspace R=$(curl -s "$BASE/workspaces/$PM_ID") check "Get PM by ID" '"name":"Test PM"' "$R" # Update workspace position R=$(curl -s -X PATCH "$BASE/workspaces/$PM_ID" -H "Content-Type: application/json" \ -d '{"x":100,"y":200}') check "Update PM position" '"status":"updated"' "$R" # Verify position persisted R=$(curl -s "$BASE/workspaces/$PM_ID") check "PM position persisted" '"x":100' "$R" # ============================================================ # Section 2b: Runtime Assignment & Image Selection # ============================================================ echo "" echo "--- Section 2b: Runtime Assignment ---" # Create workspace with explicit runtime R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d '{"name":"RT Claude","role":"Test","tier":2,"runtime":"claude-code"}') check "Create claude-code workspace" '"status":"provisioning"' "$R" RT_CC_ID=$(echo "$R" | jq_extract "['id']") R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d '{"name":"RT LangGraph","role":"Test","tier":2,"runtime":"langgraph"}') check "Create langgraph workspace" '"status":"provisioning"' "$R" RT_LG_ID=$(echo "$R" | jq_extract "['id']") R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d '{"name":"RT CrewAI","role":"Test","tier":2,"runtime":"crewai"}') check "Create crewai workspace" '"status":"provisioning"' "$R" RT_CR_ID=$(echo "$R" | jq_extract "['id']") # Wait for containers to start (poll up to 30s for first one to appear) if command -v docker &>/dev/null; then short_cc="${RT_CC_ID:0:12}" for _ in 1 2 3 4 5 6; do sleep 5 if docker inspect "ws-${short_cc}" >/dev/null 2>&1; then break; fi done _check_image() { local ws_id="$1" expected_tag="$2" label="$3" local short_id="${ws_id:0:12}" # Poll up to 30s for image to appear local actual_image="NOT_FOUND" for _ in 1 2 3 4 5 6; do actual_image=$(docker inspect "ws-${short_id}" --format '{{.Config.Image}}' 2>/dev/null || echo "NOT_FOUND") if echo "$actual_image" | grep -qF "$expected_tag"; then break; fi sleep 5 done if echo "$actual_image" | grep -qF "$expected_tag"; then echo " PASS: $label → $actual_image" PASS=$((PASS + 1)) else echo " FAIL: $label (expected *$expected_tag, got $actual_image)" FAIL=$((FAIL + 1)) fi } _check_image "$RT_CC_ID" "claude-code" "claude-code uses claude-code image" _check_image "$RT_LG_ID" "langgraph" "langgraph uses langgraph image" _check_image "$RT_CR_ID" "crewai" "crewai uses crewai image" else echo " SKIP: Docker not available — cannot verify container images" SKIP=$((SKIP + 3)) fi # Verify runtime in agent card after registration sleep 5 for rt_id in $RT_CC_ID $RT_LG_ID $RT_CR_ID; do # Register so we can check agent card curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$rt_id\",\"url\":\"http://localhost:19999\",\"agent_card\":{\"name\":\"Test\",\"skills\":[]}}" > /dev/null 2>&1 done # Config file should reflect runtime R=$(curl -s "$BASE/workspaces/$RT_CC_ID/files/config.yaml" 2>/dev/null) if echo "$R" | grep -qF "runtime: claude-code"; then echo " PASS: claude-code config.yaml has runtime: claude-code" PASS=$((PASS + 1)) elif echo "$R" | grep -qF "error"; then echo " SKIP: config.yaml not accessible (container may not be ready)" SKIP=$((SKIP + 1)) else echo " FAIL: claude-code config.yaml missing runtime field" FAIL=$((FAIL + 1)) fi # Verify runtime change persists on restart (if provisioner supports ExecRead) # Write a new runtime to config, restart, check image changes R=$(curl -s -X PUT "$BASE/workspaces/$RT_LG_ID/files/config.yaml" \ -H "Content-Type: application/json" \ -d '{"content":"name: RT LangGraph\nruntime: deepagents\nmodel: openai:gpt-4.1-mini\ntier: 2\n"}') if echo "$R" | grep -qF "saved"; then curl -s -X POST "$BASE/workspaces/$RT_LG_ID/restart" > /dev/null 2>&1 # Poll up to 30s for the new container image to appear (restart can take a while) if command -v docker &>/dev/null; then short_id="${RT_LG_ID:0:12}" for _ in 1 2 3 4 5 6; do sleep 5 actual=$(docker inspect "ws-${short_id}" --format '{{.Config.Image}}' 2>/dev/null || echo "") if echo "$actual" | grep -qF "deepagents"; then break; fi done _check_image "$RT_LG_ID" "deepagents" "Runtime change langgraph→deepagents on restart" else echo " SKIP: Docker not available" SKIP=$((SKIP + 1)) fi else echo " SKIP: Could not write config (container offline)" SKIP=$((SKIP + 1)) fi # Clean up runtime test workspaces for rt_id in $RT_CC_ID $RT_LG_ID $RT_CR_ID; do curl -s -X DELETE "$BASE/workspaces/$rt_id?confirm=true" > /dev/null 2>&1 sleep 0.3 done # ============================================================ # Section 3: Registry & Heartbeat # ============================================================ echo "" echo "--- Section 3: Registry & Heartbeat ---" # Dev was already registered in Section 2 right after creation (to beat # the provisioner in the token-issuance race). The async provisioner # may have flipped the status to "failed" in the meantime (no real # container image in test env). Send a fresh heartbeat first so status # goes back to online before we assert. curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"active_tasks\":0,\"uptime_seconds\":1}" > /dev/null R=$(curl -s "$BASE/workspaces/$DEV_ID") check "Dev status online after register" '"status":"online"' "$R" # Heartbeat with current_task R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"active_tasks\":1,\"current_task\":\"Running tests\"}") check "Heartbeat with task" '"status":"ok"' "$R" # Verify current_task visible R=$(curl -s "$BASE/workspaces/$DEV_ID") check "Current task visible" '"current_task":"Running tests"' "$R" # Heartbeat with error rate (trigger degraded — needs >0.5 AND registered) R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"error_rate\":0.8,\"sample_error\":\"timeout\"}") check "Degraded heartbeat" '"status":"ok"' "$R" # Verify degraded status sleep 1 R=$(curl -s "$BASE/workspaces/$DEV_ID") check "Dev degraded" '"last_error_rate":0.8' "$R" # Recover R=$(curl -s -X POST "$BASE/registry/heartbeat" -H "Content-Type: application/json" \ -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"error_rate\":0.0}") R=$(curl -s "$BASE/workspaces/$DEV_ID") check "Dev recovered" '"last_error_rate":0' "$R" # ============================================================ # Section 4: Discovery & Access Control # ============================================================ echo "" echo "--- Section 4: Discovery & Access Control ---" # PM was registered in Section 2 right after creation. # Discover requires X-Workspace-ID CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/registry/discover/$DEV_ID") check_status "Discover without header → 400" "400" "$CODE" # PM discovers Dev (parent→child: allowed) R=$(curl -s -H "X-Workspace-ID: $PM_ID" -H "Authorization: Bearer $PM_TOKEN" "$BASE/registry/discover/$DEV_ID") check "PM discovers Dev (parent→child)" "$DEV_ID" "$R" # Dev discovers QA (siblings: allowed) — QA was registered in Section 2 R=$(curl -s -H "X-Workspace-ID: $DEV_ID" -H "Authorization: Bearer $DEV_TOKEN" "$BASE/registry/discover/$QA_ID") check "Dev discovers QA (siblings)" "$QA_ID" "$R" # Check access: PM → Dev (allowed) R=$(curl -s -X POST "$BASE/registry/check-access" -H "Content-Type: application/json" \ -d "{\"caller_id\":\"$PM_ID\",\"target_id\":\"$DEV_ID\"}") check "Access PM→Dev (parent→child)" '"allowed":true' "$R" # Check access: Dev → Outsider (denied) R=$(curl -s -X POST "$BASE/registry/check-access" -H "Content-Type: application/json" \ -d "{\"caller_id\":\"$DEV_ID\",\"target_id\":\"$OUTSIDER_ID\"}") check "Access Dev→Outsider (denied)" '"allowed":false' "$R" # Peers — Dev should see PM and QA R=$(curl -s -H "X-Workspace-ID: $DEV_ID" -H "Authorization: Bearer $DEV_TOKEN" "$BASE/registry/$DEV_ID/peers") check "Dev peers include PM" "$PM_ID" "$R" check "Dev peers include QA" "$QA_ID" "$R" # ============================================================ # Section 5: Secrets # ============================================================ echo "" echo "--- Section 5: Secrets ---" # List secrets (initial state — may have secrets from org import .env) R=$(curl -s "$BASE/workspaces/$PM_ID/secrets") check "List secrets (responds)" '[' "$R" # Set a secret R=$(curl -s -X POST "$BASE/workspaces/$PM_ID/secrets" -H "Content-Type: application/json" \ -d '{"key":"OPENAI_API_KEY","value":"sk-test-12345"}') check "Set secret" '"status":"saved"' "$R" # List secrets (1 item, value not exposed) R=$(curl -s "$BASE/workspaces/$PM_ID/secrets") check "Secret listed" '"key":"OPENAI_API_KEY"' "$R" check "Secret value hidden" '"has_value":true' "$R" # Get model (derived from secrets or config) R=$(curl -s "$BASE/workspaces/$PM_ID/model") # Model endpoint returns whatever is configured check "Get model endpoint" '{' "$R" # Delete secret R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID/secrets/OPENAI_API_KEY") check "Delete secret" '"status":"deleted"' "$R" # ============================================================ # Section 6: Config & Files # ============================================================ echo "" echo "--- Section 6: Config & Files ---" # Note: Config read requires container or template — test error case CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/workspaces/$PM_ID/files/config.yaml") # May return 404 if no container/template exists (expected) echo " INFO: GET config.yaml → HTTP $CODE (expected 200 or 404)" # ============================================================ # Section 7: Workspace Memory (HMA) # ============================================================ echo "" # Pause before memory tests to avoid rate limits from prior sections sleep 3 echo "--- Section 7: Workspace Memory ---" # Commit LOCAL memory R=$(curl -s -X POST "$BASE/workspaces/$DEV_ID/memories" -H "Content-Type: application/json" \ -d '{"content":"Architecture uses Go + React","scope":"LOCAL"}') check "Commit LOCAL memory" '"scope":"LOCAL"' "$R" MEM_ID=$(echo "$R" | jq_extract "['id']" 2>/dev/null || echo "") # Commit TEAM memory R=$(curl -s -X POST "$BASE/workspaces/$DEV_ID/memories" -H "Content-Type: application/json" \ -d '{"content":"Sprint goal: ship v2.0 by Friday","scope":"TEAM"}') check "Commit TEAM memory" '"scope":"TEAM"' "$R" TEAM_MEM_ID=$(echo "$R" | jq_extract "['id']" 2>/dev/null || echo "") # List all memories R=$(curl -s "$BASE/workspaces/$DEV_ID/memories") check "List all memories" 'Architecture uses Go' "$R" check "List includes TEAM memory" 'Sprint goal' "$R" # Filter by scope R=$(curl -s "$BASE/workspaces/$DEV_ID/memories?scope=LOCAL") check "Filter LOCAL scope" 'Architecture' "$R" R=$(curl -s "$BASE/workspaces/$DEV_ID/memories?scope=TEAM") check "Filter TEAM scope" 'Sprint goal' "$R" # Invalid scope rejected R=$(curl -s -X POST "$BASE/workspaces/$DEV_ID/memories" -H "Content-Type: application/json" \ -d '{"content":"test","scope":"INVALID"}') check "Invalid scope rejected" 'error' "$R" # Empty content rejected R=$(curl -s -X POST "$BASE/workspaces/$DEV_ID/memories" -H "Content-Type: application/json" \ -d '{"content":"","scope":"LOCAL"}') check "Empty content rejected" 'error' "$R" # Memory persists across API calls (simulate recall after restart) R=$(curl -s "$BASE/workspaces/$DEV_ID/memories") check "Memory persists (recall)" 'Architecture' "$R" # Delete memory if [ -n "$MEM_ID" ]; then R=$(curl -s -X DELETE "$BASE/workspaces/$DEV_ID/memories/$MEM_ID") check "Delete LOCAL memory" '"status"' "$R" fi # Verify deleted memory is gone R=$(curl -s "$BASE/workspaces/$DEV_ID/memories?scope=LOCAL") if echo "$R" | grep -qF "Architecture"; then echo " FAIL: Deleted memory still visible" FAIL=$((FAIL + 1)) else echo " PASS: Deleted memory removed" PASS=$((PASS + 1)) fi # Clean up TEAM memory if [ -n "$TEAM_MEM_ID" ]; then curl -s -X DELETE "$BASE/workspaces/$DEV_ID/memories/$TEAM_MEM_ID" > /dev/null fi sleep 2 # Cross-workspace memory isolation — PM should NOT see Dev's LOCAL memories R=$(curl -s -X POST "$BASE/workspaces/$DEV_ID/memories" -H "Content-Type: application/json" \ -d '{"content":"Dev secret note","scope":"LOCAL"}') DEV_SECRET_ID=$(echo "$R" | jq_extract "['id']" 2>/dev/null || echo "") R=$(curl -s "$BASE/workspaces/$PM_ID/memories") if echo "$R" | grep -qF "Dev secret note"; then echo " FAIL: PM can see Dev's LOCAL memory (isolation broken)" FAIL=$((FAIL + 1)) else echo " PASS: Memory isolation — PM cannot see Dev's LOCAL" PASS=$((PASS + 1)) fi # Clean up if [ -n "$DEV_SECRET_ID" ]; then curl -s -X DELETE "$BASE/workspaces/$DEV_ID/memories/$DEV_SECRET_ID" > /dev/null fi # ============================================================ # Section 8: Activity Logging # ============================================================ echo "" echo "--- Section 8: Activity Logging ---" # Report activity R=$(curl -s -X POST "$BASE/workspaces/$DEV_ID/activity" -H "Content-Type: application/json" \ -d '{"activity_type":"agent_log","summary":"Running unit tests","status":"ok"}') check "Report activity" '"status"' "$R" # List activity R=$(curl -s "$BASE/workspaces/$DEV_ID/activity?limit=5") check "List activity" 'Running unit tests' "$R" # Filter by type R=$(curl -s "$BASE/workspaces/$DEV_ID/activity?type=agent_log") check "Filter activity by type" 'agent_log' "$R" # ============================================================ # Section 9: Events # ============================================================ echo "" echo "--- Section 9: Events ---" # List global events R=$(curl -s "$BASE/events") check "List global events" 'WORKSPACE_' "$R" # List events for PM R=$(curl -s "$BASE/events/$PM_ID") check "List PM events" "$PM_ID" "$R" # ============================================================ # Section 10: Approvals # ============================================================ echo "" echo "--- Section 10: Approvals ---" # Create approval request R=$(curl -s -X POST "$BASE/workspaces/$DEV_ID/approvals" -H "Content-Type: application/json" \ -d '{"action":"deploy to production","reason":"All tests passing"}') check "Create approval" '"status":"pending"' "$R" APPROVAL_ID=$(echo "$R" | jq_extract "['id']" 2>/dev/null || echo "") # List pending approvals R=$(curl -s "$BASE/approvals/pending") check "List pending approvals" 'deploy to production' "$R" # List workspace approvals R=$(curl -s "$BASE/workspaces/$DEV_ID/approvals") check "List Dev approvals" 'deploy to production' "$R" # Decide approval if [ -n "$APPROVAL_ID" ]; then R=$(curl -s -X POST "$BASE/workspaces/$DEV_ID/approvals/$APPROVAL_ID/decide" \ -H "Content-Type: application/json" -d '{"approved":true,"decided_by":"admin"}') check "Approve request" '"approved":true' "$R" fi # ============================================================ # Section 11: Canvas Viewport # ============================================================ echo "" echo "--- Section 11: Canvas Viewport ---" R=$(curl -s -X PUT "$BASE/canvas/viewport" -H "Content-Type: application/json" \ -d '{"x":50,"y":100,"zoom":1.5}') check "Save viewport" '"status":"saved"' "$R" R=$(curl -s "$BASE/canvas/viewport") check "Get viewport" '"zoom":1.5' "$R" # ============================================================ # Section 12: Agent Card Update # ============================================================ echo "" echo "--- Section 12: Agent Card Update ---" R=$(curl -s -X POST "$BASE/registry/update-card" -H "Content-Type: application/json" \ -H "Authorization: Bearer $DEV_TOKEN" \ -d "{\"workspace_id\":\"$DEV_ID\",\"agent_card\":{\"name\":\"Dev Agent v2\",\"skills\":[{\"id\":\"code\",\"name\":\"Coding\"}],\"version\":\"2.0.0\"}}") check "Update agent card" '"status":"updated"' "$R" R=$(curl -s "$BASE/workspaces/$DEV_ID") check "Agent card updated" '"name":"Dev Agent v2"' "$R" # ============================================================ # Section 13: Bundle Export/Import # ============================================================ echo "" sleep 3 echo "--- Section 13: Bundle Export/Import ---" # Export PM bundle R=$(curl -s "$BASE/bundles/export/$PM_ID") check "Export PM bundle" '"name":"Test PM"' "$R" check "Bundle has workspace data" '"name":"Test PM"' "$R" # Import bundle (create from exported data) BUNDLE=$(curl -s "$BASE/bundles/export/$PM_ID") R=$(curl -s -X POST "$BASE/bundles/import" -H "Content-Type: application/json" -d "$BUNDLE") check "Import bundle" '"status"' "$R" # ============================================================ # Section 14: Workspace Delete (Cascade) # ============================================================ echo "" echo "--- Section 14: Cleanup & Delete ---" # Delete with children — should require confirmation R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID") check "Delete PM requires confirmation" '"confirmation_required"' "$R" # Delete with confirmation R=$(curl -s -X DELETE "$BASE/workspaces/$PM_ID?confirm=true") check "Delete PM cascades" '"cascade_deleted"' "$R" # Delete outsider curl -s -X DELETE "$BASE/workspaces/$OUTSIDER_ID?confirm=true" > /dev/null # Clean up remaining workspaces (bundle imports, runtime test workspaces, etc.) sleep 2 curl -s "$BASE/workspaces" | python3 -c " import json, sys, subprocess, time ws = json.load(sys.stdin) for w in ws: time.sleep(0.5) # avoid rate limit subprocess.run(['curl', '-s', '-X', 'DELETE', '$BASE/workspaces/' + w['id'] + '?confirm=true'], capture_output=True) " 2>/dev/null # Poll for clean state up to 30s — DB cascade + container stop is async on busy systems for _ in 1 2 3 4 5 6; do sleep 5 R=$(curl -s "$BASE/workspaces") if [ "$R" = "[]" ]; then break; fi done check "All workspaces cleaned" '[]' "$R" # ============================================================ # Summary # ============================================================ echo "" echo "============================================" echo " Results: $PASS passed, $FAIL failed, $SKIP skipped" echo " Total: $((PASS + FAIL + SKIP)) checks" echo "============================================" [ "$FAIL" -eq 0 ] && exit 0 || exit 1