#!/usr/bin/env bash ######################################################################################### #### gitlab-smoke-tests.sh — Verify GitLab instance health after upgrades or changes #### #### Zero external dependencies. Runs in air-gapped environments. #### #### Requires: bash 4+, curl, git, openssl (optional) #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.02 #### #### #### #### Usage: #### #### export GITLAB_URL="https://gitlab.example.com" #### #### export GITLAB_TOKEN="glpat-xxxxxxxxxxxxxxxxxxxx" #### #### export GITLAB_HEALTH_TOKEN="your-health-token" # optional #### #### ./gitlab-smoke-tests.sh #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── GITLAB_URL="${GITLAB_URL:-}" GITLAB_TOKEN="${GITLAB_TOKEN:-}" GITLAB_USER="${GITLAB_USER:-root}" SMOKE_PROJECT_PREFIX="${SMOKE_PROJECT_PREFIX:-smoke-test}" CURL_TIMEOUT="${CURL_TIMEOUT:-10}" CURL_INSECURE="${CURL_INSECURE:-false}" SKIP_GIT="${SKIP_GIT:-false}" SKIP_REGISTRY="${SKIP_REGISTRY:-false}" SKIP_CLEANUP="${SKIP_CLEANUP:-false}" GITLAB_HEALTH_TOKEN="${GITLAB_HEALTH_TOKEN:-}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" # text, tap, junit JUNIT_FILE="${JUNIT_FILE:-smoke-results.xml}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── PASS=0 FAIL=0 SKIP=0 TOTAL=0 RESULTS=() CLEANUP_PROJECT_ID="" TMPDIR_SMOKE="" START_TIME="" GIT_CLONE_OK="false" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" local detail="${2:-}" ((PASS++)) || true ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}" fi } record_fail() { local name="$1" local detail="${2:-}" ((FAIL++)) || true ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" else echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}" fi } record_skip() { local name="$1" local reason="${2:-}" ((SKIP++)) || true ((TOTAL++)) || true RESULTS+=("SKIP|${name}|${reason}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${reason}" else echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}" fi } # ── curl wrapper ────────────────────────────────────────────────────── api_curl() { local method="$1" local endpoint="$2" shift 2 local curl_opts=(-s -S --max-time "$CURL_TIMEOUT" -X "$method") [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) [[ -n "$GITLAB_TOKEN" ]] && curl_opts+=(-H "PRIVATE-TOKEN: ${GITLAB_TOKEN}") curl_opts+=(-H "Content-Type: application/json") local url="${GITLAB_URL}/api/v4${endpoint}" verbose "curl ${method} ${url} $*" curl "${curl_opts[@]}" "$@" "$url" 2>/dev/null } api_curl_status() { local method="$1" local endpoint="$2" shift 2 local curl_opts=(-s -S -o /dev/null -w "%{http_code}" --max-time "$CURL_TIMEOUT" -X "$method") [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) [[ -n "$GITLAB_TOKEN" ]] && curl_opts+=(-H "PRIVATE-TOKEN: ${GITLAB_TOKEN}") curl_opts+=(-H "Content-Type: application/json") local url="${GITLAB_URL}/api/v4${endpoint}" curl "${curl_opts[@]}" "$@" "$url" 2>/dev/null } # ── JSON parsing (no jq required) ──────────────────────────────────── # Extract a top-level string/number value from flat JSON json_value() { local key="$1" local json="$2" echo "$json" | { grep -oP "\"${key}\"\s*:\s*\"?\K[^\",}]+" || true; } | head -1 } json_value_string() { local key="$1" local json="$2" echo "$json" | { grep -oP "\"${key}\"\s*:\s*\"\K[^\"]*" || true; } | head -1 } # ── Cleanup ─────────────────────────────────────────────────────────── cleanup() { if [[ -n "$CLEANUP_PROJECT_ID" && "$SKIP_CLEANUP" != "true" ]]; then verbose "Cleaning up smoke test project (ID: ${CLEANUP_PROJECT_ID})" api_curl DELETE "/projects/${CLEANUP_PROJECT_ID}" >/dev/null 2>&1 || true fi if [[ -n "$TMPDIR_SMOKE" && -d "$TMPDIR_SMOKE" ]]; then rm -rf "$TMPDIR_SMOKE" fi } trap cleanup EXIT # ══════════════════════════════════════════════════════════════════════ # TEST SUITES # ══════════════════════════════════════════════════════════════════════ # ── 1. Connectivity ────────────────────────────────────────────────── test_connectivity() { echo "" echo -e "${BOLD}Connectivity${RESET}" # 1a. HTTP(S) reachable local curl_opts=(-s -o /dev/null -w "%{http_code}" --max-time "$CURL_TIMEOUT") [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) local health_qs="" [[ -n "$GITLAB_HEALTH_TOKEN" ]] && health_qs="?token=${GITLAB_HEALTH_TOKEN}" local http_code http_code=$(curl "${curl_opts[@]}" "${GITLAB_URL}/-/health${health_qs}" 2>/dev/null) || http_code="000" if [[ "$http_code" == "200" ]]; then record_pass "GitLab health endpoint reachable" "HTTP ${http_code}" else record_fail "GitLab health endpoint reachable" "HTTP ${http_code}" fi # 1b. Readiness check http_code=$(curl "${curl_opts[@]}" "${GITLAB_URL}/-/readiness${health_qs}" 2>/dev/null) || http_code="000" if [[ "$http_code" == "200" ]]; then record_pass "GitLab readiness check" "HTTP ${http_code}" else record_fail "GitLab readiness check" "HTTP ${http_code}" fi # 1c. Liveness check http_code=$(curl "${curl_opts[@]}" "${GITLAB_URL}/-/liveness${health_qs}" 2>/dev/null) || http_code="000" if [[ "$http_code" == "200" ]]; then record_pass "GitLab liveness check" "HTTP ${http_code}" else record_fail "GitLab liveness check" "HTTP ${http_code}" fi # 1d. TLS certificate validity (if HTTPS) if [[ "$GITLAB_URL" == https://* ]]; then local host host=$(echo "$GITLAB_URL" | sed 's|https://||' | cut -d/ -f1 | cut -d: -f1) local port port=$(echo "$GITLAB_URL" | grep -oP ':\K[0-9]+$' || echo "443") local expiry expiry=$(echo | openssl s_client -servername "$host" -connect "${host}:${port}" 2>/dev/null | \ openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) || expiry="" if [[ -n "$expiry" ]]; then local expiry_epoch expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null) || expiry_epoch=0 local now_epoch now_epoch=$(date +%s) local days_left=$(( (expiry_epoch - now_epoch) / 86400 )) if [[ $days_left -gt 30 ]]; then record_pass "TLS certificate valid" "${days_left} days remaining" elif [[ $days_left -gt 0 ]]; then record_pass "TLS certificate valid" "${days_left} days remaining (renew soon)" else record_fail "TLS certificate valid" "expired or expiring in ${days_left} days" fi else record_skip "TLS certificate check" "could not retrieve certificate" fi else record_skip "TLS certificate check" "not using HTTPS" fi } # ── 2. API ──────────────────────────────────────────────────────────── test_api() { echo "" echo -e "${BOLD}API${RESET}" # 2a. Version endpoint local version_json version_json=$(api_curl GET "/version" 2>/dev/null) || version_json="" local gl_version gl_version=$(json_value_string "version" "$version_json") local gl_revision gl_revision=$(json_value_string "revision" "$version_json") if [[ -n "$gl_version" ]]; then record_pass "API version endpoint" "GitLab ${gl_version} (${gl_revision})" else record_fail "API version endpoint" "no version returned" fi # 2b. Authentication local auth_status auth_status=$(api_curl_status GET "/user") if [[ "$auth_status" == "200" ]]; then local user_json user_json=$(api_curl GET "/user") local username username=$(json_value_string "username" "$user_json") record_pass "API authentication" "authenticated as ${username}" elif [[ "$auth_status" == "401" ]]; then record_fail "API authentication" "token rejected (HTTP 401)" else record_fail "API authentication" "HTTP ${auth_status}" fi # 2c. List projects (verify database queries work) local projects_status projects_status=$(api_curl_status GET "/projects?per_page=1") if [[ "$projects_status" == "200" ]]; then record_pass "API list projects" "database responding" else record_fail "API list projects" "HTTP ${projects_status}" fi # 2d. List users local users_status users_status=$(api_curl_status GET "/users?per_page=1") if [[ "$users_status" == "200" ]]; then record_pass "API list users" "user directory accessible" else record_fail "API list users" "HTTP ${users_status}" fi # 2e. Sidekiq health (job processing) local sidekiq_json sidekiq_json=$(api_curl GET "/sidekiq/compound_metrics" 2>/dev/null) || sidekiq_json="" if [[ -n "$sidekiq_json" && "$sidekiq_json" != *"error"* ]]; then local processes processes=$(echo "$sidekiq_json" | { grep -oP '"hostname"\s*:' || true; } | wc -l) record_pass "Sidekiq running" "${processes} process(es) responding" else record_fail "Sidekiq running" "could not query Sidekiq metrics" fi # 2f. Runners endpoint local runners_status runners_status=$(api_curl_status GET "/runners/all?per_page=1") if [[ "$runners_status" == "200" ]]; then record_pass "API runners endpoint" "runner management accessible" elif [[ "$runners_status" == "403" ]]; then record_skip "API runners endpoint" "token lacks admin scope" else record_fail "API runners endpoint" "HTTP ${runners_status}" fi # 2g. Search endpoint local search_status search_status=$(api_curl_status GET "/search?scope=projects&search=test") if [[ "$search_status" == "200" ]]; then record_pass "API search" "search index responding" elif [[ "$search_status" == "403" ]]; then record_skip "API search" "search disabled or token lacks scope" else record_fail "API search" "HTTP ${search_status}" fi } # ── 3. Git Operations ──────────────────────────────────────────────── test_git() { if [[ "$SKIP_GIT" == "true" ]]; then echo "" echo -e "${BOLD}Git Operations${RESET}" record_skip "Git clone" "SKIP_GIT=true" record_skip "Git push" "SKIP_GIT=true" return fi echo "" echo -e "${BOLD}Git Operations${RESET}" # Create a test project via API local project_name project_name="${SMOKE_PROJECT_PREFIX}-$(date +%s)" local create_json create_json=$(api_curl POST "/projects" -d "{\"name\":\"${project_name}\",\"visibility\":\"private\",\"initialize_with_readme\":true}") local project_id project_id=$(json_value "id" "$create_json") local http_url http_url=$(json_value_string "http_url_to_repo" "$create_json") if [[ -z "$project_id" || "$project_id" == "null" ]]; then record_fail "Create test project" "API returned: $(echo "$create_json" | head -c 200)" record_skip "Git clone" "no test project" record_skip "Git push" "no test project" return fi CLEANUP_PROJECT_ID="$project_id" record_pass "Create test project" "${project_name} (ID: ${project_id})" # Clone TMPDIR_SMOKE=$(mktemp -d) # Fallback: if http_url_to_repo wasn't parsed, construct it if [[ -z "$http_url" ]]; then http_url="${GITLAB_URL}/${GITLAB_USER}/${project_name}.git" verbose "http_url_to_repo not found in API response, constructed: ${http_url}" fi verbose "Clone URL (from API): ${http_url}" # Replace the hostname in the API-returned URL with GITLAB_URL # (the API may return an internal hostname that's unreachable remotely) local api_origin api_origin=$(echo "$http_url" | grep -oP 'https?://[^/]+') if [[ -n "$api_origin" && "$api_origin" != "$GITLAB_URL" ]]; then http_url="${http_url/$api_origin/$GITLAB_URL}" verbose "Rewrote clone URL to: ${http_url}" fi local clone_url # Inject token into URL for HTTPS clone if [[ "$http_url" == https://* ]]; then clone_url="https://oauth2:${GITLAB_TOKEN}@${http_url#https://}" elif [[ "$http_url" == http://* ]]; then clone_url="http://oauth2:${GITLAB_TOKEN}@${http_url#http://}" else clone_url="$http_url" fi local git_opts=() [[ "$CURL_INSECURE" == "true" ]] && git_opts+=(-c http.sslVerify=false) # Brief wait for repository initialization (initialize_with_readme is async) sleep 2 verbose "Running: git clone ${TMPDIR_SMOKE}/repo" local clone_err clone_rc clone_err=$(git ${git_opts[@]+"${git_opts[@]}"} clone "$clone_url" "${TMPDIR_SMOKE}/repo" 2>&1) && clone_rc=0 || clone_rc=$? if [[ "$clone_rc" -eq 0 ]]; then GIT_CLONE_OK="true" record_pass "Git clone (HTTPS)" "Gitaly responding" else local short_err redacted_url short_err=$(echo "$clone_err" | grep -i -E 'fatal|error' | head -1 | sed "s|${GITLAB_TOKEN}|[REDACTED]|g") redacted_url=$(echo "$http_url" | sed "s|${GITLAB_TOKEN}|[REDACTED]|g") verbose "Full clone output: $(echo "$clone_err" | sed "s|${GITLAB_TOKEN}|[REDACTED]|g")" local redacted_clone redacted_clone=$(echo "$clone_url" | sed "s|${GITLAB_TOKEN}|[REDACTED]|g") verbose "Attempted URL: ${redacted_clone}" record_fail "Git clone (HTTPS)" "${short_err:-clone failed (exit $clone_rc)}" return fi # Push a commit pushd "${TMPDIR_SMOKE}/repo" >/dev/null git config user.email "smoke-test@example.com" git config user.name "Smoke Test" echo "smoke test $(date -u +%Y-%m-%dT%H:%M:%SZ)" > smoke-test.txt git add smoke-test.txt git commit -m "smoke test commit" >/dev/null 2>&1 if git ${git_opts[@]+"${git_opts[@]}"} push origin main >/dev/null 2>&1 || \ git ${git_opts[@]+"${git_opts[@]}"} push origin master >/dev/null 2>&1; then record_pass "Git push (HTTPS)" "write to Gitaly succeeded" else record_fail "Git push (HTTPS)" "push failed" fi popd >/dev/null } # ── 4. Container Registry ──────────────────────────────────────────── test_registry() { if [[ "$SKIP_REGISTRY" == "true" ]]; then echo "" echo -e "${BOLD}Container Registry${RESET}" record_skip "Registry API" "SKIP_REGISTRY=true" return fi echo "" echo -e "${BOLD}Container Registry${RESET}" # Check if registry is enabled via application settings API local registry_enabled="" local settings_json settings_json=$(api_curl GET "/application/settings" 2>/dev/null) || settings_json="" if [[ -n "$settings_json" ]]; then registry_enabled=$(json_value "container_registry_enabled" "$settings_json" 2>/dev/null || echo "") fi if [[ "$registry_enabled" == "false" ]]; then record_skip "Registry API reachable" "container registry disabled in application settings" record_skip "Registry project endpoint" "container registry disabled in application settings" return fi # Try the registry v2 API endpoint local host host=$(echo "$GITLAB_URL" | sed 's|https\?://||' | cut -d/ -f1) local curl_opts=(-s -o /dev/null -w "%{http_code}" --max-time "$CURL_TIMEOUT") [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) local registry_status registry_status=$(curl "${curl_opts[@]}" "${GITLAB_URL}:5050/v2/" 2>/dev/null) || \ registry_status=$(curl "${curl_opts[@]}" "https://${host}:5050/v2/" 2>/dev/null) || \ registry_status=$(curl "${curl_opts[@]}" "https://registry.${host}/v2/" 2>/dev/null) || \ registry_status="000" if [[ "$registry_status" == "200" || "$registry_status" == "401" ]]; then record_pass "Registry API reachable" "HTTP ${registry_status}" elif [[ "$registry_status" == "000" ]]; then if [[ "$registry_enabled" == "true" ]]; then record_fail "Registry API reachable" "enabled in settings but not reachable at standard ports/hosts" else record_skip "Registry API reachable" "not found at standard ports/hosts (settings unreadable — may need admin token)" fi else record_fail "Registry API reachable" "HTTP ${registry_status}" fi # Check registry via GitLab API (project-level) if [[ -n "$CLEANUP_PROJECT_ID" ]]; then local reg_status reg_status=$(api_curl_status GET "/projects/${CLEANUP_PROJECT_ID}/registry/repositories") if [[ "$reg_status" == "200" ]]; then record_pass "Registry project endpoint" "project registry accessible" elif [[ "$reg_status" == "404" ]]; then record_skip "Registry project endpoint" "container registry not enabled for project" else record_fail "Registry project endpoint" "HTTP ${reg_status}" fi fi } # ── 5. CI/CD ────────────────────────────────────────────────────────── test_cicd() { echo "" echo -e "${BOLD}CI/CD${RESET}" # Check runners local runners_json runners_json=$(api_curl GET "/runners/all?per_page=100" 2>/dev/null) || runners_json="" if [[ "$runners_json" == "["* ]]; then local runner_count runner_count=$(echo "$runners_json" | { grep -oP '"id"\s*:' || true; } | wc -l) local online_count online_count=$(echo "$runners_json" | { grep -oP '"status"\s*:\s*"online"' || true; } | wc -l) if [[ $online_count -gt 0 ]]; then record_pass "CI/CD runners online" "${online_count}/${runner_count} runners online" elif [[ $runner_count -gt 0 ]]; then record_fail "CI/CD runners online" "0/${runner_count} runners online" else record_skip "CI/CD runners online" "no runners registered" fi else record_skip "CI/CD runners" "could not query runners (admin token required)" fi # Check CI/CD settings via API local cicd_status cicd_status=$(api_curl_status GET "/application/settings") if [[ "$cicd_status" == "200" ]]; then record_pass "CI/CD settings accessible" "application settings readable" elif [[ "$cicd_status" == "403" ]]; then record_skip "CI/CD settings accessible" "admin token required" else record_fail "CI/CD settings accessible" "HTTP ${cicd_status}" fi } # ── 6. Background Migrations ───────────────────────────────────────── test_migrations() { echo "" echo -e "${BOLD}Background Migrations${RESET}" # Batched background migrations (admin only) local migrations_json migrations_json=$(api_curl GET "/admin/batched_background_migrations?database=main" 2>/dev/null) || migrations_json="" if [[ "$migrations_json" == "["* ]]; then local total_mig total_mig=$(echo "$migrations_json" | { grep -oP '"id"\s*:' || true; } | wc -l) local failed_mig failed_mig=$(echo "$migrations_json" | { grep -oP '"status"\s*:\s*"failed"' || true; } | wc -l) local active_mig active_mig=$(echo "$migrations_json" | { grep -oP '"status"\s*:\s*"active"' || true; } | wc -l) local paused_mig paused_mig=$(echo "$migrations_json" | { grep -oP '"status"\s*:\s*"paused"' || true; } | wc -l) local finalized_mig finalized_mig=$(echo "$migrations_json" | { grep -oP '"status"\s*:\s*"finished"' || true; } | wc -l) if [[ $failed_mig -gt 0 ]]; then record_fail "Background migrations" "${failed_mig} failed, ${active_mig} active, ${paused_mig} paused, ${finalized_mig} finished of ${total_mig}" elif [[ $paused_mig -gt 0 ]]; then record_fail "Background migrations" "${paused_mig} paused, ${active_mig} active, ${finalized_mig} finished of ${total_mig}" elif [[ $active_mig -gt 0 ]]; then record_pass "Background migrations" "${active_mig} active, ${finalized_mig} finished of ${total_mig} (in progress)" else record_pass "Background migrations" "all ${total_mig} finished" fi else local mig_status mig_status=$(api_curl_status GET "/admin/batched_background_migrations?database=main") if [[ "$mig_status" == "403" ]]; then record_skip "Background migrations" "admin token required" else record_skip "Background migrations" "could not query (HTTP ${mig_status})" fi fi } # ── 7. Storage & Components ────────────────────────────────────────── test_components() { echo "" echo -e "${BOLD}Components${RESET}" # Metadata endpoint local metadata_json metadata_json=$(api_curl GET "/metadata" 2>/dev/null) || metadata_json="" if [[ -n "$metadata_json" ]]; then local gl_version gl_version=$(json_value_string "version" "$metadata_json") local enterprise enterprise=$(json_value "enterprise" "$metadata_json") if [[ -n "$gl_version" ]]; then local edition="CE" [[ "$enterprise" == "true" ]] && edition="EE" record_pass "GitLab metadata" "${gl_version} ${edition}" else record_pass "GitLab metadata" "endpoint reachable" fi else record_skip "GitLab metadata" "metadata endpoint not available" fi # Statistics (admin) local stats_json stats_json=$(api_curl GET "/application/statistics" 2>/dev/null) || stats_json="" if [[ -n "$stats_json" && "$stats_json" != *"error"* && "$stats_json" != *"403"* ]]; then local active_users active_users=$(json_value "active_users" "$stats_json") local projects projects=$(json_value "projects" "$stats_json") local groups groups=$(json_value "groups" "$stats_json") if [[ -n "$active_users" ]]; then record_pass "Instance statistics" "${active_users} users, ${projects} projects, ${groups} groups" else record_pass "Instance statistics" "endpoint reachable" fi else record_skip "Instance statistics" "admin token required" fi # Gitaly check — only report pass if clone actually succeeded if [[ "$GIT_CLONE_OK" == "true" ]]; then record_pass "Gitaly storage" "project created and cloned successfully" elif [[ -n "$CLEANUP_PROJECT_ID" ]]; then record_skip "Gitaly storage" "project created but clone was not tested or failed" fi # PostgreSQL (inferred from API responsiveness) local pg_test pg_test=$(api_curl_status GET "/projects?per_page=1&order_by=updated_at") if [[ "$pg_test" == "200" ]]; then record_pass "PostgreSQL" "database queries succeeding" else record_fail "PostgreSQL" "sorted query failed (HTTP ${pg_test})" fi # Redis (inferred from session/cache) local redis_test redis_test=$(api_curl_status GET "/user") if [[ "$redis_test" == "200" ]]; then record_pass "Redis" "session/cache operational (auth succeeded)" else record_skip "Redis" "cannot verify independently" fi } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ print_summary() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) echo "" echo -e "${BOLD}────────────────────────────────────────${RESET}" echo -e "${BOLD}Summary${RESET} ${GITLAB_URL}" echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)" echo -e "${BOLD}────────────────────────────────────────${RESET}" if [[ $FAIL -eq 0 ]]; then echo -e "${GREEN}${BOLD}All tests passed.${RESET}" else echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}" fi } print_tap_header() { echo "TAP version 13" } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# skip ${SKIP}" } write_junit() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) cat > "$JUNIT_FILE" < JUNIT_EOF for result in "${RESULTS[@]}"; do local status name detail status=$(echo "$result" | cut -d'|' -f1) name=$(echo "$result" | cut -d'|' -f2) detail=$(echo "$result" | cut -d'|' -f3) # XML-escape the values name=$(echo "$name" | sed 's/&/\&/g; s//\>/g; s/"/\"/g') detail=$(echo "$detail" | sed 's/&/\&/g; s//\>/g; s/"/\"/g') case "$status" in PASS) echo " " >> "$JUNIT_FILE" [[ -n "$detail" ]] && echo " ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; FAIL) echo " " >> "$JUNIT_FILE" echo " FAILED: ${name} — ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; SKIP) echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; esac done echo " " >> "$JUNIT_FILE" echo "" >> "$JUNIT_FILE" log "JUnit report written to ${JUNIT_FILE}" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat <