#!/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}"
|
|
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-mouse-sequence="$sequence" \
|
--round01-quick-switch-report="$report" &
|
|
APP_PID=$!
|
|
wait_for_report "$report" "$expected_last_mouse"
|
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
|
|
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 focus must set activeSpaceFocusID")
|
require(root.get("spaceLaneFocusedSpaceID") == 1, "click-space must persist focused Space")
|
require(root.get("lastSpaceLaneClickSpaceID") == 1, "last clicked Space must be recorded")
|
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") == "A1", "focused Space should be A1")
|
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(root.get("activeSpaceFocusID") == 1, "Space focus must persist after clicking App Shelf item")
|
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")
|
require(root.get("lastCommittedWindowID") is None, "App Shelf click must not commit a window")
|
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"),
|
"waterfallScrollOffset": root.get("waterfallScrollOffset"),
|
"remoteDirectionAfterClick": items[last_index].get("spaceFocusDirection")
|
}, 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"
|
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" "click-space:1" "click-space:1"
|
assert_focus_report "$FOCUS_REPORT"
|
|
run_report "$REMOTE_REPORT" "click-space:1,click-app:$REMOTE_APP_INDEX" "click-app:$REMOTE_APP_INDEX"
|
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
|