a1a17e81a1
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
519 lines
18 KiB
Bash
Executable File
519 lines
18 KiB
Bash
Executable File
#!/bin/bash
|
|
################################################################################
|
|
# Script Name: caprover-smoke-tests.sh
|
|
# Version: 1.01
|
|
# Description: Smoke test suite for CapRover PaaS — validates API health,
|
|
# app deployment lifecycle, SSL certificates, Docker Swarm status,
|
|
# and resource usage
|
|
#
|
|
# Author: Phil Connor
|
|
# Contact: contact@mylinux.work
|
|
# Website: https://mylinux.work
|
|
# License: MIT
|
|
#
|
|
# Prerequisites:
|
|
# - bash 4+
|
|
# - curl
|
|
# - jq
|
|
# - openssl (for SSL checks)
|
|
#
|
|
# Usage:
|
|
# export CAPROVER_URL="https://captain.apps.example.com"
|
|
# export CAPROVER_PASSWORD="your-password"
|
|
# ./caprover-smoke-tests.sh
|
|
# ./caprover-smoke-tests.sh --skip-app --skip-ssl
|
|
# ./caprover-smoke-tests.sh --format tap
|
|
# ./caprover-smoke-tests.sh --format junit --junit-file results.xml
|
|
#
|
|
################################################################################
|
|
|
|
set -euo pipefail
|
|
|
|
# --- Defaults ---
|
|
CAPROVER_URL="${CAPROVER_URL:-}"
|
|
CAPROVER_PASSWORD="${CAPROVER_PASSWORD:-}"
|
|
CURL_TIMEOUT="${CURL_TIMEOUT:-10}"
|
|
CURL_INSECURE="${CURL_INSECURE:-false}"
|
|
SKIP_APP="${SKIP_APP_LIFECYCLE:-false}"
|
|
SKIP_SSL="${SKIP_SSL:-false}"
|
|
OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}"
|
|
JUNIT_FILE="${JUNIT_FILE:-smoke-results.xml}"
|
|
VERBOSE=false
|
|
USE_COLOR=true
|
|
AUTH_TOKEN=""
|
|
TEST_APP_NAME=""
|
|
PASSED=0
|
|
FAILED=0
|
|
SKIPPED=0
|
|
START_TIME=""
|
|
CURL_OPTS=()
|
|
JUNIT_RESULTS=()
|
|
TAP_RESULTS=()
|
|
TEST_NUM=0
|
|
|
|
# --- Colors ---
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m'
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "$0") [OPTIONS]
|
|
|
|
Options:
|
|
--skip-app Skip app lifecycle tests
|
|
--skip-ssl Skip SSL certificate checks
|
|
--insecure Allow self-signed TLS certificates
|
|
--timeout N curl timeout in seconds (default: 10)
|
|
--format FORMAT Output: text (default), tap, junit
|
|
--junit-file FILE JUnit output path (default: smoke-results.xml)
|
|
--verbose Show debug output
|
|
--no-color Disable colored output
|
|
-h, --help Show this help
|
|
|
|
Environment:
|
|
CAPROVER_URL CapRover dashboard URL (required)
|
|
CAPROVER_PASSWORD CapRover password (required)
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
# --- Argument parsing ---
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--skip-app) SKIP_APP=true; shift ;;
|
|
--skip-ssl) SKIP_SSL=true; shift ;;
|
|
--insecure) CURL_INSECURE=true; shift ;;
|
|
--timeout) CURL_TIMEOUT="$2"; shift 2 ;;
|
|
--format) OUTPUT_FORMAT="$2"; shift 2 ;;
|
|
--junit-file) JUNIT_FILE="$2"; shift 2 ;;
|
|
--verbose) VERBOSE=true; shift ;;
|
|
--no-color) USE_COLOR=false; shift ;;
|
|
-h|--help) usage ;;
|
|
*) echo "Unknown option: $1"; usage ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "$USE_COLOR" == "false" ]]; then
|
|
RED="" GREEN="" YELLOW="" CYAN="" BOLD="" NC=""
|
|
fi
|
|
|
|
if [[ "$CURL_INSECURE" == "true" ]]; then
|
|
CURL_OPTS+=(-k)
|
|
fi
|
|
|
|
# --- Validation ---
|
|
if [[ -z "$CAPROVER_URL" ]]; then
|
|
echo "Error: CAPROVER_URL is required" >&2
|
|
exit 1
|
|
fi
|
|
if [[ -z "$CAPROVER_PASSWORD" ]]; then
|
|
echo "Error: CAPROVER_PASSWORD is required" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Strip trailing slash
|
|
CAPROVER_URL="${CAPROVER_URL%/}"
|
|
|
|
# --- Helpers ---
|
|
debug() {
|
|
if [[ "$VERBOSE" == "true" ]]; then
|
|
echo -e " ${CYAN}[debug]${NC} $*" >&2
|
|
fi
|
|
}
|
|
|
|
api_call() {
|
|
local method="$1" endpoint="$2"
|
|
shift 2
|
|
local url="${CAPROVER_URL}${endpoint}"
|
|
debug "curl -s -X $method $url"
|
|
curl -s -X "$method" \
|
|
--connect-timeout "$CURL_TIMEOUT" \
|
|
--max-time "$((CURL_TIMEOUT * 3))" \
|
|
-H "Content-Type: application/json" \
|
|
-H "x-captain-auth: ${AUTH_TOKEN}" \
|
|
"${CURL_OPTS[@]}" \
|
|
"$url" "$@"
|
|
}
|
|
|
|
pass() {
|
|
local suite="$1" msg="$2"
|
|
((TEST_NUM++)) || true
|
|
((PASSED++)) || true
|
|
case "$OUTPUT_FORMAT" in
|
|
tap) TAP_RESULTS+=("ok $TEST_NUM - [$suite] $msg") ;;
|
|
junit) JUNIT_RESULTS+=("<testcase classname=\"$suite\" name=\"$msg\" />") ;;
|
|
*) echo -e " ${GREEN}✓${NC} $msg" ;;
|
|
esac
|
|
}
|
|
|
|
fail() {
|
|
local suite="$1" msg="$2" detail="${3:-}"
|
|
((TEST_NUM++)) || true
|
|
((FAILED++)) || true
|
|
case "$OUTPUT_FORMAT" in
|
|
tap) TAP_RESULTS+=("not ok $TEST_NUM - [$suite] $msg") ;;
|
|
junit) JUNIT_RESULTS+=("<testcase classname=\"$suite\" name=\"$msg\"><failure message=\"$detail\">$detail</failure></testcase>") ;;
|
|
*) echo -e " ${RED}✗${NC} $msg${detail:+ — $detail}" ;;
|
|
esac
|
|
}
|
|
|
|
skip() {
|
|
local suite="$1" msg="$2"
|
|
((TEST_NUM++)) || true
|
|
((SKIPPED++)) || true
|
|
case "$OUTPUT_FORMAT" in
|
|
tap) TAP_RESULTS+=("ok $TEST_NUM - [$suite] $msg # SKIP") ;;
|
|
junit) JUNIT_RESULTS+=("<testcase classname=\"$suite\" name=\"$msg\"><skipped /></testcase>") ;;
|
|
*) echo -e " ${YELLOW}⊘${NC} $msg — skipped" ;;
|
|
esac
|
|
}
|
|
|
|
suite_header() {
|
|
if [[ "$OUTPUT_FORMAT" == "text" ]]; then
|
|
echo -e "\n${BOLD}$1${NC}"
|
|
fi
|
|
}
|
|
|
|
# --- Cleanup ---
|
|
cleanup() {
|
|
if [[ -n "$TEST_APP_NAME" && -n "$AUTH_TOKEN" ]]; then
|
|
debug "Cleaning up test app: $TEST_APP_NAME"
|
|
api_call POST "/api/v2/user/apps/appDefinitions/delete" \
|
|
-d "{\"appName\":\"$TEST_APP_NAME\"}" >/dev/null 2>&1 || true
|
|
TEST_APP_NAME=""
|
|
fi
|
|
}
|
|
trap cleanup EXIT INT TERM
|
|
|
|
# --- Header ---
|
|
START_TIME=$(date +%s)
|
|
if [[ "$OUTPUT_FORMAT" == "text" ]]; then
|
|
echo -e "${BOLD}CapRover Smoke Tests${NC}"
|
|
echo "Target: $CAPROVER_URL"
|
|
echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
fi
|
|
|
|
# =====================================================
|
|
# Suite 1: Connectivity
|
|
# =====================================================
|
|
suite_header "Connectivity"
|
|
|
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
--connect-timeout "$CURL_TIMEOUT" \
|
|
"${CURL_OPTS[@]}" \
|
|
"$CAPROVER_URL/" 2>/dev/null || echo "000")
|
|
|
|
if [[ "$http_code" =~ ^(200|302)$ ]]; then
|
|
pass "Connectivity" "Dashboard reachable — HTTP $http_code"
|
|
else
|
|
fail "Connectivity" "Dashboard unreachable" "HTTP $http_code"
|
|
fi
|
|
|
|
api_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
--connect-timeout "$CURL_TIMEOUT" \
|
|
"${CURL_OPTS[@]}" \
|
|
"$CAPROVER_URL/api/v2/user/system/info" 2>/dev/null || echo "000")
|
|
|
|
if [[ "$api_code" != "000" ]]; then
|
|
pass "Connectivity" "API endpoint responding — HTTP $api_code"
|
|
else
|
|
fail "Connectivity" "API endpoint not responding"
|
|
fi
|
|
|
|
# =====================================================
|
|
# Suite 2: API
|
|
# =====================================================
|
|
suite_header "API"
|
|
|
|
login_response=$(curl -s -X POST \
|
|
--connect-timeout "$CURL_TIMEOUT" \
|
|
--max-time "$((CURL_TIMEOUT * 3))" \
|
|
-H "Content-Type: application/json" \
|
|
"${CURL_OPTS[@]}" \
|
|
"$CAPROVER_URL/api/v2/login" \
|
|
-d "{\"password\":\"$CAPROVER_PASSWORD\"}" 2>/dev/null || echo "{}")
|
|
|
|
debug "Login response: $login_response"
|
|
|
|
AUTH_TOKEN=$(echo "$login_response" | jq -r '.data.token // empty' 2>/dev/null || true)
|
|
|
|
if [[ -n "$AUTH_TOKEN" ]]; then
|
|
pass "API" "API login — authenticated successfully"
|
|
else
|
|
fail "API" "API login failed" "Could not obtain auth token"
|
|
# Cannot continue without auth
|
|
if [[ "$OUTPUT_FORMAT" == "text" ]]; then
|
|
echo -e "\n${RED}Cannot continue without authentication. Aborting.${NC}"
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
# App definitions
|
|
app_response=$(api_call GET "/api/v2/user/apps/appDefinitions" 2>/dev/null || echo "{}")
|
|
app_count=$(echo "$app_response" | jq -r '.data.appDefinitions | length // 0' 2>/dev/null || echo "0")
|
|
status_code=$(echo "$app_response" | jq -r '.status // 0' 2>/dev/null || echo "0")
|
|
|
|
if [[ "$status_code" == "100" ]]; then
|
|
pass "API" "App definitions — $app_count apps found"
|
|
else
|
|
fail "API" "App definitions" "Unexpected status: $status_code"
|
|
fi
|
|
|
|
# Version
|
|
version_info=$(api_call GET "/api/v2/user/system/versioninfo" 2>/dev/null || echo "{}")
|
|
cr_version=$(echo "$version_info" | jq -r '.data.currentVersion // "unknown"' 2>/dev/null || echo "unknown")
|
|
|
|
if [[ "$cr_version" != "unknown" ]]; then
|
|
pass "API" "CapRover version — $cr_version"
|
|
else
|
|
fail "API" "CapRover version" "Could not retrieve version"
|
|
fi
|
|
|
|
# System info
|
|
sys_response=$(api_call GET "/api/v2/user/system/info" 2>/dev/null || echo "{}")
|
|
sys_status=$(echo "$sys_response" | jq -r '.status // 0' 2>/dev/null || echo "0")
|
|
|
|
if [[ "$sys_status" == "100" ]]; then
|
|
pass "API" "System info — retrieved successfully"
|
|
else
|
|
fail "API" "System info" "Unexpected status: $sys_status"
|
|
fi
|
|
|
|
# =====================================================
|
|
# Suite 3: App Lifecycle
|
|
# =====================================================
|
|
if [[ "$SKIP_APP" == "true" ]]; then
|
|
suite_header "App Lifecycle"
|
|
skip "App Lifecycle" "Create test app"
|
|
skip "App Lifecycle" "Deploy image"
|
|
skip "App Lifecycle" "App responding"
|
|
skip "App Lifecycle" "Delete test app"
|
|
else
|
|
suite_header "App Lifecycle"
|
|
|
|
TEST_APP_NAME="smoke-test-$(date +%s)"
|
|
debug "Test app name: $TEST_APP_NAME"
|
|
|
|
# Create app
|
|
create_response=$(api_call POST "/api/v2/user/apps/appDefinitions/register" \
|
|
-d "{\"appName\":\"$TEST_APP_NAME\",\"hasPersistentData\":false}" 2>/dev/null || echo "{}")
|
|
create_status=$(echo "$create_response" | jq -r '.status // 0' 2>/dev/null || echo "0")
|
|
|
|
if [[ "$create_status" == "100" ]]; then
|
|
pass "App Lifecycle" "Create test app — $TEST_APP_NAME"
|
|
else
|
|
fail "App Lifecycle" "Create test app" "$(echo "$create_response" | jq -r '.description // "unknown error"' 2>/dev/null)"
|
|
skip "App Lifecycle" "Deploy image"
|
|
skip "App Lifecycle" "App responding"
|
|
skip "App Lifecycle" "Delete test app"
|
|
TEST_APP_NAME=""
|
|
SKIP_APP=true
|
|
fi
|
|
|
|
if [[ "$SKIP_APP" != "true" ]]; then
|
|
# Deploy image
|
|
deploy_response=$(api_call POST "/api/v2/user/apps/appData/$TEST_APP_NAME" \
|
|
-d "{\"captainDefinitionContent\":\"{\\\"schemaVersion\\\":2,\\\"imageName\\\":\\\"nginxdemos/hello\\\"}\"}" 2>/dev/null || echo "{}")
|
|
deploy_status=$(echo "$deploy_response" | jq -r '.status // 0' 2>/dev/null || echo "0")
|
|
|
|
if [[ "$deploy_status" == "100" ]]; then
|
|
pass "App Lifecycle" "Deploy image — nginxdemos/hello deployed"
|
|
else
|
|
fail "App Lifecycle" "Deploy image" "$(echo "$deploy_response" | jq -r '.description // "deploy failed"' 2>/dev/null)"
|
|
fi
|
|
|
|
# Wait for app to be running (up to 60 seconds)
|
|
app_ready=false
|
|
for i in $(seq 1 12); do
|
|
sleep 5
|
|
debug "Waiting for app to start... attempt $i/12"
|
|
check=$(api_call GET "/api/v2/user/apps/appDefinitions" 2>/dev/null || echo "{}")
|
|
is_running=$(echo "$check" | jq -r ".data.appDefinitions[] | select(.appName==\"$TEST_APP_NAME\") | .isAppBuilding" 2>/dev/null || echo "true")
|
|
if [[ "$is_running" == "false" ]]; then
|
|
app_ready=true
|
|
break
|
|
fi
|
|
done
|
|
|
|
# Extract root domain from CapRover URL to build app URL
|
|
root_domain=$(echo "$CAPROVER_URL" | sed -E 's|https?://captain\.||')
|
|
app_url="http://${TEST_APP_NAME}.${root_domain}"
|
|
debug "App URL: $app_url"
|
|
|
|
if [[ "$app_ready" == "true" ]]; then
|
|
# Give nginx a moment to reconfigure
|
|
sleep 3
|
|
app_http=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
--connect-timeout "$CURL_TIMEOUT" \
|
|
"${CURL_OPTS[@]}" \
|
|
"$app_url" 2>/dev/null || echo "000")
|
|
|
|
if [[ "$app_http" == "200" ]]; then
|
|
pass "App Lifecycle" "App responding — HTTP 200 at ${TEST_APP_NAME}.${root_domain}"
|
|
else
|
|
fail "App Lifecycle" "App responding" "HTTP $app_http at $app_url"
|
|
fi
|
|
else
|
|
fail "App Lifecycle" "App responding" "Timed out waiting for app to start"
|
|
fi
|
|
|
|
# Delete test app
|
|
delete_response=$(api_call POST "/api/v2/user/apps/appDefinitions/delete" \
|
|
-d "{\"appName\":\"$TEST_APP_NAME\"}" 2>/dev/null || echo "{}")
|
|
delete_status=$(echo "$delete_response" | jq -r '.status // 0' 2>/dev/null || echo "0")
|
|
|
|
if [[ "$delete_status" == "100" ]]; then
|
|
pass "App Lifecycle" "Delete test app — cleaned up"
|
|
TEST_APP_NAME=""
|
|
else
|
|
fail "App Lifecycle" "Delete test app" "Manual cleanup may be required"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# =====================================================
|
|
# Suite 4: SSL
|
|
# =====================================================
|
|
if [[ "$SKIP_SSL" == "true" ]]; then
|
|
suite_header "SSL"
|
|
skip "SSL" "TLS certificate valid"
|
|
skip "SSL" "Certificate chain complete"
|
|
else
|
|
suite_header "SSL"
|
|
|
|
# Extract hostname from URL
|
|
cr_host=$(echo "$CAPROVER_URL" | sed -E 's|https?://||;s|/.*||;s|:.*||')
|
|
cr_port=$(echo "$CAPROVER_URL" | grep -oP ':\K[0-9]+' || echo "443")
|
|
|
|
if [[ "$CAPROVER_URL" == https://* ]]; then
|
|
cert_output=$(echo | openssl s_client -servername "$cr_host" -connect "${cr_host}:${cr_port}" 2>/dev/null || true)
|
|
cert_enddate=$(echo "$cert_output" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2 || true)
|
|
|
|
if [[ -n "$cert_enddate" ]]; then
|
|
expiry_epoch=$(date -d "$cert_enddate" +%s 2>/dev/null || echo "0")
|
|
now_epoch=$(date +%s)
|
|
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
|
|
|
|
if [[ "$days_left" -gt 0 ]]; then
|
|
pass "SSL" "TLS certificate valid — $days_left days remaining"
|
|
else
|
|
fail "SSL" "TLS certificate expired" "$days_left days past expiry"
|
|
fi
|
|
else
|
|
fail "SSL" "TLS certificate valid" "Could not read certificate"
|
|
fi
|
|
|
|
# Check chain
|
|
verify_result=$(echo | openssl s_client -servername "$cr_host" -connect "${cr_host}:${cr_port}" 2>&1 | grep "Verify return code" || true)
|
|
if echo "$verify_result" | grep -q "0 (ok)"; then
|
|
pass "SSL" "Certificate chain complete"
|
|
else
|
|
fail "SSL" "Certificate chain complete" "$verify_result"
|
|
fi
|
|
else
|
|
skip "SSL" "TLS certificate valid"
|
|
skip "SSL" "Certificate chain complete"
|
|
fi
|
|
fi
|
|
|
|
# =====================================================
|
|
# Suite 5: Docker Swarm
|
|
# =====================================================
|
|
suite_header "Docker Swarm"
|
|
|
|
node_count=$(echo "$sys_response" | jq -r '.data.swarmNodesCount // "unknown"' 2>/dev/null || echo "unknown")
|
|
|
|
if [[ "$node_count" != "unknown" && "$node_count" -gt 0 ]] 2>/dev/null; then
|
|
pass "Docker Swarm" "Swarm active — $node_count node(s)"
|
|
else
|
|
fail "Docker Swarm" "Swarm status" "Could not determine node count"
|
|
fi
|
|
|
|
# Count running services from app definitions
|
|
running_count=$(echo "$app_response" | jq '[.data.appDefinitions[] | select(.isAppBuilding == false)] | length' 2>/dev/null || echo "0")
|
|
total_count=$(echo "$app_response" | jq '.data.appDefinitions | length' 2>/dev/null || echo "0")
|
|
# Add 3 for captain-captain, captain-nginx, captain-certbot
|
|
service_count=$((running_count + 3))
|
|
|
|
pass "Docker Swarm" "Services running — $service_count services ($total_count apps + 3 system)"
|
|
|
|
# =====================================================
|
|
# Suite 6: Resources
|
|
# =====================================================
|
|
suite_header "Resources"
|
|
|
|
disk_used=$(echo "$sys_response" | jq -r '.data.diskUsedPercentage // "unknown"' 2>/dev/null || echo "unknown")
|
|
|
|
if [[ "$disk_used" != "unknown" ]]; then
|
|
pass "Resources" "Disk usage — ${disk_used}%"
|
|
else
|
|
fail "Resources" "Disk usage" "Could not retrieve disk info"
|
|
fi
|
|
|
|
volume_count=$(echo "$sys_response" | jq -r '.data.dockerVolumes | length // "unknown"' 2>/dev/null || echo "unknown")
|
|
if [[ "$volume_count" != "unknown" ]]; then
|
|
pass "Resources" "Docker volumes — $volume_count volumes"
|
|
else
|
|
# Volumes may not be in system info, skip gracefully
|
|
skip "Resources" "Docker volumes"
|
|
fi
|
|
|
|
image_count=$(echo "$sys_response" | jq -r '.data.dockerImages | length // "unknown"' 2>/dev/null || echo "unknown")
|
|
if [[ "$image_count" != "unknown" ]]; then
|
|
pass "Resources" "Docker images — $image_count images"
|
|
else
|
|
skip "Resources" "Docker images"
|
|
fi
|
|
|
|
# =====================================================
|
|
# Summary
|
|
# =====================================================
|
|
END_TIME=$(date +%s)
|
|
DURATION=$((END_TIME - START_TIME))
|
|
|
|
case "$OUTPUT_FORMAT" in
|
|
tap)
|
|
echo "TAP version 13"
|
|
echo "1..$TEST_NUM"
|
|
for line in "${TAP_RESULTS[@]}"; do
|
|
echo "$line"
|
|
done
|
|
echo "# passed: $PASSED"
|
|
echo "# failed: $FAILED"
|
|
echo "# skipped: $SKIPPED"
|
|
echo "# duration: ${DURATION}s"
|
|
;;
|
|
junit)
|
|
{
|
|
echo '<?xml version="1.0" encoding="UTF-8"?>'
|
|
echo "<testsuite name=\"CapRover Smoke Tests\" tests=\"$TEST_NUM\" failures=\"$FAILED\" skipped=\"$SKIPPED\" time=\"$DURATION\" timestamp=\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\">"
|
|
echo " <properties>"
|
|
echo " <property name=\"target\" value=\"$CAPROVER_URL\" />"
|
|
echo " </properties>"
|
|
for result in "${JUNIT_RESULTS[@]}"; do
|
|
echo " $result"
|
|
done
|
|
echo "</testsuite>"
|
|
} > "$JUNIT_FILE"
|
|
echo "JUnit results written to $JUNIT_FILE"
|
|
;;
|
|
*)
|
|
echo ""
|
|
echo "────────────────────────────────────────"
|
|
echo -e "Summary ${BOLD}$CAPROVER_URL${NC}"
|
|
echo -e " ${GREEN}$PASSED passed${NC} ${RED}$FAILED failed${NC} ${YELLOW}$SKIPPED skipped${NC} (${DURATION}s)"
|
|
echo "────────────────────────────────────────"
|
|
if [[ "$FAILED" -eq 0 ]]; then
|
|
echo -e "${GREEN}All tests passed.${NC}"
|
|
else
|
|
echo -e "${RED}Some tests failed.${NC}"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
exit $((FAILED > 0 ? 1 : 0))
|