spawn/test/mock.sh
A 5181f28704
feat: Add local cloud provider for running agents on local machine (#381)
Adds a "local" cloud provider that installs and runs agents directly on the
user's machine without any cloud provisioning. This is useful for local
development and testing.

- local/lib/common.sh: Cloud lib with local execution functions
- local/claude.sh: Claude Code agent script
- local/openclaw.sh: OpenClaw agent script
- local/nanoclaw.sh: NanoClaw agent script
- manifest.json: Added local cloud + matrix entries
- test/: Updated record.sh and mock.sh for local cloud support

Fixes #378

Agent: issue-fixer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-11 00:50:05 -08:00

638 lines
20 KiB
Bash

#!/bin/bash
# Fixture-based mock test suite for cloud provider agent scripts
#
# Uses recorded API responses from test/fixtures/{cloud}/ to test
# every agent script without making real API calls.
#
# Usage:
# bash test/mock.sh # Test all clouds with fixtures
# bash test/mock.sh hetzner # Test all agents on one cloud
# bash test/mock.sh hetzner claude # Test one agent on one cloud
set -eo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FIXTURES_DIR="${REPO_ROOT}/test/fixtures"
TEST_DIR=$(mktemp -d)
MOCK_LOG="${TEST_DIR}/mock_calls.log"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# Counters
PASSED=0
FAILED=0
SKIPPED=0
# Cleanup on exit
cleanup() {
rm -rf "${TEST_DIR}"
}
trap cleanup EXIT
# ============================================================
# Assertions (same pattern as test/run.sh)
# ============================================================
assert_exit_code() {
local actual="$1"
local expected="$2"
local msg="$3"
if [[ "${actual}" -eq "${expected}" ]]; then
printf '%b\n' " ${GREEN}${NC} ${msg}"
PASSED=$((PASSED + 1))
else
printf '%b\n' " ${RED}${NC} ${msg} (got exit code ${actual})"
FAILED=$((FAILED + 1))
fi
}
assert_log_contains() {
local pattern="$1"
local msg="$2"
if grep -qE "${pattern}" "${MOCK_LOG}" 2>/dev/null; then
printf '%b\n' " ${GREEN}${NC} ${msg}"
PASSED=$((PASSED + 1))
else
printf '%b\n' " ${RED}${NC} ${msg}"
FAILED=$((FAILED + 1))
fi
}
# ============================================================
# Mock setup
# ============================================================
setup_mock_curl() {
cat > "${TEST_DIR}/curl" << 'MOCKCURL'
#!/bin/bash
# Mock curl — returns fixture data based on URL
# Env vars from parent: MOCK_LOG, MOCK_FIXTURE_DIR, MOCK_CLOUD
# Parse curl arguments
METHOD="GET"
URL=""
HAS_WRITE_OUT=false
prev_flag=""
for arg in "$@"; do
case "$prev_flag" in
-X) METHOD="$arg"; prev_flag=""; continue ;;
-w)
case "$arg" in
*http_code*) HAS_WRITE_OUT=true ;;
esac
prev_flag=""; continue
;;
-d|-H|-o|-u|--connect-timeout|--max-time|--retry|--retry-delay) prev_flag=""; continue ;;
esac
case "$arg" in
-X|-w|-d|-H|-o|-u|--connect-timeout|--max-time|--retry|--retry-delay) prev_flag="$arg"; continue ;;
-s|-f|-S|-L|-k|-#|-fsSL|-fsS|-sS) continue ;;
http://*|https://*) URL="$arg" ;;
esac
done
echo "curl ${METHOD} ${URL}" >> "${MOCK_LOG}"
# --- Install script downloads → return no-op ---
case "$URL" in
*claude.ai/install*|*bun.sh*|*goose*download_cli*|*nodesource*|*plandex.ai*|*opencode*install*|\
*pip.pypa.io*|*get.docker.com*|*raw.githubusercontent.com/block/goose*|*install.python-poetry.org*|\
*npmjs.org*|*deb.nodesource.com*|*github.com/*/releases*|*cli.github.com*)
printf '#!/bin/bash\nexit 0\n'
exit 0
;;
*raw.githubusercontent.com/OpenRouterTeam/spawn/*)
# Remote source fallback — serve local file instead
local_path="${MOCK_REPO_ROOT}/${URL##*spawn/main/}"
if [ -f "$local_path" ]; then
cat "$local_path"
fi
exit 0
;;
*openrouter.ai*)
# OAuth check / API key exchange — return success
printf '{"key":"sk-or-v1-mock"}\n'
if [ "$HAS_WRITE_OUT" = "true" ]; then printf '\n200'; fi
exit 0
;;
esac
# --- API calls: route by cloud + endpoint ---
if [ -z "$URL" ]; then
exit 0
fi
# Strip API base to get endpoint
ENDPOINT="$URL"
case "$URL" in
https://api.hetzner.cloud/v1*) ENDPOINT="${URL#https://api.hetzner.cloud/v1}" ;;
https://api.digitalocean.com/v2*) ENDPOINT="${URL#https://api.digitalocean.com/v2}" ;;
https://api.vultr.com/v2*) ENDPOINT="${URL#https://api.vultr.com/v2}" ;;
https://api.linode.com/v4*) ENDPOINT="${URL#https://api.linode.com/v4}" ;;
https://api.lambdalabs.com/v1*) ENDPOINT="${URL#https://api.lambdalabs.com/v1}" ;;
https://api.civo.com/v2*) ENDPOINT="${URL#https://api.civo.com/v2}" ;;
https://api.upcloud.com/1.3*) ENDPOINT="${URL#https://api.upcloud.com/1.3}" ;;
https://api.binarylane.com.au/v2*) ENDPOINT="${URL#https://api.binarylane.com.au/v2}" ;;
https://api.scaleway.com/*) ENDPOINT=$(echo "$URL" | sed 's|https://api.scaleway.com/instance/v1/zones/[^/]*/||') ;;
https://api.genesiscloud.com/compute/v1*) ENDPOINT="${URL#https://api.genesiscloud.com/compute/v1}" ;;
https://console.kamatera.com/svc*) ENDPOINT="${URL#https://console.kamatera.com/svc}" ;;
https://api.latitude.sh*) ENDPOINT="${URL#https://api.latitude.sh}" ;;
https://infrahub-api.nexgencloud.com/v1*) ENDPOINT="${URL#https://infrahub-api.nexgencloud.com/v1}" ;;
*eu.api.ovh.com*) ENDPOINT=$(echo "$URL" | sed 's|https://eu.api.ovh.com/1.0||') ;;
esac
# Strip query params for matching
EP_CLEAN=$(echo "$ENDPOINT" | sed 's|?.*||')
# Try to find fixture file
_try_fixture() {
local f="${MOCK_FIXTURE_DIR}/$1.json"
if [ -f "$f" ]; then
cat "$f"
return 0
fi
return 1
}
# Route based on METHOD + endpoint
case "$METHOD" in
GET)
# Normalize endpoint to fixture name: strip leading /, replace / with _
FIXTURE_NAME=$(echo "$EP_CLEAN" | sed 's|^/||; s|/|_|g')
# Check if the last path segment is an ID (contains digits)
# e.g. /droplets/12345678, /instances/test-uuid-1234 → ID
# e.g. /droplets, /instances, /account/keys → collection
HAS_ID_SUFFIX=false
LAST_SEG=$(echo "$EP_CLEAN" | sed 's|.*/||')
case "$LAST_SEG" in
*[0-9]*) HAS_ID_SUFFIX=true ;;
esac
# Try exact fixture match first
if _try_fixture "$FIXTURE_NAME"; then
:
elif [ "$HAS_ID_SUFFIX" = "false" ]; then
# Only fall back to base fixture for list endpoints (no ID),
# not for single-resource lookups like /droplets/12345678
FIXTURE_NAME_BASE=$(echo "$FIXTURE_NAME" | sed 's|_[0-9a-f-]*$||')
_try_fixture "$FIXTURE_NAME_BASE" || printf '{}'
else
# Single-resource lookup — return synthetic "active" response
case "$MOCK_CLOUD" in
digitalocean)
printf '{"droplet":{"id":12345678,"name":"test-srv","status":"active","networks":{"v4":[{"ip_address":"10.0.0.1","type":"public"}]}}}'
;;
vultr)
printf '{"instance":{"id":"test-uuid-1234","main_ip":"10.0.0.1","status":"active","power_status":"running","label":"test-srv"}}'
;;
linode)
printf '{"id":12345678,"label":"test-srv","status":"running","ipv4":["10.0.0.1"]}'
;;
hetzner)
printf '{"server":{"id":99999,"name":"test-srv","status":"running","public_net":{"ipv4":{"ip":"10.0.0.1"}}}}'
;;
*) printf '{}' ;;
esac
fi
;;
POST)
case "$EP_CLEAN" in
/ssh_keys|/ssh-keys|/account/keys|/profile/sshkeys|/sshkeys|*/sshkey)
# SSH key registration — return success
printf '{"ssh_key":{"id":99999,"name":"test-key","fingerprint":"af:0d:c5:57:a8:fd:b2:82:5e:d4:c1:65:f0:0c:8a:9d"}}'
;;
*)
# Server/instance creation
if _try_fixture "create_server"; then
:
else
case "$MOCK_CLOUD" in
hetzner)
printf '{"server":{"id":99999,"name":"test-srv","public_net":{"ipv4":{"ip":"10.0.0.1"}}},"action":{"id":1,"status":"running"}}'
;;
digitalocean)
printf '{"droplet":{"id":12345678,"name":"test-srv","status":"new","networks":{"v4":[{"ip_address":"10.0.0.1","type":"public"}]}}}'
;;
vultr)
printf '{"instance":{"id":"test-uuid-1234","main_ip":"10.0.0.1","status":"active","power_status":"running","label":"test-srv"}}'
;;
linode)
printf '{"id":12345678,"label":"test-srv","status":"running","ipv4":["10.0.0.1"]}'
;;
*)
printf '{"id":"test-id","status":"active","ip":"10.0.0.1"}'
;;
esac
fi
;;
esac
;;
DELETE)
if _try_fixture "delete_server"; then
:
else
printf '{}'
fi
;;
*)
printf '{}'
;;
esac
# Append HTTP status code if -w was used
if [ "$HAS_WRITE_OUT" = "true" ]; then
printf '\n200'
fi
exit 0
MOCKCURL
chmod +x "${TEST_DIR}/curl"
}
setup_mock_ssh() {
# Mock ssh — log and succeed
cat > "${TEST_DIR}/ssh" << 'MOCKSSH'
#!/bin/bash
echo "ssh $*" >> "${MOCK_LOG}"
exit 0
MOCKSSH
chmod +x "${TEST_DIR}/ssh"
# Mock scp — log and succeed
cat > "${TEST_DIR}/scp" << 'MOCKSCP'
#!/bin/bash
echo "scp $*" >> "${MOCK_LOG}"
exit 0
MOCKSCP
chmod +x "${TEST_DIR}/scp"
}
setup_mock_agents() {
# Agent binaries
local agents="claude aider goose codex interpreter gemini amazonq cline gptme opencode plandex kilocode openclaw nanoclaw q"
for agent in $agents; do
cat > "${TEST_DIR}/${agent}" << MOCK
#!/bin/bash
echo "${agent} \$*" >> "\${MOCK_LOG}"
exit 0
MOCK
chmod +x "${TEST_DIR}/${agent}"
done
# Tools used during agent install
local tools="pip pip3 npm npx bun node openssl shred cargo go"
for tool in $tools; do
cat > "${TEST_DIR}/${tool}" << MOCK
#!/bin/bash
echo "${tool} \$*" >> "\${MOCK_LOG}"
exit 0
MOCK
chmod +x "${TEST_DIR}/${tool}"
done
# Mock 'clear' to prevent terminal clearing
cat > "${TEST_DIR}/clear" << 'MOCK'
#!/bin/bash
exit 0
MOCK
chmod +x "${TEST_DIR}/clear"
# Mock 'sleep' to speed up tests
cat > "${TEST_DIR}/sleep" << 'MOCK'
#!/bin/bash
exit 0
MOCK
chmod +x "${TEST_DIR}/sleep"
# Mock 'ssh-keygen' — returns MD5 fingerprint matching fixture data
cat > "${TEST_DIR}/ssh-keygen" << 'MOCK'
#!/bin/bash
echo "ssh-keygen $*" >> "${MOCK_LOG}"
# Check for -l flag (fingerprint listing)
for arg in "$@"; do
case "$arg" in
-l*) echo "256 MD5:af:0d:c5:57:a8:fd:b2:82:5e:d4:c1:65:f0:0c:8a:9d test@test (ED25519)"; exit 0 ;;
esac
done
# Parse -f flag for key creation
KEY_PATH=""
prev=""
for arg in "$@"; do
if [ "$prev" = "-f" ]; then
KEY_PATH="$arg"
fi
prev="$arg"
done
if [ -n "$KEY_PATH" ]; then
mkdir -p "$(dirname "$KEY_PATH")"
touch "$KEY_PATH"
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHmcVdzydp72a/B69nmENZvCvjuk7xGpKdi5CvhkmNsv test@test" > "${KEY_PATH}.pub"
fi
exit 0
MOCK
chmod +x "${TEST_DIR}/ssh-keygen"
# Mock 'git' for agents that clone repos
cat > "${TEST_DIR}/git" << 'MOCK'
#!/bin/bash
echo "git $*" >> "${MOCK_LOG}"
exit 0
MOCK
chmod +x "${TEST_DIR}/git"
}
setup_fake_home() {
local fake_home="${TEST_DIR}/fakehome"
mkdir -p "${fake_home}/.ssh"
mkdir -p "${fake_home}/.config/spawn"
mkdir -p "${fake_home}/.claude"
mkdir -p "${fake_home}/.local/bin"
# Create dummy SSH key pair
echo "-----BEGIN OPENSSH PRIVATE KEY-----" > "${fake_home}/.ssh/id_ed25519"
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHmcVdzydp72a/B69nmENZvCvjuk7xGpKdi5CvhkmNsv test@test" > "${fake_home}/.ssh/id_ed25519.pub"
chmod 600 "${fake_home}/.ssh/id_ed25519"
echo "${fake_home}"
}
# ============================================================
# Cloud-specific env var setup
# ============================================================
setup_env_for_cloud() {
local cloud="$1"
# Universal env vars
export OPENROUTER_API_KEY="sk-or-v1-0000000000000000000000000000000000000000000000000000000000000000"
export MODEL_ID="openrouter/auto"
export INSTANCE_STATUS_POLL_DELAY=0
case "$cloud" in
hetzner)
export HCLOUD_TOKEN="test-token-hetzner"
export HETZNER_SERVER_NAME="test-srv"
export HETZNER_SERVER_TYPE="cpx11"
export HETZNER_LOCATION="fsn1"
;;
digitalocean)
export DO_API_TOKEN="test-token-do"
export DO_DROPLET_NAME="test-srv"
export DO_DROPLET_SIZE="s-2vcpu-2gb"
export DO_REGION="nyc3"
;;
vultr)
export VULTR_API_KEY="test-token-vultr"
export VULTR_SERVER_NAME="test-srv"
export VULTR_PLAN="vc2-1c-2gb"
export VULTR_REGION="ewr"
;;
linode)
export LINODE_API_TOKEN="test-token-linode"
export LINODE_SERVER_NAME="test-srv"
export LINODE_TYPE="g6-standard-1"
export LINODE_REGION="us-east"
;;
lambda)
export LAMBDA_API_KEY="test-token-lambda"
export LAMBDA_SERVER_NAME="test-srv"
;;
civo)
export CIVO_API_TOKEN="test-token-civo"
export CIVO_SERVER_NAME="test-srv"
export CIVO_REGION="lon1"
;;
upcloud)
export UPCLOUD_USERNAME="test-user"
export UPCLOUD_PASSWORD="test-pass"
export UPCLOUD_SERVER_NAME="test-srv"
export UPCLOUD_PLAN="1xCPU-1GB"
export UPCLOUD_ZONE="us-chi1"
;;
binarylane)
export BINARYLANE_API_TOKEN="test-token-bl"
export BINARYLANE_SERVER_NAME="test-srv"
export BINARYLANE_SIZE="std-min"
export BINARYLANE_REGION="syd"
;;
ovh)
export OVH_APPLICATION_KEY="test-app-key"
export OVH_APPLICATION_SECRET="test-app-secret"
export OVH_CONSUMER_KEY="test-consumer-key"
export OVH_PROJECT_ID="test-project-id"
export OVH_SERVER_NAME="test-srv"
;;
scaleway)
export SCW_SECRET_KEY="test-token-scw"
export SCALEWAY_SERVER_NAME="test-srv"
export SCALEWAY_ZONE="fr-par-1"
;;
genesiscloud)
export GENESIS_API_KEY="test-token-genesis"
export GENESIS_SERVER_NAME="test-srv"
;;
kamatera)
export KAMATERA_API_CLIENT_ID="test-client-id"
export KAMATERA_API_SECRET="test-secret"
export KAMATERA_SERVER_NAME="test-srv"
;;
latitude)
export LATITUDE_API_KEY="test-token-lat"
export LATITUDE_SERVER_NAME="test-srv"
;;
hyperstack)
export HYPERSTACK_API_KEY="test-token-hyper"
export HYPERSTACK_SERVER_NAME="test-srv"
;;
local)
# No cloud credentials needed for local
;;
esac
}
# ============================================================
# Discovery
# ============================================================
discover_clouds() {
for fixture_dir in "${FIXTURES_DIR}"/*/; do
local cloud
cloud=$(basename "$fixture_dir")
if [[ -f "${fixture_dir}/_metadata.json" ]]; then
echo "$cloud"
fi
done
}
discover_agents() {
local cloud="$1"
for script in "${REPO_ROOT}/${cloud}"/*.sh; do
[[ -f "$script" ]] || continue
local agent
agent=$(basename "$script" .sh)
echo "$agent"
done
}
# ============================================================
# Test runner
# ============================================================
run_test() {
local cloud="$1"
local agent="$2"
local script_path="${REPO_ROOT}/${cloud}/${agent}.sh"
local _prev_failed="$FAILED"
if [[ ! -f "$script_path" ]]; then
printf '%b\n' " ${YELLOW}skip${NC} ${cloud}/${agent}.sh — file not found"
SKIPPED=$((SKIPPED + 1))
return 0
fi
printf '%b\n' " ${CYAN}test${NC} ${cloud}/${agent}.sh"
# Reset mock log
: > "${MOCK_LOG}"
# Set up environment
setup_env_for_cloud "$cloud"
# Fake HOME to avoid polluting real home
local fake_home
fake_home=$(setup_fake_home)
# Run the script with mocked PATH + HOME (10s timeout — all calls are fake)
local exit_code=0
MOCK_LOG="${MOCK_LOG}" \
MOCK_FIXTURE_DIR="${FIXTURES_DIR}/${cloud}" \
MOCK_CLOUD="${cloud}" \
MOCK_REPO_ROOT="${REPO_ROOT}" \
PATH="${TEST_DIR}:${PATH}" \
HOME="${fake_home}" \
bash "${script_path}" < /dev/null > "${TEST_DIR}/output.log" 2>&1 &
local pid=$!
local i=0
while kill -0 "$pid" 2>/dev/null; do
if [[ "$i" -ge 4 ]]; then
kill -9 "$pid" 2>/dev/null
wait "$pid" 2>/dev/null || true
exit_code=124
break
fi
sleep 1
i=$((i + 1))
done
if [[ "$exit_code" -ne 124 ]]; then
wait "$pid" 2>/dev/null || exit_code=$?
fi
# Show last lines of output on failure
if [[ "${exit_code}" -ne 0 ]]; then
printf '%b\n' " ${RED}--- output (last 20 lines) ---${NC}"
tail -20 "${TEST_DIR}/output.log" 2>/dev/null | while IFS= read -r line; do
printf ' %s\n' "$line"
done
printf '%b\n' " ${RED}--- end output ---${NC}"
fi
# --- Assertions ---
assert_exit_code "${exit_code}" 0 "exits successfully"
# Check that API calls were made (curl for installs or cloud APIs)
assert_log_contains "curl (GET|POST) https://" "makes API calls"
# Check that SSH was used (for remote execution) — skip for local cloud
if [[ "$cloud" != "local" ]]; then
assert_log_contains "ssh " "uses SSH"
fi
# Append result to RESULTS_FILE if set
if [[ -n "${RESULTS_FILE:-}" ]]; then
if [[ "$FAILED" -gt "$_prev_failed" ]] || [[ "$exit_code" -ne 0 ]]; then
printf '%s/%s:fail\n' "$cloud" "$agent" >> "${RESULTS_FILE}"
else
printf '%s/%s:pass\n' "$cloud" "$agent" >> "${RESULTS_FILE}"
fi
fi
printf '\n'
}
# ============================================================
# Main
# ============================================================
printf '%b\n' "${CYAN}===============================${NC}"
printf '%b\n' "${CYAN} Spawn Mock Test Suite${NC}"
printf '%b\n' "${CYAN}===============================${NC}"
printf '\n'
# Parse arguments
FILTER_CLOUD="${1:-}"
FILTER_AGENT="${2:-}"
# Set up mocks once
setup_mock_curl
setup_mock_ssh
setup_mock_agents
# Discover what to test
if [[ -n "$FILTER_CLOUD" ]]; then
CLOUDS="$FILTER_CLOUD"
if [[ ! -d "${FIXTURES_DIR}/${FILTER_CLOUD}" ]]; then
printf '%b\n' "${RED}No fixtures for cloud: ${FILTER_CLOUD}${NC}"
printf "Available: %s\n" "$(discover_clouds | tr '\n' ' ')"
exit 1
fi
else
CLOUDS=$(discover_clouds)
fi
if [[ -z "$CLOUDS" ]]; then
printf '%b\n' "${YELLOW}No fixture data found in ${FIXTURES_DIR}/${NC}"
printf "Run test/record.sh first to record API fixtures.\n"
exit 0
fi
printf "Fixtures dir: %s\n" "${FIXTURES_DIR}"
printf "Clouds: %s\n" "$CLOUDS"
printf '\n'
for cloud in $CLOUDS; do
printf '%b\n' "${CYAN}━━━ ${cloud} ━━━${NC}"
if [[ -n "$FILTER_AGENT" ]]; then
AGENTS="$FILTER_AGENT"
else
AGENTS=$(discover_agents "$cloud")
fi
if [[ -z "$AGENTS" ]]; then
printf '%b\n' " ${YELLOW}skip${NC} no agent scripts found in ${cloud}/"
SKIPPED=$((SKIPPED + 1))
continue
fi
for agent in $AGENTS; do
run_test "$cloud" "$agent"
done
printf '\n'
done
# --- Summary ---
printf '%b\n' "${CYAN}===============================${NC}"
TOTAL=$((PASSED + FAILED + SKIPPED))
printf '%b\n' " Results: ${GREEN}${PASSED} passed${NC}, ${RED}${FAILED} failed${NC}, ${YELLOW}${SKIPPED} skipped${NC}, ${TOTAL} total"
printf '%b\n' "${CYAN}===============================${NC}"
if [[ "$FAILED" -gt 0 ]]; then
exit 1
fi
exit 0