Fix multi-page targeting and split view tile QA
9 files modified
1 files added
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>0.0.62</string> |
| | | <string>0.0.63</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260612.2049</string> |
| | | <string>20260613.1401</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | | <string>26.0</string> |
| | | <key>NSHighResolutionCapable</key> |
| | |
| | | return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .splitView) |
| | | } |
| | | |
| | | if round1QuickSwitchOptions.fixtureMultiPageIdentity { |
| | | return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .multiPageIdentity) |
| | | } |
| | | |
| | | if let fixtureAppCount = round1QuickSwitchOptions.fixtureAppCount { |
| | | return FixtureQuickSwitchSnapshotLoader( |
| | | appCount: fixtureAppCount, |
| | |
| | | let privateActivationOutcome = activateViaPrivateWindowServerAPI(window) |
| | | let didPrivatelyActivate = privateActivationOutcome?.succeeded == true |
| | | |
| | | let axWindow = AXWindowMetadataReader |
| | | .metadata(appCategorizer: appCategorizer) |
| | | .first { metadata in |
| | | if let windowID = metadata.windowID { |
| | | return windowID == window.id |
| | | } |
| | | |
| | | return metadata.processIdentifier == window.app.processIdentifier |
| | | && metadata.title == window.title |
| | | } |
| | | let axWindow = axWindow( |
| | | for: window, |
| | | in: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer), |
| | | operation: "windowActivation", |
| | | matchAttempt: "initial" |
| | | ) |
| | | |
| | | DevelopmentDiagnostics.log("windowActivation.activate.axLookup", [ |
| | | "windowID": window.id, |
| | |
| | | var matchedAXWindow = axWindow(for: window, in: axMetadata) |
| | | var finalAXMetadata = axMetadata |
| | | |
| | | let shouldPreferFocusedPageClose = matchedAXWindow?.windowID != window.id |
| | | if shouldPreferFocusedPageClose, |
| | | let focusedPageCloseResult = closeFocusedPageViaCommandW( |
| | | window: window, |
| | | reason: matchedAXWindow == nil ? "axMissing" : "nonDirectAXMatch" |
| | | ) { |
| | | return focusedPageCloseResult |
| | | } |
| | | |
| | | if matchedAXWindow == nil { |
| | | Thread.sleep(forTimeInterval: 0.12) |
| | | finalAXMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer) |
| | |
| | | "matchingSizeCandidateCount": matchingAXSizeCandidateCount(for: window, in: finalAXMetadata) |
| | | ]) |
| | | return .windowNotFound |
| | | } |
| | | |
| | | if matchedAXWindow.windowID != window.id, |
| | | let focusedPageCloseResult = closeFocusedPageViaCommandW( |
| | | window: window, |
| | | reason: "retryNonDirectAXMatch" |
| | | ) { |
| | | return focusedPageCloseResult |
| | | } |
| | | |
| | | guard let closeButton = closeButton(for: matchedAXWindow) else { |
| | |
| | | } |
| | | } |
| | | |
| | | private func closeFocusedPageViaCommandW( |
| | | window: AlignerWindow, |
| | | reason: String |
| | | ) -> WindowCloseResult? { |
| | | guard window.identifierSource == .cgWindow, |
| | | let processIdentifier = window.app.processIdentifier |
| | | else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.skipped", [ |
| | | "windowID": window.id, |
| | | "reason": reason, |
| | | "identifierSource": String(describing: window.identifierSource), |
| | | "hasPID": window.app.processIdentifier != nil |
| | | ]) |
| | | return nil |
| | | } |
| | | |
| | | guard let activationOutcome = activateViaPrivateWindowServerAPI(window), |
| | | activationOutcome.succeeded |
| | | else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.activationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return nil |
| | | } |
| | | |
| | | Thread.sleep(forTimeInterval: 0.08) |
| | | guard postCommandW(to: processIdentifier) else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.postFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return .failed("commandWPostFailed") |
| | | } |
| | | |
| | | DevelopmentDiagnostics.log("windowClose.commandW.requested", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return .requested |
| | | } |
| | | |
| | | private func postCommandW(to processIdentifier: Int32) -> Bool { |
| | | let source = CGEventSource(stateID: .combinedSessionState) |
| | | guard let keyDown = CGEvent( |
| | | keyboardEventSource: source, |
| | | virtualKey: Self.commandWVirtualKeyCode, |
| | | keyDown: true |
| | | ), |
| | | let keyUp = CGEvent( |
| | | keyboardEventSource: source, |
| | | virtualKey: Self.commandWVirtualKeyCode, |
| | | keyDown: false |
| | | ) |
| | | else { |
| | | return false |
| | | } |
| | | |
| | | keyDown.flags = .maskCommand |
| | | keyUp.flags = .maskCommand |
| | | keyDown.postToPid(processIdentifier) |
| | | keyUp.postToPid(processIdentifier) |
| | | return true |
| | | } |
| | | |
| | | private func axWindow( |
| | | for window: AlignerWindow, |
| | | in axMetadata: [AXWindowMetadata], |
| | | operation: String = "windowClose", |
| | | matchAttempt: String = "initial" |
| | | ) -> AXWindowMetadata? { |
| | | if let directMatch = axMetadata.first(where: { $0.windowID == window.id }) { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.directWindowID", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.directWindowID", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "attempt": matchAttempt |
| | |
| | | return nil |
| | | } |
| | | |
| | | if let geometryFingerprint = axWindowGeometryFingerprint(for: window) { |
| | | let geometryMatches = axMetadata.filter { metadata in |
| | | AXWindowGeometryFingerprint(metadata) == geometryFingerprint |
| | | } |
| | | if geometryMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.geometryFingerprint", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | return geometryMatches[0] |
| | | } |
| | | |
| | | if geometryMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.geometryFingerprintAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": geometryMatches.count, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | } |
| | | } |
| | | |
| | | if let cgFingerprint = cgWindowFingerprint(for: window) { |
| | | let fingerprintMatches = axMetadata.filter { metadata in |
| | | AXWindowFingerprint(metadata) == cgFingerprint |
| | | } |
| | | if fingerprintMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.fingerprint", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.fingerprint", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | |
| | | } |
| | | |
| | | if fingerprintMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.fingerprintAmbiguous", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.fingerprintAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": fingerprintMatches.count |
| | | "candidateCount": fingerprintMatches.count, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | } |
| | | } |
| | |
| | | metadata.processIdentifier == processIdentifier |
| | | && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle |
| | | } |
| | | if titleMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.normalizedTitle", [ |
| | | if !normalizedTitle.isEmpty, titleMatches.count == 1 { |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.normalizedTitle", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | |
| | | processIdentifier: processIdentifier, |
| | | in: axMetadata |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.finderFallback", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.finderFallback", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "attempt": matchAttempt |
| | |
| | | } |
| | | |
| | | if titleMatches.count > 1 { |
| | | DevelopmentDiagnostics.log("windowClose.axMatch.titleAmbiguous", [ |
| | | DevelopmentDiagnostics.log("\(operation).axMatch.titleAmbiguous", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "candidateCount": titleMatches.count |
| | | "candidateCount": titleMatches.count, |
| | | "attempt": matchAttempt |
| | | ]) |
| | | } |
| | | |
| | |
| | | title: normalizedWindowTitle(title(rawWindow: rawWindow, axWindow: nil)), |
| | | size: bounds(rawWindow[kCGWindowBounds as String]).size |
| | | ) |
| | | } |
| | | |
| | | private func axWindowGeometryFingerprint(for window: AlignerWindow) -> AXWindowGeometryFingerprint? { |
| | | guard let processIdentifier = window.app.processIdentifier else { return nil } |
| | | let frame = cgWindowFrame(for: window) ?? window.frame |
| | | return AXWindowGeometryFingerprint( |
| | | processIdentifier: processIdentifier, |
| | | title: normalizedWindowTitle(window.title), |
| | | frame: frame |
| | | ) |
| | | } |
| | | |
| | | private func cgWindowFrame(for window: AlignerWindow) -> CGRect? { |
| | | guard let rawWindows = CGWindowListCopyWindowInfo( |
| | | [.optionIncludingWindow], |
| | | CGWindowID(window.id) |
| | | ) as? [[String: Any]], |
| | | let rawWindow = rawWindows.first |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return bounds(rawWindow[kCGWindowBounds as String]) |
| | | } |
| | | |
| | | private func matchingAXTitleCandidateCount( |
| | |
| | | } |
| | | } |
| | | |
| | | private static let commandWVirtualKeyCode: CGKeyCode = 13 |
| | | } |
| | | |
| | | private struct AXWindowMetadata { |
| | |
| | | } |
| | | } |
| | | |
| | | private struct AXWindowGeometryFingerprint: Hashable { |
| | | let processIdentifier: Int32 |
| | | let title: String |
| | | let x: Int |
| | | let y: Int |
| | | let width: Int |
| | | let height: Int |
| | | |
| | | init?(processIdentifier: Int32, title: String, frame: CGRect?) { |
| | | guard let frame else { return nil } |
| | | |
| | | self.processIdentifier = processIdentifier |
| | | self.title = title |
| | | self.x = Int(frame.origin.x.rounded()) |
| | | self.y = Int(frame.origin.y.rounded()) |
| | | self.width = Int(frame.size.width.rounded()) |
| | | self.height = Int(frame.size.height.rounded()) |
| | | } |
| | | |
| | | init?(_ metadata: AXWindowMetadata) { |
| | | self.init( |
| | | processIdentifier: metadata.processIdentifier, |
| | | title: normalizedWindowTitle(metadata.title ?? ""), |
| | | frame: metadata.frame |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private struct RestorableAXWindow { |
| | | let processIdentifier: Int32 |
| | | let element: AXUIElement |
| | |
| | | |
| | | private func layoutSpaceLaneSegmentContents(_ segment: SpaceLaneSegmentLayers) { |
| | | let bounds = segment.containerLayer.bounds |
| | | let splitAppNames = splitViewAppNames(for: segment.space) |
| | | let isSplitView = splitAppNames.count >= 2 |
| | | segment.labelLayer.frame = CGRect(x: 10, y: bounds.height - 29, width: max(1, bounds.width - 48), height: 20) |
| | | segment.countLayer.frame = CGRect(x: bounds.width - 34, y: bounds.height - 29, width: 24, height: 20) |
| | | segment.countLayer.string = isSplitView ? "" : "\(segment.space.windowCount)" |
| | | segment.countLayer.isHidden = isSplitView |
| | | if segment.space.type == .fullscreen { |
| | | let markerFrame = fullscreenMarkerFrame(in: bounds) |
| | | let splitAppNames = splitViewAppNames(for: segment.space) |
| | | segment.appLayer.isHidden = splitAppNames.count >= 2 |
| | | if splitAppNames.count >= 2 { |
| | | segment.appLayer.isHidden = isSplitView |
| | | if isSplitView { |
| | | let labelY = markerFrame.midY - 8.5 |
| | | let halfWidth = markerFrame.width / 2 |
| | | for (index, splitAppLayer) in segment.splitAppLayers.enumerated() { |
| | |
| | | layoutFullscreenMarkerLayer( |
| | | fullscreenMarkerLayer, |
| | | in: bounds, |
| | | isSplitView: splitViewAppNames(for: segment.space).count >= 2 |
| | | isSplitView: isSplitView |
| | | ) |
| | | } |
| | | |
| | |
| | | |
| | | let bounds = shapeLayer.bounds |
| | | if isSplitView { |
| | | let insetBounds = bounds.insetBy(dx: 0.6, dy: 0.6) |
| | | let radius = min(7, max(5, insetBounds.height * 0.22)) |
| | | let path = CGMutablePath() |
| | | path.addRoundedRect( |
| | | in: insetBounds, |
| | | cornerWidth: radius, |
| | | cornerHeight: radius |
| | | ) |
| | | path.move(to: CGPoint(x: bounds.midX, y: insetBounds.minY + 1.5)) |
| | | path.addLine(to: CGPoint(x: bounds.midX, y: insetBounds.maxY - 1.5)) |
| | | path.move(to: CGPoint(x: bounds.midX, y: bounds.minY)) |
| | | path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY)) |
| | | shapeLayer.path = path |
| | | shapeLayer.lineCap = .butt |
| | | return |
| | | } |
| | | |
| | |
| | | path.addLine(to: CGPoint(x: 0, y: bounds.maxY - cornerLength)) |
| | | |
| | | shapeLayer.path = path |
| | | shapeLayer.lineCap = .round |
| | | } |
| | | |
| | | private func fullscreenMarkerFrame(in segmentBounds: CGRect) -> CGRect { |
| | |
| | | markerLayer: segment.fullscreenMarkerLayer, |
| | | splitAppNames: splitAppNames |
| | | ), |
| | | "fullscreenMarkerPathStyle": fullscreenMarkerPathStyle( |
| | | markerLayer: segment.fullscreenMarkerLayer, |
| | | splitAppNames: splitAppNames |
| | | ), |
| | | "fullscreenMarkerFrame": fullscreenMarkerFrame.map { dictionary(from: $0) } ?? NSNull(), |
| | | "fullscreenMarkerCenterRatio": fullscreenMarkerCenterRatio ?? NSNull(), |
| | | "splitViewAppNames": splitAppNames, |
| | |
| | | "windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) }, |
| | | "windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) }, |
| | | "labelColor": colorDictionary(from: segment.labelLayer.foregroundColor), |
| | | "countText": string(from: segment.countLayer.string), |
| | | "countVisible": !segment.countLayer.isHidden && segment.countLayer.opacity > 0, |
| | | "countColor": colorDictionary(from: segment.countLayer.foregroundColor), |
| | | "appColor": colorDictionary(from: segment.appLayer.foregroundColor), |
| | | "fullscreenMarkerStrokeColor": colorDictionary( |
| | |
| | | private func fullscreenMarkerKind(markerLayer: CALayer?, splitAppNames: [String]) -> String { |
| | | guard markerLayer != nil else { return "none" } |
| | | return splitAppNames.count >= 2 ? "splitViewPair" : "cornerBracket" |
| | | } |
| | | |
| | | private func fullscreenMarkerPathStyle(markerLayer: CALayer?, splitAppNames: [String]) -> String { |
| | | guard markerLayer != nil else { return "none" } |
| | | return splitAppNames.count >= 2 ? "centerDividerOnly" : "cornerBracket" |
| | | } |
| | | |
| | | private func visualStates(for space: QuickSwitchSpaceViewModel) -> [String] { |
| | |
| | | ] |
| | | } |
| | | |
| | | private func string(from textLayerValue: Any?) -> String { |
| | | if let string = textLayerValue as? String { |
| | | return string |
| | | } |
| | | if let attributedString = textLayerValue as? NSAttributedString { |
| | | return attributedString.string |
| | | } |
| | | return "" |
| | | } |
| | | |
| | | private func colorDictionary(from color: CGColor?) -> [String: Double] { |
| | | guard let color, |
| | | let nsColor = NSColor(cgColor: color)?.usingColorSpace(.deviceRGB) |
| | |
| | | let fixtureActivation: Bool |
| | | let fixtureSpaceFilter: Bool |
| | | let fixtureSplitView: Bool |
| | | let fixtureMultiPageIdentity: Bool |
| | | let debugHoveredAppGroupIndex: Int? |
| | | let debugOverlayWidth: CGFloat? |
| | | let debugKeySequence: [String] |
| | |
| | | fixtureActivation: arguments.contains("--round01-fixture-window-activation"), |
| | | fixtureSpaceFilter: arguments.contains("--round01-fixture-space-filter"), |
| | | fixtureSplitView: arguments.contains("--round01-fixture-split-view"), |
| | | fixtureMultiPageIdentity: arguments.contains("--round01-fixture-multi-page-identity"), |
| | | debugHoveredAppGroupIndex: intValue(for: "--round01-debug-hover-app-index", in: arguments), |
| | | debugOverlayWidth: cgFloatValue(for: "--round01-debug-overlay-width", in: arguments), |
| | | debugKeySequence: stringListValue(for: "--round01-debug-key-sequence", in: arguments), |
| | |
| | | case activation |
| | | case spaceFilter |
| | | case splitView |
| | | case multiPageIdentity |
| | | } |
| | | |
| | | final class LiveQuickSwitchSnapshotLoader: QuickSwitchSnapshotLoading { |
| | |
| | | } |
| | | if mode == .splitView { |
| | | return splitViewSnapshot() |
| | | } |
| | | if mode == .multiPageIdentity { |
| | | return multiPageIdentitySnapshot() |
| | | } |
| | | |
| | | return layoutSnapshot() |
| | |
| | | currentSpaceIDs: [1] |
| | | ) |
| | | } |
| | | |
| | | private func multiPageIdentitySnapshot() -> QuickSwitchSnapshotLoad { |
| | | let displayUUID = "fixture-display-a" |
| | | let displays = [ |
| | | AlignerDisplay( |
| | | uuid: displayUUID, |
| | | physical: true, |
| | | spaces: [ |
| | | AlignerSpace(id: 1, type: .user, displayUUID: displayUUID, index: 1) |
| | | ] |
| | | ) |
| | | ] |
| | | |
| | | let app = AlignerApp( |
| | | bundleIdentifier: "com.example.multipage.identity", |
| | | name: "Multi Page App", |
| | | category: .generic, |
| | | processIdentifier: 70_001 |
| | | ) |
| | | |
| | | let windows = [ |
| | | AlignerWindow( |
| | | id: 70_101, |
| | | app: app, |
| | | title: "Shared Page", |
| | | frame: CGRect(x: 120, y: 120, width: 920, height: 640), |
| | | spaceIDs: [1] |
| | | ), |
| | | AlignerWindow( |
| | | id: 70_102, |
| | | app: app, |
| | | title: "Shared Page", |
| | | frame: CGRect(x: 160, y: 140, width: 920, height: 640), |
| | | spaceIDs: [1] |
| | | ), |
| | | AlignerWindow( |
| | | id: 70_103, |
| | | app: app, |
| | | title: "Shared Page", |
| | | frame: CGRect(x: 200, y: 160, width: 920, height: 640), |
| | | spaceIDs: [1] |
| | | ) |
| | | ] |
| | | |
| | | return QuickSwitchSnapshotLoad( |
| | | snapshot: QuickSwitchSnapshotBuilder.snapshot(displays: displays, windows: windows), |
| | | currentSpaceIDs: [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 |
| | | if [[ "$command" == "$APP/Contents/MacOS/Aligner"* ]] \ |
| | | || [[ "$command" == "/Applications/Aligner.app/Contents/MacOS/Aligner"* ]] \ |
| | | || [[ "$command" == "$HOME/Applications/Aligner.app/Contents/MacOS/Aligner"* ]]; then |
| | | echo "$pid" |
| | | fi |
| | | done |
| | | } |
| | | |
| | |
| | | run_step screenshot-session "$SCRIPT_DIR/round1-screenshot-session-qa.sh" |
| | | run_step keyboard-navigation "$SCRIPT_DIR/round1-keyboard-navigation-fixture-qa.sh" |
| | | run_step window-activation "$SCRIPT_DIR/round1-window-activation-fixture-qa.sh" |
| | | run_step multi-page-identity "$SCRIPT_DIR/round1-multi-page-identity-fixture-qa.sh" |
| | | run_step mouse-interaction "$SCRIPT_DIR/round1-mouse-interaction-fixture-qa.sh" |
| | | run_step app-column-alignment "$SCRIPT_DIR/round1-app-column-alignment-fixture-qa.sh" |
| | | run_step horizontal-waterfall "$SCRIPT_DIR/round1-horizontal-waterfall-fixture-qa.sh" |
| | |
| | | Log directory: $LOG_DIR |
| | | Covered: trigger, overlay uniqueness, keyboard navigation, candidate filtering, |
| | | screenshots/fallbacks, minimized-window restore/activation, mouse activation, |
| | | App column alignment, Split View Space Lane rendering, and final no-residual check. |
| | | multi-page precise activation/close targeting, App column alignment, |
| | | Split View Space Lane rendering, and final no-residual check. |
| | | EOF |
| New file |
| | |
| | | #!/bin/bash |
| | | # Round01 multi-page identity fixture QA. It verifies that Quick Switch keeps |
| | | # same-App/same-title pages distinct for activation and close requests. |
| | | |
| | | 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" |
| | | REPORT_WAIT="${ALIGNER_ROUND1_MULTI_PAGE_REPORT_WAIT:-7.0}" |
| | | ACTIVATION_REPORT="$BUILD_REPORT_ROOT/round01-multi-page-activation-report.json" |
| | | CLOSE_REPORT="$BUILD_REPORT_ROOT/round01-multi-page-close-report.json" |
| | | |
| | | fail() { |
| | | echo "Round01 multi-page identity 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 == "activated": |
| | | return ( |
| | | report.get("quickSwitchVisible") is False |
| | | and report.get("lastActivationResult") 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"multi-page identity 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 activation_flag="${4:-}" |
| | | local close_flag="${5:-}" |
| | | |
| | | stop_current_aligner |
| | | reset_close_confirmation_preference |
| | | rm -f "$report" |
| | | |
| | | local args=( |
| | | --round0-skip-permissions |
| | | --round01-open-quick-switch |
| | | --round01-waterfall-view-mode=vertical |
| | | --round01-fixture-multi-page-identity |
| | | --round01-disable-screenshot-refresh |
| | | --round01-debug-mouse-sequence="$sequence" |
| | | --round01-quick-switch-report="$report" |
| | | ) |
| | | if [ -n "$activation_flag" ]; then |
| | | args+=(--round01-debug-window-activation) |
| | | fi |
| | | if [ -n "$close_flag" ]; then |
| | | args+=(--round01-debug-window-close) |
| | | 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_report "$report" "$mode" |
| | | kill "$APP_PID" 2>/dev/null || true |
| | | wait "$APP_PID" 2>/dev/null || true |
| | | trap - EXIT |
| | | stop_current_aligner |
| | | } |
| | | |
| | | assert_activation_report() { |
| | | /usr/bin/python3 - "$ACTIVATION_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 = columns[0].get("cards", []) if columns else [] |
| | | ids = [card.get("windowID") for card in cards] |
| | | |
| | | require(report.get("snapshotLoaded") is True, "activation fixture must load") |
| | | require(report.get("quickSwitchVisible") is False, "activation must close Quick Switch") |
| | | require(report.get("appCount") == 1, "multi-page fixture must expose one App") |
| | | require(report.get("windowCount") == 3, "multi-page fixture must expose three pages") |
| | | require(ids == [70101, 70102, 70103], "same-title pages must keep stable windowID order") |
| | | require(report.get("lastCommittedAppGroupIndex") == 0, "activation must target App group 0") |
| | | require(report.get("lastCommittedWindowIndex") == 1, "activation must target second page index") |
| | | require(report.get("lastCommittedWindowID") == 70102, "activation commit must target page windowID 70102") |
| | | require(report.get("lastActivationWindowID") == 70102, "activation service must receive exact page windowID 70102") |
| | | require(report.get("lastActivationResult") == "activated", "debug activation must report activated") |
| | | require(root.get("selectedWindowID") == 70102, "selectedWindowID must track exact activated page") |
| | | |
| | | print(json.dumps({ |
| | | "case": "multiPageActivation", |
| | | "windowIDs": ids, |
| | | "lastActivationWindowID": report.get("lastActivationWindowID") |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | assert_close_report() { |
| | | /usr/bin/python3 - "$CLOSE_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 = columns[0].get("cards", []) if columns else [] |
| | | ids = [card.get("windowID") for card in cards] |
| | | |
| | | require(report.get("snapshotLoaded") is True, "close fixture must load") |
| | | require(report.get("quickSwitchVisible") is True, "close verification failure must keep Quick Switch visible") |
| | | require(report.get("appCount") == 1, "multi-page close fixture must expose one App") |
| | | require(report.get("windowCount") == 3, "fixture must still expose all three pages when debug close does not remove them") |
| | | require(ids == [70101, 70102, 70103], "close must not reorder or fake-remove same-title pages") |
| | | require(report.get("lastCloseTargetKind") == "window", "close must target a window/page") |
| | | require(report.get("lastCloseAppGroupIndex") == 0, "close must target App group 0") |
| | | require(report.get("lastCloseWindowID") == 70103, "close service must receive exact page windowID 70103") |
| | | require(report.get("lastCloseResult") == "requested", "debug close must report requested") |
| | | require(report.get("suppressedCloseTargetCount") == 0, "requested-but-still-present must not suppress the card") |
| | | require(root.get("closeFeedbackKind") == "failure", "still-present debug close must show verification failure") |
| | | |
| | | print(json.dumps({ |
| | | "case": "multiPageClose", |
| | | "windowIDs": ids, |
| | | "lastCloseWindowID": report.get("lastCloseWindowID"), |
| | | "closeFeedbackKind": root.get("closeFeedbackKind") |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | stop_current_aligner |
| | | "$SCRIPT_DIR/package-app.sh" >&2 |
| | | |
| | | run_case "$ACTIVATION_REPORT" "click-card:0:1" "activated" "activation" "" |
| | | assert_activation_report |
| | | |
| | | run_case "$CLOSE_REPORT" "click-card-close:0:2,confirm-close" "verifiedFailure" "" "close" |
| | | assert_close_report |
| | | |
| | | echo "Round01 multi-page identity fixture QA passed." |
| | |
| | | require(split.get("type") == "fullscreen", "Split View tile must still be a fullscreen Space") |
| | | require(split.get("windowCount") == 2, "Split View tile must count both fullscreen windows") |
| | | require(split.get("appCount") == 2, "Split View tile must count both Split View apps") |
| | | require(split.get("countVisible") is False, "Split View tile must hide the top-right count label") |
| | | require(split.get("countText") == "", "Split View tile must clear the top-right count text") |
| | | require(split.get("windowBlockCount") == 0, "Split View tile must not draw normal window blocks") |
| | | require("splitView" in split.get("visualStates", []), "Split View tile must expose splitView visual state") |
| | | require(split.get("fullscreenMarkerPathStyle") == "centerDividerOnly", "Split View marker must draw only the center divider") |
| | | require( |
| | | split.get("splitViewAppNames") == ["左侧应用", "右侧应用"], |
| | | "Split View app names must be ordered by physical window frame from left to right" |
| | |
| | | "splitSpace": split.get("label"), |
| | | "splitNames": split.get("splitViewAppNames"), |
| | | "markerKind": split.get("fullscreenMarkerKind"), |
| | | "markerPathStyle": split.get("fullscreenMarkerPathStyle"), |
| | | "countVisible": split.get("countVisible"), |
| | | "singleFullscreenLabels": [segment.get("label") for segment in single_fullscreen], |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |