#!/bin/bash
|
# Round01 real-desktop QA for Finder tabs/pages. It verifies that CG-only
|
# Finder pages inherit the high-confidence fullscreen Space from their visible
|
# host, and that clicking such a card actually selects the matching Finder tab
|
# instead of only reporting a WindowServer activation success.
|
|
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"
|
RUN_ID="$(date +%Y%m%d_%H%M%S)"
|
REPORT_DIR="$BUILD_REPORT_ROOT/round01-finder-tabs-live-$RUN_ID"
|
DEV_LOG="$HOME/Library/Logs/Aligner/aligner-dev.log"
|
REPORT_WAIT="${ALIGNER_ROUND1_FINDER_TABS_REPORT_WAIT:-35.0}"
|
|
fail() {
|
echo "Round01 Finder tabs live 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"
|
}
|
|
dev_log_size() {
|
if [ -f "$DEV_LOG" ]; then
|
stat -f%z "$DEV_LOG"
|
else
|
echo 0
|
fi
|
}
|
|
extract_dev_log_since() {
|
local offset="$1"
|
local output="$2"
|
if [ ! -f "$DEV_LOG" ]; then
|
: >"$output"
|
return
|
fi
|
|
dd if="$DEV_LOG" bs=1 skip="$offset" 2>/dev/null >"$output" || : >"$output"
|
}
|
|
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):
|
if report.get("snapshotLoaded") is not True:
|
return False
|
if mode == "loaded":
|
return True
|
if mode == "activated":
|
return (
|
report.get("quickSwitchVisible") is False
|
and report.get("lastActivationResult") is not None
|
)
|
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"Finder tabs report {path} did not reach mode={mode} within {timeout:.1f}s", file=sys.stderr)
|
sys.exit(1)
|
PY
|
}
|
|
run_snapshot_dump() {
|
local snapshot="$1"
|
local stderr_log="$2"
|
stop_current_aligner
|
ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \
|
"$APP/Contents/MacOS/Aligner" \
|
--round0-skip-permissions \
|
--round01-dump-window-snapshot \
|
--round01-dump-window-snapshot-pretty >"$snapshot" 2>"$stderr_log"
|
}
|
|
run_report() {
|
local report="$1"
|
local app_log="$2"
|
stop_current_aligner
|
rm -f "$report"
|
ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \
|
"$APP/Contents/MacOS/Aligner" \
|
--round0-skip-permissions \
|
--round01-open-quick-switch \
|
--round01-disable-screenshot-refresh \
|
--round01-quick-switch-auto-hide-after=20.0 \
|
--round01-quick-switch-quit-after=21.0 \
|
--round01-quick-switch-report="$report" >"$app_log" 2>&1 &
|
local app_pid="$!"
|
if ! wait_for_report "$report" "loaded"; then
|
kill "$app_pid" 2>/dev/null || true
|
wait "$app_pid" 2>/dev/null || true
|
return 1
|
fi
|
kill "$app_pid" 2>/dev/null || true
|
wait "$app_pid" 2>/dev/null || true
|
}
|
|
run_click() {
|
local window_id="$1"
|
local report="$2"
|
local app_log="$3"
|
stop_current_aligner
|
rm -f "$report"
|
ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \
|
"$APP/Contents/MacOS/Aligner" \
|
--round0-skip-permissions \
|
--round01-open-quick-switch \
|
--round01-disable-screenshot-refresh \
|
--round01-debug-mouse-sequence="click-card-id:$window_id" \
|
--round01-quick-switch-quit-after=34.0 \
|
--round01-quick-switch-report="$report" >"$app_log" 2>&1 &
|
local app_pid="$!"
|
if ! wait_for_report "$report" "activated"; then
|
kill "$app_pid" 2>/dev/null || true
|
wait "$app_pid" 2>/dev/null || true
|
return 1
|
fi
|
wait "$app_pid" 2>/dev/null || true
|
}
|
|
focused_finder_window_report() {
|
local output="$1"
|
swift - "$output" <<'SWIFT'
|
import AppKit
|
import ApplicationServices
|
import Foundation
|
|
func fail(_ message: String, code: Int32 = 2) -> Never {
|
fputs(message + "\n", stderr)
|
exit(code)
|
}
|
|
func jsonString(_ value: String) -> String {
|
let data = try! JSONSerialization.data(withJSONObject: ["value": value], options: [])
|
let text = String(data: data, encoding: .utf8)!
|
let prefix = "{\"value\":\""
|
let suffix = "\"}"
|
return String(text.dropFirst(prefix.count).dropLast(suffix.count))
|
}
|
|
func stableFingerprint(_ text: String) -> String {
|
var hash: UInt64 = 0xcbf29ce484222325
|
for byte in text.utf8 {
|
hash ^= UInt64(byte)
|
hash = hash &* 0x100000001b3
|
}
|
return String(format: "%016llx", hash)
|
}
|
|
func title(of element: AXUIElement) -> String? {
|
var value: CFTypeRef?
|
guard AXUIElementCopyAttributeValue(element, kAXTitleAttribute as CFString, &value) == .success else {
|
return nil
|
}
|
return value as? String
|
}
|
|
func focusedWindowID(_ element: AXUIElement) -> UInt32? {
|
var value: CFTypeRef?
|
if AXUIElementCopyAttributeValue(element, "AXWindowNumber" as CFString, &value) == .success,
|
let number = value as? NSNumber {
|
return number.uint32Value
|
}
|
return nil
|
}
|
|
let outputPath = CommandLine.arguments[1]
|
guard let finder = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.finder" }) else {
|
fail("Finder is not running")
|
}
|
|
let appElement = AXUIElementCreateApplication(finder.processIdentifier)
|
var windowValue: CFTypeRef?
|
var axError = AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &windowValue)
|
if axError != .success || windowValue == nil || CFGetTypeID(windowValue!) != AXUIElementGetTypeID() {
|
axError = AXUIElementCopyAttributeValue(appElement, kAXMainWindowAttribute as CFString, &windowValue)
|
}
|
guard axError == .success,
|
let rawWindow = windowValue,
|
CFGetTypeID(rawWindow) == AXUIElementGetTypeID()
|
else {
|
fail("Finder focused/main window is unavailable: \(axError)")
|
}
|
|
let window = rawWindow as! AXUIElement
|
let windowTitle = title(of: window) ?? ""
|
let output = """
|
{
|
"bundleIdentifier": "com.apple.finder",
|
"pid": \(finder.processIdentifier),
|
"focusedWindowID": \(focusedWindowID(window).map(String.init) ?? "null"),
|
"title": "\(jsonString(windowTitle))",
|
"titleLength": \(windowTitle.count),
|
"titleHash": "\(stableFingerprint(windowTitle))"
|
}
|
"""
|
try output.write(toFile: outputPath, atomically: true, encoding: .utf8)
|
SWIFT
|
}
|
|
extract_attributed_ids() {
|
local log_file="$1"
|
/usr/bin/python3 - "$log_file" <<'PY'
|
import re
|
import sys
|
|
ids = []
|
for line in open(sys.argv[1], "r", encoding="utf-8", errors="replace"):
|
if "event=windowEnumeration.finderTabSpaceAttribution" not in line:
|
continue
|
match = re.search(r'attributedWindowIDs="?(\[[^\]"]*\])"?', line)
|
if not match:
|
continue
|
ids = [int(value) for value in re.findall(r"\d+", match.group(1))]
|
|
print(",".join(str(value) for value in sorted(set(ids))))
|
PY
|
}
|
|
select_targets() {
|
local snapshot="$1"
|
local report="$2"
|
local attributed_ids_csv="$3"
|
local output="$4"
|
/usr/bin/python3 - "$snapshot" "$report" "$attributed_ids_csv" "$output" <<'PY'
|
import json
|
import os
|
import sys
|
|
snapshot_path, report_path, ids_csv, output_path = sys.argv[1:5]
|
attributed_ids = {int(value) for value in ids_csv.split(",") if value}
|
max_targets = max(1, int(os.environ.get("ALIGNER_ROUND1_FINDER_TABS_MAX_TARGETS", "4")))
|
with open(snapshot_path, "r", encoding="utf-8") as file:
|
snapshot = json.load(file)
|
with open(report_path, "r", encoding="utf-8") as file:
|
report = json.load(file)
|
|
def require(condition, message):
|
if not condition:
|
print(message, file=sys.stderr)
|
sys.exit(4)
|
|
snapshot_windows = {window.get("id"): window for window in snapshot.get("windows", [])}
|
finder_candidates = [
|
window for window_id, window in snapshot_windows.items()
|
if window.get("app", {}).get("bundleIdentifier") == "com.apple.finder"
|
and window.get("identifierSource") == "cgWindow"
|
and window.get("title")
|
]
|
require(finder_candidates, "snapshot must contain at least one Finder CG-only tab/page")
|
|
root = report.get("rootView", {})
|
cards = []
|
for column in root.get("waterfallColumns", []):
|
if column.get("bundleIdentifier") != "com.apple.finder":
|
continue
|
for card in column.get("cards", []):
|
window = snapshot_windows.get(card.get("windowID"), {})
|
if window.get("app", {}).get("bundleIdentifier") != "com.apple.finder":
|
continue
|
if window.get("identifierSource") != "cgWindow":
|
continue
|
visible = card.get("visibleFrame") or {}
|
if visible.get("width", 0) <= 1 or visible.get("height", 0) <= 1:
|
continue
|
title = card.get("title") or snapshot_windows.get(card.get("windowID"), {}).get("title") or ""
|
if not title.strip():
|
continue
|
cards.append((column, card, title))
|
|
require(cards, "Quick Switch report must expose at least one visible Finder CG tab/page card")
|
|
title_counts = {}
|
for _, _, title in cards:
|
title_counts[title] = title_counts.get(title, 0) + 1
|
|
cards.sort(key=lambda item: (
|
0 if not (snapshot_windows.get(item[1].get("windowID"), {}).get("spaceIDs") or []) else 1,
|
0 if title_counts[item[2]] == 1 else 1,
|
item[1].get("globalIndex", 10**9),
|
item[1].get("windowID", 10**9),
|
))
|
targets = []
|
seen_window_ids = set()
|
for column, card, title in cards:
|
window_id = card["windowID"]
|
if window_id in seen_window_ids:
|
continue
|
seen_window_ids.add(window_id)
|
targets.append({
|
"windowID": window_id,
|
"appGroupIndex": column["appGroupIndex"],
|
"windowIndex": card["windowIndex"],
|
"primarySpaceID": card.get("primarySpaceID"),
|
"hasAttributedSpace": window_id in attributed_ids,
|
"snapshotSpaceIDs": snapshot_windows.get(window_id, {}).get("spaceIDs") or [],
|
"title": title,
|
"titleLength": len(title),
|
"titleHash": card.get("titleHash") or snapshot_windows.get(window_id, {}).get("titleHash"),
|
"visibleFrame": card.get("visibleFrame"),
|
})
|
if len(targets) >= max_targets:
|
break
|
|
require(targets, "Quick Switch report must expose at least one selectable Finder tab/page target")
|
|
with open(output_path, "w", encoding="utf-8") as file:
|
json.dump(targets, file, indent=2, ensure_ascii=False)
|
|
print(f"TARGET_COUNT={len(targets)}")
|
PY
|
}
|
|
target_at_index() {
|
local targets="$1"
|
local index="$2"
|
local output="$3"
|
/usr/bin/python3 - "$targets" "$index" "$output" <<'PY'
|
import json
|
import sys
|
|
targets_path, index_text, output_path = sys.argv[1:4]
|
index = int(index_text)
|
with open(targets_path, "r", encoding="utf-8") as file:
|
targets = json.load(file)
|
if index < 0 or index >= len(targets):
|
print(f"target index {index} is out of range", file=sys.stderr)
|
sys.exit(4)
|
target = targets[index]
|
with open(output_path, "w", encoding="utf-8") as file:
|
json.dump(target, file, indent=2, ensure_ascii=False)
|
print(f"TARGET_WINDOW_ID={target['windowID']}")
|
print(f"TARGET_SPACE_ID={target.get('primarySpaceID')}")
|
PY
|
}
|
|
assert_click_report() {
|
local report="$1"
|
local target="$2"
|
/usr/bin/python3 - "$report" "$target" <<'PY'
|
import json
|
import sys
|
|
with open(sys.argv[1], "r", encoding="utf-8") as file:
|
report = json.load(file)
|
with open(sys.argv[2], "r", encoding="utf-8") as file:
|
target = json.load(file)
|
|
def require(condition, message):
|
if not condition:
|
print(message, file=sys.stderr)
|
print(json.dumps({
|
"targetWindowID": target.get("windowID"),
|
"lastCommittedWindowID": report.get("lastCommittedWindowID"),
|
"lastActivationWindowID": report.get("lastActivationWindowID"),
|
"lastActivationResult": report.get("lastActivationResult"),
|
"lastActivationError": report.get("lastActivationError"),
|
"quickSwitchVisible": report.get("quickSwitchVisible"),
|
}, indent=2, ensure_ascii=False), file=sys.stderr)
|
sys.exit(5)
|
|
require(report.get("lastCommittedWindowID") == target["windowID"], "commit must target the clicked Finder card")
|
require(report.get("lastActivationWindowID") == target["windowID"], "activation must receive exact Finder tab/page windowID")
|
require(report.get("lastActivationResult") == "activated", "activation result must be activated")
|
require(report.get("quickSwitchVisible") is False, "Quick Switch must close after click activation")
|
PY
|
}
|
|
assert_focused_finder_title() {
|
local target="$1"
|
local focused="$2"
|
/usr/bin/python3 - "$target" "$focused" <<'PY'
|
import json
|
import sys
|
|
with open(sys.argv[1], "r", encoding="utf-8") as file:
|
target = json.load(file)
|
with open(sys.argv[2], "r", encoding="utf-8") as file:
|
focused = json.load(file)
|
|
target_title = target.get("title", "").strip()
|
focused_title = focused.get("title", "").strip()
|
if target_title != focused_title:
|
print("Focused Finder window title must match clicked tab/page title", file=sys.stderr)
|
print(json.dumps({
|
"targetWindowID": target.get("windowID"),
|
"targetTitleHash": target.get("titleHash"),
|
"targetTitleLength": target.get("titleLength"),
|
"focusedWindowID": focused.get("focusedWindowID"),
|
"focusedTitleHash": focused.get("titleHash"),
|
"focusedTitleLength": focused.get("titleLength"),
|
}, indent=2, ensure_ascii=False), file=sys.stderr)
|
sys.exit(6)
|
|
print(json.dumps({
|
"case": "finderTabRealActivation",
|
"targetWindowID": target.get("windowID"),
|
"targetSpaceID": target.get("primarySpaceID"),
|
"targetTitleHash": target.get("titleHash"),
|
"targetTitleLength": target.get("titleLength"),
|
"focusedWindowID": focused.get("focusedWindowID"),
|
"focusedTitleHash": focused.get("titleHash"),
|
"focusedTitleLength": focused.get("titleLength"),
|
}, indent=2, ensure_ascii=False))
|
PY
|
}
|
|
assert_no_finder_activation_failure_log() {
|
local log_file="$1"
|
local target="$2"
|
/usr/bin/python3 - "$log_file" "$target" <<'PY'
|
import json
|
import sys
|
|
log_path, target_path = sys.argv[1:3]
|
with open(target_path, "r", encoding="utf-8") as file:
|
target = json.load(file)
|
|
window_id = str(target.get("windowID"))
|
bad_lines = []
|
for line in open(log_path, "r", encoding="utf-8", errors="replace"):
|
if f"windowID={window_id}" not in line:
|
continue
|
if "event=windowActivation.finderTab.noSelectableHost" in line:
|
bad_lines.append(line.strip())
|
elif "event=windowActivation.finderTab.hostMissing" in line:
|
bad_lines.append(line.strip())
|
elif "event=windowActivation.activate.finderTabResult" in line and "activated=false" in line:
|
bad_lines.append(line.strip())
|
elif "event=quickSwitch.activation.commit.result" in line and "result=activationFailed" in line:
|
bad_lines.append(line.strip())
|
|
if bad_lines:
|
print("Finder activation log contains failure markers for clicked target", file=sys.stderr)
|
print(json.dumps({
|
"targetWindowID": target.get("windowID"),
|
"hasAttributedSpace": target.get("hasAttributedSpace"),
|
"snapshotSpaceIDs": target.get("snapshotSpaceIDs"),
|
"lines": bad_lines[-8:],
|
}, indent=2, ensure_ascii=False), file=sys.stderr)
|
sys.exit(7)
|
PY
|
}
|
|
mkdir -p "$REPORT_DIR"
|
stop_current_aligner
|
"$SCRIPT_DIR/package-app.sh" >&2
|
|
SNAPSHOT_JSON="$REPORT_DIR/snapshot.json"
|
SNAPSHOT_STDERR="$REPORT_DIR/snapshot.stderr.log"
|
SNAPSHOT_DEV_LOG="$REPORT_DIR/snapshot-dev.log"
|
INITIAL_REPORT="$REPORT_DIR/initial-report.json"
|
INITIAL_APP_LOG="$REPORT_DIR/initial-app.log"
|
TARGETS_JSON="$REPORT_DIR/targets.json"
|
|
SNAPSHOT_LOG_OFFSET="$(dev_log_size)"
|
run_snapshot_dump "$SNAPSHOT_JSON" "$SNAPSHOT_STDERR"
|
sleep 0.4
|
extract_dev_log_since "$SNAPSHOT_LOG_OFFSET" "$SNAPSHOT_DEV_LOG"
|
ATTRIBUTED_IDS="$(extract_attributed_ids "$SNAPSHOT_DEV_LOG")"
|
|
run_report "$INITIAL_REPORT" "$INITIAL_APP_LOG"
|
eval "$(select_targets "$SNAPSHOT_JSON" "$INITIAL_REPORT" "$ATTRIBUTED_IDS" "$TARGETS_JSON")"
|
|
for ((target_index = 0; target_index < TARGET_COUNT; target_index++)); do
|
TARGET_JSON="$REPORT_DIR/target-$target_index.json"
|
CLICK_REPORT="$REPORT_DIR/click-report-$target_index.json"
|
CLICK_APP_LOG="$REPORT_DIR/click-app-$target_index.log"
|
CLICK_DEV_LOG="$REPORT_DIR/click-dev-$target_index.log"
|
FOCUSED_JSON="$REPORT_DIR/focused-finder-$target_index.json"
|
|
eval "$(target_at_index "$TARGETS_JSON" "$target_index" "$TARGET_JSON")"
|
CLICK_LOG_OFFSET="$(dev_log_size)"
|
run_click "$TARGET_WINDOW_ID" "$CLICK_REPORT" "$CLICK_APP_LOG"
|
sleep 0.8
|
extract_dev_log_since "$CLICK_LOG_OFFSET" "$CLICK_DEV_LOG"
|
focused_finder_window_report "$FOCUSED_JSON"
|
|
assert_click_report "$CLICK_REPORT" "$TARGET_JSON"
|
assert_focused_finder_title "$TARGET_JSON" "$FOCUSED_JSON"
|
assert_no_finder_activation_failure_log "$CLICK_DEV_LOG" "$TARGET_JSON"
|
done
|
|
stop_current_aligner
|
|
cat <<EOF
|
Round01 Finder tabs live QA passed
|
Log directory: $REPORT_DIR
|
Covered:
|
- Finder CG-only tab/page records are clicked even when Space attribution is unavailable.
|
- Quick Switch clicks target exact Finder tab/page windowIDs.
|
- Finder focused window title matches each clicked card after activation.
|
- Finder activation logs contain no hostMissing/noSelectableHost/activationFailed for clicked targets.
|
- Multiple Finder tab/page targets are exercised in one run to catch intermittent host mismatch.
|
EOF
|