ci: run each ios flow in its own maestro process and add the feature flows

This commit is contained in:
Linus Rath
2026-10-22 02:15:35 +02:00
parent 273051e5f5
commit de414f7ed6
35 changed files with 962 additions and 75 deletions
+2
View File
@@ -44,6 +44,8 @@ xcrun simctl spawn "$UDID" defaults write com.apple.WebUI AutoFillPasswords -boo
xcrun simctl ui "$UDID" appearance "$APPEARANCE"
xcrun simctl install "$UDID" "$APP"
# A photo for the compose flow's attachment.
xcrun simctl addmedia "$UDID" "$(dirname "$0")/../../assets/icon.png" || true
{
echo "UDID=$UDID"
+73 -36
View File
@@ -5,18 +5,29 @@
#
# Needs in the environment: UDID, OUT (output folder), and for the flows the
# E2E_* values (see .github/workflows/e2e-ios.yml). Output: $OUT/report.xml,
# $OUT/screenshots, $OUT/run.mp4, $OUT/app.log.
# $OUT/screenshots, $OUT/run.mp4, $OUT/app.log, $OUT/steps.txt.
#
# Secrets: Maestro's debug output and the JSON under ~/.maestro/tests hold
# every typed text, passwords included, and artifacts of this public repo are
# public. Only the warm-up (which types a made-up address) prints a debug
# log; afterwards only PNGs are copied, and every text file in $OUT is
# scrubbed of the secret values before upload.
# Each flow runs in its own Maestro process with a time cap: when the XCTest
# driver dies (it does, now and then), only that flow fails and the next one
# starts a fresh driver.
#
# Secrets: Maestro's debug output holds every typed text, passwords included,
# and artifacts of this public repo are public. Only the warm-up (which types
# a made-up address) prints a debug log; the debug output of the real flows
# stays on the runner, and only PNGs and an argument-free summary of the
# steps (failed-steps.mjs) are copied. Every text file in $OUT is scrubbed of
# the secret values before upload.
set -uo pipefail
SUITE=$1
ONLY=${2:-}
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
mkdir -p "$OUT/screenshots"
FLOW_TIMEOUT=${FLOW_TIMEOUT:-900}
mkdir -p "$OUT/screenshots" "$OUT/junit"
DEBUG_DIR="$RUNNER_TEMP/maestro-debug"
mkdir -p "$DEBUG_DIR"
# perl's alarm stands in for coreutils timeout, which macOS lacks.
capped() { perl -e 'alarm shift; exec @ARGV' "$FLOW_TIMEOUT" "$@"; }
maestro --version
@@ -24,15 +35,15 @@ xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$OUT/run.mp4" &
RECORDER=$!
# Warm-up: the XCTest driver takes minutes to start on a cold runner.
touch "$RUNNER_TEMP/flows-started"
(cd "$OUT/screenshots" && maestro --device "$UDID" test \
WARMUP_START=$(date +%s)
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
--debug-output "$RUNNER_TEMP/maestro-warmup" "$ROOT/.maestro/launch.yaml")
STATUS=$?
echo "warm-up took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
if [ $STATUS -ne 0 ]; then
echo "::group::Maestro warm-up log"
find "$RUNNER_TEMP/maestro-warmup" -name 'maestro.log' -exec tail -n 300 {} \;
echo "::endgroup::"
maestro --device "$UDID" hierarchy > "$OUT/hierarchy-warmup.json" 2>/dev/null || true
fi
if [ $STATUS -eq 0 ]; then
@@ -48,48 +59,74 @@ if [ $STATUS -eq 0 ]; then
-e "DEVICE=${DEVICE:-phone}"
-e "APPEARANCE=${APPEARANCE:-light}"
)
TARGETS=()
FLOWS=()
if [ -n "$ONLY" ]; then
IFS=',' read -ra NAMES <<< "$ONLY"
for n in "${NAMES[@]}"; do
f="$ROOT/.maestro/$SUITE/${n%.yaml}.yaml"
[ -f "$f" ] && TARGETS+=("$f") || echo "::warning::no flow $SUITE/$n"
if [ -f "$f" ]; then FLOWS+=("$f"); else echo "::warning::no flow $SUITE/$n"; fi
done
else
TARGETS=("$ROOT/.maestro/$SUITE")
for f in "$ROOT/.maestro/$SUITE"/*.yaml; do FLOWS+=("$f"); done
fi
if [ ${#TARGETS[@]} -gt 0 ]; then
(cd "$OUT/screenshots" && maestro --device "$UDID" test \
--format junit --output "$OUT/report.xml" \
"${ARGS[@]}" "${TARGETS[@]}")
STATUS=$?
FAILED=()
for f in "${FLOWS[@]}"; do
name=$(basename "$f" .yaml)
started=$(date +%s)
echo "::group::$name"
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
--format junit --output "$OUT/junit/$name.xml" \
--debug-output "$DEBUG_DIR/$name" \
"${ARGS[@]}" "$f")
rc=$?
echo "::endgroup::"
echo "$name: exit $rc after $(( $(date +%s) - started )) s"
if [ $rc -ne 0 ]; then
FAILED+=("$name")
# What the screen showed when it failed.
xcrun simctl io "$UDID" screenshot "$OUT/screenshots/FAILED-$name.png" >/dev/null 2>&1 || true
fi
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "::error::Failed flows: ${FAILED[*]}"
STATUS=1
fi
node -e '
const fs = require("fs"), path = require("path");
const dir = process.argv[1];
const cases = fs.readdirSync(dir).filter((f) => f.endsWith(".xml")).sort()
.map((f) => (fs.readFileSync(path.join(dir, f), "utf8").match(/<testcase[\s\S]*?<\/testcase>/g) || []).join("\n"));
fs.writeFileSync(process.argv[2], `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n<testsuite name="${process.argv[3]}">\n${cases.join("\n")}\n</testsuite>\n</testsuites>\n`);
' "$OUT/junit" "$OUT/report.xml" "$SUITE" || true
fi
kill -INT "$RECORDER" 2>/dev/null || true
wait "$RECORDER" 2>/dev/null || true
# Screenshots of failed steps land under ~/.maestro/tests. Images only, plus
# a summary of the steps without their arguments (see failed-steps.mjs).
find "$HOME/.maestro/tests" -name '*.png' -exec cp {} "$OUT/screenshots/" \; 2>/dev/null || true
node "$ROOT/scripts/e2e/failed-steps.mjs" "$HOME/.maestro/tests" > "$OUT/steps.txt" 2>&1 || true
# Images of failed steps, plus a summary of the steps without arguments.
find "$DEBUG_DIR" "$HOME/.maestro/tests" -name '*.png' -exec cp {} "$OUT/screenshots/" \; 2>/dev/null || true
node "$ROOT/scripts/e2e/failed-steps.mjs" "$DEBUG_DIR" > "$OUT/steps.txt" 2>&1 || true
find "$DEBUG_DIR" -type f | sed "s|$DEBUG_DIR/||" > "$OUT/debug-files.txt" 2>/dev/null || true
xcrun simctl spawn "$UDID" log show --last 60m --style compact \
xcrun simctl spawn "$UDID" log show --last 90m --style compact \
--predicate 'process == "BulwarkMobile"' > "$OUT/app.log" 2>&1 || true
# Scrub secrets from every text file that gets uploaded.
for f in $(find "$OUT" -type f \( -name '*.xml' -o -name '*.log' -o -name '*.json' -o -name '*.txt' \)); do
node -e '
const fs = require("fs");
const f = process.argv[1];
let s = fs.readFileSync(f, "utf8");
for (const k of ["E2E_PASSWORD", "E2E_USERB_PASSWORD", "E2E_TOTP_SECRET"]) {
const v = process.env[k];
if (v && v.length >= 6) s = s.split(v).join("***");
}
fs.writeFileSync(f, s);
' "$f"
done
find "$OUT" -type f \( -name '*.xml' -o -name '*.log' -o -name '*.json' -o -name '*.txt' \) -print0 |
while IFS= read -r -d '' f; do
node -e '
const fs = require("fs");
const f = process.argv[1];
let s = fs.readFileSync(f, "utf8");
for (const k of ["E2E_PASSWORD", "E2E_USERB_PASSWORD", "E2E_TOTP_SECRET"]) {
const v = process.env[k];
if (v && v.length >= 6) s = s.split(v).join("***");
}
fs.writeFileSync(f, s);
' "$f"
done
ls -la "$OUT/screenshots" | tail -n +2 | wc -l | xargs echo "screenshots:"
echo "screenshots: $(ls "$OUT/screenshots" | wc -l)"
exit $STATUS