import AppKit import ConjureKit import Testing @testable import Conjure @Test(.serialized, arguments: [true, false]) @MainActor func themeToggleDismissesOnlyOnSuccessAndPreventsOverlappingRequests(_ succeeds: Bool) async throws { _ = NSApplication.shared let suite = "ConjureTheme.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }) let theme = Application(command: .toggleTheme) let competitor = Application(url: URL(fileURLWithPath: "/Applications/Zebra.app"), name: "Zebra") let entries = [theme, competitor] history.recordLaunch(of: competitor) var pending: CheckedContinuation? var requests = 0 let launcher = LauncherWindowController(history: history, defaults: defaults, toggleSystemTheme: { requests += 1 try await withCheckedThrowingContinuation { pending = $0 } }) let window = try #require(launcher.window) defer { if let sheet = window.attachedSheet { window.endSheet(sheet) sheet.orderOut(nil) } window.orderOut(nil) } // Exercise search and dismissal without activating the test host or another running app. window.orderFront(nil) func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } let content = try #require(window.contentView) let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) search.stringValue = "theme" launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) _ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:))) try await waitForTheme("Request started") { pending != nil } #expect(window.isVisible) #expect(history.suggestions(in: entries).first?.id == competitor.id) launcher.toggle(command: .toggleTheme) #expect(requests == 1) let continuation = try #require(pending) if succeeds { continuation.resume() try await waitForTheme("Success dismissed launcher") { !window.isVisible } #expect(history.suggestions(in: entries).first?.id == theme.id) } else { continuation.resume(throwing: NSError(domain: "ThemeTest", code: -1743, userInfo: [NSLocalizedDescriptionKey: "Automation permission denied"])) try await waitForTheme("Failure showed alert") { window.attachedSheet != nil } #expect(window.isVisible) #expect(history.suggestions(in: entries).first?.id == competitor.id) let sheet = try #require(window.attachedSheet) window.endSheet(sheet) sheet.orderOut(nil) try await waitForTheme("Failure alert closed") { window.attachedSheet == nil } } } @MainActor private final class ThemeWindowEvents { var becameKey = false } @Test(.serialized, arguments: [true, false], [true, false]) @MainActor func actionActivationPreservesWindowVisibilityAndSearch(_ directPresentation: Bool, _ initiallyVisible: Bool) async throws { _ = NSApplication.shared let suite = "ConjureBackgroundTheme.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }) let theme = Application(command: .toggleTheme) let competitor = Application(url: URL(fileURLWithPath: "/Applications/Zebra.app"), name: "Zebra") let entries = [theme, competitor] history.recordLaunch(of: competitor) var pending: CheckedContinuation? let launcher = LauncherWindowController(history: history, defaults: defaults, toggleSystemTheme: { await withCheckedContinuation { pending = $0 } }) let window = try #require(launcher.window) defer { window.orderOut(nil) } if initiallyVisible { window.orderFront(nil) } func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } let content = try #require(window.contentView) let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) search.stringValue = "keep this search" let events = ThemeWindowEvents() let observer = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main ) { _ in MainActor.assumeIsolated { events.becameKey = true } } defer { NotificationCenter.default.removeObserver(observer) } if directPresentation { launcher.present(command: .toggleTheme) } else { launcher.toggle(command: .toggleTheme) } #expect(window.isVisible == initiallyVisible) try await waitForTheme("Background action started") { pending != nil } #expect(window.isVisible == initiallyVisible) #expect(search.stringValue == "keep this search") #expect(history.suggestions(in: entries).first?.id == competitor.id) pending?.resume() try await waitForTheme("Background action completed") { history.suggestions(in: entries).first?.id == theme.id } #expect(window.isVisible == initiallyVisible) #expect(search.stringValue == "keep this search") #expect(!events.becameKey) } @MainActor private func waitForTheme(_ state: Comment, _ condition: () -> Bool) async throws { // Scheduling deadline only; launch ranking uses the fixed clock above. for _ in 0..<1_000 { if condition() { return } try await Task.sleep(for: .milliseconds(1)) } try #require(condition(), state) }