#!/bin/bash # Round01 keyboard navigation fixture QA. It drives deterministic key commands # through the Quick Switch debug hook and verifies vertical App-index focus, # column-local movement, visible scrolling, Tab ignore, Enter activation reporting, # and clean 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-keyboard-navigation-fixture-report.json" FIXTURE_APP_COUNT="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_APP_COUNT:-12}" FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_WINDOWS_PER_APP:-8}" KEY_SEQUENCE="${ALIGNER_ROUND1_KEYBOARD_SEQUENCE:-A,down,down,down,down,down,down,down,down,tab,enter}" EXPECTED_APP_INDEX="${ALIGNER_ROUND1_KEYBOARD_EXPECTED_APP_INDEX:-10}" EXPECTED_WINDOW_INDEX="${ALIGNER_ROUND1_KEYBOARD_EXPECTED_WINDOW_INDEX:-$((FIXTURE_WINDOWS_PER_APP - 1))}" REPORT_WAIT="${ALIGNER_ROUND1_KEYBOARD_REPORT_WAIT:-6.0}" fail() { echo "Round01 keyboard navigation 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_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 root = report.get("rootView", {}) if ( report.get("snapshotLoaded") is True and report.get("quickSwitchVisible") is False and report.get("lastActivationResult") is not None and root.get("lastCommittedWindowID") 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"keyboard navigation report did not become committed within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_report() { /usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$KEY_SEQUENCE" "$EXPECTED_APP_INDEX" "$EXPECTED_WINDOW_INDEX" <<'PY' import json import sys path = sys.argv[1] fixture_app_count = int(sys.argv[2]) fixture_windows_per_app = int(sys.argv[3]) key_sequence = [part for part in sys.argv[4].split(",") if part] expected_selected_app_group_index = int(sys.argv[5]) expected_selected_window_index = int(sys.argv[6]) 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", []) index_symbols = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" def normalized_key_command(command): upper = command.upper() if len(upper) == 1 and upper in index_symbols: return f"app:{upper}" if command == "tab": return "tabIgnored" return command require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is False, "Enter activation 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") selected_column = columns[expected_selected_app_group_index] selected_card = selected_column.get("cards", [])[expected_selected_window_index] expected_selected_window_id = selected_card.get("windowID") require(root.get("selectedAppGroupIndex") == expected_selected_app_group_index, "index-key App focus plus down-arrow navigation must select the expected App column") require(root.get("selectedWindowIndex") == expected_selected_window_index, "down-arrow navigation must move within the focused App column") require(root.get("selectedWindowID") == expected_selected_window_id, "selectedWindowID must match selected card") require(root.get("keyboardFocusedAppGroupIndex") == expected_selected_app_group_index, "keyboard App focus must stay on the target App") require(root.get("hoveredAppGroupIndex") == expected_selected_app_group_index, "keyboard App focus must expose hoveredAppGroupIndex") require(root.get("hoverTargetKind") == "app", "keyboard App focus must use an App hover target") require(root.get("hoverTargetSource") == "keyboard", "keyboard App focus must report keyboard source") expected_app_index_command = next((normalized_key_command(command) for command in key_sequence if normalized_key_command(command).startswith("app:")), None) require(root.get("lastKeyboardAppIndexCommand") == expected_app_index_command, "App index command must be reported") require(report.get("lastCommittedWindowID") == expected_selected_window_id, "Enter commit must be surfaced at session level") require(report.get("lastCommitSource") == "keyboard", "Enter commit source must be keyboard") require(report.get("lastActivationWindowID") == expected_selected_window_id, "Enter activation window must match selected card") require(report.get("lastActivationResult") == "activated", "debug activation must activate a non-minimized selected window") require(root.get("lastCommittedWindowID") == expected_selected_window_id, "Enter commit must be surfaced at root view level") require(root.get("lastCommittedAppGroupIndex") == expected_selected_app_group_index, "Enter commit app group must match selected App") require(root.get("lastCommittedWindowIndex") == expected_selected_window_index, "Enter commit window index must match selected card") commands = root.get("keyboardCommandsApplied", []) require(commands == [normalized_key_command(command) for command in key_sequence], "debug keyboard sequence must be applied in order with App index and Tab normalized") require(root.get("tabIgnoredCount") == 1, "Tab must be ignored exactly once") require(root.get("lastKeyboardCommand") == "enter", "Enter must be the final keyboard command") require(root.get("boundaryBounceCount") == 0, "in-range down-arrow movement must not report a boundary bounce") 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 keyboard navigation") require(selected_cards[0].get("windowID") == expected_selected_window_id, "selected Waterfall card must match selectedWindowID") require(selected_cards[0].get("isKeyboardFocused") is True, "keyboard-selected Waterfall card must expose keyboard focus") require("selected" in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must expose selected visual state") require("keyboardFocused" in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must expose keyboardFocused visual state") require("selectedVisualSuppressed" not in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must not suppress selected visual state") require(selected_cards[0].get("shineVisible") is True, "keyboard-selected Waterfall card must expose shine") require(selected_cards[0].get("zPosition", 0) > 0, "keyboard-selected Waterfall card must float above normal cards") require("appLinked" in selected_column.get("cards", [])[0].get("visualStates", []), "App-focused vertical column must keep its first card linked to App hover") selected_items = [item for item in items if item.get("isSelected") is True] require(len(selected_items) == 1, "App Shelf must expose exactly one selected App") require(selected_items[0].get("index") == expected_selected_app_group_index, "App Shelf selection must follow keyboard-selected Waterfall column") require(selected_items[0].get("isHovered") is True, "App Shelf selected App must also expose keyboard hover") require("selected" in selected_items[0].get("visualStates", []), "keyboard-focused selected App Shelf item must expose selected visual") require("hover" in selected_items[0].get("visualStates", []), "keyboard-focused selected App Shelf item must expose hover visual") history = { entry.get("appGroupIndex"): entry.get("windowIndex") for entry in root.get("columnSelectionHistory", []) } require(history.get(expected_selected_app_group_index) == expected_selected_window_index, "final column history must remember selected window") require(root.get("waterfallScrollable") is True, "fixture must make Waterfall horizontally scrollable") require(root.get("waterfallMaxScrollOffset", 0) > 0, "Waterfall must expose horizontal overflow") require(root.get("waterfallScrollOffset", 0) > 0, "selected far-right App column must auto-scroll into view") require(selected_column.get("maxVerticalScrollOffset", 0) > 0, "selected fixture column must expose vertical overflow") require(selected_column.get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view") frame = selected_cards[0].get("visibleFrame", {}) column_frame = selected_column.get("visibleFrame", {}) header_frame = selected_column.get("headerFrame", {}) cards_clip_frame = selected_column.get("cardsClipFrame", {}) visibility_tolerance = 1 require(frame.get("x", -1) >= 0, "selected Waterfall card must be horizontally visible") require(frame.get("x", 0) + frame.get("width", 0) <= root.get("waterfallVisibleWidth", 0) + 1, "selected Waterfall card must fit in visible Waterfall width") require(frame.get("y", -1) >= -visibility_tolerance, "selected Waterfall card must be vertically visible") require(frame.get("y", 0) + frame.get("height", 0) <= root.get("waterfallFrame", {}).get("height", 0) + 1, "selected Waterfall card must fit in visible Waterfall height") require(selected_column.get("cardsClipMasksToBounds") is True, "vertical Waterfall column must clip cards below the pinned header") require(cards_clip_frame.get("y", -1) == 0, "cards clip layer must start below the column bottom edge") require(cards_clip_frame.get("height", 0) <= header_frame.get("y", 0) + 1, "cards clip layer must stop before the pinned header") header_min_y = column_frame.get("y", 0) + header_frame.get("y", 0) require(frame.get("y", 0) + frame.get("height", 0) <= header_min_y + 1, "selected Waterfall card must not overlap the pinned column header") print(json.dumps({ "selectedAppGroupIndex": root.get("selectedAppGroupIndex"), "selectedWindowIndex": root.get("selectedWindowIndex"), "selectedWindowID": root.get("selectedWindowID"), "lastCommittedWindowID": root.get("lastCommittedWindowID"), "lastActivationResult": report.get("lastActivationResult"), "quickSwitchVisible": report.get("quickSwitchVisible"), "waterfallScrollOffset": root.get("waterfallScrollOffset"), "selectedColumnVerticalScrollOffset": selected_column.get("verticalScrollOffset"), "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"), "tabIgnoredCount": root.get("tabIgnoredCount") }, indent=2, ensure_ascii=False)) 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-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-key-sequence="$KEY_SEQUENCE" \ --round01-quick-switch-report="$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_loaded_report swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2 assert_report kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true sleep 0.5 swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2 trap - EXIT stop_current_aligner