#!/bin/bash
|
# Round01 real Quick Switch main UI performance gate.
|
|
set -euo pipefail
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
# shellcheck source=build-output-paths.sh
|
source "$SCRIPT_DIR/build-output-paths.sh"
|
APP="$BUILD_CURRENT_APP"
|
BUILD_ROOT="$OUTPUT_ROOT/C2.builds"
|
INTERACTION_REPORT="$BUILD_REPORT_ROOT/round01-main-ui-performance-interaction-report.json"
|
LIFECYCLE_REPORT="$BUILD_REPORT_ROOT/round01-main-ui-performance-lifecycle-report.json"
|
INSTRUMENTS_REPORT="$BUILD_REPORT_ROOT/round01-main-ui-performance-instruments-report.json"
|
TRACE="$BUILD_REPORT_ROOT/round01-main-ui-performance-time-profile.trace"
|
POTENTIAL_HANGS_XML="$BUILD_REPORT_ROOT/round01-main-ui-performance-potential-hangs.xml"
|
HANG_RISKS_XML="$BUILD_REPORT_ROOT/round01-main-ui-performance-hang-risks.xml"
|
SUMMARY="$BUILD_REPORT_ROOT/round01-main-ui-performance-summary.json"
|
RUN_ID="$(date +%Y%m%d_%H%M%S)"
|
LOG_DIR="${ALIGNER_ROUND1_MAIN_UI_PERF_LOG_DIR:-/tmp/aligner-round01-main-ui-performance-$RUN_ID}"
|
APP_COUNT="${ALIGNER_ROUND1_MAIN_UI_PERF_APP_COUNT:-20}"
|
WINDOWS_PER_APP="${ALIGNER_ROUND1_MAIN_UI_PERF_WINDOWS_PER_APP:-10}"
|
LIFECYCLE_CYCLES="${ALIGNER_ROUND1_MAIN_UI_PERF_LIFECYCLE_CYCLES:-100}"
|
LIFECYCLE_INTERVAL="${ALIGNER_ROUND1_MAIN_UI_PERF_LIFECYCLE_INTERVAL:-0.02}"
|
LIFECYCLE_VISIBLE_DURATION="${ALIGNER_ROUND1_MAIN_UI_PERF_LIFECYCLE_VISIBLE_DURATION:-0.16}"
|
REPORT_WAIT="${ALIGNER_ROUND1_MAIN_UI_PERF_REPORT_WAIT:-8.0}"
|
INSTRUMENTS_TIME_LIMIT="${ALIGNER_ROUND1_MAIN_UI_PERF_INSTRUMENTS_TIME_LIMIT:-12s}"
|
|
fail() {
|
echo "Round01 main UI performance gate failed: $*" >&2
|
exit 1
|
}
|
|
run_logged() {
|
local name="$1"
|
shift
|
local log="$LOG_DIR/$name.log"
|
|
if "$@" >"$log" 2>&1; then
|
echo "PASS $name"
|
else
|
local code="$?"
|
echo "FAIL $name (log: $log)" >&2
|
tail -c 6000 "$log" >&2 || true
|
exit "$code"
|
fi
|
}
|
|
aligner_pids_for_current_app() {
|
ps -axo pid=,args= | while read -r pid command; do
|
case "$command" in
|
"$APP/Contents/MacOS/Aligner"*) echo "$pid" ;;
|
esac
|
done
|
}
|
|
stop_current_aligner() {
|
for pid in $(aligner_pids_for_current_app); do
|
kill "$pid" 2>/dev/null || true
|
done
|
|
for _ in {1..30}; do
|
[ -z "$(aligner_pids_for_current_app)" ] && return
|
sleep 0.1
|
done
|
|
fail "current Aligner app did not exit before performance gate"
|
}
|
|
wait_for_interaction_report() {
|
/usr/bin/python3 - "$INTERACTION_REPORT" "$REPORT_WAIT" "$APP_COUNT" "$WINDOWS_PER_APP" <<'PY'
|
import json
|
import os
|
import sys
|
import time
|
|
path = sys.argv[1]
|
timeout = float(sys.argv[2])
|
app_count = int(sys.argv[3])
|
windows_per_app = int(sys.argv[4])
|
deadline = time.monotonic() + timeout
|
expected_windows = app_count * windows_per_app
|
last_report = None
|
|
def stable_fingerprint(value):
|
hash_value = 0xCBF29CE484222325
|
for byte in (value or "").encode("utf-8"):
|
hash_value ^= byte
|
hash_value = (hash_value * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
return f"{hash_value:016x}"
|
|
while time.monotonic() < deadline:
|
try:
|
with open(path, "r", encoding="utf-8") as file:
|
report = json.load(file)
|
last_report = report
|
root = report.get("rootView", {})
|
if (
|
report.get("snapshotLoaded") is True
|
and report.get("quickSwitchVisible") is True
|
and report.get("appCount") == app_count
|
and report.get("windowCount") == expected_windows
|
and root.get("firstFrameLatencySampleCount", 0) >= 1
|
and root.get("keyboardResponseSampleCount", 0) >= 40
|
and root.get("hoverResponseSampleCount", 0) >= 40
|
):
|
sys.exit(0)
|
except FileNotFoundError:
|
pass
|
except json.JSONDecodeError:
|
pass
|
time.sleep(0.1)
|
|
if last_report is not None:
|
root = last_report.get("rootView", {})
|
print(json.dumps({
|
"reportBasename": os.path.basename(path),
|
"reportPathHash": stable_fingerprint(path),
|
"snapshotLoaded": last_report.get("snapshotLoaded"),
|
"quickSwitchVisible": last_report.get("quickSwitchVisible"),
|
"appCount": last_report.get("appCount"),
|
"windowCount": last_report.get("windowCount"),
|
"firstFrameLatencySampleCount": root.get("firstFrameLatencySampleCount"),
|
"keyboardResponseSampleCount": root.get("keyboardResponseSampleCount"),
|
"hoverResponseSampleCount": root.get("hoverResponseSampleCount"),
|
}, indent=2, ensure_ascii=False), file=sys.stderr)
|
print(f"interaction performance report did not become ready within {timeout:.1f}s", file=sys.stderr)
|
sys.exit(1)
|
PY
|
}
|
|
assert_interaction_report() {
|
local report_path="$1"
|
/usr/bin/python3 - "$report_path" "$APP_COUNT" "$WINDOWS_PER_APP" <<'PY'
|
import json
|
import os
|
import sys
|
|
path = sys.argv[1]
|
app_count = int(sys.argv[2])
|
windows_per_app = int(sys.argv[3])
|
expected_windows = app_count * windows_per_app
|
with open(path, "r", encoding="utf-8") as file:
|
report = json.load(file)
|
|
def stable_fingerprint(value):
|
hash_value = 0xCBF29CE484222325
|
for byte in (value or "").encode("utf-8"):
|
hash_value ^= byte
|
hash_value = (hash_value * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
return f"{hash_value:016x}"
|
|
def summary():
|
root = report.get("rootView", {})
|
return {
|
"reportBasename": os.path.basename(path),
|
"reportPathHash": stable_fingerprint(path),
|
"snapshotLoaded": report.get("snapshotLoaded"),
|
"quickSwitchVisible": report.get("quickSwitchVisible"),
|
"appCount": report.get("appCount"),
|
"windowCount": report.get("windowCount"),
|
"firstFrameLatencySampleCount": root.get("firstFrameLatencySampleCount"),
|
"firstFrameLatencyP95Milliseconds": root.get("firstFrameLatencyP95Milliseconds"),
|
"keyboardResponseSampleCount": root.get("keyboardResponseSampleCount"),
|
"keyboardResponseP95Milliseconds": root.get("keyboardResponseP95Milliseconds"),
|
"hoverResponseSampleCount": root.get("hoverResponseSampleCount"),
|
"hoverResponseP95Milliseconds": root.get("hoverResponseP95Milliseconds"),
|
"screenshotNotRequestedCount": root.get("screenshotNotRequestedCount"),
|
}
|
|
def require(condition, message):
|
if not condition:
|
print(message, file=sys.stderr)
|
print(json.dumps(summary(), indent=2, ensure_ascii=False), file=sys.stderr)
|
sys.exit(1)
|
|
root = report.get("rootView", {})
|
require(report.get("snapshotLoaded") is True, "snapshot must be loaded")
|
require(report.get("appCount") == app_count, "fixture app count must match")
|
require(report.get("windowCount") == expected_windows, "fixture window count must match")
|
require(root.get("firstFrameLatencySampleCount", 0) >= 1, "first frame sample is required")
|
require(root.get("firstFrameLatencyP95Milliseconds") < 150, "first frame P95 must be < 150ms")
|
require(root.get("keyboardResponseSampleCount", 0) >= 40, "keyboard sample count must be >= 40")
|
require(root.get("keyboardResponseP95Milliseconds") < 50, "keyboard P95 must be < 50ms")
|
require(root.get("hoverResponseSampleCount", 0) >= 40, "hover sample count must be >= 40")
|
require(root.get("hoverResponseP95Milliseconds") <= 32, "hover P95 must be <= 32ms")
|
require(root.get("interactionPerformanceGatePassed") is True, "interaction gate must pass")
|
require(root.get("screenshotNotRequestedCount") == expected_windows, "disabled screenshot run must mark all cards not requested")
|
|
print(json.dumps({
|
"firstFrameP95": root.get("firstFrameLatencyP95Milliseconds"),
|
"keyboardP95": root.get("keyboardResponseP95Milliseconds"),
|
"hoverP95": root.get("hoverResponseP95Milliseconds"),
|
"keyboardSamples": root.get("keyboardResponseSampleCount"),
|
"hoverSamples": root.get("hoverResponseSampleCount"),
|
"windowCount": report.get("windowCount")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
assert_lifecycle_report() {
|
/usr/bin/python3 - "$LIFECYCLE_REPORT" "$LIFECYCLE_CYCLES" "$APP_COUNT" "$WINDOWS_PER_APP" <<'PY'
|
import json
|
import os
|
import sys
|
|
path = sys.argv[1]
|
cycles = int(sys.argv[2])
|
app_count = int(sys.argv[3])
|
windows_per_app = int(sys.argv[4])
|
expected_windows = app_count * windows_per_app
|
with open(path, "r", encoding="utf-8") as file:
|
report = json.load(file)
|
|
def stable_fingerprint(value):
|
hash_value = 0xCBF29CE484222325
|
for byte in (value or "").encode("utf-8"):
|
hash_value ^= byte
|
hash_value = (hash_value * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
|
return f"{hash_value:016x}"
|
|
def summary():
|
root = report.get("rootView", {})
|
return {
|
"reportBasename": os.path.basename(path),
|
"reportPathHash": stable_fingerprint(path),
|
"quickSwitchVisible": report.get("quickSwitchVisible"),
|
"appCount": report.get("appCount"),
|
"windowCount": report.get("windowCount"),
|
"lifecycleTargetCycles": report.get("lifecycleTargetCycles"),
|
"lifecycleCompletedCycles": report.get("lifecycleCompletedCycles"),
|
"lifecycleGatePassed": report.get("lifecycleGatePassed"),
|
"lifecycleMaximumOverlayWindows": report.get("lifecycleMaximumOverlayWindows"),
|
"lifecycleMaximumVisibleOverlayWindows": report.get("lifecycleMaximumVisibleOverlayWindows"),
|
"lifecycleResidualOverlayWindows": report.get("lifecycleResidualOverlayWindows"),
|
"lifecycleResidualVisibleOverlayWindows": report.get("lifecycleResidualVisibleOverlayWindows"),
|
"firstFrameLatencySampleCount": root.get("firstFrameLatencySampleCount"),
|
"firstFrameLatencyP95Milliseconds": root.get("firstFrameLatencyP95Milliseconds"),
|
"screenshotNotRequestedCount": root.get("screenshotNotRequestedCount"),
|
}
|
|
def require(condition, message):
|
if not condition:
|
print(message, file=sys.stderr)
|
print(json.dumps(summary(), indent=2, ensure_ascii=False), file=sys.stderr)
|
sys.exit(1)
|
|
root = report.get("rootView", {})
|
require(report.get("lifecycleTargetCycles") == cycles, "lifecycle target cycles must match")
|
require(report.get("lifecycleCompletedCycles") == cycles, "lifecycle completed cycles must match")
|
require(report.get("lifecycleGatePassed") is True, "lifecycle gate must pass")
|
require(report.get("lifecycleMaximumOverlayWindows") <= 1, "maximum overlay windows must be <= 1")
|
require(report.get("lifecycleMaximumVisibleOverlayWindows") <= 1, "maximum visible overlay windows must be <= 1")
|
require(report.get("lifecycleResidualVisibleOverlayWindows") == 0, "visible overlay residual must be 0")
|
require(report.get("lifecycleResidualOverlayWindows") <= 1, "hidden reusable overlay residual must be <= 1")
|
require(report.get("quickSwitchVisible") is False, "Quick Switch must be hidden after lifecycle")
|
require(report.get("appCount") == app_count, "final lifecycle app count must match")
|
require(report.get("windowCount") == expected_windows, "final lifecycle window count must match")
|
require(root.get("firstFrameLatencySampleCount") == cycles, "first frame samples must match lifecycle cycles")
|
require(root.get("firstFrameLatencyP95Milliseconds") < 150, "lifecycle first frame P95 must be < 150ms")
|
require(root.get("screenshotNotRequestedCount") == expected_windows, "disabled screenshot lifecycle must mark all cards not requested")
|
|
print(json.dumps({
|
"cycles": report.get("lifecycleCompletedCycles"),
|
"firstFrameP95": root.get("firstFrameLatencyP95Milliseconds"),
|
"maximumOverlayWindows": report.get("lifecycleMaximumOverlayWindows"),
|
"residualVisibleOverlayWindows": report.get("lifecycleResidualVisibleOverlayWindows")
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
count_trace_rows() {
|
local xml_path="$1"
|
local expected_schema="$2"
|
/usr/bin/python3 - "$xml_path" "$expected_schema" <<'PY'
|
import sys
|
import xml.etree.ElementTree as ET
|
|
xml_path, expected_schema = sys.argv[1:3]
|
root = ET.parse(xml_path).getroot()
|
schemas = root.findall(f".//schema[@name='{expected_schema}']")
|
if not schemas:
|
print(f"missing xctrace schema: {expected_schema} in {xml_path}", file=sys.stderr)
|
sys.exit(2)
|
print(len(root.findall(".//row")))
|
PY
|
}
|
|
KEY_SEQUENCE="$(/usr/bin/python3 - <<'PY'
|
commands = ["right", "down", "right", "up", "left", "down", "right", "up"] * 8
|
print(",".join(commands))
|
PY
|
)"
|
|
MOUSE_SEQUENCE="$(/usr/bin/python3 - "$APP_COUNT" "$WINDOWS_PER_APP" <<'PY'
|
import sys
|
|
app_count = int(sys.argv[1])
|
windows_per_app = int(sys.argv[2])
|
commands = []
|
for index in range(48):
|
app = index % app_count
|
window = (index * 3) % windows_per_app
|
commands.append(f"hover-card:{app}:{window}")
|
for index in range(16):
|
commands.append(f"hover-app:{index % app_count}")
|
print(",".join(commands))
|
PY
|
)"
|
|
cleanup() {
|
stop_current_aligner || true
|
}
|
trap cleanup EXIT
|
|
mkdir -p "$LOG_DIR"
|
stop_current_aligner
|
run_logged package-app "$SCRIPT_DIR/package-app.sh"
|
|
rm -f "$INTERACTION_REPORT" "$LIFECYCLE_REPORT" "$INSTRUMENTS_REPORT" "$POTENTIAL_HANGS_XML" "$HANG_RISKS_XML" "$SUMMARY"
|
rm -rf "$TRACE"
|
|
"$APP/Contents/MacOS/Aligner" \
|
--round0-skip-permissions \
|
--round01-open-quick-switch \
|
--round01-fixture-app-count="$APP_COUNT" \
|
--round01-fixture-windows-per-app="$WINDOWS_PER_APP" \
|
--round01-disable-screenshot-refresh \
|
--round01-debug-key-sequence="$KEY_SEQUENCE" \
|
--round01-debug-mouse-sequence="$MOUSE_SEQUENCE" \
|
--round01-quick-switch-report="$INTERACTION_REPORT" &
|
|
APP_PID=$!
|
wait_for_interaction_report
|
assert_interaction_report "$INTERACTION_REPORT"
|
kill "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
stop_current_aligner
|
|
"$APP/Contents/MacOS/Aligner" \
|
--round0-skip-permissions \
|
--round01-fixture-app-count="$APP_COUNT" \
|
--round01-fixture-windows-per-app="$WINDOWS_PER_APP" \
|
--round01-disable-screenshot-refresh \
|
--round01-quick-switch-lifecycle-cycles="$LIFECYCLE_CYCLES" \
|
--round01-quick-switch-lifecycle-interval="$LIFECYCLE_INTERVAL" \
|
--round01-quick-switch-lifecycle-visible-duration="$LIFECYCLE_VISIBLE_DURATION" \
|
--round01-quick-switch-report="$LIFECYCLE_REPORT"
|
|
assert_lifecycle_report
|
run_logged no-quick-switch-after-lifecycle swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch
|
|
run_logged screenshot-session env \
|
ALIGNER_ROUND1_SCREENSHOT_SESSION_SKIP_OVERLAY_FRAME_CHECK=1 \
|
ALIGNER_ROUND1_SCREENSHOT_SESSION_REQUIRE_REAL=0 \
|
"$SCRIPT_DIR/round1-screenshot-session-qa.sh"
|
|
run_logged xctrace-record xcrun xctrace record \
|
--quiet \
|
--no-prompt \
|
--template 'Time Profiler' \
|
--time-limit "$INSTRUMENTS_TIME_LIMIT" \
|
--output "$TRACE" \
|
--launch -- "$APP/Contents/MacOS/Aligner" \
|
--round0-skip-permissions \
|
--round01-open-quick-switch \
|
--round01-fixture-app-count="$APP_COUNT" \
|
--round01-fixture-windows-per-app="$WINDOWS_PER_APP" \
|
--round01-disable-screenshot-refresh \
|
--round01-debug-key-sequence="$KEY_SEQUENCE" \
|
--round01-debug-mouse-sequence="$MOUSE_SEQUENCE" \
|
--round01-quick-switch-auto-hide-after=8.0 \
|
--round01-quick-switch-quit-after=10.0 \
|
--round01-quick-switch-report="$INSTRUMENTS_REPORT"
|
|
assert_interaction_report "$INSTRUMENTS_REPORT"
|
run_logged xctrace-export-potential-hangs xcrun xctrace export --input "$TRACE" --xpath '/trace-toc/run[@number="1"]/data/table[@schema="potential-hangs"]' --output "$POTENTIAL_HANGS_XML"
|
run_logged xctrace-export-hang-risks xcrun xctrace export --input "$TRACE" --xpath '/trace-toc/run[@number="1"]/data/table[@schema="hang-risks"]' --output "$HANG_RISKS_XML"
|
|
POTENTIAL_HANGS_COUNT="$(count_trace_rows "$POTENTIAL_HANGS_XML" "potential-hangs")"
|
HANG_RISKS_COUNT="$(count_trace_rows "$HANG_RISKS_XML" "hang-risks")"
|
[ "$POTENTIAL_HANGS_COUNT" = "0" ] || fail "Instruments potential-hangs must be 0, got $POTENTIAL_HANGS_COUNT"
|
[ "$HANG_RISKS_COUNT" = "0" ] || fail "Instruments hang-risks must be 0, got $HANG_RISKS_COUNT"
|
|
/usr/bin/python3 - "$INTERACTION_REPORT" "$LIFECYCLE_REPORT" "$INSTRUMENTS_REPORT" "$TRACE" "$POTENTIAL_HANGS_COUNT" "$HANG_RISKS_COUNT" "$SUMMARY" <<'PY'
|
import json
|
import sys
|
|
interaction_path, lifecycle_path, instruments_path, trace_path, potential_hangs, hang_risks, summary_path = sys.argv[1:8]
|
with open(interaction_path, "r", encoding="utf-8") as file:
|
interaction = json.load(file)
|
with open(lifecycle_path, "r", encoding="utf-8") as file:
|
lifecycle = json.load(file)
|
with open(instruments_path, "r", encoding="utf-8") as file:
|
instruments = json.load(file)
|
|
summary = {
|
"interactionReport": interaction_path,
|
"lifecycleReport": lifecycle_path,
|
"instrumentsReport": instruments_path,
|
"trace": trace_path,
|
"interaction": {
|
"firstFrameP95Milliseconds": interaction["rootView"].get("firstFrameLatencyP95Milliseconds"),
|
"keyboardP95Milliseconds": interaction["rootView"].get("keyboardResponseP95Milliseconds"),
|
"hoverP95Milliseconds": interaction["rootView"].get("hoverResponseP95Milliseconds"),
|
"windowCount": interaction.get("windowCount"),
|
},
|
"lifecycle": {
|
"cycles": lifecycle.get("lifecycleCompletedCycles"),
|
"firstFrameP95Milliseconds": lifecycle["rootView"].get("firstFrameLatencyP95Milliseconds"),
|
"maximumOverlayWindows": lifecycle.get("lifecycleMaximumOverlayWindows"),
|
"residualVisibleOverlayWindows": lifecycle.get("lifecycleResidualVisibleOverlayWindows"),
|
},
|
"instruments": {
|
"firstFrameP95Milliseconds": instruments["rootView"].get("firstFrameLatencyP95Milliseconds"),
|
"keyboardP95Milliseconds": instruments["rootView"].get("keyboardResponseP95Milliseconds"),
|
"hoverP95Milliseconds": instruments["rootView"].get("hoverResponseP95Milliseconds"),
|
"potentialHangs": int(potential_hangs),
|
"hangRisks": int(hang_risks),
|
},
|
}
|
|
with open(summary_path, "w", encoding="utf-8") as file:
|
json.dump(summary, file, indent=2, ensure_ascii=False)
|
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
PY
|
|
stop_current_aligner
|
trap - EXIT
|