#!/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) def near(lhs, rhs, tolerance=0.01): return abs((lhs or 0) - (rhs or 0)) <= tolerance def is_blue(color, min_alpha=0.30): return ( isinstance(color, dict) and color.get("blue", 0) >= 0.85 and color.get("green", 0) >= 0.35 and color.get("red", 1) <= 0.18 and color.get("alpha", 0) >= min_alpha ) 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", []) root_bounds = root_view.get("bounds", {}) glass_frame = root_view.get("glassFrame", {}) safe_area_top_inset = root_view.get("safeAreaTopInset", 0) safe_area_fill_frame = root_view.get("safeAreaFillFrame", {}) lane_frame = root_view.get("spaceLaneFrame", {}) shelf_frame = root_view.get("appShelfFrame", {}) waterfall_frame = root_view.get("waterfallFrame", {}) require(near(glass_frame.get("x"), root_bounds.get("x")), "Quick Switch glass layer x must match root bounds") require(near(glass_frame.get("y"), root_bounds.get("y")), "Quick Switch glass layer y must match root bounds") require(near(glass_frame.get("width"), root_bounds.get("width")), "Quick Switch glass layer width must match root bounds") require(near(glass_frame.get("height"), root_bounds.get("height")), "Quick Switch glass layer height must match root bounds") require(root_view.get("glassCornerRadius") == 0, "Quick Switch glass layer must not draw an outer rounded frame") require(root_view.get("glassBorderWidth") == 0, "Quick Switch glass layer must not draw an outer border") require(root_view.get("glassShadowOpacity") == 0, "Quick Switch glass layer must not draw an outer shadow") require(safe_area_top_inset >= 0, "safeAreaTopInset must be non-negative") if safe_area_top_inset > 0.5: require(near(safe_area_fill_frame.get("height"), safe_area_top_inset), "safe-area fill height must match safeAreaTopInset") require(near(safe_area_fill_frame.get("y") + safe_area_fill_frame.get("height"), root_bounds.get("height")), "safe-area fill must sit at the top edge") 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") require(all(isinstance(segment.get("borderColor"), dict) for segment in lane_segments), "Space Lane segments must expose border colors") require(all(isinstance(segment.get("windowBlockColors"), list) for segment in lane_segments), "Space Lane segments must expose window block colors") occupied_segments = [segment for segment in lane_segments if segment.get("windowCount", 0) > 0] empty_segments = [segment for segment in lane_segments if segment.get("windowCount", 0) == 0] require(all(is_blue(segment.get("borderColor"), min_alpha=0.34) for segment in occupied_segments), "occupied Space segments must use blue borders") require(all(not is_blue(segment.get("borderColor"), min_alpha=0.34) for segment in empty_segments), "empty Space segments must not use blue borders") occupied_floating_segments = [segment for segment in occupied_segments if segment.get("type") != "fullscreen"] require(all(segment.get("windowBlockCount", 0) > 0 for segment in occupied_floating_segments), "occupied non-fullscreen Spaces must expose window blocks") for segment in occupied_floating_segments: require(all(is_blue(color, min_alpha=0.50) for color in segment.get("windowBlockColors", [])), "occupied Space window blocks must be blue") same_display_gaps = [] cross_display_gaps = [] for previous, current in zip(lane_segments, lane_segments[1:]): previous_frame = previous.get("visibleFrame", {}) current_frame = current.get("visibleFrame", {}) gap = current_frame.get("x", 0) - (previous_frame.get("x", 0) + previous_frame.get("width", 0)) if previous.get("displayUUID") == current.get("displayUUID"): same_display_gaps.append(gap) else: cross_display_gaps.append(gap) if cross_display_gaps: require(all(gap >= 24 for gap in cross_display_gaps), "Space Lane must leave a visible gap between displays") if same_display_gaps: require(min(cross_display_gaps) > max(same_display_gaps) + 8, "display gap must be clearly larger than same-display Space gaps") 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") require( all(segment.get("fullscreenMarkerKind") in {"cornerBracket", "splitViewPair"} for segment in fullscreen_segments), "fullscreen segments must use a known fullscreen marker" ) require(all(segment.get("windowBlockCount") == 0 for segment in fullscreen_segments), "fullscreen segments must not expose floating-window blocks") non_fullscreen_segments = [segment for segment in lane_segments if segment.get("type") != "fullscreen"] require(all(segment.get("fullscreenMarkerKind") == "none" for segment in non_fullscreen_segments), "non-fullscreen segments must not expose fullscreen marker") for segment in fullscreen_segments: require("fullscreen" in segment.get("visualStates", []), "fullscreen segments must expose fullscreen visual state") marker_frame = segment.get("fullscreenMarkerFrame", {}) app_frame = segment.get("appFrame", {}) require(isinstance(marker_frame, dict), "fullscreen segments must expose marker frame") marker_mid_y = marker_frame.get("y", 0) + marker_frame.get("height", 0) / 2 if segment.get("fullscreenMarkerKind") == "splitViewPair": label_frames = segment.get("splitViewLabelFrames", []) require(len(label_frames) == 2, "Split View fullscreen segments must expose two app label frames") require( segment.get("splitViewAppNames") and len(segment.get("splitViewAppNames")) == 2, "Split View fullscreen segments must expose two ordered app names" ) require( label_frames[0].get("x", 0) + label_frames[0].get("width", 0) / 2 < marker_frame.get("x", 0) + marker_frame.get("width", 0) / 2 < label_frames[1].get("x", 0) + label_frames[1].get("width", 0) / 2, "Split View app labels must occupy left and right marker halves" ) for label_frame in label_frames: app_mid_y = label_frame.get("y", 0) + label_frame.get("height", 0) / 2 require(abs(marker_mid_y - app_mid_y) <= 2, "Split View App labels must be vertically centered in marker") else: require(isinstance(app_frame, dict), "fullscreen segments must expose centered App label frame") app_mid_y = app_frame.get("y", 0) + app_frame.get("height", 0) / 2 require(abs(marker_mid_y - app_mid_y) <= 2, "fullscreen App label must be vertically centered in marker") 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") if lane_segments: first_frame = lane_segments[0].get("visibleFrame", {}) last_frame = lane_segments[-1].get("visibleFrame", {}) left_blank = first_frame.get("x", 0) right_blank = lane_visible_width - (last_frame.get("x", 0) + last_frame.get("width", 0)) require(abs(left_blank - right_blank) <= 2, "non-overflowing Space Lane segments must be centered") 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("isHovered") is True: expected_icon_size = root_view.get("appShelfHoverIconSize") else: expected_icon_size = icon_size if item.get("isSelected") is True and item.get("isHovered") is not True: require("selectedVisualSuppressed" in item.get("visualStates", []), "non-hovered selected App Shelf item must suppress selected visual state") 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("titleBarFrame"), dict), "Waterfall cards must expose title bar frames") require(card.get("titleBarBorderWidth") == 0, "Waterfall title area must not draw an enclosing rounded rectangle") require(isinstance(card.get("titleFrame"), dict), "Waterfall cards must expose title frames") card_frame = card.get("frame", {}) thumbnail_frame = card.get("thumbnailFrame", {}) title_bar_frame = card.get("titleBarFrame", {}) require(thumbnail_frame.get("width", 0) >= card_frame.get("width", 0) * 0.82, "Waterfall thumbnail must use most of the card width") require(title_bar_frame.get("y", 0) > thumbnail_frame.get("y", 0) + thumbnail_frame.get("height", 0), "Waterfall title bar must sit above the thumbnail") 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") if root_view.get("activeSpaceFocusID") is None: require( all("spaceFocused" not in card.get("visualStates", []) for column in waterfall_columns for card in column.get("cards", [])), "Waterfall cards must not expose spaceFocused when no Space Lane focus is active" ) 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("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "non-hovered selected Waterfall card must suppress selected visual state") require(selected_cards[0].get("shineVisible") is False, "non-hovered selected Waterfall card must not expose shine layer") require(selected_cards[0].get("zPosition", 0) == 0, "non-hovered selected Waterfall card must not float 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