#!/bin/bash # Round01 session snapshot QA. It opens the Quick Switch session through the app # entrypoint and verifies that the session froze a real Space/Window snapshot # into a UI-facing report. 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" REPORT="$BUILD_REPORT_ROOT/round01-quick-switch-session-report.json" OPEN_WAIT="${ALIGNER_ROUND1_SESSION_SNAPSHOT_OPEN_WAIT:-1.2}" REPORT_WAIT="${ALIGNER_ROUND1_SESSION_SNAPSHOT_REPORT_WAIT:-6.0}" fail() { echo "Round01 session snapshot QA failed: $*" >&2 exit 1 } 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 QA" } assert_report() { /usr/bin/python3 - "$REPORT" <<'PY' import json import sys path = sys.argv[1] with open(path, "r", encoding="utf-8") as file: report = json.load(file) def require(condition, message): if not condition: print(message, file=sys.stderr) print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr) sys.exit(1) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("displayCount", 0) >= 1, "displayCount must be >= 1") require(report.get("spaceCount", 0) >= 1, "spaceCount must be >= 1") require(report.get("appCount", -1) == report.get("columnCount", -2), "appCount must equal columnCount") require(report.get("windowCount", 0) >= 1, "windowCount must be >= 1 on the QA desktop") require(report.get("initialSelectionWindowID") is not None, "initialSelectionWindowID must exist when windows exist") require(len(report.get("spaceLabels", [])) == report.get("spaceCount"), "spaceLabels count must match spaceCount") require(report.get("snapshotRanOnMainThread") is False, "snapshot must not run on the main thread") require(report.get("rootView", {}).get("viewClass") == "QuickSwitchRootView", "root view must be QuickSwitchRootView") require(report.get("rootView", {}).get("isLayerBacked") is True, "root view must be layer-backed AppKit") require(report.get("rootView", {}).get("waterfallFrame", {}).get("width", 0) > 0, "waterfall frame must be laid out") root_view = report.get("rootView", {}) space_labels = report.get("spaceLabels", []) lane_labels = root_view.get("spaceLaneLabels", []) lane_segments = root_view.get("spaceLaneSegments", []) lane_frame = root_view.get("spaceLaneFrame", {}) shelf_frame = root_view.get("appShelfFrame", {}) waterfall_frame = root_view.get("waterfallFrame", {}) require(root_view.get("spaceLaneSegmentCount") == report.get("spaceCount"), "Space Lane segment count must match spaceCount") require(lane_labels == space_labels, "Space Lane labels must match report spaceLabels exactly") require(len(set(lane_labels)) == len(lane_labels), "Space Lane labels must be unique") require(len(lane_segments) == report.get("spaceCount"), "Space Lane segment reports must match spaceCount") require([segment.get("label") for segment in lane_segments] == space_labels, "Space Lane segment reports must follow spaceLabels order") require(all(isinstance(segment.get("windowCount"), int) for segment in lane_segments), "Space Lane segments must expose window counts") require(all(isinstance(segment.get("appCount"), int) for segment in lane_segments), "Space Lane segments must expose app counts") require(all(isinstance(segment.get("appNames"), list) for segment in lane_segments), "Space Lane segments must expose app names") require(all(isinstance(segment.get("frame"), dict) for segment in lane_segments), "Space Lane segments must expose content frames") require(all(isinstance(segment.get("visibleFrame"), dict) for segment in lane_segments), "Space Lane segments must expose visible frames") current_labels = root_view.get("spaceLaneCurrentLabels", []) fullscreen_labels = root_view.get("spaceLaneFullscreenLabels", []) require(len(current_labels) >= 1, "Space Lane must mark at least one current Space") require(set(current_labels).issubset(set(space_labels)), "current Space labels must be a subset of Space Lane labels") require(set(fullscreen_labels).issubset(set(space_labels)), "fullscreen Space labels must be a subset of Space Lane labels") require(len(fullscreen_labels) >= 1, "QA desktop must include at least one fullscreen Space in Space Lane") fullscreen_segments = [segment for segment in lane_segments if segment.get("type") == "fullscreen"] require([segment.get("label") for segment in fullscreen_segments] == fullscreen_labels, "fullscreen segment reports must match fullscreen labels") require(all(segment.get("fullscreenMarkerVisible") is True for segment in fullscreen_segments), "fullscreen segments must expose fullscreen marker") for segment in fullscreen_segments: require("fullscreen" in segment.get("visualStates", []), "fullscreen segments must expose fullscreen visual state") for segment in lane_segments: if segment.get("isCurrent") is True: require("current" in segment.get("visualStates", []), "current segment must expose current visual state") if set(current_labels).intersection(fullscreen_labels): for segment in lane_segments: if segment.get("label") in set(current_labels).intersection(fullscreen_labels): states = set(segment.get("visualStates", [])) require({"current", "fullscreen"}.issubset(states), "current fullscreen segment must expose both visual states") require(lane_frame.get("width", 0) > lane_frame.get("height", 0) * 4, "Space Lane must be a top horizontal region") require(lane_frame.get("y", 0) > shelf_frame.get("y", 0), "Space Lane must be above App Shelf") require(shelf_frame.get("y", 0) > waterfall_frame.get("y", 0), "App Shelf must be above Waterfall") lane_visible_width = root_view.get("spaceLaneVisibleWidth", 0) lane_content_width = root_view.get("spaceLaneContentWidth", 0) lane_max_scroll_offset = root_view.get("spaceLaneMaxScrollOffset", 0) require(lane_visible_width == lane_frame.get("width", 0), "Space Lane visible width must match frame width") require(lane_content_width >= lane_visible_width, "Space Lane content width must cover visible width") require(lane_max_scroll_offset == max(0, lane_content_width - lane_visible_width), "Space Lane max scroll offset must match content overflow") if lane_content_width > lane_visible_width: require(root_view.get("spaceLaneScrollable") is True, "overflowing Space Lane must be scrollable") require(lane_max_scroll_offset > 0, "overflowing Space Lane must expose positive max scroll offset") else: require(root_view.get("spaceLaneScrollable") is False, "non-overflowing Space Lane should not report scrollable") app_shelf_items = root_view.get("appShelfItems", []) app_shelf_names = root_view.get("appShelfNames", []) require(root_view.get("appShelfItemCount") == report.get("appCount"), "App Shelf item count must match appCount") require(app_shelf_names == report.get("appShelfNames", []), "App Shelf order must match report appShelfNames") require(len(app_shelf_items) == report.get("appCount"), "App Shelf item reports must match appCount") require([item.get("name") for item in app_shelf_items] == report.get("appShelfNames", []), "App Shelf item reports must follow appShelfNames order") require(root_view.get("appShelfMaxRows") == 2, "App Shelf max rows must be 2") require(1 <= root_view.get("appShelfRows", 0) <= 2, "App Shelf must use 1 or 2 rows when apps exist") require(root_view.get("appShelfNormalIconSize") == 56, "App Shelf normal icon size must be 56pt") require(root_view.get("appShelfHoverIconSize") == 68, "App Shelf hover icon size must be 68pt") require(root_view.get("appShelfSelectedIconSize") == 68, "App Shelf selected icon size must be 68pt") require(root_view.get("appShelfMinIconSize") == 36, "App Shelf min icon size must be 36pt") icon_size = root_view.get("appShelfIconSize", 0) require(36 <= icon_size <= 56, "App Shelf layout icon size must stay between 36pt and 56pt") require(all(item.get("labelTruncationMode") == "middle" for item in app_shelf_items), "App Shelf labels must use middle truncation") require(sum(1 for item in app_shelf_items if item.get("isSelected") is True) == 1, "App Shelf must expose one selected app") require(all(isinstance(item.get("frame"), dict) for item in app_shelf_items), "App Shelf items must expose frames") require(all(isinstance(item.get("iconFrame"), dict) for item in app_shelf_items), "App Shelf items must expose icon frames") for item in app_shelf_items: if item.get("isSelected") is True: expected_icon_size = root_view.get("appShelfSelectedIconSize") elif item.get("isHovered") is True: expected_icon_size = root_view.get("appShelfHoverIconSize") else: expected_icon_size = icon_size require(abs(item.get("iconFrame", {}).get("width", -1) - expected_icon_size) < 0.01, "App Shelf icon frame width must match visual icon size") require(abs(item.get("iconFrame", {}).get("height", -1) - expected_icon_size) < 0.01, "App Shelf icon frame height must match visual icon size") row_y_values = {round(item.get("frame", {}).get("y", 0), 2) for item in app_shelf_items} require(len(row_y_values) == root_view.get("appShelfRows"), "App Shelf reported row count must match visible row y positions") app_visible_width = root_view.get("appShelfVisibleWidth", 0) app_content_width = root_view.get("appShelfContentWidth", 0) app_max_scroll_offset = root_view.get("appShelfMaxScrollOffset", 0) require(app_visible_width == shelf_frame.get("width", 0), "App Shelf visible width must match frame width") require(app_content_width >= app_visible_width, "App Shelf content width must cover visible width") require(app_max_scroll_offset == max(0, app_content_width - app_visible_width), "App Shelf max scroll offset must match content overflow") if app_content_width > app_visible_width: require(root_view.get("appShelfScrollable") is True, "overflowing App Shelf must be scrollable") else: require(root_view.get("appShelfScrollable") is False, "non-overflowing App Shelf should not report scrollable") waterfall_columns = root_view.get("waterfallColumns", []) waterfall_names = root_view.get("waterfallColumnNames", []) require(root_view.get("waterfallColumnCount") == report.get("columnCount"), "Waterfall column count must match report columnCount") require(root_view.get("waterfallColumnCount") == report.get("appCount"), "Waterfall column count must match appCount") require(waterfall_names == app_shelf_names, "Waterfall column order must match App Shelf order") require(len(waterfall_columns) == report.get("columnCount"), "Waterfall column reports must match columnCount") require(root_view.get("waterfallClipsToBounds") is True, "Waterfall viewport must clip overflowing columns") waterfall_visible_width = root_view.get("waterfallVisibleWidth", 0) waterfall_content_width = root_view.get("waterfallContentWidth", 0) waterfall_max_scroll_offset = root_view.get("waterfallMaxScrollOffset", 0) require(waterfall_visible_width == waterfall_frame.get("width", 0), "Waterfall visible width must match frame width") require(waterfall_content_width >= waterfall_visible_width, "Waterfall content width must cover visible width") require(waterfall_max_scroll_offset == max(0, waterfall_content_width - waterfall_visible_width), "Waterfall max scroll offset must match content overflow") if waterfall_content_width > waterfall_visible_width: require(root_view.get("waterfallScrollable") is True, "overflowing Waterfall must be scrollable") else: require(root_view.get("waterfallScrollable") is False, "non-overflowing Waterfall should not report scrollable") waterfall_card_count = 0 global_indexes = [] window_ids = [] low_information_card_keys = [] valid_thumbnail_strategies = { "skeleton", "skeletonPreferred", "pendingScreenshot", "realScreenshot", "skeletonFallback", } valid_screenshot_sources = { "pending", "notRequested", "realScreenshot", "skeletonFallback", } for expected_index, column in enumerate(waterfall_columns): require(column.get("appGroupIndex") == expected_index, "Waterfall appGroupIndex must be sequential") require(column.get("appName") == app_shelf_names[expected_index], "Waterfall column app name must match App Shelf order") require(isinstance(column.get("frame"), dict), "Waterfall columns must expose frames") require(isinstance(column.get("visibleFrame"), dict), "Waterfall columns must expose visible frames") require(isinstance(column.get("maxVerticalScrollOffset"), (int, float)), "Waterfall columns must expose vertical scroll range") require(isinstance(column.get("verticalScrollOffset"), (int, float)), "Waterfall columns must expose vertical scroll offset") cards = column.get("cards", []) require(len(cards) == column.get("windowCount"), "Waterfall card count must match column windowCount") require([card.get("windowIndex") for card in cards] == list(range(len(cards))), "Waterfall card windowIndex must be sequential per column") for card in cards: require(card.get("appGroupIndex") == expected_index, "Waterfall card appGroupIndex must match its column") require(isinstance(card.get("globalIndex"), int), "Waterfall cards must expose globalIndex") require(isinstance(card.get("windowID"), int), "Waterfall cards must expose windowID") window_ids.append(card.get("windowID")) if card.get("identifierSource") == "cgWindow" and card.get("titleLength") == 0: low_information_card_keys.append(( column.get("bundleIdentifier"), card.get("titleHash"), card.get("titleLength"), card.get("primarySpaceID"), card.get("identifierSource"), )) require(isinstance(card.get("frame"), dict), "Waterfall cards must expose frames") require(isinstance(card.get("visibleFrame"), dict), "Waterfall cards must expose visible frames") require(card.get("thumbnailStrategy") in valid_thumbnail_strategies, "Waterfall cards must expose valid thumbnail strategy") require(card.get("screenshotSource") in valid_screenshot_sources, "Waterfall cards must expose valid screenshot source") if card.get("screenshotSource") == "skeletonFallback": require(card.get("screenshotFallbackReason") is not None, "skeleton fallback cards must expose fallback reason") if card.get("screenshotSource") == "notRequested": require(card.get("screenshotNotRequestedReason") is not None, "not-requested screenshot cards must expose reason") require(isinstance(card.get("thumbnailFrame"), dict), "Waterfall cards must expose thumbnail frames") require(isinstance(card.get("appIconFrame"), dict), "Waterfall cards must expose App icon frames") require(isinstance(card.get("visualStates"), list), "Waterfall cards must expose visual states") require(card.get("titleTruncationMode") == "middle", "Waterfall card titles must use middle truncation") global_indexes.append(card.get("globalIndex")) waterfall_card_count += len(cards) require(waterfall_card_count == report.get("windowCount"), "Waterfall cards must cover report windowCount") require(len(window_ids) == len(set(window_ids)), "Waterfall windowIDs must be unique") require( len(low_information_card_keys) == len(set(low_information_card_keys)), "Waterfall must not expose duplicate low-information CG window candidates" ) require(isinstance(root_view.get("screenshotEligibleCount"), int), "root view must expose screenshotEligibleCount") require(isinstance(root_view.get("screenshotResolvedCount"), int), "root view must expose screenshotResolvedCount") require(isinstance(root_view.get("screenshotPendingCount"), int), "root view must expose screenshotPendingCount") require(isinstance(root_view.get("screenshotNotRequestedCount"), int), "root view must expose screenshotNotRequestedCount") require(root_view.get("screenshotResolvedCount") + root_view.get("screenshotPendingCount") == root_view.get("screenshotEligibleCount"), "resolved + pending screenshots must match eligible count") require(root_view.get("screenshotEligibleCount") + root_view.get("screenshotNotRequestedCount") == waterfall_card_count, "eligible + not-requested screenshots must cover all cards") require(global_indexes == sorted(global_indexes), "Waterfall global indexes must be monotonic across columns") selected_cards = [ card for column in waterfall_columns for card in column.get("cards", []) if card.get("isSelected") is True ] require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card") require("selected" in selected_cards[0].get("visualStates", []), "selected Waterfall card must expose selected visual state") require(selected_cards[0].get("shineVisible") is True, "selected Waterfall card must expose shine layer") require(selected_cards[0].get("zPosition", 0) > 0, "selected Waterfall card must be above normal cards") require(isinstance(report.get("overlayOpenElapsedMilliseconds"), (int, float)), "overlay open timing must exist") require(isinstance(report.get("snapshotStartElapsedMilliseconds"), (int, float)), "snapshot start timing must exist") require(report["snapshotStartElapsedMilliseconds"] >= report["overlayOpenElapsedMilliseconds"], "snapshot must start after overlay open path") require(isinstance(report.get("snapshotDurationMilliseconds"), (int, float)), "snapshot duration timing must exist") print(json.dumps(report, indent=2, ensure_ascii=False)) PY } click_target_from_report() { /usr/bin/python3 - "$REPORT" <<'PY' import json import sys path = sys.argv[1] with open(path, "r", encoding="utf-8") as file: report = json.load(file) root = report.get("rootView", {}) for column in root.get("waterfallColumns", []): for card in column.get("cards", []): if card.get("identifierSource") == "cgWindow" and card.get("isMinimized") is False: print(f"{card.get('appGroupIndex')}:{card.get('windowIndex')}:{card.get('windowID')}") sys.exit(0) print("no non-minimized cgWindow card available for click activation QA", file=sys.stderr) sys.exit(1) PY } wait_for_click_report() { /usr/bin/python3 - "$REPORT" "$REPORT_WAIT" <<'PY' import json import sys import time path = sys.argv[1] timeout = float(sys.argv[2]) deadline = time.monotonic() + timeout last_report = None while time.monotonic() < deadline: try: with open(path, "r", encoding="utf-8") as file: report = json.load(file) last_report = report if report.get("lastCommitSource") == "mouse" and report.get("lastActivationResult") is not None: sys.exit(0) except FileNotFoundError: pass except json.JSONDecodeError: pass time.sleep(0.2) if last_report is not None: print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr) print(f"click activation report did not complete within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_click_report() { /usr/bin/python3 - "$REPORT" "$1" <<'PY' import json import sys path = sys.argv[1] expected_window_id = int(sys.argv[2]) with open(path, "r", encoding="utf-8") as file: report = json.load(file) def require(condition, message): if not condition: print(message, file=sys.stderr) print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr) sys.exit(1) require(report.get("lastCommitSource") == "mouse", "click activation source must be mouse") require(report.get("lastActivationWindowID") == expected_window_id, "click activation must target the selected card windowID") require(report.get("lastActivationResult") == "activated", "click activation must activate the target window") print(json.dumps({ "lastCommitSource": report.get("lastCommitSource"), "lastActivationWindowID": report.get("lastActivationWindowID"), "lastActivationResult": report.get("lastActivationResult"), }, indent=2, ensure_ascii=False)) PY } wait_for_loaded_report() { /usr/bin/python3 - "$REPORT" "$REPORT_WAIT" <<'PY' import json import sys import time path = sys.argv[1] timeout = float(sys.argv[2]) deadline = time.monotonic() + timeout last_report = None while time.monotonic() < deadline: try: with open(path, "r", encoding="utf-8") as file: report = json.load(file) last_report = report if report.get("snapshotLoaded") is True: sys.exit(0) except FileNotFoundError: pass except json.JSONDecodeError: pass time.sleep(0.2) if last_report is not None: print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr) print(f"snapshot report did not become loaded within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 rm -f "$REPORT" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-quick-switch-report="$REPORT" & APP_PID=$! cleanup() { if kill -0 "$APP_PID" 2>/dev/null; then kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true fi } trap cleanup EXIT sleep "$OPEN_WAIT" swift "$SCRIPT_DIR/window-logic-qa.swift" \ --expect-quick-switch \ --expect-single-aligner-overlay \ --expect-overlay-on-mouse-screen \ --expect-overlay-uses-screen-frame wait_for_loaded_report assert_report CLICK_TARGET="$(click_target_from_report)" cleanup trap - EXIT rm -f "$REPORT" IFS=: read -r CLICK_APP_INDEX CLICK_WINDOW_INDEX CLICK_WINDOW_ID <<<"$CLICK_TARGET" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-disable-screenshot-refresh \ --round01-debug-mouse-sequence="click-card:$CLICK_APP_INDEX:$CLICK_WINDOW_INDEX" \ --round01-quick-switch-report="$REPORT" & APP_PID=$! trap cleanup EXIT wait_for_click_report assert_click_report "$CLICK_WINDOW_ID" cleanup trap - EXIT swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch