#!/usr/bin/env bash # test-infra-runners.sh — Integration tests for self-hosted CI runner infrastructure. # # Tests cover: # 1. Shell script syntax (bash -n) for all infrastructure scripts # 2. runner.sh argument parsing and help output # 3. setup.sh cross-platform dispatch logic # 4. Docker image builds (slim + full) with content verification # 5. Docker Compose configuration validation # 6. ci.yml runner variable expression syntax # 7. lib.sh headless emulator function structure # 8. entrypoint.sh env validation logic # # Usage: ./scripts/test-infra-runners.sh [--skip-docker] # # --skip-docker Skip Docker image build tests (useful in CI without Docker) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PASS_COUNT=0 FAIL_COUNT=0 SKIP_COUNT=0 SKIP_DOCKER=false # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- log() { echo "[test-infra] $*"; } pass() { PASS_COUNT=$((PASS_COUNT + 1)); log "PASS: $*"; } fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); log "FAIL: $*"; } skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); log "SKIP: $*"; } assert_file_exists() { local path="$1" label="$2" if [[ -f "$path" ]]; then pass "$label" else fail "$label — file not found: $path" fi } assert_file_executable() { local path="$1" label="$2" if [[ -x "$path" ]]; then pass "$label" else fail "$label — not executable: $path" fi } assert_contains() { local haystack="$1" needle="$2" label="$3" if echo "$haystack" | grep -qF -- "$needle"; then pass "$label" else fail "$label — expected to contain: $needle" fi } assert_not_contains() { local haystack="$1" needle="$2" label="$3" if ! echo "$haystack" | grep -qF -- "$needle"; then pass "$label" else fail "$label — should NOT contain: $needle" fi } assert_exit_code() { local expected="$1" label="$2" shift 2 local actual set +e "$@" >/dev/null 2>&1 actual=$? set -e if [[ "$actual" -eq "$expected" ]]; then pass "$label" else fail "$label — expected exit $expected, got $actual" fi } # --------------------------------------------------------------------------- # Parse args # --------------------------------------------------------------------------- while [[ $# -gt 0 ]]; do case "$1" in --skip-docker) SKIP_DOCKER=true; shift ;; *) echo "Unknown arg: $1"; exit 1 ;; esac done # =========================================================================== # Section 1: File existence and permissions # =========================================================================== log "" log "=== Section 1: File existence and permissions ===" assert_file_exists "$PROJECT_ROOT/infra/runners/Dockerfile" "Dockerfile exists" assert_file_exists "$PROJECT_ROOT/infra/runners/docker-compose.yml" "docker-compose.yml exists" assert_file_exists "$PROJECT_ROOT/infra/runners/entrypoint.sh" "entrypoint.sh exists" assert_file_exists "$PROJECT_ROOT/infra/runners/.env.example" "env.example exists" assert_file_exists "$PROJECT_ROOT/infra/runners/envs/periodvault.env.example" "periodvault.env.example exists" assert_file_exists "$PROJECT_ROOT/infra/runners/.gitignore" ".gitignore exists" assert_file_exists "$PROJECT_ROOT/infra/runners/README.md" "runners README exists" assert_file_exists "$PROJECT_ROOT/scripts/runner.sh" "runner.sh exists" assert_file_exists "$PROJECT_ROOT/scripts/setup.sh" "setup.sh exists" assert_file_exists "$PROJECT_ROOT/.github/workflows/build-runner-image.yml" "build-runner-image workflow exists" assert_file_executable "$PROJECT_ROOT/infra/runners/entrypoint.sh" "entrypoint.sh is executable" assert_file_executable "$PROJECT_ROOT/scripts/runner.sh" "runner.sh is executable" assert_file_executable "$PROJECT_ROOT/scripts/setup.sh" "setup.sh is executable" # =========================================================================== # Section 2: Shell script syntax validation (bash -n) # =========================================================================== log "" log "=== Section 2: Shell script syntax ===" for script in \ "$PROJECT_ROOT/scripts/runner.sh" \ "$PROJECT_ROOT/scripts/setup.sh" \ "$PROJECT_ROOT/infra/runners/entrypoint.sh"; do name="$(basename "$script")" if bash -n "$script" 2>/dev/null; then pass "bash -n $name" else fail "bash -n $name — syntax error" fi done # =========================================================================== # Section 3: runner.sh argument parsing # =========================================================================== log "" log "=== Section 3: runner.sh argument parsing ===" # --help should exit 0 and print usage HELP_OUT="$("$PROJECT_ROOT/scripts/runner.sh" --help 2>&1)" || true assert_contains "$HELP_OUT" "Usage:" "runner.sh --help shows usage" assert_contains "$HELP_OUT" "--mode" "runner.sh --help mentions --mode" assert_contains "$HELP_OUT" "build-image" "runner.sh --help mentions build-image" assert_exit_code 0 "runner.sh --help exits 0" "$PROJECT_ROOT/scripts/runner.sh" --help # Missing --mode should fail assert_exit_code 1 "runner.sh without --mode exits 1" "$PROJECT_ROOT/scripts/runner.sh" # Invalid mode should fail assert_exit_code 1 "runner.sh --mode invalid exits 1" "$PROJECT_ROOT/scripts/runner.sh" --mode invalid # =========================================================================== # Section 4: setup.sh platform dispatch # =========================================================================== log "" log "=== Section 4: setup.sh structure ===" SETUP_CONTENT="$(cat "$PROJECT_ROOT/scripts/setup.sh")" assert_contains "$SETUP_CONTENT" "Darwin" "setup.sh handles macOS" assert_contains "$SETUP_CONTENT" "Linux" "setup.sh handles Linux" assert_contains "$SETUP_CONTENT" "setup-dev-environment.sh" "setup.sh dispatches to setup-dev-environment.sh" # =========================================================================== # Section 5: entrypoint.sh validation logic # =========================================================================== log "" log "=== Section 5: entrypoint.sh structure ===" ENTRY_CONTENT="$(cat "$PROJECT_ROOT/infra/runners/entrypoint.sh")" assert_contains "$ENTRY_CONTENT" "GITHUB_PAT" "entrypoint.sh validates GITHUB_PAT" assert_contains "$ENTRY_CONTENT" "REPO_URL" "entrypoint.sh validates REPO_URL" assert_contains "$ENTRY_CONTENT" "RUNNER_NAME" "entrypoint.sh validates RUNNER_NAME" assert_contains "$ENTRY_CONTENT" "--ephemeral" "entrypoint.sh uses ephemeral mode" assert_contains "$ENTRY_CONTENT" "trap cleanup" "entrypoint.sh traps for cleanup" assert_contains "$ENTRY_CONTENT" "registration-token" "entrypoint.sh generates registration token" assert_contains "$ENTRY_CONTENT" "remove-token" "entrypoint.sh handles removal token" # =========================================================================== # Section 6: Dockerfile structure # =========================================================================== log "" log "=== Section 6: Dockerfile structure ===" DOCKERFILE="$(cat "$PROJECT_ROOT/infra/runners/Dockerfile")" assert_contains "$DOCKERFILE" "FROM ubuntu:24.04 AS base" "Dockerfile has base stage" assert_contains "$DOCKERFILE" "FROM base AS slim" "Dockerfile has slim stage" assert_contains "$DOCKERFILE" "FROM slim AS full" "Dockerfile has full stage" assert_contains "$DOCKERFILE" "openjdk-17-jdk-headless" "Dockerfile installs JDK 17" assert_contains "$DOCKERFILE" "platforms;android-34" "Dockerfile installs Android SDK 34" assert_contains "$DOCKERFILE" "build-tools;34.0.0" "Dockerfile installs build-tools 34" assert_contains "$DOCKERFILE" "system-images;android-34;google_apis;x86_64" "Full stage includes system images" assert_contains "$DOCKERFILE" "avdmanager create avd" "Full stage pre-creates AVD" assert_contains "$DOCKERFILE" "kvm" "Full stage sets up KVM group" assert_contains "$DOCKERFILE" "HEALTHCHECK" "Dockerfile has HEALTHCHECK" assert_contains "$DOCKERFILE" "ENTRYPOINT" "Dockerfile has ENTRYPOINT" assert_contains "$DOCKERFILE" 'userdel -r ubuntu' "Dockerfile removes ubuntu user (GID 1000 conflict fix)" # =========================================================================== # Section 7: docker-compose.yml structure # =========================================================================== log "" log "=== Section 7: docker-compose.yml structure ===" COMPOSE="$(cat "$PROJECT_ROOT/infra/runners/docker-compose.yml")" assert_contains "$COMPOSE" "registry:" "Compose has registry service" assert_contains "$COMPOSE" "runner-slim-1:" "Compose has runner-slim-1" assert_contains "$COMPOSE" "runner-slim-2:" "Compose has runner-slim-2" assert_contains "$COMPOSE" "runner-emulator:" "Compose has runner-emulator" assert_contains "$COMPOSE" "registry:2" "Registry uses official image" assert_contains "$COMPOSE" "/dev/kvm" "Emulator gets KVM device" assert_contains "$COMPOSE" "no-new-privileges" "Security: no-new-privileges" assert_contains "$COMPOSE" "init: true" "Uses tini (init: true)" assert_contains "$COMPOSE" "stop_grace_period" "Emulator has stop grace period" assert_contains "$COMPOSE" "android-emulator" "Emulator runner has android-emulator label" # =========================================================================== # Section 8: ci.yml runner variable expressions # =========================================================================== log "" log "=== Section 8: ci.yml runner variable expressions ===" CI_YML="$(cat "$PROJECT_ROOT/.github/workflows/ci.yml")" assert_contains "$CI_YML" 'vars.CI_RUNS_ON_MACOS' "ci.yml uses CI_RUNS_ON_MACOS variable" assert_contains "$CI_YML" 'vars.CI_RUNS_ON_ANDROID' "ci.yml uses CI_RUNS_ON_ANDROID variable" assert_contains "$CI_YML" 'vars.CI_RUNS_ON ' "ci.yml uses CI_RUNS_ON variable" assert_contains "$CI_YML" 'fromJSON(' "ci.yml uses fromJSON() for runner targeting" # Verify fallback values are present (safe default = current macOS runner) assert_contains "$CI_YML" '"self-hosted","macOS","periodvault"' "ci.yml has macOS fallback" # Verify parallelism: test-ios-simulator should NOT depend on test-android-emulator # Extract test-ios-simulator needs line IOS_SECTION="$(awk '/test-ios-simulator:/,/runs-on:/' "$PROJECT_ROOT/.github/workflows/ci.yml")" assert_not_contains "$IOS_SECTION" "test-android-emulator" "test-ios-simulator does NOT depend on test-android-emulator (parallel)" assert_contains "$IOS_SECTION" "test-shared" "test-ios-simulator depends on test-shared" # Verify audit-quality-gate waits for both platform tests AUDIT_SECTION="$(awk '/audit-quality-gate:/,/runs-on:/' "$PROJECT_ROOT/.github/workflows/ci.yml")" assert_contains "$AUDIT_SECTION" "test-android-emulator" "audit-quality-gate waits for android emulator" assert_contains "$AUDIT_SECTION" "test-ios-simulator" "audit-quality-gate waits for ios simulator" # =========================================================================== # Section 9: lib.sh headless emulator support # =========================================================================== log "" log "=== Section 9: lib.sh headless emulator support ===" LIB_SH="$(cat "$PROJECT_ROOT/scripts/lib.sh")" assert_contains "$LIB_SH" "start_emulator_headless()" "lib.sh defines start_emulator_headless()" assert_contains "$LIB_SH" "-no-window" "Headless emulator uses -no-window" assert_contains "$LIB_SH" "-no-audio" "Headless emulator uses -no-audio" assert_contains "$LIB_SH" "swiftshader_indirect" "Headless emulator uses swiftshader GPU" # Verify OS-aware dispatch in ensure_android_emulator assert_contains "$LIB_SH" '"$(uname -s)" == "Linux"' "ensure_android_emulator detects Linux" assert_contains "$LIB_SH" 'start_emulator_headless' "ensure_android_emulator calls headless on Linux" assert_contains "$LIB_SH" 'start_emulator_windowed' "ensure_android_emulator calls windowed on macOS" # Verify headless zombie kill is macOS-only ZOMBIE_LINE="$(grep -n 'is_emulator_headless' "$PROJECT_ROOT/scripts/lib.sh" | grep 'Darwin' || true)" if [[ -n "$ZOMBIE_LINE" ]]; then pass "Headless zombie kill is guarded by Darwin check" else fail "Headless zombie kill should be macOS-only (Darwin guard)" fi # =========================================================================== # Section 10: .gitignore protects secrets # =========================================================================== log "" log "=== Section 10: .gitignore protects secrets ===" GITIGNORE="$(cat "$PROJECT_ROOT/infra/runners/.gitignore")" assert_contains "$GITIGNORE" ".env" ".gitignore excludes .env" assert_contains "$GITIGNORE" "!.env.example" ".gitignore keeps .example files" # =========================================================================== # Section 11: Docker image builds (requires Docker) # =========================================================================== log "" log "=== Section 11: Docker image builds ===" if $SKIP_DOCKER; then skip "Docker image build tests (--skip-docker)" elif ! command -v docker &>/dev/null; then skip "Docker image build tests (docker not found)" elif ! docker info >/dev/null 2>&1; then skip "Docker image build tests (docker daemon not running)" else DOCKER_PLATFORM="linux/amd64" # --- Build slim --- log "Building slim image (this may take a few minutes)..." if docker build --platform "$DOCKER_PLATFORM" --target slim \ -t periodvault-runner-test:slim "$PROJECT_ROOT/infra/runners/" >/dev/null 2>&1; then pass "Docker build: slim target succeeds" # Verify slim image contents SLIM_JAVA="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:slim \ java -version 2>&1 | head -1)" || true if echo "$SLIM_JAVA" | grep -q "17"; then pass "Slim image: Java 17 is installed" else fail "Slim image: Java 17 not found — got: $SLIM_JAVA" fi SLIM_SDK="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:slim \ bash -c 'ls $ANDROID_HOME/platforms/' 2>&1)" || true if echo "$SLIM_SDK" | grep -q "android-34"; then pass "Slim image: Android SDK 34 is installed" else fail "Slim image: Android SDK 34 not found — got: $SLIM_SDK" fi SLIM_RUNNER="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:slim \ bash -c 'ls /home/runner/actions-runner/run.sh' 2>&1)" || true if echo "$SLIM_RUNNER" | grep -q "run.sh"; then pass "Slim image: GitHub Actions runner agent is installed" else fail "Slim image: runner agent not found" fi SLIM_USER="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:slim \ whoami 2>&1)" || true if [[ "$SLIM_USER" == "runner" ]]; then pass "Slim image: runs as 'runner' user" else fail "Slim image: expected user 'runner', got '$SLIM_USER'" fi SLIM_ENTRY="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:slim \ bash -c 'test -x /home/runner/entrypoint.sh && echo ok' 2>&1)" || true if [[ "$SLIM_ENTRY" == "ok" ]]; then pass "Slim image: entrypoint.sh is present and executable" else fail "Slim image: entrypoint.sh not executable" fi # Verify slim does NOT have emulator SLIM_EMU="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:slim \ bash -c 'command -v emulator || echo not-found' 2>&1)" || true if echo "$SLIM_EMU" | grep -q "not-found"; then pass "Slim image: does NOT include emulator (expected)" else fail "Slim image: unexpectedly contains emulator" fi else fail "Docker build: slim target failed" fi # --- Build full --- log "Building full image (this may take several minutes)..." if docker build --platform "$DOCKER_PLATFORM" --target full \ -t periodvault-runner-test:full "$PROJECT_ROOT/infra/runners/" >/dev/null 2>&1; then pass "Docker build: full target succeeds" # Verify full image has emulator FULL_EMU="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:full \ bash -c 'command -v emulator && echo found' 2>&1)" || true if echo "$FULL_EMU" | grep -q "found"; then pass "Full image: emulator is installed" else fail "Full image: emulator not found" fi # Verify full image has AVD pre-created FULL_AVD="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:full \ bash -c '${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager list avd 2>/dev/null | grep "Name:" || echo none' 2>&1)" || true if echo "$FULL_AVD" | grep -q "phone"; then pass "Full image: AVD 'phone' is pre-created" else fail "Full image: AVD 'phone' not found — got: $FULL_AVD" fi # Verify full image has system images FULL_SYSIMG="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:full \ bash -c 'ls $ANDROID_HOME/system-images/android-34/google_apis/x86_64/ 2>/dev/null | head -1 || echo none' 2>&1)" || true if [[ "$FULL_SYSIMG" != "none" ]]; then pass "Full image: system-images;android-34;google_apis;x86_64 installed" else fail "Full image: system images not found" fi # Verify full image has xvfb FULL_XVFB="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:full \ bash -c 'command -v Xvfb && echo found || echo not-found' 2>&1)" || true if echo "$FULL_XVFB" | grep -q "found"; then pass "Full image: Xvfb is installed" else fail "Full image: Xvfb not found" fi # Verify kvm group exists and runner is a member FULL_KVM="$(docker run --rm --platform "$DOCKER_PLATFORM" periodvault-runner-test:full \ bash -c 'id runner 2>/dev/null' 2>&1)" || true if echo "$FULL_KVM" | grep -q "kvm"; then pass "Full image: runner user is in kvm group" else fail "Full image: runner not in kvm group — got: $FULL_KVM" fi else fail "Docker build: full target failed" fi # --- Docker Compose validation --- log "Validating docker-compose.yml..." # Create temp env files for validation cp "$PROJECT_ROOT/infra/runners/.env.example" "$PROJECT_ROOT/infra/runners/.env" cp "$PROJECT_ROOT/infra/runners/envs/periodvault.env.example" "$PROJECT_ROOT/infra/runners/envs/periodvault.env" if docker compose -f "$PROJECT_ROOT/infra/runners/docker-compose.yml" config --quiet 2>/dev/null; then pass "docker compose config validates" else fail "docker compose config failed" fi # Verify compose defines expected services COMPOSE_SERVICES="$(docker compose -f "$PROJECT_ROOT/infra/runners/docker-compose.yml" config --services 2>/dev/null)" assert_contains "$COMPOSE_SERVICES" "registry" "Compose service: registry" assert_contains "$COMPOSE_SERVICES" "runner-slim-1" "Compose service: runner-slim-1" assert_contains "$COMPOSE_SERVICES" "runner-slim-2" "Compose service: runner-slim-2" assert_contains "$COMPOSE_SERVICES" "runner-emulator" "Compose service: runner-emulator" # Clean up temp env files rm -f "$PROJECT_ROOT/infra/runners/.env" "$PROJECT_ROOT/infra/runners/envs/periodvault.env" # --- Cleanup test images --- docker rmi periodvault-runner-test:slim periodvault-runner-test:full 2>/dev/null || true fi # =========================================================================== # Section 12: build-runner-image.yml workflow structure # =========================================================================== log "" log "=== Section 12: build-runner-image.yml structure ===" BUILD_WF="$(cat "$PROJECT_ROOT/.github/workflows/build-runner-image.yml")" assert_contains "$BUILD_WF" "slim" "Build workflow includes slim target" assert_contains "$BUILD_WF" "full" "Build workflow includes full target" assert_contains "$BUILD_WF" "matrix" "Build workflow uses matrix strategy" assert_contains "$BUILD_WF" "ghcr.io" "Build workflow pushes to GHCR" # =========================================================================== # Results # =========================================================================== log "" log "==============================" TOTAL=$((PASS_COUNT + FAIL_COUNT + SKIP_COUNT)) log "Results: $PASS_COUNT passed, $FAIL_COUNT failed, $SKIP_COUNT skipped (total: $TOTAL)" log "==============================" if [[ $FAIL_COUNT -gt 0 ]]; then log "FAILED" exit 1 fi log "ALL PASSED" exit 0