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