molecule-core/tests/e2e/test_comprehensive_e2e.sh
Hongming Wang e3db196077 fix(e2e): make provisioning-status assertions robust to CI environment
CI run of test_api.sh failed on "Re-imported workspace exists" because
the assertion checked for status:"provisioning" but the async
provisioner flipped the workspace to status:"failed" first (CI has no
Docker images for agent runtimes — autogen/langgraph containers can't
actually start there).

Root cause is the same thing the rest of the E2E suite handles: the
test is about bundle round-trip fidelity, not provisioning success.

Fixes:
- test_api.sh: assert workspace id is present, not a specific status
- test_comprehensive_e2e.sh: send a fresh heartbeat before the
  "Dev status online after register" check so status is re-asserted
  to online regardless of what the provisioner did async

Verified locally against the same no-Docker-image state as CI:
- test_api.sh              -> 62/62
- test_comprehensive_e2e.sh -> 67/67

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:31:07 -07:00

592 lines
23 KiB
Bash
Executable File

#!/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