#!/bin/bash
|
# Round01 Waterfall fixture QA. It verifies that Waterfall renders one column
|
# per App in App Shelf order and exposes vertical overflow for long columns.
|
|
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-waterfall-fixture-report.json"
|
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_WATERFALL_FIXTURE_APP_COUNT:-8}"
|
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_WATERFALL_FIXTURE_WINDOWS_PER_APP:-8}"
|
REPORT_WAIT="${ALIGNER_ROUND1_WATERFALL_REPORT_WAIT:-6.0}"
|
|
fail() {
|
echo "Round01 Waterfall 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
|
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() {
|
/usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" <<'PY'
|
import json
|
import re
|
import sys
|
|
path = sys.argv[1]
|
fixture_app_count = int(sys.argv[2])
|
fixture_windows_per_app = int(sys.argv[3])
|
|
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)
|
|
def space_rank(label):
|
if label is None:
|
return 999
|
match = re.match(r"^[A-Z]+(\\d+)$", label)
|
if not match:
|
return 999
|
return int(match.group(1))
|
|
root = report.get("rootView", {})
|
columns = root.get("waterfallColumns", [])
|
app_names = root.get("appShelfNames", [])
|
|
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
|
require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count")
|
require(report.get("columnCount") == fixture_app_count, "Waterfall columnCount must match app 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 root column count must match app count")
|
require(root.get("waterfallColumnNames") == app_names, "Waterfall column order must exactly match App Shelf order")
|
require(len(columns) == fixture_app_count, "Waterfall column reports must match app count")
|
require(root.get("waterfallClipsToBounds") is True, "Waterfall viewport must clip overflowing columns")
|
require(root.get("waterfallScrollable") is True, "8-column fixture must make Waterfall horizontally scrollable")
|
require(root.get("waterfallContentWidth", 0) > root.get("waterfallVisibleWidth", 0), "Waterfall content must overflow visible width")
|
require(root.get("waterfallMaxScrollOffset", 0) > 0, "Waterfall must expose positive horizontal max scroll offset")
|
|
global_indexes = []
|
for expected_index, column in enumerate(columns):
|
require(column.get("appGroupIndex") == expected_index, "Waterfall appGroupIndex must be sequential")
|
require(column.get("appName") == app_names[expected_index], "Waterfall column name must match App Shelf name")
|
require(column.get("windowCount") == fixture_windows_per_app, "Waterfall column window count must match fixture")
|
require(column.get("appNameAlignment") == "center", "Waterfall column app name must be center aligned")
|
column_frame = column.get("frame", {})
|
header_frame = column.get("headerFrame", {})
|
app_name_frame = column.get("appNameFrame", {})
|
count_frame = column.get("countFrame", {})
|
header_to_first_card_gap = column.get("headerToFirstCardGap")
|
top_to_first_card_gap = column.get("topToFirstCardGap")
|
require(isinstance(header_frame, dict), "Waterfall column must expose headerFrame")
|
require(isinstance(app_name_frame, dict), "Waterfall column must expose appNameFrame")
|
require(isinstance(count_frame, dict), "Waterfall column must expose countFrame")
|
column_width = column_frame.get("width", 0)
|
app_name_center_x = app_name_frame.get("x", 0) + app_name_frame.get("width", 0) / 2
|
require(abs(app_name_center_x - column_width / 2) <= 1.0, "Waterfall column app name frame must be horizontally centered")
|
require(app_name_frame.get("width", 0) >= column_width * 0.88, "Waterfall column app name frame must span most of the title bar")
|
require(count_frame.get("x", 0) + count_frame.get("width", 0) <= column_width + 1.0, "Waterfall count frame must remain inside the title bar")
|
require(count_frame.get("x", 0) >= column_width * 0.72, "Waterfall count frame must stay in the right side of the title bar")
|
require(abs(header_to_first_card_gap) <= 1.0, "Waterfall first card must sit directly under the title bar")
|
require(top_to_first_card_gap <= header_frame.get("height", 0) + 1.0, "Waterfall title-to-card height must remain compact")
|
require(column.get("maxVerticalScrollOffset", 0) > 0, "Long Waterfall column must expose vertical scroll overflow")
|
require(column.get("verticalScrollOffset") == 0, "Waterfall column should start at top")
|
|
cards = column.get("cards", [])
|
require(len(cards) == fixture_windows_per_app, "Waterfall cards must match fixture windows per app")
|
require([card.get("windowIndex") for card in cards] == list(range(fixture_windows_per_app)), "Waterfall card windowIndex must be sequential after sorting")
|
require(all(card.get("appGroupIndex") == expected_index for card in cards), "Waterfall cards must belong to their column appGroupIndex")
|
require(all(card.get("titleTruncationMode") == "middle" for card in cards), "Waterfall card titles must use middle truncation")
|
require(all(card.get("thumbnailStrategy") == "skeleton" for card in cards), "Waterfall fixture cards must expose skeleton thumbnail strategy")
|
require(all(card.get("screenshotSource") == "notRequested" for card in cards), "Waterfall fixture cards must not start screenshot refresh")
|
require(all(card.get("screenshotNotRequestedReason") == "disabledByLaunchOption" for card in cards), "Waterfall fixture screenshot refresh must be disabled explicitly")
|
require(all(isinstance(card.get("thumbnailFrame"), dict) for card in cards), "Waterfall cards must expose thumbnail frames")
|
require(all(isinstance(card.get("titleBarFrame"), dict) for card in cards), "Waterfall cards must expose title bar frames")
|
require(all(card.get("titleBarBorderWidth") == 0 for card in cards), "Waterfall title area must not draw an enclosing rounded rectangle")
|
require(all(card.get("thumbnailFrame", {}).get("width", 0) >= card.get("frame", {}).get("width", 0) * 0.82 for card in cards), "Waterfall thumbnails must use most of the card width")
|
require(all(card.get("titleBarFrame", {}).get("y", 0) > card.get("thumbnailFrame", {}).get("y", 0) + card.get("thumbnailFrame", {}).get("height", 0) for card in cards), "Waterfall title bars must sit above thumbnails")
|
require(all(isinstance(card.get("appIconFrame"), dict) for card in cards), "Waterfall cards must expose App icon frames")
|
require(any(card.get("frame", {}).get("y", 0) < 0 for card in cards), "Long Waterfall column must have below-viewport cards at offset zero")
|
|
ranks = [space_rank(card.get("primarySpaceLabel")) for card in cards]
|
require(ranks == sorted(ranks), "Waterfall cards must be sorted by Space label order within column")
|
global_indexes.extend(card.get("globalIndex") for card in cards)
|
|
require(global_indexes == sorted(global_indexes), "Waterfall global indexes must be monotonic across columns")
|
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")
|
selected = selected_cards[0]
|
require("selectedVisualSuppressed" in selected.get("visualStates", []), "non-hovered selected Waterfall card must suppress selected visual state")
|
require(selected.get("shineVisible") is False, "non-hovered selected Waterfall card must not expose shine layer")
|
require(selected.get("zPosition", 0) == 0, "non-hovered selected Waterfall card must not float above normal cards")
|
normal_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is not True]
|
require(normal_cards, "Waterfall fixture must include normal cards")
|
require(all(card.get("shineVisible") is False for card in normal_cards), "non-selected Waterfall cards must not show shine")
|
|
print(json.dumps({
|
"appCount": report.get("appCount"),
|
"windowCount": report.get("windowCount"),
|
"columns": root.get("waterfallColumnCount"),
|
"scrollable": root.get("waterfallScrollable"),
|
"maxScrollOffset": root.get("waterfallMaxScrollOffset"),
|
"firstColumnMaxVerticalScrollOffset": columns[0].get("maxVerticalScrollOffset"),
|
"selectedWindowID": selected.get("windowID"),
|
"selectedShineVisible": selected.get("shineVisible")
|
}, 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-fixture-app-count="$FIXTURE_APP_COUNT" \
|
--round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \
|
--round01-disable-screenshot-refresh \
|
--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-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
|