#!/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:<appGroupIndex>")
|
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:<appGroupIndex>")
|
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:<appGroupIndex>:<windowIndex>")
|
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
|