spawn/sh/test/e2e-lib.sh
A 5e0144b645
fix(zeroclaw): remove broken zeroclaw agent (repo 404) (#3107)
* fix(zeroclaw): remove broken zeroclaw agent (repo 404)

The zeroclaw-labs/zeroclaw GitHub repository returns 404 — all installs
fail. Remove zeroclaw entirely from the matrix: agent definition,
setup code, shell scripts, e2e tests, packer config, skill files,
and documentation.

Fixes #3102

Agent: code-health
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(zeroclaw): remove stale zeroclaw reference from discovery.md ARM agents list

Addresses security review on PR #3107 — the last remaining zeroclaw
reference in .claude/rules/discovery.md is now removed.

Agent: issue-fixer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(zeroclaw): remove remaining stale zeroclaw references from CI/packer

Remove zeroclaw from:
- .github/workflows/agent-tarballs.yml ARM build matrix
- .github/workflows/docker.yml agent matrix
- packer/digitalocean.pkr.hcl comment
- sh/e2e/e2e.sh comment

Addresses all 5 stale references flagged in security review of PR #3107.

Agent: issue-fixer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-30 15:35:40 -07:00

521 lines
19 KiB
Bash

#!/bin/bash
# sh/test/e2e-lib.sh — Unit tests for E2E library functions (common.sh, verify.sh, provision.sh)
#
# Tests pure functions without requiring cloud credentials or remote instances.
# Bash 3.2 compatible (no set -u, no echo -e, no (( ++ ))).
#
# Usage:
# bash sh/test/e2e-lib.sh
set -eo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# ---------------------------------------------------------------------------
# Test harness
# ---------------------------------------------------------------------------
_TESTS_RUN=0
_TESTS_PASSED=0
_TESTS_FAILED=0
_FAIL_DETAILS=""
RED='\033[0;31m'
GREEN='\033[0;32m'
BOLD='\033[1m'
NC='\033[0m'
assert_eq() {
local label="$1"
local expected="$2"
local actual="$3"
_TESTS_RUN=$((_TESTS_RUN + 1))
if [ "${expected}" = "${actual}" ]; then
_TESTS_PASSED=$((_TESTS_PASSED + 1))
else
_TESTS_FAILED=$((_TESTS_FAILED + 1))
_FAIL_DETAILS="${_FAIL_DETAILS}\n FAIL: ${label}\n expected: '${expected}'\n actual: '${actual}'"
fi
}
assert_match() {
local label="$1"
local pattern="$2"
local actual="$3"
_TESTS_RUN=$((_TESTS_RUN + 1))
if printf '%s' "${actual}" | grep -qE "${pattern}"; then
_TESTS_PASSED=$((_TESTS_PASSED + 1))
else
_TESTS_FAILED=$((_TESTS_FAILED + 1))
_FAIL_DETAILS="${_FAIL_DETAILS}\n FAIL: ${label}\n pattern: '${pattern}'\n actual: '${actual}'"
fi
}
assert_exit() {
local label="$1"
local expected_exit="$2"
shift 2
local actual_exit=0
"$@" >/dev/null 2>&1 || actual_exit=$?
_TESTS_RUN=$((_TESTS_RUN + 1))
if [ "${expected_exit}" -eq "${actual_exit}" ]; then
_TESTS_PASSED=$((_TESTS_PASSED + 1))
else
_TESTS_FAILED=$((_TESTS_FAILED + 1))
_FAIL_DETAILS="${_FAIL_DETAILS}\n FAIL: ${label}\n expected exit: ${expected_exit}\n actual exit: ${actual_exit}"
fi
}
# ---------------------------------------------------------------------------
# Source the libraries under test
# We need to suppress set -e in common.sh since it validates env on source
# ---------------------------------------------------------------------------
# Stub out commands that common.sh checks for (we don't need real ones for unit tests)
export OPENROUTER_API_KEY="test-key-for-unit-tests"
# Source common.sh (provides helpers, constants, logging)
source "${REPO_ROOT}/sh/e2e/lib/common.sh"
# Source verify.sh (provides _validate_timeout, _validate_base64, etc.)
source "${REPO_ROOT}/sh/e2e/lib/verify.sh"
# ===================================================================
# common.sh tests
# ===================================================================
# --- format_duration ---
printf '%b\n' "${BOLD}Testing: format_duration${NC}"
assert_eq "format_duration 0" "0m 0s" "$(format_duration 0)"
assert_eq "format_duration 59" "0m 59s" "$(format_duration 59)"
assert_eq "format_duration 60" "1m 0s" "$(format_duration 60)"
assert_eq "format_duration 61" "1m 1s" "$(format_duration 61)"
assert_eq "format_duration 3661" "61m 1s" "$(format_duration 3661)"
assert_eq "format_duration 120" "2m 0s" "$(format_duration 120)"
# --- make_app_name ---
printf '%b\n' "${BOLD}Testing: make_app_name${NC}"
# Without ACTIVE_CLOUD
ACTIVE_CLOUD=""
result=$(make_app_name "claude")
assert_match "make_app_name claude (no cloud)" '^e2e-claude-[0-9]+$' "${result}"
# With ACTIVE_CLOUD
ACTIVE_CLOUD="aws"
result=$(make_app_name "openclaw")
assert_match "make_app_name openclaw (aws)" '^e2e-aws-openclaw-[0-9]+$' "${result}"
ACTIVE_CLOUD="sprite"
result=$(make_app_name "codex")
assert_match "make_app_name codex (sprite)" '^e2e-sprite-codex-[0-9]+$' "${result}"
# Reset
ACTIVE_CLOUD=""
# --- track_app / untrack_app ---
printf '%b\n' "${BOLD}Testing: track_app / untrack_app${NC}"
_TRACKED_APPS=""
track_app "app-1"
assert_eq "track_app first" "app-1" "${_TRACKED_APPS}"
track_app "app-2"
assert_eq "track_app second" "app-1 app-2" "${_TRACKED_APPS}"
track_app "app-3"
assert_eq "track_app third" "app-1 app-2 app-3" "${_TRACKED_APPS}"
untrack_app "app-2"
assert_eq "untrack_app middle" "app-1 app-3" "${_TRACKED_APPS}"
untrack_app "app-1"
assert_eq "untrack_app first" "app-3" "${_TRACKED_APPS}"
untrack_app "app-3"
assert_eq "untrack_app last" "" "${_TRACKED_APPS}"
# Untrack non-existent (should be no-op)
_TRACKED_APPS="x y z"
untrack_app "w"
assert_eq "untrack_app non-existent" "x y z" "${_TRACKED_APPS}"
_TRACKED_APPS=""
# --- get_provision_timeout ---
printf '%b\n' "${BOLD}Testing: get_provision_timeout${NC}"
# Default agent (no override)
result=$(get_provision_timeout "claude")
assert_eq "get_provision_timeout claude (default)" "${PROVISION_TIMEOUT}" "${result}"
# Junie has a built-in override
result=$(get_provision_timeout "junie")
assert_eq "get_provision_timeout junie (built-in)" "1200" "${result}"
# Env var override takes precedence
export PROVISION_TIMEOUT_codex=999
result=$(get_provision_timeout "codex")
assert_eq "get_provision_timeout codex (env override)" "999" "${result}"
unset PROVISION_TIMEOUT_codex
# Non-numeric env var override is ignored
export PROVISION_TIMEOUT_codex="abc"
result=$(get_provision_timeout "codex")
assert_eq "get_provision_timeout codex (non-numeric env ignored)" "${PROVISION_TIMEOUT}" "${result}"
unset PROVISION_TIMEOUT_codex
# Agent name sanitization (special chars → underscore)
result=$(get_provision_timeout "my-agent")
assert_eq "get_provision_timeout my-agent (sanitized)" "${PROVISION_TIMEOUT}" "${result}"
# --- get_agent_timeout ---
printf '%b\n' "${BOLD}Testing: get_agent_timeout${NC}"
# Default agent
result=$(get_agent_timeout "claude")
assert_eq "get_agent_timeout claude (default)" "${AGENT_TIMEOUT}" "${result}"
# Junie has a built-in override
result=$(get_agent_timeout "junie")
assert_eq "get_agent_timeout junie (built-in)" "2400" "${result}"
# Env var override
export AGENT_TIMEOUT_hermes=500
result=$(get_agent_timeout "hermes")
assert_eq "get_agent_timeout hermes (env override)" "500" "${result}"
unset AGENT_TIMEOUT_hermes
# Non-numeric env var ignored — falls through to built-in hermes default (3600), not global
export AGENT_TIMEOUT_hermes="not-a-number"
result=$(get_agent_timeout "hermes")
assert_eq "get_agent_timeout hermes (non-numeric ignored)" "3600" "${result}"
unset AGENT_TIMEOUT_hermes
# --- Numeric validation (constants) ---
printf '%b\n' "${BOLD}Testing: numeric validation${NC}"
# The constants should be numeric after common.sh's validation
assert_match "PROVISION_TIMEOUT is numeric" '^[0-9]+$' "${PROVISION_TIMEOUT}"
assert_match "INSTALL_WAIT is numeric" '^[0-9]+$' "${INSTALL_WAIT}"
assert_match "INPUT_TEST_TIMEOUT is numeric" '^[0-9]+$' "${INPUT_TEST_TIMEOUT}"
assert_match "AGENT_TIMEOUT is numeric" '^[0-9]+$' "${AGENT_TIMEOUT}"
# Verify defaults
assert_eq "PROVISION_TIMEOUT default" "720" "${PROVISION_TIMEOUT}"
assert_eq "INSTALL_WAIT default" "600" "${INSTALL_WAIT}"
assert_eq "INPUT_TEST_TIMEOUT default" "120" "${INPUT_TEST_TIMEOUT}"
assert_eq "AGENT_TIMEOUT default" "1800" "${AGENT_TIMEOUT}"
# Test that non-numeric values get reset to defaults (spawn a subshell)
result=$(INPUT_TEST_TIMEOUT="DROP TABLE;" bash -c 'source "'"${REPO_ROOT}"'/sh/e2e/lib/common.sh" && printf "%s" "${INPUT_TEST_TIMEOUT}"' 2>/dev/null)
assert_eq "INPUT_TEST_TIMEOUT injection reset" "120" "${result}"
result=$(PROVISION_TIMEOUT='$(whoami)' bash -c 'source "'"${REPO_ROOT}"'/sh/e2e/lib/common.sh" && printf "%s" "${PROVISION_TIMEOUT}"' 2>/dev/null)
assert_eq "PROVISION_TIMEOUT injection reset" "720" "${result}"
result=$(AGENT_TIMEOUT="" bash -c 'source "'"${REPO_ROOT}"'/sh/e2e/lib/common.sh" && printf "%s" "${AGENT_TIMEOUT}"' 2>/dev/null)
assert_eq "AGENT_TIMEOUT empty reset" "1800" "${result}"
# --- OpenRouter API key fallback ---
printf '%b\n' "${BOLD}Testing: OpenRouter API key fallback${NC}"
# Test: ANTHROPIC_AUTH_TOKEN with openrouter base URL should set OPENROUTER_API_KEY
result=$(
unset OPENROUTER_API_KEY
ANTHROPIC_AUTH_TOKEN="sk-or-test-123" \
ANTHROPIC_BASE_URL="https://openrouter.ai/api" \
bash -c 'source "'"${REPO_ROOT}"'/sh/e2e/lib/common.sh" && printf "%s" "${OPENROUTER_API_KEY:-}"' 2>/dev/null
)
assert_eq "API key fallback (openrouter URL)" "sk-or-test-123" "${result}"
# Test: non-openrouter base URL should NOT set OPENROUTER_API_KEY
result=$(
unset OPENROUTER_API_KEY
ANTHROPIC_AUTH_TOKEN="sk-ant-test-456" \
ANTHROPIC_BASE_URL="https://api.anthropic.com" \
bash -c 'source "'"${REPO_ROOT}"'/sh/e2e/lib/common.sh" && printf "%s" "${OPENROUTER_API_KEY:-}"' 2>/dev/null
)
assert_eq "API key fallback (non-openrouter URL)" "" "${result}"
# Test: existing OPENROUTER_API_KEY should NOT be overwritten
result=$(
OPENROUTER_API_KEY="existing-key" \
ANTHROPIC_AUTH_TOKEN="sk-or-new-key" \
ANTHROPIC_BASE_URL="https://openrouter.ai/api" \
bash -c 'source "'"${REPO_ROOT}"'/sh/e2e/lib/common.sh" && printf "%s" "${OPENROUTER_API_KEY}"' 2>/dev/null
)
assert_eq "API key fallback (existing key preserved)" "existing-key" "${result}"
# --- cloud_max_parallel / cloud_install_wait defaults ---
printf '%b\n' "${BOLD}Testing: cloud_max_parallel / cloud_install_wait defaults${NC}"
# When no cloud-specific function exists, should return defaults
ACTIVE_CLOUD="nonexistent"
result=$(cloud_max_parallel 2>/dev/null)
assert_eq "cloud_max_parallel default" "99" "${result}"
result=$(cloud_install_wait 2>/dev/null)
assert_eq "cloud_install_wait default" "${INSTALL_WAIT}" "${result}"
# ===================================================================
# verify.sh tests
# ===================================================================
# --- _validate_timeout ---
printf '%b\n' "${BOLD}Testing: _validate_timeout${NC}"
INPUT_TEST_TIMEOUT=120
assert_exit "_validate_timeout valid (120)" 0 _validate_timeout
INPUT_TEST_TIMEOUT=0
assert_exit "_validate_timeout valid (0)" 0 _validate_timeout
INPUT_TEST_TIMEOUT=99999
assert_exit "_validate_timeout valid (99999)" 0 _validate_timeout
INPUT_TEST_TIMEOUT="abc"
assert_exit "_validate_timeout invalid (abc)" 1 _validate_timeout
INPUT_TEST_TIMEOUT='$(whoami)'
assert_exit "_validate_timeout invalid (injection)" 1 _validate_timeout
INPUT_TEST_TIMEOUT=""
assert_exit "_validate_timeout invalid (empty)" 1 _validate_timeout
INPUT_TEST_TIMEOUT="12 34"
assert_exit "_validate_timeout invalid (space)" 1 _validate_timeout
INPUT_TEST_TIMEOUT="120;rm -rf /"
assert_exit "_validate_timeout invalid (semicolon injection)" 1 _validate_timeout
# Reset to valid
INPUT_TEST_TIMEOUT=120
# --- _validate_base64 ---
printf '%b\n' "${BOLD}Testing: _validate_base64${NC}"
assert_exit "_validate_base64 valid" 0 _validate_base64 "SGVsbG8gV29ybGQ="
assert_exit "_validate_base64 valid (no padding)" 0 _validate_base64 "SGVsbG8"
assert_exit "_validate_base64 valid (with +/)" 0 _validate_base64 "abc+def/ghi="
assert_exit "_validate_base64 empty" 1 _validate_base64 ""
assert_exit "_validate_base64 invalid (spaces)" 1 _validate_base64 "SGVs bG8="
assert_exit "_validate_base64 invalid (shell metachar)" 1 _validate_base64 'SGVsbG8;rm -rf /'
assert_exit "_validate_base64 invalid (backtick)" 1 _validate_base64 'SGVsbG8`whoami`'
assert_exit "_validate_base64 invalid (dollar)" 1 _validate_base64 'SGVsbG8$(id)'
# NOTE: _validate_base64 uses grep which matches per-line, so a string with
# newlines passes if each line is individually valid. This is a known limitation
# but low risk — the base64 encoding step always strips newlines (tr -d '\n'),
# and the data is piped via stdin, never interpolated into commands.
assert_exit "_validate_base64 newline (known: passes per-line)" 0 _validate_base64 "$(printf 'SGVs\nbG8=')"
# --- run_input_test dispatch ---
printf '%b\n' "${BOLD}Testing: run_input_test dispatch${NC}"
# Unknown agent should fail
assert_exit "run_input_test unknown agent" 1 run_input_test "nonexistent-agent" "fake-app"
# SKIP_INPUT_TEST=1 should succeed for any agent
SKIP_INPUT_TEST=1
assert_exit "run_input_test skipped" 0 run_input_test "claude" "fake-app"
SKIP_INPUT_TEST=0
# TUI-only agents should pass (they return 0 with a skip message)
# These don't need cloud_exec since they skip early
assert_exit "run_input_test opencode (TUI skip)" 0 run_input_test "opencode" "fake-app"
assert_exit "run_input_test kilocode (TUI skip)" 0 run_input_test "kilocode" "fake-app"
assert_exit "run_input_test hermes (TUI skip)" 0 run_input_test "hermes" "fake-app"
assert_exit "run_input_test junie (not implemented skip)" 0 run_input_test "junie" "fake-app"
# ===================================================================
# provision.sh — app_name validation
# ===================================================================
printf '%b\n' "${BOLD}Testing: provision_agent app_name validation${NC}"
# Source provision.sh
source "${REPO_ROOT}/sh/e2e/lib/provision.sh"
_tmp_log=$(mktemp -d "${TMPDIR:-/tmp}/e2e-test-XXXXXX")
# Valid names should pass validation (will fail later on missing CLI, that's fine)
# We test that invalid names fail BEFORE any CLI interaction
# Empty name
assert_exit "provision_agent empty name" 1 provision_agent "claude" "" "${_tmp_log}"
# Name with shell metacharacters
assert_exit "provision_agent semicolon injection" 1 provision_agent "claude" "app;rm -rf /" "${_tmp_log}"
assert_exit "provision_agent backtick injection" 1 provision_agent "claude" 'app`whoami`' "${_tmp_log}"
assert_exit "provision_agent dollar injection" 1 provision_agent "claude" 'app$(id)' "${_tmp_log}"
assert_exit "provision_agent space in name" 1 provision_agent "claude" "app name" "${_tmp_log}"
assert_exit "provision_agent pipe in name" 1 provision_agent "claude" "app|cat" "${_tmp_log}"
rm -rf "${_tmp_log}"
# ===================================================================
# Integration: e2e.sh argument parsing (via --help, invalid args)
# ===================================================================
printf '%b\n' "${BOLD}Testing: e2e.sh argument parsing${NC}"
E2E_SCRIPT="${REPO_ROOT}/sh/e2e/e2e.sh"
# --help should exit 0
assert_exit "e2e.sh --help" 0 bash "${E2E_SCRIPT}" --help
# No --cloud should exit 1
assert_exit "e2e.sh no args" 1 bash "${E2E_SCRIPT}"
# Unknown cloud should exit 1
assert_exit "e2e.sh unknown cloud" 1 bash "${E2E_SCRIPT}" --cloud fakecloudxyz
# Unknown agent should exit 1
assert_exit "e2e.sh unknown agent" 1 bash "${E2E_SCRIPT}" --cloud aws fakeagentxyz
# Unknown option should exit 1
assert_exit "e2e.sh unknown option" 1 bash "${E2E_SCRIPT}" --cloud aws --bogus
# --parallel without number should exit 1
assert_exit "e2e.sh --parallel no arg" 1 bash "${E2E_SCRIPT}" --cloud aws --parallel
# --parallel 0 should exit 1
assert_exit "e2e.sh --parallel 0" 1 bash "${E2E_SCRIPT}" --cloud aws --parallel 0
# --parallel 999 should exit 1 (> 50)
assert_exit "e2e.sh --parallel 999" 1 bash "${E2E_SCRIPT}" --cloud aws --parallel 999
# --parallel abc should exit 1
assert_exit "e2e.sh --parallel abc" 1 bash "${E2E_SCRIPT}" --cloud aws --parallel abc
# ===================================================================
# ALL_AGENTS constant completeness
# ===================================================================
printf '%b\n' "${BOLD}Testing: ALL_AGENTS completeness${NC}"
# Every agent in ALL_AGENTS should have a verify_* and input_test_* function
for agent in ${ALL_AGENTS}; do
# Check verify function exists
if type "verify_${agent}" >/dev/null 2>&1; then
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_PASSED=$((_TESTS_PASSED + 1))
else
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_FAILED=$((_TESTS_FAILED + 1))
_FAIL_DETAILS="${_FAIL_DETAILS}\n FAIL: verify_${agent} function missing"
fi
# Check input_test function exists
if type "input_test_${agent}" >/dev/null 2>&1; then
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_PASSED=$((_TESTS_PASSED + 1))
else
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_FAILED=$((_TESTS_FAILED + 1))
_FAIL_DETAILS="${_FAIL_DETAILS}\n FAIL: input_test_${agent} function missing"
fi
done
# ===================================================================
# Cloud driver interface compliance
# ===================================================================
printf '%b\n' "${BOLD}Testing: cloud driver interface compliance${NC}"
REQUIRED_FUNCTIONS="validate_env headless_env provision_verify exec teardown"
for driver_file in "${REPO_ROOT}"/sh/e2e/lib/clouds/*.sh; do
driver_name=$(basename "${driver_file}" .sh)
# Source the driver
source "${driver_file}"
for fn in ${REQUIRED_FUNCTIONS}; do
full_fn="_${driver_name}_${fn}"
if type "${full_fn}" >/dev/null 2>&1; then
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_PASSED=$((_TESTS_PASSED + 1))
else
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_FAILED=$((_TESTS_FAILED + 1))
_FAIL_DETAILS="${_FAIL_DETAILS}\n FAIL: ${driver_name} driver missing ${full_fn}()"
fi
done
done
# ===================================================================
# Bash syntax check on all E2E scripts
# ===================================================================
printf '%b\n' "${BOLD}Testing: bash -n syntax check on E2E scripts${NC}"
for script in \
"${REPO_ROOT}/sh/e2e/e2e.sh" \
"${REPO_ROOT}/sh/e2e/lib/common.sh" \
"${REPO_ROOT}/sh/e2e/lib/provision.sh" \
"${REPO_ROOT}/sh/e2e/lib/verify.sh" \
"${REPO_ROOT}/sh/e2e/lib/teardown.sh" \
"${REPO_ROOT}/sh/e2e/lib/soak.sh" \
"${REPO_ROOT}/sh/e2e/lib/interactive.sh" \
"${REPO_ROOT}/sh/e2e/lib/ai-review.sh" \
"${REPO_ROOT}/sh/e2e/lib/clouds/aws.sh" \
"${REPO_ROOT}/sh/e2e/lib/clouds/digitalocean.sh" \
"${REPO_ROOT}/sh/e2e/lib/clouds/gcp.sh" \
"${REPO_ROOT}/sh/e2e/lib/clouds/hetzner.sh" \
"${REPO_ROOT}/sh/e2e/lib/clouds/sprite.sh"; do
script_name=$(basename "${script}")
if bash -n "${script}" 2>/dev/null; then
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_PASSED=$((_TESTS_PASSED + 1))
else
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_FAILED=$((_TESTS_FAILED + 1))
_FAIL_DETAILS="${_FAIL_DETAILS}\n FAIL: bash -n ${script_name}"
fi
done
# ===================================================================
# macOS compat linter on E2E scripts
# ===================================================================
printf '%b\n' "${BOLD}Testing: macOS compat linter on E2E scripts${NC}"
compat_script="${REPO_ROOT}/sh/test/macos-compat.sh"
if [ -f "${compat_script}" ]; then
for script in \
"${REPO_ROOT}/sh/e2e/lib/common.sh" \
"${REPO_ROOT}/sh/e2e/lib/provision.sh" \
"${REPO_ROOT}/sh/e2e/lib/verify.sh" \
"${REPO_ROOT}/sh/e2e/lib/teardown.sh"; do
script_name=$(basename "${script}")
if bash "${compat_script}" "${script}" >/dev/null 2>&1; then
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_PASSED=$((_TESTS_PASSED + 1))
else
_TESTS_RUN=$((_TESTS_RUN + 1))
_TESTS_FAILED=$((_TESTS_FAILED + 1))
_FAIL_DETAILS="${_FAIL_DETAILS}\n FAIL: macOS compat ${script_name}"
fi
done
fi
# ===================================================================
# Results
# ===================================================================
printf '\n%b================================%b\n' "${BOLD}" "${NC}"
if [ "${_TESTS_FAILED}" -eq 0 ]; then
printf '%b%d/%d tests passed%b\n' "${GREEN}" "${_TESTS_PASSED}" "${_TESTS_RUN}" "${NC}"
else
printf '%b%d/%d tests passed, %d failed%b\n' "${RED}" "${_TESTS_PASSED}" "${_TESTS_RUN}" "${_TESTS_FAILED}" "${NC}"
printf '%b%b%b\n' "${RED}" "${_FAIL_DETAILS}" "${NC}"
fi
printf '%b================================%b\n' "${BOLD}" "${NC}"
if [ "${_TESTS_FAILED}" -gt 0 ]; then
exit 1
fi
exit 0