mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
test(macos): await completed native menu tracking
Join actual inspection and the matching AppKit tracking-end notification instead of deciding completion with a three-second timer started before accessibility dispatch. Retain menu identity through cancellation and synchronous dismissal, and preserve every preference and lifecycle assertion. The named-profile suite passed 20 runs under two CPU burners. Both rendered suites passed, cancellation retirement was exercised, and P2 review found no actionable issues. The whole package also exposed independent filesystem-watcher and WebKit-cleanup failures; the watcher failure reproduced on the unmodified base.
This commit is contained in:
parent
7153f72e1e
commit
e6d8dae5c2
3 changed files with 66 additions and 76 deletions
|
|
@ -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<Void, Never>?
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue