#!/bin/bash set -euo pipefail # Kill all child processes on Ctrl-C trap 'printf "\n\033[31m ✘\033[0m Interrupted — stopping all jobs...\n"; kill 0; exit 130' INT TERM # ─── Configuration ──────────────────────────────────────────────────────────── # Override these via environment variables, a config file, or CLI argument. # # Config file format (shell): # SCHEME="MyAppScreenshots" # APP_BUNDLE_ID="com.example.myapp" # UITEST_BUNDLE_ID="com.example.myapp.UITests" # LANGUAGES=(en de fr) # DEVICE_MATRIX=( # "iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max" # ) # OUTPUT_DIR="Shots/Screenshots" CONFIG_FILE="${1:-./screenshot.config}" if [[ -f "$CONFIG_FILE" ]]; then # shellcheck source=/dev/null source "$CONFIG_FILE" fi # Required — must be set in config or environment SCHEME="${SCHEME:?Set SCHEME in $CONFIG_FILE or environment}" APP_BUNDLE_ID="${APP_BUNDLE_ID:?Set APP_BUNDLE_ID in $CONFIG_FILE or environment}" UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-}" # Optional with defaults OUTPUT_DIR="${OUTPUT_DIR:-Shots/Screenshots}" DERIVED_DATA="${DERIVED_DATA:-DerivedData-Screenshots}" RESET_SIMULATOR="${RESET_SIMULATOR:-1}" PARALLEL="${PARALLEL:-1}" VERBOSE="${VERBOSE:-0}" STATUS_BAR_TIME="${STATUS_BAR_TIME:-9:41}" if [[ -z "${LANGUAGES+x}" ]]; then LANGUAGES=(en) fi if [[ -z "${DEVICE_MATRIX+x}" ]]; then DEVICE_MATRIX=( "iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max" ) fi # ─── Pretty output ──────────────────────────────────────────────────────────── BOLD="\033[1m" DIM="\033[2m" GREEN="\033[32m" RED="\033[31m" CYAN="\033[36m" RST="\033[0m" ok() { printf "${GREEN} ✔${RST} %s\n" "$*"; } fail() { printf "${RED} ✘${RST} %s\n" "$*"; } info() { printf "${CYAN} ›${RST} %s\n" "$*"; } step() { printf "\n${BOLD}%s${RST}\n" "$*"; } is_truthy() { case "$1" in 1|true|TRUE|yes|YES|on|ON) return 0 ;; *) return 1 ;; esac } # ─── Dependency check ───────────────────────────────────────────────────────── command -v xcparse >/dev/null 2>&1 || { fail "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" exit 1 } # ─── Simulator helpers ──────────────────────────────────────────────────────── resolve_udid() { local name="$1"; local os="$2" if [[ -n "$os" ]]; then xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' ' /^--.*--$/ { in_section = ($0 ~ o) } in_section && $0 ~ n { print $2; exit } ' else xcrun simctl list devices | awk -v n="$name" -F '[()]' ' $0 ~ n { print $2; exit } ' fi } ensure_simulator() { local name="$1"; local runtime="$2"; local device_type="$3" local udid udid=$(resolve_udid "$name" "$runtime") if [[ -n "$udid" ]]; then echo "$udid" return 0 fi local runtime_id runtime_id=$(xcrun simctl list runtimes | awk -v r="iOS $runtime" '$0 ~ r {for(i=1;i<=NF;i++) if($i ~ /com\.apple/) {print $i; exit}}') if [[ -z "$runtime_id" ]]; then fail "Runtime iOS $runtime not found" >&2 return 1 fi info "Creating simulator: $name (iOS $runtime)" >&2 udid=$(xcrun simctl create "$name" "$device_type" "$runtime_id") echo "$udid" } prepare_simulator() { local udid="$1" lang="$2" local region region=$(echo "$lang" | tr '[:lower:]' '[:upper:]') xcrun simctl boot "$udid" 2>/dev/null || true # Language & locale xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLanguages -array "$lang" xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}" # Suppress notifications xcrun simctl spawn "$udid" defaults write com.apple.springboard DoNotDisturb -bool true 2>/dev/null || true xcrun simctl spawn "$udid" defaults write com.apple.generativeexperiences.corefollowup \ DateOfLastAppleIntelligenceReadinessCFU -date "2020-01-01T00:00:00Z" 2>/dev/null || true xcrun simctl spawn "$udid" defaults write com.apple.corefollow DisableFollowUp -bool true 2>/dev/null || true xcrun simctl spawn "$udid" defaults write com.apple.corefollowup DisableFollowUp -bool true 2>/dev/null || true # Reboot for language change xcrun simctl shutdown "$udid" 2>/dev/null || true xcrun simctl boot "$udid" # Clean status bar xcrun simctl status_bar "$udid" override \ --time "$STATUS_BAR_TIME" \ --batteryState charged --batteryLevel 100 \ --wifiBars 3 \ 2>/dev/null || true } # ─── Build ──────────────────────────────────────────────────────────────────── build_for_testing() { local device_entry="$1" IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry" local udid udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type") if [[ -z "$udid" ]]; then fail "Could not resolve or create simulator for $dev_name"; return 1 fi info "Building $dev_name..." local log_file="/tmp/shooter-build-${dev_slug}.log" if xcodebuild build-for-testing \ -scheme "$SCHEME" \ -destination "id=$udid" \ -derivedDataPath "$DERIVED_DATA" \ -quiet \ > "$log_file" 2>&1; then ok "Build succeeded ($dev_name)" else fail "Build failed ($dev_name)" cat "$log_file" return 1 fi } # ─── Test one language ──────────────────────────────────────────────────────── run_one_language() { local device_entry="$1" local lang="$2" IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry" local label="${dev_slug}/${lang}" local udid udid=$(resolve_udid "$dev_name" "$dev_runtime") if [[ -z "$udid" ]]; then udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type") fi if [[ -z "$udid" ]]; then fail "[$label] Could not resolve simulator"; return 1 fi # Reset xcrun simctl shutdown "$udid" 2>/dev/null || true if is_truthy "$RESET_SIMULATOR"; then xcrun simctl erase "$udid" else for bundle in "$APP_BUNDLE_ID" ${UITEST_BUNDLE_ID:+"$UITEST_BUNDLE_ID"}; do xcrun simctl terminate "$udid" "$bundle" 2>/dev/null || true xcrun simctl uninstall "$udid" "$bundle" 2>/dev/null || true done fi udid=$(resolve_udid "$dev_name" "$dev_runtime") prepare_simulator "$udid" "$lang" local bundle="results-${dev_slug}-${lang}.xcresult" local outdir="${OUTPUT_DIR}/${dev_slug}/$lang" rm -rf "$bundle" "$outdir" mkdir -p "$outdir" local log_file="/tmp/shooter-test-${dev_slug}-${lang}.log" info "[$label] Testing..." local test_exit=0 xcodebuild test-without-building \ -scheme "$SCHEME" \ -destination "id=$udid" \ -derivedDataPath "$DERIVED_DATA" \ -resultBundlePath "$bundle" \ > "$log_file" 2>&1 || test_exit=$? if [[ $test_exit -eq 0 ]]; then xcparse screenshots "$bundle" "$outdir" > /dev/null 2>&1 local count count=$(find "$outdir" -name '*.png' 2>/dev/null | wc -l | tr -d ' ') ok "[$label] ${count} screenshots" else fail "[$label] Tests failed" grep -E '(error:|FAIL|failed)' "$log_file" | head -20 if is_truthy "$VERBOSE"; then printf "${DIM}"; cat "$log_file"; printf "${RST}" else printf " ${DIM}Full log: $log_file${RST}\n" fi fi xcrun simctl shutdown "$udid" 2>/dev/null || true return $test_exit } # ─── Main ───────────────────────────────────────────────────────────────────── step "Configuration" info "Scheme: $SCHEME" info "Bundle ID: $APP_BUNDLE_ID" info "Output: $OUTPUT_DIR" info "Languages: ${LANGUAGES[*]}" info "Devices: ${#DEVICE_MATRIX[@]}" step "Building for testing" for device_entry in "${DEVICE_MATRIX[@]}"; do build_for_testing "$device_entry" done step "Running screenshot tests" total_runs=$((${#DEVICE_MATRIX[@]} * ${#LANGUAGES[@]})) info "${#DEVICE_MATRIX[@]} devices × ${#LANGUAGES[@]} languages = ${total_runs} runs" failed=0 if is_truthy "$PARALLEL"; then info "Devices run in parallel" pids=() for device_entry in "${DEVICE_MATRIX[@]}"; do ( for lang in "${LANGUAGES[@]}"; do run_one_language "$device_entry" "$lang" || true done ) & pids+=($!) done for pid in "${pids[@]}"; do wait "$pid" || failed=$((failed + 1)) done else for device_entry in "${DEVICE_MATRIX[@]}"; do for lang in "${LANGUAGES[@]}"; do run_one_language "$device_entry" "$lang" || failed=$((failed + 1)) done done fi # ─── Summary ────────────────────────────────────────────────────────────────── step "Done" total_screenshots=$(find "$OUTPUT_DIR" -name '*.png' 2>/dev/null | wc -l | tr -d ' ') if [[ $failed -eq 0 ]]; then ok "All runs passed — ${total_screenshots} screenshots in ${OUTPUT_DIR}/" else fail "${failed} run(s) had errors — ${total_screenshots} screenshots in ${OUTPUT_DIR}/" printf " ${DIM}Re-run with VERBOSE=1 for full logs${RST}\n" exit 1 fi