Automate App Store screenshots via XCUITests
Replace preview-based screenshot rendering with simulator XCUITests driven by shooter.sh (per-language locale + status bar overrides, xcparse extraction). Add batteries-list accessibility identifier and update CLAUDE.md screenshot docs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
328
shooter.sh
328
shooter.sh
@@ -1,102 +1,288 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCHEME="CableScreenshots"
|
||||
# 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}"
|
||||
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}"
|
||||
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}"
|
||||
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 ;;
|
||||
0|false|FALSE|no|NO|off|OFF|"") return 1 ;;
|
||||
*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
DEVICE_MATRIX=(
|
||||
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
|
||||
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
|
||||
)
|
||||
|
||||
# ─── Dependency check ─────────────────────────────────────────────────────────
|
||||
command -v xcparse >/dev/null 2>&1 || {
|
||||
echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2
|
||||
fail "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1)
|
||||
# ─── Simulator helpers ────────────────────────────────────────────────────────
|
||||
|
||||
resolve_udid() {
|
||||
local name="$1"; local os="$2"
|
||||
if [[ -n "$os" ]]; then
|
||||
# Prefer Shutdown state for a clean start
|
||||
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \
|
||||
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}'
|
||||
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 && /Shutdown/ {print $2; exit}'
|
||||
xcrun simctl list devices | awk -v n="$name" -F '[()]' '
|
||||
$0 ~ n { print $2; exit }
|
||||
'
|
||||
fi
|
||||
}
|
||||
|
||||
for device_entry in "${DEVICE_MATRIX[@]}"; do
|
||||
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry"
|
||||
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ==="
|
||||
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
|
||||
|
||||
for lang in de fr en es nl; do
|
||||
echo "Resetting simulator for a clean start..."
|
||||
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
|
||||
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
|
||||
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
if is_truthy "$RESET_SIMULATOR"; then
|
||||
xcrun simctl erase "$UDID"
|
||||
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
|
||||
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do
|
||||
if [[ -n "$bundle" ]]; then
|
||||
xcrun simctl terminate "$UDID" "$bundle" || true
|
||||
xcrun simctl uninstall "$UDID" "$bundle" || true
|
||||
fi
|
||||
done
|
||||
printf " ${DIM}Full log: $log_file${RST}\n"
|
||||
fi
|
||||
echo "Running screenshots for $lang"
|
||||
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
|
||||
fi
|
||||
|
||||
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
|
||||
fi
|
||||
xcrun simctl shutdown "$udid" 2>/dev/null || true
|
||||
return $test_exit
|
||||
}
|
||||
|
||||
xcrun simctl boot "$UDID" || true
|
||||
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
|
||||
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
xcrun simctl boot "$UDID"
|
||||
xcrun simctl status_bar booted override \
|
||||
--time "9:41" \
|
||||
--batteryState charged --batteryLevel 100 \
|
||||
--wifiBars 3
|
||||
|
||||
xcrun simctl spawn "$UDID" defaults write com.apple.springboard DoNotDisturb -bool true
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
|
||||
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
|
||||
rm -rf "$bundle" "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
step "Configuration"
|
||||
info "Scheme: $SCHEME"
|
||||
info "Bundle ID: $APP_BUNDLE_ID"
|
||||
info "Output: $OUTPUT_DIR"
|
||||
info "Languages: ${LANGUAGES[*]}"
|
||||
info "Devices: ${#DEVICE_MATRIX[@]}"
|
||||
|
||||
xcodebuild test \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "id=$UDID" \
|
||||
-resultBundlePath "$bundle"
|
||||
|
||||
xcparse screenshots "$bundle" "$outdir"
|
||||
echo "Exported screenshots to $outdir"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
done
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user