10bc457c72
Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84.
242 lines
8.1 KiB
Bash
Executable File
242 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# Physical-device PiP / host-wake stress using `devicectl --console` logs.
|
||
#
|
||
# Usage:
|
||
# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50] [SUITES=cold,bgfg,hold,reclaim]
|
||
#
|
||
# Suites:
|
||
# cold — terminate-existing launch (force-quit / cold start)
|
||
# bgfg — open Safari (background) then relaunch OSG (foreground restore)
|
||
# hold — start PiP, wait 20s in session, relaunch to verify still recoverable
|
||
# reclaim — foreground another PiP-capable app, then reopen OSG to reclaim
|
||
set -euo pipefail
|
||
|
||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||
cd "$ROOT"
|
||
|
||
UDID="${1:-00008130-001C249C0E52001C}"
|
||
COUNT="${2:-50}"
|
||
SUITES="${3:-cold,bgfg,hold,reclaim}"
|
||
BUNDLE="com.osgkeyboard.ios"
|
||
SAFARI="com.apple.mobilesafari"
|
||
TAKEOVER_BUNDLE="${PIP_TAKEOVER_BUNDLE:-$SAFARI}"
|
||
TAKEOVER_SETTLE_SECONDS="${PIP_TAKEOVER_SETTLE_SECONDS:-5}"
|
||
OUT_DIR="${ROOT}/.tmp/device-pip-stress-$(date +%Y%m%d-%H%M%S)"
|
||
mkdir -p "$OUT_DIR"
|
||
REPORT="$OUT_DIR/report.jsonl"
|
||
SUMMARY="$OUT_DIR/summary.txt"
|
||
APP="$ROOT/.derivedData-device-stress/Build/Products/Debug-iphoneos/OSGKeyboard.app"
|
||
|
||
echo "==> Out: $OUT_DIR"
|
||
echo "==> Device: $UDID count/suite: $COUNT"
|
||
|
||
if [[ ! -d "$APP" ]]; then
|
||
echo "error: missing $APP — build for device first" >&2
|
||
exit 1
|
||
fi
|
||
|
||
echo "==> Ensuring install"
|
||
xcrun devicectl device install app --device "$UDID" "$APP" --timeout 180 >/dev/null
|
||
|
||
# Capture one console launch (timeout seconds). Prints raw console to stdout file.
|
||
console_launch() {
|
||
local outfile="$1"
|
||
local secs="${2:-14}"
|
||
local extra_flags="${3:-}" # e.g. --terminate-existing
|
||
# shellcheck disable=SC2086
|
||
timeout "$secs" xcrun devicectl device process launch \
|
||
--device "$UDID" \
|
||
--console \
|
||
$extra_flags \
|
||
"$BUNDLE" >"$outfile" 2>&1 || true
|
||
}
|
||
|
||
# Launch without attaching a console. Killing a timed `--console` command also
|
||
# terminates the app, so preparation phases must use a detached launch.
|
||
plain_launch() {
|
||
xcrun devicectl device process launch \
|
||
--device "$UDID" \
|
||
--terminate-existing \
|
||
"$BUNDLE" >/dev/null 2>&1
|
||
}
|
||
|
||
classify_file() {
|
||
local f="$1"
|
||
local host="unknown" pip="unknown" mic="unknown" recovery="none"
|
||
|
||
if rg -q "OSGKeyboardApp\.init|MainAppRoot\.onAppear|activateOnForeground" "$f"; then
|
||
host="success"
|
||
elif rg -q "Launched application with com\.osgkeyboard\.ios" "$f"; then
|
||
host="launch_only"
|
||
fi
|
||
|
||
if rg -q "onboarding incomplete" "$f"; then
|
||
pip="onboarding"
|
||
elif rg -q "aborted reason=permissions|blocked.*permissions" "$f"; then
|
||
pip="permissions"
|
||
elif rg -q "Flow session started \(PiP keep-alive\)|low-profile PiP active|startSessionAsync\.ready" "$f"; then
|
||
pip="success"
|
||
elif rg -q "failure=unsupported|failed to start: unsupported" "$f"; then
|
||
pip="unsupported"
|
||
elif rg -q "PiP keep-alive failed to start|startSessionAsync\.failed.*pipUnavailable|startAndWait failed" "$f"; then
|
||
pip="fail"
|
||
elif rg -q "PiP start attempt failed" "$f"; then
|
||
# Retry path — only fail if we never saw success above
|
||
pip="retry_then_unknown"
|
||
elif rg -q "activateOnForeground|autoPiP|startSessionAsync\.begin" "$f"; then
|
||
pip="seen_no_result"
|
||
fi
|
||
|
||
# Mic keep-alive contract: idle releases mic after PiP proves
|
||
if rg -q "mic released between utterances|released audio session and frame pump" "$f"; then
|
||
mic="released_ok"
|
||
elif rg -q "PiP audio session ready" "$f"; then
|
||
mic="armed"
|
||
else
|
||
mic="unknown"
|
||
fi
|
||
|
||
# Promote retry_then_unknown if success markers appeared (classification order already handled)
|
||
if [[ "$pip" == "retry_then_unknown" ]]; then
|
||
if rg -q "low-profile PiP active|Flow session started \(PiP keep-alive\)" "$f"; then
|
||
pip="success"
|
||
else
|
||
pip="fail"
|
||
fi
|
||
fi
|
||
|
||
if rg -q "pip\.unexpectedStop" "$f" \
|
||
&& rg -q "startSessionAsync\.ready.*trigger=(foreground|healthCheck|existingSession)" "$f"; then
|
||
recovery="reclaimed"
|
||
elif rg -q "startSessionAsync\.ready.*trigger=(foreground|healthCheck|existingSession|manualRetry)" "$f"; then
|
||
recovery="reconciled"
|
||
elif rg -q "failed after bounded recovery|pipState=failed" "$f"; then
|
||
recovery="exhausted"
|
||
elif rg -q "pipRecovery\.attempt" "$f"; then
|
||
recovery="attempted"
|
||
fi
|
||
|
||
echo "$host|$pip|$mic|$recovery"
|
||
}
|
||
|
||
run_suite() {
|
||
local suite="$1"
|
||
local i outfile result host pip mic recovery start_ts elapsed flags
|
||
echo "==> Suite: $suite × $COUNT"
|
||
for i in $(seq 1 "$COUNT"); do
|
||
outfile="$OUT_DIR/${suite}-$i.console.log"
|
||
start_ts=$(date +%s)
|
||
flags=""
|
||
|
||
case "$suite" in
|
||
cold)
|
||
flags="--terminate-existing"
|
||
console_launch "$outfile" 14 "$flags"
|
||
;;
|
||
bgfg)
|
||
# Ensure app running with PiP first
|
||
plain_launch
|
||
sleep 7
|
||
# Background by opening Safari
|
||
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
|
||
sleep 3
|
||
# Resume OSG without terminate
|
||
console_launch "$outfile" 12 ""
|
||
;;
|
||
hold)
|
||
plain_launch
|
||
sleep 7
|
||
# Leave session alive ~20s (PiP should keep host)
|
||
sleep 20
|
||
# Background briefly then resume
|
||
xcrun devicectl device process launch --device "$UDID" "$SAFARI" >/dev/null 2>&1 || true
|
||
sleep 2
|
||
console_launch "$outfile" 12 ""
|
||
;;
|
||
reclaim)
|
||
plain_launch
|
||
sleep 7
|
||
# The target app must already be configured to enter PiP when launched.
|
||
# Safari is only a foreground/background fallback unless media is playing.
|
||
xcrun devicectl device process launch --device "$UDID" "$TAKEOVER_BUNDLE" >/dev/null 2>&1 || true
|
||
sleep "$TAKEOVER_SETTLE_SECONDS"
|
||
console_launch "$outfile" 14 ""
|
||
;;
|
||
esac
|
||
|
||
elapsed=$(( $(date +%s) - start_ts ))
|
||
result="$(classify_file "$outfile")"
|
||
host="${result%%|*}"
|
||
rest="${result#*|}"
|
||
pip="${rest%%|*}"
|
||
rest="${rest#*|}"
|
||
mic="${rest%%|*}"
|
||
recovery="${rest##*|}"
|
||
|
||
printf '{"suite":"%s","i":%d,"host":"%s","pip":"%s","mic":"%s","recovery":"%s","elapsed_s":%d}\n' \
|
||
"$suite" "$i" "$host" "$pip" "$mic" "$recovery" "$elapsed" >>"$REPORT"
|
||
printf "[%s %3d/%d] host=%-11s pip=%-12s mic=%-12s recovery=%-10s %2ds\n" \
|
||
"$suite" "$i" "$COUNT" "$host" "$pip" "$mic" "$recovery" "$elapsed"
|
||
done
|
||
}
|
||
|
||
: >"$REPORT"
|
||
IFS=',' read -r -a selected_suites <<<"$SUITES"
|
||
for selected_suite in "${selected_suites[@]}"; do
|
||
case "$selected_suite" in
|
||
cold|bgfg|hold|reclaim) run_suite "$selected_suite" ;;
|
||
*)
|
||
echo "error: unknown suite '$selected_suite'" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
python3 - "$REPORT" "$SUMMARY" "$UDID" "$COUNT" "$OUT_DIR" <<'PY'
|
||
import json, collections, sys
|
||
from pathlib import Path
|
||
report, summary, udid, count, out = sys.argv[1:6]
|
||
rows = [json.loads(l) for l in Path(report).read_text().splitlines() if l.strip()]
|
||
by = collections.defaultdict(list)
|
||
for r in rows:
|
||
by[r["suite"]].append(r)
|
||
|
||
selected = [name for name in ("cold", "bgfg", "hold", "reclaim") if by.get(name)]
|
||
lines = [
|
||
"Device PiP / mic keep-alive stress summary",
|
||
f"device=Rocky 15 PM udid={udid} count_per_suite={count}",
|
||
f"total_rows={len(rows)}",
|
||
"",
|
||
]
|
||
for suite in selected:
|
||
rs = by.get(suite, [])
|
||
n = max(len(rs), 1)
|
||
host_ok = sum(1 for r in rs if r["host"] in ("success", "launch_only"))
|
||
pip_c = collections.Counter(r["pip"] for r in rs)
|
||
mic_c = collections.Counter(r["mic"] for r in rs)
|
||
recovery_c = collections.Counter(r["recovery"] for r in rs)
|
||
pip_ok = pip_c.get("success", 0)
|
||
lines += [
|
||
f"[{suite}]",
|
||
f" host_ok={host_ok}/{len(rs)} ({host_ok/n*100:.1f}%)",
|
||
f" pip_success={pip_ok}/{len(rs)} ({pip_ok/n*100:.1f}%) breakdown={dict(pip_c)}",
|
||
f" mic={dict(mic_c)}",
|
||
f" recovery={dict(recovery_c)}",
|
||
"",
|
||
]
|
||
lines += [
|
||
f"Artifacts: {out}",
|
||
"Notes:",
|
||
" - cold uses --terminate-existing (force-quit recovery).",
|
||
" - bgfg backgrounds via Safari then resumes.",
|
||
" - hold keeps session ~20s then Safari background + resume.",
|
||
" - reclaim launches PIP_TAKEOVER_BUNDLE, then verifies OSG foreground reconciliation.",
|
||
" - Keyboard-extension mic tap is not automated.",
|
||
]
|
||
text = "\n".join(lines) + "\n"
|
||
Path(summary).write_text(text)
|
||
print(text)
|
||
PY
|
||
|
||
echo "==> Done"
|