#!/bin/bash # Round01 three-zone linkage fixture QA. It verifies Space Lane focus drives # App Shelf highlights/badges and Waterfall positioning. 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_DIR="$BUILD_REPORT_ROOT" FIXTURE_APP_COUNT="${ALIGNER_ROUND1_LINKAGE_FIXTURE_APP_COUNT:-8}" FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_LINKAGE_FIXTURE_WINDOWS_PER_APP:-8}" REPORT_WAIT="${ALIGNER_ROUND1_LINKAGE_REPORT_WAIT:-6.0}" OVERLAY_WIDTH="${ALIGNER_ROUND1_LINKAGE_OVERLAY_WIDTH:-1180}" LOCK_DIR="$BUILD_LOCK_ROOT/round1-three-zone-linkage-fixture-qa.lock" APP_PID="" LOCK_HELD=0 fail() { echo "Round01 three-zone linkage fixture QA failed: $*" >&2 exit 1 } REMOTE_APP_INDEX=$((FIXTURE_APP_COUNT - 1)) if [ "$FIXTURE_APP_COUNT" -lt 8 ]; then fail "fixture app count must be at least 8 to verify right-side remote App linkage" fi if [ "$FIXTURE_WINDOWS_PER_APP" -lt 8 ]; then fail "fixture windows per app must be at least 8 so every fixture App has a Space A1 window" fi acquire_lock() { if ! mkdir "$LOCK_DIR" 2>/dev/null; then fail "another round1-three-zone-linkage-fixture-qa.sh appears to be running; run packaging QA scripts serially" fi LOCK_HELD=1 } release_lock() { if [ "$LOCK_HELD" -eq 1 ]; then rmdir "$LOCK_DIR" 2>/dev/null || true LOCK_HELD=0 fi } aligner_pids_for_current_app() { ps -axo pid=,args= | while read -r pid command; do case "$command" in "$APP/Contents/MacOS/Aligner"*) echo "$pid" ;; esac done } stop_current_aligner() { for pid in $(aligner_pids_for_current_app); do kill "$pid" 2>/dev/null || true done for _ in {1..30}; do [ -z "$(aligner_pids_for_current_app)" ] && return sleep 0.1 done fail "current Aligner app did not exit before QA" } cleanup() { if [ -n "${APP_PID:-}" ]; then kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true APP_PID="" fi stop_current_aligner release_lock } wait_for_report() { local report="$1" local expected_last_mouse="$2" /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_last_mouse" <<'PY' import json import sys import time path = sys.argv[1] timeout = float(sys.argv[2]) expected_last_mouse = sys.argv[3] 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 root.get("lastMouseCommand") == expected_last_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"report did not reach expected mouse command {expected_last_mouse!r} within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } run_report() { local report="$1" local sequence="$2" local expected_last_mouse="$3" local app_count="${4:-$FIXTURE_APP_COUNT}" local windows_per_app="${5:-$FIXTURE_WINDOWS_PER_APP}" local quick_switch_expectation="${6:-visible}" rm -f "$report" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-app-count="$app_count" \ --round01-fixture-windows-per-app="$windows_per_app" \ --round01-debug-overlay-width="$OVERLAY_WIDTH" \ --round01-disable-screenshot-refresh \ --round01-debug-window-activation \ --round01-debug-mouse-sequence="$sequence" \ --round01-quick-switch-report="$report" & APP_PID=$! wait_for_report "$report" "$expected_last_mouse" if [ "$quick_switch_expectation" = "hidden" ]; then swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2 else swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2 fi kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true APP_PID="" sleep 0.3 stop_current_aligner } assert_focus_report() { /usr/bin/python3 - "$1" "$FIXTURE_APP_COUNT" <<'PY' import json import sys path = sys.argv[1] fixture_app_count = 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) root = report.get("rootView", {}) items = root.get("appShelfItems", []) columns = root.get("waterfallColumns", []) segments = root.get("spaceLaneSegments", []) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(root.get("activeSpaceFocusID") == 1, "Space Lane hover must set activeSpaceFocusID") require(root.get("spaceLaneHoveredSpaceID") == 1, "hover-space must expose hovered Space") require(root.get("spaceLaneFocusedSpaceID") is None, "hover-space must not persist clicked Space focus") require(root.get("lastSpaceLaneClickSpaceID") is None, "hover-space must not record a Space click") require(root.get("lastSpaceFocusedAppGroupIndex") == 0, "first related App column must be appGroupIndex 0") require(root.get("spaceFocusAppGroupIndexes") == list(range(fixture_app_count)), "all fixture apps must be marked related to focused Space") require(root.get("waterfallScrollOffset") == 0, "first related App column should remain visible at offset zero") focused_segments = [segment for segment in segments if segment.get("isFocused") is True] require(len(focused_segments) == 1, "exactly one Space Lane segment must be focused") require(focused_segments[0].get("label") == "01", "focused Space should be 01") require("focused" in focused_segments[0].get("visualStates", []), "focused Space segment must expose focused visual state") require(len(items) == fixture_app_count, "App Shelf fixture item count must match app count") require(all(item.get("isSpaceFocused") is True for item in items), "all fixture apps must be highlighted for Space A1") require(all("spaceFocused" in item.get("visualStates", []) for item in items), "App Shelf items must expose spaceFocused visual state") require(all(item.get("spaceFocusWindowCount", 0) > 0 for item in items), "focused Space badge count must be positive for every fixture app") require(all(item.get("badgeVisible") is True for item in items), "focused Space badges must be visible") require(items[0].get("spaceFocusDirection") == "visible", "first related App must be visible") require(items[-1].get("spaceFocusDirection") == "right", "last fixture App must be reported to the right before remote click") require(str(items[-1].get("badgeText", "")).endswith(">"), "right-side remote App badge must include right direction marker") require(len(columns) == fixture_app_count, "Waterfall column reports must match app count") require(columns[0].get("isSpaceFocused") is True, "first Waterfall column must be marked focused for Space") require(columns[0].get("spaceFocusDirection") == "visible", "first Waterfall column must be visible") require(columns[-1].get("isSpaceFocused") is True, "last Waterfall column must be marked focused for Space") require(columns[-1].get("spaceFocusDirection") == "right", "last Waterfall column must report right direction before remote click") print(json.dumps({ "activeSpaceFocusID": root.get("activeSpaceFocusID"), "focusedApps": root.get("spaceFocusAppGroupIndexes"), "lastRemoteDirection": items[-1].get("spaceFocusDirection"), "lastRemoteBadgeText": items[-1].get("badgeText") }, indent=2, ensure_ascii=False)) PY } assert_remote_click_report() { /usr/bin/python3 - "$1" "$FIXTURE_APP_COUNT" <<'PY' import json import sys path = sys.argv[1] fixture_app_count = 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) root = report.get("rootView", {}) items = root.get("appShelfItems", []) columns = root.get("waterfallColumns", []) last_index = fixture_app_count - 1 require(report.get("quickSwitchVisible") is False, "remote App click must close Quick Switch after committing its first window") require(root.get("activeSpaceFocusID") == 1, "Space hover preview should still be reflected in the pre-commit report") require(root.get("lastClickedAppGroupIndex") == last_index, "remote App click must be recorded") require(root.get("lastAppShelfClickChangedSelection") is True, "App Shelf click must move keyboard selection to the remote App column") require(root.get("selectedAppGroupIndex") == last_index, "remote App click must focus the remote App column") expected_window_id = columns[last_index].get("cards", [])[0].get("windowID") require(root.get("lastCommittedWindowID") == expected_window_id, "App Shelf click must commit the first remote window") require(report.get("lastCommittedWindowID") == expected_window_id, "session must surface the remote App commit") require(report.get("lastActivationWindowID") == expected_window_id, "activation window must be the first remote window") require(report.get("lastActivationResult") == "activated", "debug activation must activate the first remote window") require(report.get("lastCommitSource") == "mouse", "remote App commit source must be mouse") require(root.get("waterfallScrollOffset", 0) > 0, "remote App click must scroll Waterfall horizontally") require(items[last_index].get("spaceFocusDirection") == "visible", "remote App must become visible after click") require(columns[last_index].get("spaceFocusDirection") == "visible", "remote Waterfall column must become visible after click") require(items[last_index].get("badgeVisible") is True, "focused Space badge must remain visible after remote click") print(json.dumps({ "activeSpaceFocusID": root.get("activeSpaceFocusID"), "lastClickedAppGroupIndex": root.get("lastClickedAppGroupIndex"), "lastCommittedWindowID": root.get("lastCommittedWindowID"), "lastActivationResult": report.get("lastActivationResult"), "waterfallScrollOffset": root.get("waterfallScrollOffset"), "remoteDirectionAfterClick": items[last_index].get("spaceFocusDirection") }, indent=2, ensure_ascii=False)) PY } assert_space_click_report() { /usr/bin/python3 - "$1" <<'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) root = report.get("rootView", {}) columns = sorted(root.get("waterfallColumns", []), key=lambda column: column.get("appGroupIndex", 0)) target_space_id = 1 first_space_card = None for column in columns: cards = sorted(column.get("cards", []), key=lambda card: card.get("windowIndex", 0)) first_space_card = next((card for card in cards if card.get("primarySpaceID") == target_space_id), None) if first_space_card is not None: break require(first_space_card is not None, "fixture must include at least one window in Space A1") expected_window_id = first_space_card.get("windowID") require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is False, "click-space must close Quick Switch after committing a window in that Space") require(root.get("lastSpaceLaneClickSpaceID") == target_space_id, "click-space must record the clicked Space") require(root.get("spaceLaneFocusedSpaceID") is None, "click-space must not reuse persistent focus as its behavior") require(root.get("lastCommittedWindowID") == expected_window_id, "click-space must commit the first window in the clicked Space") require(report.get("lastCommittedWindowID") == expected_window_id, "session must surface the Space click commit") require(report.get("lastActivationWindowID") == expected_window_id, "activation window must match the first window in clicked Space") require(report.get("lastActivationResult") == "activated", "debug activation must activate the clicked Space window") require(report.get("lastCommitSource") == "mouse", "click-space commit source must be mouse") print(json.dumps({ "clickedSpaceID": root.get("lastSpaceLaneClickSpaceID"), "lastCommittedWindowID": root.get("lastCommittedWindowID"), "lastActivationResult": report.get("lastActivationResult") }, indent=2, ensure_ascii=False)) PY } assert_app_shelf_scroll_report() { /usr/bin/python3 - "$1" "$1" <<'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) root = report.get("rootView", {}) items = root.get("appShelfItems", []) columns = root.get("waterfallColumns", []) synced = root.get("lastAppShelfScrollSyncedAppGroupIndex") require(root.get("appShelfScrollable") is True, "App Shelf must be scrollable in the scroll linkage fixture") require(root.get("appShelfScrollOffset", 0) > 0, "debug App Shelf scroll must move App Shelf") require(isinstance(synced, int), "App Shelf scroll must record the synced App group") require(root.get("waterfallScrollOffset", 0) > 0, "App Shelf scroll must move Waterfall to the synced App column") require(0 <= synced < len(items), "synced App group must point at a real App Shelf item") require(columns[synced].get("spaceFocusDirection") == "none", "App Shelf scroll QA should not depend on Space focus state") visible = columns[synced].get("visibleFrame", {}) require(visible.get("x", -10_000) < root.get("waterfallVisibleWidth", 0), "synced Waterfall column must enter the visible viewport") require(visible.get("x", 10_000) + visible.get("width", 0) > 0, "synced Waterfall column must not remain fully offscreen") print(json.dumps({ "appShelfScrollOffset": root.get("appShelfScrollOffset"), "syncedAppGroupIndex": synced, "waterfallScrollOffset": root.get("waterfallScrollOffset") }, indent=2, ensure_ascii=False)) PY } trap cleanup EXIT acquire_lock stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 FOCUS_REPORT="$REPORT_DIR/round01-three-zone-linkage-focus-report.json" SPACE_CLICK_REPORT="$REPORT_DIR/round01-three-zone-linkage-space-click-report.json" REMOTE_REPORT="$REPORT_DIR/round01-three-zone-linkage-remote-report.json" SCROLL_REPORT="$REPORT_DIR/round01-three-zone-linkage-scroll-report.json" run_report "$FOCUS_REPORT" "hover-space:1" "hover-space:1" assert_focus_report "$FOCUS_REPORT" run_report "$SPACE_CLICK_REPORT" "click-space:1" "click-space:1" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" hidden assert_space_click_report "$SPACE_CLICK_REPORT" run_report "$REMOTE_REPORT" "hover-space:1,click-app:$REMOTE_APP_INDEX" "click-app:$REMOTE_APP_INDEX" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" hidden assert_remote_click_report "$REMOTE_REPORT" run_report "$SCROLL_REPORT" "scroll-app-shelf:99999" "scroll-app-shelf:99999" 60 1 assert_app_shelf_scroll_report "$SCROLL_REPORT" swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2 stop_current_aligner release_lock trap - EXIT