mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-06 08:10:48 +00:00
* Add NanoClaw spawn script NanoClaw is a lightweight WhatsApp-based Claude AI assistant that runs agents in isolated containers. This script sets up a sprite with nanoclaw pre-configured: clones the repo, installs dependencies, configures the API key, and launches in dev mode for WhatsApp QR auth. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Fix verify_sprite_connectivity exiting script early after single failed check Retry connectivity up to 6 attempts (30s) instead of trying once and silently continuing, which caused the next sprite exec to fail under set -e. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add test harness for spawn scripts Mocks the sprite CLI and runs each script end-to-end verifying: - common.sh sources correctly and all functions resolve - Log functions write to stderr (not stdout) - Env var flow (SPRITE_NAME, OPENROUTER_API_KEY) - Sprite commands called in correct order - Temp files created and cleaned up - Each script reaches its final interactive launch Usage: bash test/run.sh 42 tests, all passing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Sprite <noreply@sprite.dev> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
335 lines
11 KiB
Bash
335 lines
11 KiB
Bash
#!/bin/bash
|
|
# Test harness for spawn scripts
|
|
#
|
|
# Mocks the `sprite` CLI and runs each script end-to-end to verify:
|
|
# 1. common.sh sources correctly (local + remote)
|
|
# 2. All functions resolve
|
|
# 3. Env var flow works (SPRITE_NAME, OPENROUTER_API_KEY)
|
|
# 4. sprite commands are called in the correct order with correct args
|
|
# 5. Temp files are created and cleaned up
|
|
# 6. Each script reaches its final launch command
|
|
#
|
|
# Usage:
|
|
# bash test/run.sh # test all scripts
|
|
# bash test/run.sh claude # test one script
|
|
# bash test/run.sh --remote # test remote source (from GitHub)
|
|
|
|
set -uo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
TEST_DIR=$(mktemp -d)
|
|
MOCK_LOG="$TEST_DIR/sprite_calls.log"
|
|
PASSED=0
|
|
FAILED=0
|
|
FILTER="${1:-}"
|
|
REMOTE=false
|
|
|
|
if [[ "$FILTER" == "--remote" ]]; then
|
|
REMOTE=true
|
|
FILTER="${2:-}"
|
|
fi
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
NC='\033[0m'
|
|
|
|
cleanup() {
|
|
rm -rf "$TEST_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# --- Mock sprite CLI ---
|
|
# Records every call to a log, returns success for expected commands
|
|
setup_mocks() {
|
|
cat > "$TEST_DIR/sprite" << 'MOCK'
|
|
#!/bin/bash
|
|
echo "sprite $*" >> "$MOCK_LOG"
|
|
|
|
case "$1" in
|
|
org) exit 0 ;; # auth check passes
|
|
list) echo "existing-sprite"; exit 0 ;; # list returns no match for test sprite
|
|
create) exit 0 ;;
|
|
exec)
|
|
# If there's a -file flag, just pretend to upload
|
|
if [[ "$*" == *"-file"* ]]; then
|
|
exit 0
|
|
fi
|
|
# If -tty, this is the final interactive launch — signal success and exit
|
|
if [[ "$*" == *"-tty"* ]]; then
|
|
echo "[MOCK] Would launch interactive session: $*" >> "$MOCK_LOG"
|
|
exit 0
|
|
fi
|
|
# Regular exec — just succeed
|
|
exit 0
|
|
;;
|
|
login) exit 0 ;;
|
|
*) exit 0 ;;
|
|
esac
|
|
MOCK
|
|
chmod +x "$TEST_DIR/sprite"
|
|
}
|
|
|
|
# --- Mock other commands that shouldn't run for real ---
|
|
setup_extra_mocks() {
|
|
# mock claude (for claude.sh install step)
|
|
cat > "$TEST_DIR/claude" << 'MOCK'
|
|
#!/bin/bash
|
|
echo "claude $*" >> "$MOCK_LOG"
|
|
exit 0
|
|
MOCK
|
|
chmod +x "$TEST_DIR/claude"
|
|
|
|
# mock openssl
|
|
cat > "$TEST_DIR/openssl" << 'MOCK'
|
|
#!/bin/bash
|
|
echo "mock-gateway-token-abc123"
|
|
MOCK
|
|
chmod +x "$TEST_DIR/openssl"
|
|
}
|
|
|
|
# --- Assertions ---
|
|
assert_contains() {
|
|
local file="$1" pattern="$2" msg="$3"
|
|
if grep -qE "$pattern" "$file" 2>/dev/null; then
|
|
echo -e " ${GREEN}✓${NC} $msg"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} $msg"
|
|
echo -e " expected pattern: $pattern"
|
|
echo -e " in: $file"
|
|
((FAILED++))
|
|
fi
|
|
}
|
|
|
|
assert_not_contains() {
|
|
local file="$1" pattern="$2" msg="$3"
|
|
if ! grep -qE "$pattern" "$file" 2>/dev/null; then
|
|
echo -e " ${GREEN}✓${NC} $msg"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} $msg"
|
|
((FAILED++))
|
|
fi
|
|
}
|
|
|
|
assert_exit_code() {
|
|
local actual="$1" expected="$2" msg="$3"
|
|
if [[ "$actual" -eq "$expected" ]]; then
|
|
echo -e " ${GREEN}✓${NC} $msg"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} $msg (got exit code $actual, expected $expected)"
|
|
((FAILED++))
|
|
fi
|
|
}
|
|
|
|
# --- Test runner for a single script ---
|
|
run_script_test() {
|
|
local script_name="$1"
|
|
local script_path="$REPO_ROOT/sprite/${script_name}.sh"
|
|
local output_file="$TEST_DIR/${script_name}_output.log"
|
|
|
|
echo ""
|
|
echo -e "${YELLOW}━━━ Testing ${script_name}.sh ━━━${NC}"
|
|
|
|
# Reset mock log
|
|
> "$MOCK_LOG"
|
|
|
|
# Run the script with mocked PATH and env vars (timeout 30s)
|
|
local exit_code=0
|
|
MOCK_LOG="$MOCK_LOG" \
|
|
SPRITE_NAME="test-sprite-${script_name}" \
|
|
OPENROUTER_API_KEY="sk-or-v1-0000000000000000000000000000000000000000000000000000000000000000" \
|
|
PATH="$TEST_DIR:$PATH" \
|
|
timeout 30 bash "$script_path" > "$output_file" 2>&1 || exit_code=$?
|
|
|
|
assert_exit_code "$exit_code" 0 "Script exits successfully"
|
|
|
|
# Common assertions for all scripts
|
|
assert_contains "$MOCK_LOG" "sprite org list" "Checks sprite authentication"
|
|
assert_contains "$MOCK_LOG" "sprite list" "Checks if sprite exists"
|
|
assert_contains "$MOCK_LOG" "sprite create.*test-sprite-${script_name}" "Creates sprite with correct name"
|
|
assert_contains "$MOCK_LOG" "sprite exec.*test-sprite-${script_name}" "Runs commands on sprite"
|
|
|
|
# Check env var injection (temp file upload)
|
|
assert_contains "$MOCK_LOG" "sprite exec.*-file.*/tmp/env_config" "Uploads env config to sprite"
|
|
|
|
# Check final interactive launch (flag order varies: -s NAME -tty or -tty -s NAME)
|
|
assert_contains "$MOCK_LOG" "sprite exec.*-tty.*" "Launches interactive session"
|
|
|
|
# Script-specific assertions
|
|
case "$script_name" in
|
|
claude)
|
|
assert_contains "$MOCK_LOG" "claude install" "Installs Claude Code"
|
|
assert_contains "$MOCK_LOG" "sprite exec.*-file.*/tmp/claude_settings" "Uploads Claude settings"
|
|
assert_contains "$MOCK_LOG" "sprite exec.*-file.*/tmp/claude_global" "Uploads Claude global state"
|
|
;;
|
|
openclaw)
|
|
assert_contains "$MOCK_LOG" "sprite exec.*bun install -g openclaw" "Installs openclaw via bun"
|
|
assert_contains "$MOCK_LOG" "sprite exec.*openclaw gateway" "Starts openclaw gateway"
|
|
;;
|
|
nanoclaw)
|
|
assert_contains "$MOCK_LOG" "sprite exec.*git clone.*nanoclaw" "Clones nanoclaw repo"
|
|
assert_contains "$MOCK_LOG" "sprite exec.*-file.*/tmp/nanoclaw_env" "Uploads nanoclaw .env"
|
|
;;
|
|
esac
|
|
|
|
# Check no temp files leaked
|
|
local leaked_temps=$(find /tmp -maxdepth 1 -name "tmp.*" -newer "$MOCK_LOG" 2>/dev/null | wc -l)
|
|
if [[ "$leaked_temps" -eq 0 ]]; then
|
|
echo -e " ${GREEN}✓${NC} No temp files leaked"
|
|
((PASSED++))
|
|
fi
|
|
}
|
|
|
|
# --- Test common.sh sourcing ---
|
|
test_common_source() {
|
|
echo ""
|
|
echo -e "${YELLOW}━━━ Testing common.sh ━━━${NC}"
|
|
|
|
# Test 1: Source locally and check all functions exist
|
|
local output
|
|
output=$(bash -c '
|
|
source "'"$REPO_ROOT"'/sprite/lib/common.sh"
|
|
for fn in log_info log_warn log_error safe_read \
|
|
ensure_sprite_installed ensure_sprite_authenticated \
|
|
get_sprite_name ensure_sprite_exists verify_sprite_connectivity \
|
|
run_sprite setup_shell_environment \
|
|
get_openrouter_api_key_manual try_oauth_flow \
|
|
get_openrouter_api_key_oauth open_browser; do
|
|
type "$fn" &>/dev/null && echo "OK:$fn" || echo "MISSING:$fn"
|
|
done
|
|
' 2>/dev/null)
|
|
|
|
local missing=$(echo "$output" | grep "^MISSING:" || true)
|
|
if [[ -z "$missing" ]]; then
|
|
echo -e " ${GREEN}✓${NC} All functions defined"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} Missing functions: $missing"
|
|
((FAILED++))
|
|
fi
|
|
|
|
# Test 2: log functions write to stderr, not stdout
|
|
local stdout stderr
|
|
stdout=$(timeout 5 bash -c 'source "'"$REPO_ROOT"'/sprite/lib/common.sh" && log_info "test"' </dev/null 2>/dev/null)
|
|
stderr=$(timeout 5 bash -c 'source "'"$REPO_ROOT"'/sprite/lib/common.sh" && log_info "test"' </dev/null 2>&1 >/dev/null)
|
|
if [[ -z "$stdout" && -n "$stderr" ]]; then
|
|
echo -e " ${GREEN}✓${NC} Log functions write to stderr"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} Log functions should write to stderr only"
|
|
((FAILED++))
|
|
fi
|
|
|
|
# Test 3: get_sprite_name uses SPRITE_NAME env var
|
|
local name
|
|
name=$(timeout 5 bash -c 'SPRITE_NAME=from-env; source "'"$REPO_ROOT"'/sprite/lib/common.sh" && get_sprite_name' 2>/dev/null)
|
|
if [[ "$name" == "from-env" ]]; then
|
|
echo -e " ${GREEN}✓${NC} get_sprite_name reads SPRITE_NAME env var"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} get_sprite_name should return 'from-env', got '$name'"
|
|
((FAILED++))
|
|
fi
|
|
|
|
# Test 4: get_sprite_name fails gracefully without TTY or env var
|
|
local rc=0
|
|
timeout 5 bash -c 'SPRITE_NAME=""; source "'"$REPO_ROOT"'/sprite/lib/common.sh" && get_sprite_name' </dev/null >/dev/null 2>&1 || rc=$?
|
|
if [[ "$rc" -ne 0 ]]; then
|
|
echo -e " ${GREEN}✓${NC} get_sprite_name fails without TTY or env var"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} get_sprite_name should fail without input"
|
|
((FAILED++))
|
|
fi
|
|
|
|
# Test 5: Syntax check
|
|
if bash -n "$REPO_ROOT/sprite/lib/common.sh" 2>/dev/null; then
|
|
echo -e " ${GREEN}✓${NC} Syntax valid"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} Syntax errors"
|
|
((FAILED++))
|
|
fi
|
|
|
|
# Test 6: Remote source (if --remote)
|
|
if [[ "$REMOTE" == true ]]; then
|
|
local remote_fns
|
|
remote_fns=$(bash -c '
|
|
source <(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sprite/lib/common.sh)
|
|
type log_info &>/dev/null && echo "OK" || echo "FAIL"
|
|
' 2>/dev/null)
|
|
if [[ "$remote_fns" == "OK" ]]; then
|
|
echo -e " ${GREEN}✓${NC} Remote source from GitHub works"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} Remote source from GitHub failed"
|
|
((FAILED++))
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# --- Test source detection in each script ---
|
|
test_source_detection() {
|
|
echo ""
|
|
echo -e "${YELLOW}━━━ Testing source detection ━━━${NC}"
|
|
|
|
for script in claude openclaw nanoclaw; do
|
|
local script_path="$REPO_ROOT/sprite/${script}.sh"
|
|
[[ -f "$script_path" ]] || continue
|
|
|
|
# Verify the source block checks for local file existence
|
|
if grep -q 'if \[\[ -f "$SCRIPT_DIR/lib/common.sh" \]\]' "$script_path"; then
|
|
echo -e " ${GREEN}✓${NC} ${script}.sh uses file-existence check for sourcing"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} ${script}.sh missing file-existence source check"
|
|
((FAILED++))
|
|
fi
|
|
|
|
# Verify syntax
|
|
if bash -n "$script_path" 2>/dev/null; then
|
|
echo -e " ${GREEN}✓${NC} ${script}.sh syntax valid"
|
|
((PASSED++))
|
|
else
|
|
echo -e " ${RED}✗${NC} ${script}.sh syntax error"
|
|
((FAILED++))
|
|
fi
|
|
done
|
|
}
|
|
|
|
# --- Main ---
|
|
echo "==============================="
|
|
echo " Spawn Script Test Suite"
|
|
echo "==============================="
|
|
echo ""
|
|
echo "Repo: $REPO_ROOT"
|
|
echo "Temp dir: $TEST_DIR"
|
|
echo "Filter: ${FILTER:-all}"
|
|
echo "Remote: $REMOTE"
|
|
|
|
setup_mocks
|
|
setup_extra_mocks
|
|
|
|
test_common_source
|
|
test_source_detection
|
|
|
|
# Run per-script tests
|
|
for script in claude openclaw nanoclaw; do
|
|
if [[ -n "$FILTER" && "$FILTER" != "$script" && "$FILTER" != "--remote" ]]; then
|
|
continue
|
|
fi
|
|
[[ -f "$REPO_ROOT/sprite/${script}.sh" ]] && run_script_test "$script"
|
|
done
|
|
|
|
# --- Summary ---
|
|
echo ""
|
|
echo "==============================="
|
|
TOTAL=$((PASSED + FAILED))
|
|
echo -e " Results: ${GREEN}${PASSED} passed${NC}, ${RED}${FAILED} failed${NC}, ${TOTAL} total"
|
|
echo "==============================="
|
|
|
|
[[ "$FAILED" -eq 0 ]] && exit 0 || exit 1
|