#!/bin/bash # Round01 vertical keyboard App focus fixture QA. It verifies that App Shelf # index keys and left/right arrows behave like App icon hover in vertical # Waterfall, while up/down move only inside the focused column and report # boundary bounce without wrapping. 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_VERTICAL_KEYBOARD_APP_COUNT:-12}" FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_WINDOWS_PER_APP:-8}" REPORT_WAIT="${ALIGNER_ROUND1_VERTICAL_KEYBOARD_REPORT_WAIT:-6.0}" APP_PID="" fail() { echo "Round01 vertical keyboard App focus fixture QA failed: $*" >&2 exit 1 } aligner_pids_for_current_app() { ps -axo pid=,args= | while read -r pid command; do if [[ "$command" == "$APP/Contents/MacOS/Aligner"* ]] \ || [[ "$command" == "/Applications/Aligner.app/Contents/MacOS/Aligner"* ]] \ || [[ "$command" == "$HOME/Applications/Aligner.app/Contents/MacOS/Aligner"* ]]; then echo "$pid" fi 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 } normalize_keyboard_sequence() { local sequence="$1" local symbols="1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" local normalized="" local IFS=, for raw_part in $sequence; do local upper upper="$(printf "%s" "$raw_part" | tr '[:lower:]' '[:upper:]')" local part="$raw_part" if [ "${#upper}" -eq 1 ] && [[ "$symbols" == *"$upper"* ]]; then part="app:$upper" fi if [ -n "$normalized" ]; then normalized="$normalized,$part" else normalized="$part" fi done echo "$normalized" } wait_for_report() { local report="$1" local expected_sequence="$2" /usr/bin/python3 - "$report" "$REPORT_WAIT" "$expected_sequence" <<'PY' import json import sys import time path = sys.argv[1] timeout = float(sys.argv[2]) expected_sequence = [part for part in sys.argv[3].split(",") if part] 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 True and root.get("keyboardCommandsApplied") == expected_sequence ): 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"vertical keyboard report did not reach expected commands within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_report() { local report="$1" local expected_sequence="$2" local expected_app_index="$3" local expected_window_index="$4" local expected_boundary_count="$5" local expected_boundary_direction="$6" local expect_selection_changed="$7" /usr/bin/python3 - \ "$report" \ "$expected_sequence" \ "$expected_app_index" \ "$expected_window_index" \ "$expected_boundary_count" \ "$expected_boundary_direction" \ "$expect_selection_changed" \ "$FIXTURE_APP_COUNT" \ "$FIXTURE_WINDOWS_PER_APP" <<'PY' import json import sys path = sys.argv[1] expected_sequence = [part for part in sys.argv[2].split(",") if part] expected_app_index = int(sys.argv[3]) expected_window_index = int(sys.argv[4]) expected_boundary_count = int(sys.argv[5]) expected_boundary_direction = None if sys.argv[6] == "none" else sys.argv[6] expect_selection_changed = sys.argv[7] == "true" fixture_app_count = int(sys.argv[8]) fixture_windows_per_app = int(sys.argv[9]) 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", []) target_item = next((item for item in items if item.get("index") == expected_app_index), None) target_column = next((column for column in columns if column.get("appGroupIndex") == expected_app_index), None) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("quickSwitchVisible") is True, "fixture must keep Quick Switch visible") require(report.get("appCount") == fixture_app_count, "fixture appCount must match") require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match") require(root.get("waterfallViewMode") == "verticalColumns", "fixture must run in vertical Waterfall") require(root.get("keyboardCommandsApplied") == expected_sequence, "keyboard commands must match normalized sequence") require(root.get("keyboardFocusedAppGroupIndex") == expected_app_index, "keyboard focus must land on expected App") require(root.get("hoveredAppGroupIndex") == expected_app_index, "keyboard focus must expose hovered App") require(root.get("hoverTargetKind") == "app", "keyboard focus must use App hover target") require(root.get("hoverTargetSource") == "keyboard", "keyboard focus must report keyboard source") require(root.get("lastCommittedWindowID") is None, "no-Enter keyboard focus runs must not commit") require(root.get("lastCommitSource") is None, "no-Enter keyboard focus runs must not report commit source") require(target_item is not None, "target App Shelf item must exist") require(target_column is not None, "target Waterfall column must exist") require(target_item.get("isHovered") is True, "target App Shelf item must expose hover") require("hover" in target_item.get("visualStates", []), "target App Shelf item must expose hover visual") require(target_column.get("isHovered") is True, "target Waterfall column must expose hover") require("hover" in target_column.get("visualStates", []), "target Waterfall column must expose hover visual") require(root.get("selectedAppGroupIndex") == expected_app_index, "selected App must match expected App after column entry") require(root.get("selectedWindowIndex") == expected_window_index, "selected window index must match expected column movement") require(root.get("selectionChangedByLastCommand") is expect_selection_changed, "selectionChangedByLastCommand must match final command") require(root.get("boundaryBounceCount") == expected_boundary_count, "boundary bounce count must match") if expected_boundary_direction is None: require(root.get("lastBoundaryBounceAxis") is None, "no-boundary run must not report boundary axis") require(root.get("lastBoundaryBounceDirection") is None, "no-boundary run must not report boundary direction") else: require(root.get("lastBoundaryBounceAxis") == "vertical", "boundary run must report vertical axis") require(root.get("lastBoundaryBounceDirection") == expected_boundary_direction, "boundary direction must match") print(json.dumps({ "commands": root.get("keyboardCommandsApplied"), "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"), "selectedWindowIndex": root.get("selectedWindowIndex"), "boundaryBounceCount": root.get("boundaryBounceCount"), "lastBoundaryBounceDirection": root.get("lastBoundaryBounceDirection") }, indent=2, ensure_ascii=False)) PY } run_case() { local name="$1" local sequence="$2" local expected_app_index="$3" local expected_window_index="$4" local expected_boundary_count="$5" local expected_boundary_direction="$6" local expect_selection_changed="$7" local report="$REPORT_DIR/round01-vertical-keyboard-$name-report.json" local expected_sequence expected_sequence="$(normalize_keyboard_sequence "$sequence")" stop_current_aligner 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-key-sequence="$sequence" \ --round01-quick-switch-report="$report" & APP_PID=$! wait_for_report "$report" "$expected_sequence" swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2 assert_report \ "$report" \ "$expected_sequence" \ "$expected_app_index" \ "$expected_window_index" \ "$expected_boundary_count" \ "$expected_boundary_direction" \ "$expect_selection_changed" kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true APP_PID="" sleep 0.3 swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2 } if [ "$FIXTURE_APP_COUNT" -lt 12 ]; then fail "fixture app count must be at least 12" fi if [ "$FIXTURE_WINDOWS_PER_APP" -lt 8 ]; then fail "fixture windows per app must be at least 8" fi trap cleanup EXIT stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 run_case "wrap" "1,left,right" 0 0 0 "none" "false" run_case "bottom-boundary" "3,down,down,down,down,down,down,down,down,down" 2 7 1 "down" "false" run_case "top-boundary" "4,down,up" 3 0 1 "up" "false" trap - EXIT cleanup