#!/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