#!/bin/bash # Round01 mouse interaction fixture QA. It drives deterministic hover/click # commands through the Quick Switch debug hook and verifies App Shelf, # Waterfall, Space Lane linkage, App click column positioning, card commit, # and clean overlay close. 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-mouse-interaction-fixture-report.json" APP_ONLY_REPORT="$BUILD_REPORT_ROOT/round01-mouse-app-only-fixture-report.json" FIXTURE_APP_COUNT="${ALIGNER_ROUND1_MOUSE_FIXTURE_APP_COUNT:-8}" FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_MOUSE_FIXTURE_WINDOWS_PER_APP:-8}" MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_SEQUENCE:-hover-card:1:7,hover-app:5,hover-card:7:0,click-card:7:0}" APP_ONLY_MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_APP_ONLY_SEQUENCE:-hover-app:5,click-app:7}" REPORT_WAIT="${ALIGNER_ROUND1_MOUSE_REPORT_WAIT:-6.0}" WINDOW_WAIT="${ALIGNER_ROUND1_MOUSE_WINDOW_WAIT:-6.0}" fail() { echo "Round01 mouse interaction fixture 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" } wait_for_committed_hidden_report() { local report="${1:-$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 root = report.get("rootView", {}) if ( report.get("snapshotLoaded") is True and report.get("quickSwitchVisible") is False and report.get("lastCommitSource") == "mouse" and report.get("lastActivationResult") is not None and root.get("lastCommitSource") == "mouse" ): 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"mouse interaction report did not become committed+hidden within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } wait_for_visible_loaded_report() { local report="$1" /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 and report.get("quickSwitchVisible") 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"mouse app-only report did not become visible+loaded within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } wait_for_window_logic() { local timeout="$1" shift /usr/bin/python3 - "$SCRIPT_DIR" "$timeout" "$@" <<'PY' import subprocess import sys import time script_dir = sys.argv[1] timeout = float(sys.argv[2]) args = sys.argv[3:] deadline = time.monotonic() + timeout last_output = "" while time.monotonic() < deadline: result = subprocess.run( ["swift", f"{script_dir}/window-logic-qa.swift", *args], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) last_output = result.stdout if result.returncode == 0: print(last_output, end="") sys.exit(0) time.sleep(0.2) print(last_output, end="") sys.exit(1) PY } assert_app_only_report() { /usr/bin/python3 - "$APP_ONLY_REPORT" "$FIXTURE_APP_COUNT" "$APP_ONLY_MOUSE_SEQUENCE" <<'PY' import json import sys path = sys.argv[1] fixture_app_count = int(sys.argv[2]) mouse_sequence = [part for part in sys.argv[3].split(",") if part] 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) root = report.get("rootView", {}) items = root.get("appShelfItems", []) columns = root.get("waterfallColumns", []) click_app_commands = [command for command in mouse_sequence if command.startswith("click-app:")] require(click_app_commands, "app-only sequence must include click-app:") last_click_app = click_app_commands[-1] last_click_parts = last_click_app.split(":") require(len(last_click_parts) == 2, "click-app command must be click-app:") target_app_group_index = int(last_click_parts[1]) require(report.get("snapshotLoaded") is True, "app-only snapshotLoaded must be true") require(report.get("quickSwitchVisible") is False, "click App Shelf must close Quick Switch after committing its first window") require(root.get("mouseCommandsApplied") == mouse_sequence, "app-only mouse sequence must be applied in order") require(0 <= target_app_group_index < fixture_app_count, "click-app target App group must be inside fixture range") target_column = columns[target_app_group_index] target_first_card = target_column.get("cards", [])[0] expected_window_id = target_first_card.get("windowID") require(root.get("selectedAppGroupIndex") == target_app_group_index, "click App Shelf must move keyboard selection to target App") require(root.get("selectedWindowIndex") == target_first_card.get("windowIndex"), "click App Shelf must select the first target-column window") require(root.get("selectedWindowID") == target_first_card.get("windowID"), "click App Shelf must focus the first target-column window") require(root.get("lastCommittedWindowID") == expected_window_id, "click App Shelf must commit the first target-column window") require(report.get("lastCommittedWindowID") == expected_window_id, "session must surface the App Shelf commit") require(root.get("lastCommitSource") == "mouse", "root App Shelf commit source must be mouse") require(report.get("lastCommitSource") == "mouse", "session App Shelf commit source must be mouse") require(report.get("lastActivationWindowID") == expected_window_id, "App Shelf activation window must be the first target-column window") require(report.get("lastActivationResult") == "activated", "debug activation must activate the App Shelf first window") require(root.get("lastClickedAppGroupIndex") == target_app_group_index, "click App Shelf must record target App group") require(root.get("lastAppShelfClickChangedSelection") is True, "click App Shelf must explicitly report selection moved to target column") require(root.get("waterfallScrollOffset", 0) > 0, "clicking far-right App Shelf item must position its Waterfall column") hovered_items = [item for item in items if item.get("isHovered") is True] require(len(hovered_items) == 1, "App-only hover must expose exactly one hovered App Shelf item") require(hovered_items[0].get("index") == target_app_group_index, "clicked App Shelf item must remain hovered") print(json.dumps({ "appOnlyQuickSwitchVisible": report.get("quickSwitchVisible"), "selectedAppGroupIndex": root.get("selectedAppGroupIndex"), "lastCommittedWindowID": root.get("lastCommittedWindowID"), "lastActivationResult": report.get("lastActivationResult"), "lastClickedAppGroupIndex": root.get("lastClickedAppGroupIndex"), "waterfallScrollOffset": root.get("waterfallScrollOffset") }, indent=2, ensure_ascii=False)) PY } assert_report() { /usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$MOUSE_SEQUENCE" <<'PY' import json import sys path = sys.argv[1] fixture_app_count = int(sys.argv[2]) fixture_windows_per_app = int(sys.argv[3]) mouse_sequence = [part for part in sys.argv[4].split(",") if part] 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) root = report.get("rootView", {}) columns = root.get("waterfallColumns", []) items = root.get("appShelfItems", []) segments = root.get("spaceLaneSegments", []) last_command = mouse_sequence[-1] if mouse_sequence else "" last_parts = last_command.split(":") require(len(last_parts) == 3 and last_parts[0] == "click-card", "final mouse command must be click-card::") expected_app_group_index = int(last_parts[1]) expected_window_index = int(last_parts[2]) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is False, "clicking a window card must close Quick Switch") require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count") require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match requested shape") require(root.get("waterfallColumnCount") == fixture_app_count, "Waterfall column count must match app count") require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count") require(len(items) == fixture_app_count, "App Shelf item reports must match fixture count") require(root.get("mouseCommandsApplied") == mouse_sequence, "debug mouse sequence must be applied in order") require(root.get("lastMouseCommand") == last_command, "final mouse command must click the target card") require(0 <= expected_app_group_index < fixture_app_count, "final click-card App group must be inside fixture range") require(0 <= expected_window_index < fixture_windows_per_app, "final click-card window index must be inside fixture range") selected_column = columns[expected_app_group_index] selected_card = selected_column.get("cards", [])[expected_window_index] expected_window_id = selected_card.get("windowID") expected_space_id = selected_card.get("primarySpaceID") require(root.get("selectedAppGroupIndex") == expected_app_group_index, "clicked card must become selected") require(root.get("selectedWindowIndex") == expected_window_index, "clicked card window index must match") require(root.get("selectedWindowID") == expected_window_id, "selectedWindowID must match clicked card") require(root.get("lastClickedWindowID") == expected_window_id, "lastClickedWindowID must match clicked card") require(root.get("lastCommittedWindowID") == expected_window_id, "clicked card must commit the selected window") require(report.get("lastCommittedWindowID") == expected_window_id, "session report must surface clicked card commit") require(root.get("lastCommitSource") == "mouse", "root commit source must be mouse") require(report.get("lastCommitSource") == "mouse", "session commit source must be mouse") require(report.get("lastActivationWindowID") == expected_window_id, "session activation window must match clicked card") require(report.get("lastActivationResult") == "activated", "debug activation must activate a non-minimized clicked card") require(root.get("waterfallScrollable") is True, "fixture must make Waterfall horizontally scrollable") require(root.get("waterfallScrollOffset", 0) > 0, "clicking far-right card must scroll its Waterfall column into view") selected_cards = [card for column in 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 after click") require(selected_cards[0].get("windowID") == expected_window_id, "selected Waterfall card must match clicked card") require("selected" in selected_cards[0].get("visualStates", []), "clicked card must expose selected visual state") require("hover" in selected_cards[0].get("visualStates", []), "clicked card must keep hover visual state") require(selected_cards[0].get("shineVisible") is True, "clicked selected card must expose shine") require(selected_cards[0].get("zPosition", 0) > 0, "clicked selected card must float above normal cards") selected_items = [item for item in items if item.get("isSelected") is True] hovered_items = [item for item in items if item.get("isHovered") is True] require(len(selected_items) == 1, "App Shelf must expose exactly one selected App") require(selected_items[0].get("index") == expected_app_group_index, "App Shelf selection must follow clicked Waterfall card") require(len(hovered_items) == 1, "App Shelf must expose exactly one hovered App") require(hovered_items[0].get("index") == expected_app_group_index, "hovered window card must link to App Shelf") hovered_segments = [segment for segment in segments if "hover" in segment.get("visualStates", [])] space_focused_segments = [segment for segment in segments if "spaceFocus" in segment.get("visualStates", [])] window_associated_segments = [segment for segment in segments if segment.get("isWindowAssociated") is True] require(root.get("hoverTargetKind") == "window", "hovered Waterfall card must report window hover target") require(root.get("activeSpaceFocusID") is None, "hovered Waterfall card must not create Space Lane focus") require(len(hovered_segments) == 0, "hovered Waterfall card must not mark a Space Lane segment as hovered") require(len(space_focused_segments) == 0, "hovered Waterfall card must not mark a Space Lane segment as focused") require(root.get("hoveredSpaceID") == expected_space_id, "root hoveredSpaceID may still mirror clicked card primarySpaceID for window context") require(root.get("windowHoverAssociatedSpaceID") == expected_space_id, "hovered Waterfall card must expose its Space as windowHoverAssociatedSpaceID") require(len(window_associated_segments) == 1, "hovered Waterfall card must highlight exactly one associated Space segment") require(window_associated_segments[0].get("spaceID") == expected_space_id, "window-associated Space segment must match hovered card Space") require("windowAssociated" in window_associated_segments[0].get("visualStates", []), "window-associated Space segment must expose windowAssociated") require("focused" not in window_associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose focused") frame = selected_cards[0].get("visibleFrame", {}) selected_card_visual_overflow_tolerance = 2.5 require(frame.get("x", -1) >= 0, "clicked Waterfall card must be horizontally visible") require( frame.get("x", 0) + frame.get("width", 0) <= root.get("waterfallVisibleWidth", 0) + selected_card_visual_overflow_tolerance, "clicked card must fit in visible Waterfall width" ) print(json.dumps({ "quickSwitchVisible": report.get("quickSwitchVisible"), "selectedAppGroupIndex": root.get("selectedAppGroupIndex"), "selectedWindowID": root.get("selectedWindowID"), "lastCommitSource": root.get("lastCommitSource"), "lastActivationResult": report.get("lastActivationResult"), "waterfallScrollOffset": root.get("waterfallScrollOffset"), "hoveredSpaceID": root.get("hoveredSpaceID"), "windowHoverAssociatedSpaceID": root.get("windowHoverAssociatedSpaceID"), "hoverTargetKind": root.get("hoverTargetKind"), "activeSpaceFocusID": root.get("activeSpaceFocusID") }, indent=2, ensure_ascii=False)) PY } stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 rm -f "$REPORT" "$APP_ONLY_REPORT" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-waterfall-view-mode=vertical \ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ --round01-disable-screenshot-refresh \ --round01-debug-window-activation \ --round01-debug-mouse-sequence="$APP_ONLY_MOUSE_SEQUENCE" \ --round01-quick-switch-report="$APP_ONLY_REPORT" & APP_PID=$! cleanup() { kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true stop_current_aligner } trap cleanup EXIT wait_for_committed_hidden_report "$APP_ONLY_REPORT" assert_app_only_report wait_for_window_logic "$WINDOW_WAIT" --expect-no-quick-switch >&2 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-open-quick-switch \ --round01-waterfall-view-mode=vertical \ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ --round01-disable-screenshot-refresh \ --round01-debug-window-activation \ --round01-debug-mouse-sequence="$MOUSE_SEQUENCE" \ --round01-quick-switch-report="$REPORT" & APP_PID=$! wait_for_committed_hidden_report wait_for_window_logic "$WINDOW_WAIT" --expect-no-quick-switch >&2 assert_report kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true trap - EXIT stop_current_aligner