#!/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(selected_cards[0].get("titleBarBackgroundColor", {}).get("alpha", 0) > 0.10, "keyboard-selected Waterfall card title bar must expose blue fill")
|
first_selected_column_card = selected_column.get("cards", [])[0]
|
if first_selected_column_card.get("windowID") != expected_selected_window_id:
|
require("appLinked" not in first_selected_column_card.get("visualStates", []), "App-linked first card must yield to keyboard-focused window")
|
require(first_selected_column_card.get("titleBarBackgroundColor", {}).get("alpha", 0) <= 0.01, "first card title bar must clear App hover fill while another window has keyboard focus")
|
|
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
|