#!/bin/bash # Round01 candidate filtering fixture QA. It uses a deterministic snapshot # containing accepted and rejected window records, then verifies that only the # accepted candidates reach the Quick Switch UI. 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-candidate-filter-fixture-report.json" REPORT_WAIT="${ALIGNER_ROUND1_CANDIDATE_FILTER_REPORT_WAIT:-6.0}" fail() { echo "Round01 candidate filter 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"candidate filter report did not become loaded within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_report() { /usr/bin/python3 - "$REPORT" <<'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", {}) columns = root.get("waterfallColumns", []) cards = [card for column in columns for card in column.get("cards", [])] def stable_fingerprint(value): text = value or "" hash_value = 0xCBF29CE484222325 for byte in text.encode("utf-8"): hash_value ^= byte hash_value = (hash_value * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF return f"{hash_value:016x}" title_hashes = {card.get("titleHash") for card in cards} expected_titles = { "Standard Candidate", "Minimized Candidate", "Fullscreen Candidate", "Sheet Candidate", "Dialog Candidate", "Candidate Standard Untitled", "CG-only Space-backed Candidate", } rejected_titles = { "Accessory Rejected", "Hidden Rejected", "Tiny Rejected", "Ghost Rejected", "Popover Rejected", "Desktop", "Security Confirmation", "Noninteractive Rejected", } expected_title_hashes = {stable_fingerprint(title) for title in expected_titles} rejected_title_hashes = {stable_fingerprint(title) for title in rejected_titles} require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") require(report.get("windowCount") == len(expected_titles), "candidate fixture must expose only accepted windows") require(len(cards) == len(expected_titles), "Waterfall cards must match accepted candidate count") require(title_hashes == expected_title_hashes, "Waterfall must include exactly the accepted candidate title hashes") require(title_hashes.isdisjoint(rejected_title_hashes), "Waterfall must not include rejected candidate title hashes") require(len(columns) == 1, "candidate fixture should group accepted candidates under one app") require(root.get("screenshotNotRequestedCount") == len(expected_titles), "candidate fixture must disable screenshot refresh") by_title_hash = {card.get("titleHash"): card for card in cards} minimized = by_title_hash[stable_fingerprint("Minimized Candidate")] fullscreen = by_title_hash[stable_fingerprint("Fullscreen Candidate")] sheet = by_title_hash[stable_fingerprint("Sheet Candidate")] dialog = by_title_hash[stable_fingerprint("Dialog Candidate")] untitled = by_title_hash[stable_fingerprint("Candidate Standard Untitled")] require(minimized.get("isMinimized") is True, "minimized candidate must remain marked minimized") require(minimized.get("identifierSource") == "syntheticAX", "minimized fixture must expose syntheticAX source") require(fullscreen.get("isFullscreen") is True, "fullscreen candidate must remain marked fullscreen") require("fullscreen" in fullscreen.get("visualStates", []), "fullscreen candidate must expose fullscreen visual state") require(sheet.get("isMinimized") is False, "sheet candidate must be a normal selectable card") require(dialog.get("isMinimized") is False, "dialog candidate must be a normal selectable card") require(untitled.get("titleHash") == stable_fingerprint("Candidate Standard Untitled"), "untitled windows must use App name + Untitled fallback") print(json.dumps({ "windowCount": report.get("windowCount"), "acceptedTitleHashes": sorted(title_hashes), "rejectedTitleHashesAbsent": sorted(rejected_title_hashes), "notRequested": root.get("screenshotNotRequestedCount"), }, 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-candidate-filtering \ --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