#!/bin/bash # Round01 App Shelf fixture QA. It uses a deterministic Quick Switch snapshot # with many apps so compression, two-row layout, scrolling, and hover visuals # are covered without relying on the current desktop state. 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-app-shelf-fixture-report.json" NARROW_REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-narrow-fixture-report.json" FIXTURE_APP_COUNT="${ALIGNER_ROUND1_APP_SHELF_FIXTURE_COUNT:-60}" HOVER_INDEX="${ALIGNER_ROUND1_APP_SHELF_HOVER_INDEX:-5}" NARROW_FIXTURE_APP_COUNT="${ALIGNER_ROUND1_APP_SHELF_NARROW_FIXTURE_COUNT:-10}" NARROW_HOVER_INDEX="${ALIGNER_ROUND1_APP_SHELF_NARROW_HOVER_INDEX:-5}" NARROW_OVERLAY_WIDTH="${ALIGNER_ROUND1_APP_SHELF_NARROW_OVERLAY_WIDTH:-780}" REPORT_WAIT="${ALIGNER_ROUND1_APP_SHELF_REPORT_WAIT:-6.0}" fail() { echo "Round01 App Shelf 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() { 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: 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"snapshot report did not become loaded within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_report() { local report="$1" local fixture_app_count="$2" local hover_index="$3" local expected_rows="$4" local expected_icon_size="$5" local expected_scrollable="$6" /usr/bin/python3 - "$report" "$fixture_app_count" "$hover_index" "$expected_rows" "$expected_icon_size" "$expected_scrollable" <<'PY' import json import sys path = sys.argv[1] fixture_app_count = int(sys.argv[2]) hover_index = int(sys.argv[3]) expected_rows = int(sys.argv[4]) expected_icon_size = int(sys.argv[5]) expected_scrollable = sys.argv[6].lower() == "true" 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", []) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count") require(root.get("appShelfItemCount") == fixture_app_count, "App Shelf item count must match fixture count") require(len(items) == fixture_app_count, "App Shelf item reports must match fixture count") require(root.get("appShelfRows") == expected_rows, "fixture App Shelf row count must match expected rows") require(root.get("appShelfMaxRows") == 2, "App Shelf max rows must be two") require(root.get("appShelfIconSize") == expected_icon_size, "fixture App Shelf icon size must match expected size") require(root.get("appShelfMinIconSize") == 36, "App Shelf min icon size must be 36pt") require(root.get("appShelfSelectedIconSize") == 68, "App Shelf selected icon size must be 68pt") require(root.get("appShelfHoverIconSize") == 68, "App Shelf hover icon size must be 68pt") require(root.get("appShelfScrollable") is expected_scrollable, "fixture App Shelf scrollable state must match expected") if expected_scrollable: require(root.get("appShelfContentWidth", 0) > root.get("appShelfVisibleWidth", 0), "scrollable fixture App Shelf content must overflow visible width") require(root.get("appShelfMaxScrollOffset", 0) > 0, "scrollable fixture App Shelf must expose positive max scroll offset") else: require(root.get("appShelfContentWidth", 0) <= root.get("appShelfVisibleWidth", 0), "non-scrollable fixture App Shelf content must fit visible width") require(root.get("appShelfMaxScrollOffset", 0) == 0, "non-scrollable fixture App Shelf must expose zero max scroll offset") require(root.get("appShelfHoveredIndex") == hover_index, "fixture App Shelf must expose requested hovered index") require(sum(1 for item in items if item.get("isSelected") is True) == 1, "App Shelf must expose one selected app") require(sum(1 for item in items if item.get("isHovered") is True) == 1, "App Shelf must expose one hovered app") require(items[0].get("isSelected") is True, "fixture first app should be selected") require(items[hover_index].get("isHovered") is True, "fixture requested app should be hovered") require("selected" in items[0].get("visualStates", []), "selected app must expose selected visual state") require("hover" in items[hover_index].get("visualStates", []), "hovered app must expose hover visual state") require(items[0].get("iconFrame", {}).get("width") == 68, "selected app icon must be 68pt") require(items[hover_index].get("iconFrame", {}).get("width") == 68, "hovered app icon must be 68pt") normal_items = [ item for item in items if item.get("isSelected") is not True and item.get("isHovered") is not True ] require(normal_items, "fixture must include normal app items") require(all(item.get("iconFrame", {}).get("width") == expected_icon_size for item in normal_items), "normal app icons must match expected fixture icon size") require(all(item.get("labelTruncationMode") == "middle" for item in items), "all App Shelf labels must use middle truncation") row_y_values = {round(item.get("frame", {}).get("y", 0), 2) for item in items} require(len(row_y_values) == expected_rows, "fixture App Shelf visible rows must match expected rows") require([item.get("name") for item in items] == root.get("appShelfNames", []), "App Shelf item reports must follow App Shelf order") selected_z = items[0].get("zPosition", 0) hover_z = items[hover_index].get("zPosition", 0) normal_z = normal_items[0].get("zPosition", 0) require(selected_z > hover_z, "selected App Shelf item must stay above non-selected hover item") require(hover_z > normal_z, "hover App Shelf item must stay above normal items") print(json.dumps({ "appCount": report.get("appCount"), "rows": root.get("appShelfRows"), "iconSize": root.get("appShelfIconSize"), "selectedIcon": items[0].get("iconFrame"), "hoveredIndex": root.get("appShelfHoveredIndex"), "hoveredIcon": items[hover_index].get("iconFrame"), "selectedZ": selected_z, "hoverZ": hover_z, "scrollable": root.get("appShelfScrollable"), "maxScrollOffset": root.get("appShelfMaxScrollOffset") }, indent=2, ensure_ascii=False)) PY } run_fixture() { local report="$1" local app_count="$2" local hover_index="$3" local overlay_width="$4" local expected_rows="$5" local expected_icon_size="$6" local expected_scrollable="$7" stop_current_aligner rm -f "$report" local args=( --round0-skip-permissions --round01-open-quick-switch --round01-fixture-app-count="$app_count" --round01-disable-screenshot-refresh --round01-debug-hover-app-index="$hover_index" --round01-quick-switch-report="$report" ) if [ -n "$overlay_width" ]; then args+=(--round01-debug-overlay-width="$overlay_width") fi "$APP/Contents/MacOS/Aligner" "${args[@]}" & 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 "$report" swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2 assert_report "$report" "$app_count" "$hover_index" "$expected_rows" "$expected_icon_size" "$expected_scrollable" 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 } stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 run_fixture "$REPORT" "$FIXTURE_APP_COUNT" "$HOVER_INDEX" "" 2 36 true run_fixture "$NARROW_REPORT" "$NARROW_FIXTURE_APP_COUNT" "$NARROW_HOVER_INDEX" "$NARROW_OVERLAY_WIDTH" 2 56 false