#!/bin/bash # Round01 close interaction fixture QA. It restores the dedicated coverage for # Quick Switch close buttons, confirmation dialog, cancel flow, and App-level 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" BUNDLE_ID="com.ar.Aligner" CONFIRM_DEFAULTS_KEY="QuickSwitchConfirmBeforeClose" FIXTURE_APP_COUNT="${ALIGNER_ROUND1_CLOSE_FIXTURE_APP_COUNT:-4}" FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_CLOSE_FIXTURE_WINDOWS_PER_APP:-3}" REPORT_WAIT="${ALIGNER_ROUND1_CLOSE_REPORT_WAIT:-6.0}" CARD_DEFAULT_HOVER_REPORT="$BUILD_REPORT_ROOT/round01-close-card-default-hover-report.json" CARD_HOVER_REPORT="$BUILD_REPORT_ROOT/round01-close-card-hover-report.json" CARD_CONFIRMATION_REPORT="$BUILD_REPORT_ROOT/round01-close-card-confirmation-report.json" CARD_CANCEL_REPORT="$BUILD_REPORT_ROOT/round01-close-card-cancel-report.json" CARD_CONFIRMED_REPORT="$BUILD_REPORT_ROOT/round01-close-card-confirmed-report.json" CARD_WINDOW_NOT_FOUND_REPORT="$BUILD_REPORT_ROOT/round01-close-card-window-not-found-report.json" CARD_OPTOUT_TOGGLE_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-toggle-report.json" CARD_OPTOUT_CONFIRMED_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-confirmed-report.json" CARD_OPTOUT_SECOND_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-second-report.json" APP_CONFIRM_REPORT="$BUILD_REPORT_ROOT/round01-close-app-confirm-report.json" fail() { echo "Round01 close 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" } reset_close_confirmation_preference() { defaults delete "$BUNDLE_ID" "$CONFIRM_DEFAULTS_KEY" >/dev/null 2>&1 || true } wait_for_report() { local report="$1" local mode="$2" /usr/bin/python3 - "$report" "$REPORT_WAIT" "$mode" <<'PY' import json import sys import time path = sys.argv[1] timeout = float(sys.argv[2]) mode = sys.argv[3] deadline = time.monotonic() + timeout last_report = None def ready(report): root = report.get("rootView", {}) if report.get("snapshotLoaded") is not True: return False if mode == "visible": return report.get("quickSwitchVisible") is True if mode == "confirmed": return report.get("quickSwitchVisible") is True and report.get("lastCloseResult") is not None if mode == "verifiedFailure": return ( report.get("quickSwitchVisible") is True and report.get("lastCloseResult") is not None and root.get("closeFeedbackKind") == "failure" ) return False while time.monotonic() < deadline: try: with open(path, "r", encoding="utf-8") as file: report = json.load(file) last_report = report if ready(report): 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"close fixture report {path} did not reach mode={mode} within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } run_case() { local report="$1" local sequence="$2" local mode="$3" local preference_mode="${4:-reset}" local close_result="${5:-requested}" stop_current_aligner if [ "$preference_mode" = "reset" ]; then reset_close_confirmation_preference fi rm -f "$report" ALIGNER_DEBUG_WINDOW_CLOSE_RESULT="$close_result" "$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-debug-window-close \ --round01-debug-mouse-sequence="$sequence" \ --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_report "$report" "$mode" kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true trap - EXIT stop_current_aligner } assert_card_default_hover_report() { /usr/bin/python3 - "$CARD_DEFAULT_HOVER_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", []) card = columns[0]["cards"][0] glyph = card.get("closeButtonGlyphColor", {}) fill = card.get("closeButtonFillColor", {}) require(report.get("quickSwitchVisible") is True, "card hover must keep Quick Switch visible") require(root.get("hoveredCloseTargetKind") is None, "plain card hover must not hover the close target itself") require(card.get("closeButtonVisible") is True, "plain hovered card close button must be visible") require(card.get("closeButtonSize") == 18, "plain hovered card close button must remain compact") require(card.get("closeButtonGlyphLineWidth", 99) <= 1.5, "plain close glyph must use a lighter stroke") require(glyph.get("red", 0) >= 0.95 and glyph.get("green", 0) >= 0.95 and glyph.get("blue", 0) >= 0.95, "plain close glyph must be white, not dark") require(0.70 <= glyph.get("alpha", 0) <= 0.92, "plain close glyph must be translucent") require(fill.get("alpha", 1) <= 0.36, "plain close button fill must be subtle") print(json.dumps({ "case": "cardDefaultHover", "closeButtonSize": card.get("closeButtonSize"), "glyphColor": glyph, "fillColor": fill, "glyphLineWidth": card.get("closeButtonGlyphLineWidth") }, indent=2, ensure_ascii=False)) PY } assert_card_hover_report() { /usr/bin/python3 - "$CARD_HOVER_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", []) card = columns[0]["cards"][0] require(report.get("quickSwitchVisible") is True, "card hover must keep Quick Switch visible") require(root.get("hoveredCloseTargetKind") == "window", "card close hover must set hoveredCloseTargetKind=window") require(root.get("pendingCloseTargetKind") is None, "card close hover must not create pending close target") require(card.get("closeButtonVisible") is True, "hovered card close button must be visible") require(card.get("closeButtonSize") == 24, "hovered card close button must expand to 24pt") require(root.get("closeConfirmationVisible") is False, "card close hover must not show confirmation") print(json.dumps({ "case": "cardHover", "hoveredCloseTargetKind": root.get("hoveredCloseTargetKind"), "closeButtonSize": card.get("closeButtonSize") }, indent=2, ensure_ascii=False)) PY } assert_card_confirmation_report() { /usr/bin/python3 - "$CARD_CONFIRMATION_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", {}) require(report.get("quickSwitchVisible") is True, "card close confirmation must keep Quick Switch visible before confirm") require(root.get("pendingCloseTargetKind") == "window", "card close click must create pending window target") require(root.get("closeConfirmationVisible") is True, "card close click must show confirmation") require(root.get("closeConfirmationArrowEdge") in ("left", "right"), "confirmation must expose arrow edge") require(report.get("lastCloseResult") is None, "card close confirmation must not call service before confirm") print(json.dumps({ "case": "cardConfirmation", "pendingCloseTargetKind": root.get("pendingCloseTargetKind"), "arrowEdge": root.get("closeConfirmationArrowEdge") }, indent=2, ensure_ascii=False)) PY } assert_card_cancel_report() { /usr/bin/python3 - "$CARD_CANCEL_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", {}) require(report.get("quickSwitchVisible") is True, "cancel must keep Quick Switch visible") require(root.get("pendingCloseTargetKind") is None, "cancel must clear pending close target") require(root.get("closeConfirmationVisible") is False, "cancel must hide confirmation") require(report.get("lastCloseResult") is None, "cancel must not call close service") print(json.dumps({ "case": "cardCancel", "quickSwitchVisible": report.get("quickSwitchVisible"), "closeConfirmationVisible": root.get("closeConfirmationVisible") }, indent=2, ensure_ascii=False)) PY } assert_card_confirmed_report() { /usr/bin/python3 - "$CARD_CONFIRMED_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", {}) visible_window_ids = [ card.get("windowID") for column in root.get("waterfallColumns", []) for card in column.get("cards", []) ] require(report.get("quickSwitchVisible") is True, "confirmed window close must keep Quick Switch visible") require(report.get("lastDismissReason") is None, "confirmed window close must not dismiss Quick Switch") require(report.get("lastCloseTargetKind") == "window", "confirmed window close must record window target") require(report.get("lastCloseWindowID") is not None, "confirmed window close must record target window") require(report.get("lastCloseResult") == "requested", "debug window close service must return requested") require(report.get("lastCloseWindowID") not in visible_window_ids, "confirmed window close must optimistically remove the stale card from Waterfall") require(report.get("suppressedCloseTargetCount") == 1, "confirmed window close must expose one suppressed close target") require(root.get("closeConfirmationVisible") is False, "confirmed window close must hide confirmation") require(root.get("closeFeedbackVisible") is True, "confirmed window close must show failure feedback when target remains") require(root.get("closeFeedbackKind") == "failure", "confirmed window close feedback must be failure") require(root.get("closeFeedbackMessage") == "已发送关闭请求,但窗口仍在列表中。", "confirmed window close feedback message must explain verified close miss") require(root.get("closeFeedbackTargetKind") == "window", "confirmed window close feedback must keep target kind") print(json.dumps({ "case": "cardConfirmed", "quickSwitchVisible": report.get("quickSwitchVisible"), "lastCloseTargetKind": report.get("lastCloseTargetKind"), "lastCloseResult": report.get("lastCloseResult"), "lastDismissReason": report.get("lastDismissReason"), "closeFeedbackKind": root.get("closeFeedbackKind"), "closeFeedbackMessage": root.get("closeFeedbackMessage") }, indent=2, ensure_ascii=False)) PY } assert_card_window_not_found_report() { /usr/bin/python3 - "$CARD_WINDOW_NOT_FOUND_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", {}) visible_window_ids = [ card.get("windowID") for column in root.get("waterfallColumns", []) for card in column.get("cards", []) ] require(report.get("quickSwitchVisible") is True, "windowNotFound close must keep Quick Switch visible") require(report.get("lastCloseTargetKind") == "window", "windowNotFound close must record window target") require(report.get("lastCloseResult") == "windowNotFound", "debug close service must return windowNotFound") require(report.get("lastCloseWindowID") not in visible_window_ids, "windowNotFound close must remove the stale card from Waterfall") require(report.get("suppressedCloseTargetCount") == 1, "windowNotFound close must expose one suppressed close target") require(root.get("closeFeedbackVisible") is False, "windowNotFound close must not show technical failure feedback") require(root.get("closeFeedbackMessage") is None, "windowNotFound close must not expose a technical message") print(json.dumps({ "case": "cardWindowNotFound", "lastCloseTargetKind": report.get("lastCloseTargetKind"), "lastCloseResult": report.get("lastCloseResult"), "closeFeedbackVisible": root.get("closeFeedbackVisible"), "suppressedCloseTargetCount": report.get("suppressedCloseTargetCount") }, indent=2, ensure_ascii=False)) PY } assert_card_optout_toggle_report() { /usr/bin/python3 - "$CARD_OPTOUT_TOGGLE_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", {}) require(report.get("quickSwitchVisible") is True, "opt-out toggle must keep Quick Switch visible") require(report.get("lastCloseResult") is None, "opt-out toggle alone must not request close") require(root.get("pendingCloseTargetKind") == "window", "opt-out toggle must keep pending window close target") require(root.get("pendingCloseOptOut") is True, "opt-out toggle must show checked state") require(root.get("closeConfirmationVisible") is True, "opt-out toggle must keep confirmation visible") print(json.dumps({ "case": "cardOptOutToggle", "pendingCloseTargetKind": root.get("pendingCloseTargetKind"), "pendingCloseOptOut": root.get("pendingCloseOptOut"), "closeConfirmationVisible": root.get("closeConfirmationVisible") }, indent=2, ensure_ascii=False)) PY } assert_confirmation_preference_disabled() { local value value="$(defaults read "$BUNDLE_ID" "$CONFIRM_DEFAULTS_KEY" 2>/dev/null || true)" [ "$value" = "0" ] || fail "close confirmation preference should be disabled after opt-out, got '$value'" echo "{\"case\":\"closePreferenceDisabled\",\"$CONFIRM_DEFAULTS_KEY\":$value}" } assert_card_optout_confirmed_report() { /usr/bin/python3 - "$CARD_OPTOUT_CONFIRMED_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", {}) visible_window_ids = [ card.get("windowID") for column in root.get("waterfallColumns", []) for card in column.get("cards", []) ] require(report.get("quickSwitchVisible") is True, "opt-out confirmed close must keep Quick Switch visible") require(report.get("lastCloseTargetKind") == "window", "opt-out confirmed close must record window target") require(report.get("lastCloseResult") == "requested", "opt-out confirmed close must request close") require(report.get("lastCloseWindowID") not in visible_window_ids, "opt-out confirmed close must optimistically remove the stale card") require(report.get("suppressedCloseTargetCount") == 1, "opt-out confirmed close must expose one suppressed close target") require(root.get("closeConfirmationVisible") is False, "opt-out confirmed close must hide confirmation") require(root.get("pendingCloseOptOut") is False, "opt-out confirmed close must clear temporary checkbox state") require(root.get("closeFeedbackKind") == "failure", "opt-out confirmed close must still verify close result") print(json.dumps({ "case": "cardOptOutConfirmed", "lastCloseTargetKind": report.get("lastCloseTargetKind"), "lastCloseResult": report.get("lastCloseResult"), "pendingCloseOptOut": root.get("pendingCloseOptOut"), "closeConfirmationVisible": root.get("closeConfirmationVisible") }, indent=2, ensure_ascii=False)) PY } assert_card_optout_second_report() { /usr/bin/python3 - "$CARD_OPTOUT_SECOND_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", {}) visible_window_ids = [ card.get("windowID") for column in root.get("waterfallColumns", []) for card in column.get("cards", []) ] require(report.get("quickSwitchVisible") is True, "second close after opt-out must keep Quick Switch visible") require(report.get("lastCloseTargetKind") == "window", "second close after opt-out must directly request window close") require(report.get("lastCloseResult") == "requested", "second close after opt-out must call close service") require(report.get("lastCloseWindowID") not in visible_window_ids, "second close after opt-out must optimistically remove the stale card") require(report.get("suppressedCloseTargetCount") == 1, "second close after opt-out must expose one suppressed close target") require(root.get("pendingCloseTargetKind") is None, "second close after opt-out must not create pending close target") require(root.get("closeConfirmationVisible") is False, "second close after opt-out must not show confirmation") require(root.get("closeFeedbackKind") == "failure", "second close after opt-out must still verify close result") print(json.dumps({ "case": "cardOptOutSecondClose", "lastCloseTargetKind": report.get("lastCloseTargetKind"), "lastCloseResult": report.get("lastCloseResult"), "pendingCloseTargetKind": root.get("pendingCloseTargetKind"), "closeConfirmationVisible": root.get("closeConfirmationVisible") }, indent=2, ensure_ascii=False)) PY } assert_app_confirm_report() { /usr/bin/python3 - "$APP_CONFIRM_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", {}) require(report.get("quickSwitchVisible") is True, "confirmed App close must keep Quick Switch visible") require(report.get("lastDismissReason") is None, "confirmed App close must not dismiss Quick Switch") require(report.get("lastCloseTargetKind") == "app", "confirmed App close must record app target") require(report.get("lastCloseAppGroupIndex") == 1, "confirmed App close must record target app group") require(report.get("lastCloseResult") == "requested", "debug App close service must return requested") require(report.get("suppressedCloseTargetCount") == 1, "confirmed App close must expose one suppressed close target") require(1 not in [column.get("appGroupIndex") for column in root.get("waterfallColumns", [])], "confirmed App close must optimistically remove the target column") require(root.get("closeConfirmationVisible") is False, "confirmed App close must hide confirmation") require(root.get("closeFeedbackVisible") is True, "confirmed App close must show failure feedback when target remains") require(root.get("closeFeedbackKind") == "failure", "confirmed App close feedback must be failure") require(root.get("closeFeedbackMessage") == "已发送关闭请求,但窗口仍在列表中。", "confirmed App close feedback message must explain verified close miss") require(root.get("closeFeedbackTargetKind") == "app", "confirmed App close feedback must keep target kind") print(json.dumps({ "case": "appConfirm", "quickSwitchVisible": report.get("quickSwitchVisible"), "lastCloseTargetKind": report.get("lastCloseTargetKind"), "lastCloseResult": report.get("lastCloseResult"), "lastDismissReason": report.get("lastDismissReason"), "closeFeedbackKind": root.get("closeFeedbackKind"), "closeFeedbackMessage": root.get("closeFeedbackMessage") }, indent=2, ensure_ascii=False)) PY } stop_current_aligner "$SCRIPT_DIR/package-app.sh" >&2 run_case "$CARD_DEFAULT_HOVER_REPORT" "hover-card:0:0" "visible" assert_card_default_hover_report run_case "$CARD_HOVER_REPORT" "hover-card-close:0:0" "visible" assert_card_hover_report run_case "$CARD_CONFIRMATION_REPORT" "click-card-close:0:0" "visible" assert_card_confirmation_report run_case "$CARD_CANCEL_REPORT" "click-card-close:0:0,cancel-close" "visible" assert_card_cancel_report run_case "$CARD_CONFIRMED_REPORT" "click-card-close:0:0,confirm-close" "verifiedFailure" assert_card_confirmed_report run_case "$CARD_WINDOW_NOT_FOUND_REPORT" "click-card-close:0:0,confirm-close" "confirmed" "reset" "windowNotFound" assert_card_window_not_found_report run_case "$CARD_OPTOUT_TOGGLE_REPORT" "click-card-close:0:0,toggle-close-optout" "visible" assert_card_optout_toggle_report assert_confirmation_preference_disabled run_case "$CARD_OPTOUT_CONFIRMED_REPORT" "click-card-close:0:0,toggle-close-optout,confirm-close" "verifiedFailure" assert_card_optout_confirmed_report assert_confirmation_preference_disabled run_case "$CARD_OPTOUT_SECOND_REPORT" "click-card-close:0:1" "verifiedFailure" "preserve" assert_card_optout_second_report run_case "$APP_CONFIRM_REPORT" "click-app-close:1,confirm-close" "verifiedFailure" assert_app_confirm_report echo "Round01 close fixture QA passed."