diff --git a/apps/macos/Tests/OpenClawIPCTests/AppKitTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/AppKitTestSupport.swift index c2e29f2c1572..84a509541ed9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppKitTestSupport.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppKitTestSupport.swift @@ -176,12 +176,10 @@ enum AppKitTestSupport { node=\(ObjectIdentifier(button)) type=\(text(controlType)) role=\(String(describing: role)) identifier=\(text(button.accessibilityIdentifier?())) title=\(text(self.accessibilityTitle(of: button))) label=\(text(button.accessibilityLabel?())) value=\(text(valueText)) enabled=\(String(describing: enabled)) frame=\(String(describing: frame)) window=\(window.windowNumber) windowMatches=\(windowMatches) - pressAllowed=\(String(describing: pressAllowed)) showMenuAllowed=\(String(describing: showMenuAllowed)) remaining=\(ContinuousClock.now.duration(to: tracking.expiresAt)) appRunning=\(NSApp.isRunning) + pressAllowed=\(String(describing: pressAllowed)) showMenuAllowed=\(String(describing: showMenuAllowed)) appRunning=\(NSApp.isRunning) """) let performAction: @MainActor () throws -> (String, String?, Bool?) = { - guard ContinuousClock.now < tracking.expiresAt else { - throw InteractionFailure(message: "The menu interaction deadline expired before dispatch") - } + guard !tracking.stopped else { throw CancellationError() } let action: String var ownerType: String? var actionResult: Bool? @@ -215,38 +213,53 @@ enum AppKitTestSupport { throw InteractionFailure(message: "The fixture menu element has no allowed accessibility action: Press=\(String(describing: pressAllowed)), ShowMenu=\(String(describing: showMenuAllowed))") } - guard actionResult != nil else { + guard actionResult == true else { throw InteractionFailure( - message: "The fixture menu element does not implement its allowed \(action) action") + message: "The fixture menu element rejected its allowed \(action) action") } } return (action, ownerType, actionResult) } // AX actions enter NSMenu's nested loop synchronously. Suspend this task first so // Gateway delivery and model-owned dismissal can use the main actor during tracking. - let outcome: (action: String, ownerType: String?, result: Bool?) = try await withCheckedThrowingContinuation { - continuation in - RunLoop.main.perform(inModes: [.common]) { - MainActor.assumeIsolated { - do { continuation.resume(returning: try performAction()) } - catch { continuation.resume(throwing: error) } + let outcome: (action: String, ownerType: String?, result: Bool?) = try await withTaskCancellationHandler { + do { + let result = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<(String, String?, Bool?), Error>) in + RunLoop.main.perform(inModes: [.common]) { + MainActor.assumeIsolated { + do { try continuation.resume(returning: performAction()) } + catch { continuation.resume(throwing: error) } + } + } + CFRunLoopWakeUp(CFRunLoopGetMain()) } + await tracking.waitForCompletion() + return result + } catch { + tracking.stop() + await tracking.waitForCompletion() + throw error + } + } onCancel: { + // Cancellation must also unwind an AX action inside AppKit's nested loop. + RunLoop.main.perform(inModes: [.common, .eventTracking]) { + MainActor.assumeIsolated { tracking.stop() } } CFRunLoopWakeUp(CFRunLoopGetMain()) } - await tracking.waitForCompletion() - let completed = tracking.observed && tracking.completed && !tracking.timedOut + let completed = tracking.observed && tracking.completed print(""" Menu interaction at \(file):\(line) action=\(outcome.action) result=\(String(describing: outcome.result)) - observed=\(tracking.observed) inspected=\(tracking.inspectionCompleted) timedOut=\(tracking.timedOut) error=\(String(describing: tracking.error)) + observed=\(tracking.observed) inspected=\(tracking.inspectionCompleted) dismissed=\(tracking.dismissalObserved) error=\(String(describing: tracking.error)) control=\(controlType) owner=\(String(describing: outcome.ownerType)) role=\(String(describing: role)) appActive=\(NSApp.isActive) visible=\(window.isVisible) key=\(window.isKeyWindow) """) if let error = tracking.error { throw error } try Task.checkCancellation() // Ordinary inspections cancel tracking; lifecycle proofs wait for the owning menu to close itself. guard completed else { - throw InteractionFailure(message: "The native menu inspection must complete before its tracking deadline") + throw InteractionFailure(message: "The native menu closed before its inspection completed") } } @@ -360,24 +373,22 @@ enum AppKitTestSupport { @MainActor private final class AppKitTestMenuTracking: NSObject { - private static let timeout: TimeInterval = 3 let inspect: (NSMenu) throws -> Void - let expiresAt: ContinuousClock.Instant let waitForDismissal: Bool let requireCompositedPopup: Bool private(set) var observed = false private(set) var inspectionCompleted = false - private(set) var timedOut = false + private(set) var dismissalObserved = false + private(set) var stopped = false private(set) var error: Error? - private var dismissalObserved = false private var inspectionStarted = false private var menu: NSMenu? private var inspection: Timer? - private var deadline: Timer? + private var finished = false private var completion: CheckedContinuation? var completed: Bool { - self.inspectionCompleted && (!self.waitForDismissal || self.dismissalObserved) + self.inspectionCompleted && self.dismissalObserved } init( @@ -388,7 +399,6 @@ private final class AppKitTestMenuTracking: NSObject { self.inspect = inspect self.waitForDismissal = waitForDismissal self.requireCompositedPopup = requireCompositedPopup - self.expiresAt = ContinuousClock.now + .seconds(Self.timeout) } func start() { @@ -404,31 +414,18 @@ private final class AppKitTestMenuTracking: NSObject { NotificationCenter.default.addObserver( self, selector: #selector(self.applicationUpdated(_:)), name: NSWindow.didUpdateNotification, object: nil) - let deadline = Timer( - timeInterval: Self.timeout, - target: self, - selector: #selector(self.expire), - userInfo: nil, - repeats: false) - self.deadline = deadline - for mode in [RunLoop.Mode.eventTracking, .common] { - RunLoop.main.add(deadline, forMode: mode) - } } func waitForCompletion() async { - guard !self.completed, !self.timedOut, self.error == nil else { return } + guard !self.finished else { return } + // Cancellation requests dismissal; it must still join the native end notification. await withCheckedContinuation { self.completion = $0 } } @objc private func beganTracking(_ notification: Notification) { - guard !self.observed, let menu = notification.object as? NSMenu else { return } + guard !self.stopped, !self.observed, let menu = notification.object as? NSMenu else { return } self.observed = true self.menu = menu - guard !self.timedOut, ContinuousClock.now < self.expiresAt else { - self.expire() - return - } // AppKit tracks menus in a nested run loop; inspect and cancel in that mode too. let inspection = Timer( timeInterval: self.requireCompositedPopup ? 0.02 : 0, @@ -446,10 +443,10 @@ private final class AppKitTestMenuTracking: NSObject { guard let menu = notification.object as? NSMenu, self.menu === menu else { return } self.menu = nil self.dismissalObserved = true - if self.completed { - self.deadline?.invalidate() - self.resumeWaiter() - } + self.inspection?.invalidate() + // An inspected action can close the menu synchronously. Its defer owns + // completion until the inspection itself returns; an earlier close fails the caller's assertion. + self.finishIfReady() } @objc private func applicationUpdated(_: Notification) { @@ -457,14 +454,10 @@ private final class AppKitTestMenuTracking: NSObject { } @objc private func inspectMenu() { - guard !self.inspectionStarted, let menu = self.menu else { return } - guard !self.timedOut, ContinuousClock.now < self.expiresAt else { - self.expire() - return - } + guard !self.stopped, !self.inspectionStarted, let menu = self.menu else { return } guard NSApp.windows.contains(where: { $0.level == .popUpMenu && $0.isVisible }) else { return } if self.requireCompositedPopup { - // Window Server publication can follow the last AppKit update; retry within the same menu deadline. + // Window Server publication can follow the last AppKit update; observe it while the menu is owned. let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], 0) as? [[String: Any]] ?? [] guard windows.contains(where: { window in @@ -481,41 +474,32 @@ private final class AppKitTestMenuTracking: NSObject { defer { self.inspectionCompleted = true if !self.waitForDismissal || self.error != nil { - self.deadline?.invalidate() self.cancelTracking() - self.resumeWaiter() - } else if self.dismissalObserved { - self.deadline?.invalidate() - self.resumeWaiter() } + self.finishIfReady() } do { try self.inspect(menu) } catch { self.error = error } } - @objc private func expire() { - guard !self.completed else { return } - self.timedOut = true - self.cancelTracking() - self.resumeWaiter() - } - func stop() { + guard !self.stopped else { return } + self.stopped = true self.inspection?.invalidate() - self.deadline?.invalidate() self.cancelTracking() + self.finishIfReady() + } + + private func finishIfReady() { + guard !self.finished, self.menu == nil, !self.inspectionStarted || self.inspectionCompleted else { return } + self.finished = true NotificationCenter.default.removeObserver(self) - self.resumeWaiter() - } - - private func cancelTracking() { - let menu = self.menu - self.menu = nil - menu?.cancelTrackingWithoutAnimation() - } - - private func resumeWaiter() { let completion = self.completion self.completion = nil completion?.resume() } + + private func cancelTracking() { + // Keep the identity until didEndTracking joins the actual dismissal. + self.menu?.cancelTrackingWithoutAnimation() + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateIsolationTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateIsolationTests.swift index 0118cae618d0..894f6018602b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateIsolationTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateIsolationTests.swift @@ -320,7 +320,8 @@ struct AppStateIsolationTests { @MainActor struct ProfileChatPreferencesTests { - @Test func `full chat preferences belong to named profile`() async throws { + @Test(.timeLimit(.minutes(1))) + func `full chat preferences belong to named profile`() async throws { let profile = try #require(AppProfile.current.name) try #require(profile.hasPrefix("test-")) let favoritesKey = "openclaw.chat.modelFavorites" diff --git a/apps/macos/Tests/OpenClawIPCTests/QuickChatCatalogPresentationTests.swift b/apps/macos/Tests/OpenClawIPCTests/QuickChatCatalogPresentationTests.swift index e788f80802f8..e20d827b7dca 100644 --- a/apps/macos/Tests/OpenClawIPCTests/QuickChatCatalogPresentationTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/QuickChatCatalogPresentationTests.swift @@ -9,7 +9,8 @@ import Testing @MainActor struct QuickChatCatalogPresentationTests { - @Test func `rendered Quick Chat preserves catalog disclosure and shortcut behavior in order`() async throws { + @Test(.timeLimit(.minutes(1))) + func `rendered Quick Chat preserves catalog disclosure and shortcut behavior in order`() async throws { try await TestIsolation.withIsolatedState { try await AppKitTestSupport.startApplication() let application = AppKitTestSupport.application @@ -437,7 +438,9 @@ private actor QuickChatCatalogFixture { } } if self.restrictedCatalog == .failed { - return Data(#"{"type":"res","id":"\#(id)","ok":false,"error":{"code":"UNAVAILABLE","message":"Fixture catalog unavailable"}}"#.utf8) + return Data( + #"{"type":"res","id":"\#(id)","ok":false,"error":{"code":"UNAVAILABLE","message":"Fixture catalog unavailable"}}"# + .utf8) } if let restrictedCatalog { let models: String @@ -487,7 +490,9 @@ private actor QuickChatCatalogFixture { } else if params["model"] is NSNull { self.patches.append("model=null") if let restrictedCatalog, restrictedCatalog != .permitted { - return Data(#"{"type":"res","id":"\#(id)","ok":false,"error":{"code":"FORBIDDEN","message":"No permitted default"}}"#.utf8) + return Data( + #"{"type":"res","id":"\#(id)","ok":false,"error":{"code":"FORBIDDEN","message":"No permitted default"}}"# + .utf8) } self.model = self.restrictedCatalog == nil ? "current" : "primary" } else {