diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3c77d03e..5b45d98b 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -14,6 +14,9 @@ extension StatusItemController { } func updateBlinkingState() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif // During the loading animation, blink ticks can overwrite the animated menu bar icon and cause flicker. if self.needsMenuBarIconAnimation() { self.stopBlinking() @@ -422,6 +425,9 @@ extension StatusItemController { } @objc func handleDebugBlinkNotification() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.forceBlinkNow() } @@ -500,6 +506,9 @@ extension StatusItemController { } private func updateAnimationFrame() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.animationPhase += 0.045 // half-speed animation if self.shouldMergeIcons { self.applyIcon(phase: self.animationPhase) @@ -519,6 +528,9 @@ extension StatusItemController { } @objc func handleDebugReplayNotification(_ notification: Notification) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif if let raw = notification.userInfo?["pattern"] as? String, let selected = LoadingPattern(rawValue: raw) { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2508e25f..35820e33 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -8,7 +8,26 @@ import SwiftUI extension StatusItemController { private static let menuCardBaseWidth: CGFloat = 310 - private static let menuOpenRefreshDelay: Duration = .seconds(1.2) + private static let defaultMenuOpenRefreshDelay: Duration = .seconds(1.2) + #if DEBUG + private static var menuOpenRefreshDelayForTesting: Duration = .seconds(1.2) + static func setMenuOpenRefreshDelayForTesting(_ delay: Duration) { + self.menuOpenRefreshDelayForTesting = delay + } + + static func resetMenuOpenRefreshDelayForTesting() { + self.menuOpenRefreshDelayForTesting = self.defaultMenuOpenRefreshDelay + } + #endif + + private static var menuOpenRefreshDelay: Duration { + #if DEBUG + menuOpenRefreshDelayForTesting + #else + defaultMenuOpenRefreshDelay + #endif + } + private struct OpenAIWebMenuItems { let hasUsageBreakdown: Bool let hasCreditsHistory: Bool @@ -45,7 +64,11 @@ extension StatusItemController { if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } - self.openMenus[ObjectIdentifier(menu)] = menu + if Self.menuRefreshEnabled { + // Intentionally skip open-menu tracking when refresh is disabled (tests). + // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. + self.openMenus[ObjectIdentifier(menu)] = menu + } // Removed redundant async refresh - single pass is sufficient after initial layout return } @@ -75,7 +98,11 @@ extension StatusItemController { self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } - self.openMenus[ObjectIdentifier(menu)] = menu + if Self.menuRefreshEnabled { + // Intentionally skip open-menu tracking when refresh is disabled (tests). + // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. + self.openMenus[ObjectIdentifier(menu)] = menu + } // Only schedule refresh after menu is registered as open - refreshNow is called async if Self.menuRefreshEnabled { self.scheduleOpenMenuRefresh(for: menu) @@ -512,14 +539,12 @@ extension StatusItemController { } func refreshOpenMenusIfNeeded() { + guard Self.menuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } + var orphanedKeys: [ObjectIdentifier] = [] for (key, menu) in self.openMenus { guard key == ObjectIdentifier(menu) else { - // Clean up orphaned menu entries from all tracking dictionaries - self.openMenus.removeValue(forKey: key) - self.menuRefreshTasks.removeValue(forKey: key)?.cancel() - self.menuProviders.removeValue(forKey: key) - self.menuVersions.removeValue(forKey: key) + orphanedKeys.append(key) continue } @@ -535,6 +560,14 @@ extension StatusItemController { // Heights are already set during populateMenu, no need to remeasure } } + + // Clean up orphaned menu entries from all tracking dictionaries. + for key in orphanedKeys { + self.openMenus.removeValue(forKey: key) + self.menuRefreshTasks.removeValue(forKey: key)?.cancel() + self.menuProviders.removeValue(forKey: key) + self.menuVersions.removeValue(forKey: key) + } } private func menuProvider(for menu: NSMenu) -> UsageProvider? { @@ -562,6 +595,10 @@ extension StatusItemController { guard let self, let menu else { return } try? await Task.sleep(for: Self.menuOpenRefreshDelay) guard !Task.isCancelled else { return } + guard Self.menuRefreshEnabled else { return } + #if DEBUG + self.onDelayedMenuRefreshAttemptForTesting?() + #endif guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard !self.store.isRefreshing else { return } let provider = self.menuProvider(for: menu) ?? self.resolvedMenuProvider() diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index dce63bf7..10f72418 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -15,7 +15,19 @@ protocol StatusItemControlling: AnyObject { final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. static var menuCardRenderingEnabled = !SettingsStore.isRunningTests - static var menuRefreshEnabled = !SettingsStore.isRunningTests + private static let defaultMenuRefreshEnabled = !SettingsStore.isRunningTests + private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests + // TODO(maintainer): Confirm whether runtime toggles outside tests are supported. Current semantics assume + // test-only usage; enabling refresh while a menu is already open does not retro-register/schedule that menu. + #if DEBUG + static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { + self.menuRefreshEnabled = enabled + } + + static func resetMenuRefreshEnabledForTesting() { + self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + } + #endif typealias Factory = (UsageStore, SettingsStore, AccountInfo, UpdaterProviding, PreferencesSelection) -> StatusItemControlling static let defaultFactory: Factory = { store, settings, account, updater, selection in @@ -45,6 +57,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] + #if DEBUG + var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? + var isReleasedForTesting = false + #endif var blinkTask: Task? var loginTask: Task? { didSet { self.refreshMenusForLoginStateChange() } @@ -230,6 +246,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } @objc private func handleProviderConfigDidChange(_ notification: Notification) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let reason = notification.userInfo?["reason"] as? String ?? "unknown" if let source = notification.object as? SettingsStore, source !== self.settings @@ -256,12 +275,17 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func invalidateMenus() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.menuContentVersion &+= 1 + guard Self.menuRefreshEnabled else { return } // Don't refresh menus while they're open - wait until they close and reopen // This prevents expensive rebuilds while user is navigating the menu guard self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded() - Task { @MainActor in + Task { @MainActor [weak self] in + guard let self else { return } // AppKit can ignore menu mutations while tracking; retry on the next run loop. await Task.yield() guard self.openMenus.isEmpty else { return } @@ -295,6 +319,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func handleSettingsChange(reason: String) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() @@ -310,6 +337,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateIcons() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil @@ -336,6 +366,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func updateVisibility() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let anyEnabled = !self.store.enabledProviders().isEmpty let force = self.store.debugForceAnimation let mergeIcons = self.shouldMergeIcons @@ -373,6 +406,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func refreshMenusForLoginStateChange() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.invalidateMenus() if self.shouldMergeIcons { self.attachMenus() @@ -424,6 +460,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func rebuildProviderStatusItems() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif for item in self.statusItems.values { self.statusBar.removeStatusItem(item) } @@ -456,6 +495,43 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin return "\(prefix): \(base)" } + #if DEBUG + func releaseStatusItemsForTesting() { + guard !self.isReleasedForTesting else { return } + self.isReleasedForTesting = true + self.blinkTask?.cancel() + self.loginTask?.cancel() + self.animationDriver?.stop() + self.animationDriver = nil + self.animationPhase = 0 + self.blinkForceUntil = nil + self.blinkStates.removeAll(keepingCapacity: false) + self.blinkAmounts.removeAll(keepingCapacity: false) + self.wiggleAmounts.removeAll(keepingCapacity: false) + self.tiltAmounts.removeAll(keepingCapacity: false) + + for task in self.menuRefreshTasks.values { + task.cancel() + } + self.menuRefreshTasks.removeAll(keepingCapacity: false) + self.openMenus.removeAll(keepingCapacity: false) + self.menuProviders.removeAll(keepingCapacity: false) + self.menuVersions.removeAll(keepingCapacity: false) + self.providerMenus.removeAll(keepingCapacity: false) + self.mergedMenu = nil + self.fallbackMenu = nil + + self.statusItem.menu = nil + self.statusBar.removeStatusItem(self.statusItem) + + for item in self.statusItems.values { + item.menu = nil + self.statusBar.removeStatusItem(item) + } + self.statusItems.removeAll(keepingCapacity: false) + } + #endif + deinit { self.blinkTask?.cancel() self.loginTask?.cancel() diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 0ca26604..9c335d76 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -134,19 +134,7 @@ public enum KeychainAccessPreflight { } #endif guard !KeychainAccessGate.isDisabled else { return .notFound } - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecMatchLimit as String: kSecMatchLimitOne, - // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because - // some macOS configurations have been observed to show the legacy keychain prompt even when - // `kSecUseAuthenticationUIFail` is set. - kSecReturnAttributes as String: true, - ] - KeychainNoUIQuery.apply(to: &query) - if let account { - query[kSecAttrAccount as String] = account - } + let query = self.makeGenericPasswordPreflightQuery(service: service, account: account) var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -174,4 +162,23 @@ public enum KeychainAccessPreflight { return .notFound #endif } + + #if os(macOS) + static func makeGenericPasswordPreflightQuery(service: String, account: String?) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because + // some macOS configurations have been observed to show the legacy keychain prompt unless the query + // is strictly non-interactive. + kSecReturnAttributes as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + if let account { + query[kSecAttrAccount as String] = account + } + return query + } + #endif } diff --git a/Sources/CodexBarCore/KeychainNoUIQuery.swift b/Sources/CodexBarCore/KeychainNoUIQuery.swift index 29ef456a..58df3e08 100644 --- a/Sources/CodexBarCore/KeychainNoUIQuery.swift +++ b/Sources/CodexBarCore/KeychainNoUIQuery.swift @@ -1,19 +1,41 @@ import Foundation #if os(macOS) +import Darwin import LocalAuthentication import Security enum KeychainNoUIQuery { + private static let uiFailPolicy = KeychainNoUIQuery.resolveUIFailPolicy() + static func apply(to query: inout [String: Any]) { let context = LAContext() context.interactionNotAllowed = true query[kSecUseAuthenticationContext as String] = context - // NOTE: While Apple recommends using LAContext.interactionNotAllowed, that alone is not sufficient to - // prevent the legacy keychain "Allow/Deny" prompt on some configurations. We also set the UI policy to fail - // so SecItemCopyMatching returns errSecInteractionNotAllowed instead of showing UI. - query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail + // Keep explicit UI-fail policy for legacy keychain behavior on macOS where + // `interactionNotAllowed` alone can still surface Allow/Deny prompts. + query[kSecUseAuthenticationUI as String] = self.uiFailPolicy as CFString + } + + static func uiFailPolicyForTesting() -> String { + self.uiFailPolicy + } + + private static func resolveUIFailPolicy() -> String { + // Resolve the Security symbol at runtime to preserve the true constant value + // without directly referencing deprecated API at compile time. + let securityPath = "/System/Library/Frameworks/Security.framework/Security" + guard let handle = dlopen(securityPath, RTLD_NOW) else { + return "u_AuthUIF" + } + defer { dlclose(handle) } + + guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else { + return "u_AuthUIF" + } + let valuePointer = symbol.assumingMemoryBound(to: CFString?.self) + return (valuePointer.pointee as String?) ?? "u_AuthUIF" } } #endif diff --git a/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift b/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift index 914c8aae..ef5c7314 100644 --- a/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift +++ b/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift @@ -14,11 +14,9 @@ struct BatteryDrainDiagnosticTests { } private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } @Test("Fallback provider should not animate when all providers are disabled") @@ -54,6 +52,7 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == false, @@ -101,6 +100,7 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == false, @@ -141,6 +141,7 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == true, diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift new file mode 100644 index 00000000..95614da0 --- /dev/null +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -0,0 +1,64 @@ +import Foundation +#if os(macOS) +import Darwin +#endif +import LocalAuthentication +import Security +import Testing +@testable import CodexBarCore + +#if os(macOS) +@Suite +struct KeychainNoUIQueryTests { + private func resolveSecurityUIFailValue() -> String { + let securityPath = "/System/Library/Frameworks/Security.framework/Security" + guard let handle = dlopen(securityPath, RTLD_NOW) else { + return "u_AuthUIF" + } + defer { dlclose(handle) } + guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else { + return "u_AuthUIF" + } + let valuePointer = symbol.assumingMemoryBound(to: CFString?.self) + return (valuePointer.pointee as String?) ?? "u_AuthUIF" + } + + @Test + func apply_setsNonInteractiveContextAndUIFailPolicy() { + var query: [String: Any] = [:] + + KeychainNoUIQuery.apply(to: &query) + + let context = query[kSecUseAuthenticationContext as String] as? LAContext + #expect(context != nil) + #expect(context?.interactionNotAllowed == true) + + let uiPolicy = query[kSecUseAuthenticationUI as String] as? String + #expect(uiPolicy == self.resolveSecurityUIFailValue()) + #expect(uiPolicy == (KeychainNoUIQuery.uiFailPolicyForTesting() as String)) + #expect(uiPolicy != "kSecUseAuthenticationUIFail") + } + + @Test + func preflightQuery_isStrictlyNonInteractiveAndDoesNotRequestSecretData() { + let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( + service: "test.service", + account: "test.account") + + #expect(query[kSecReturnData as String] == nil) + #expect(query[kSecReturnAttributes as String] as? Bool == true) + #expect((query[kSecUseAuthenticationContext as String] as? LAContext)?.interactionNotAllowed == true) + #expect((query[kSecUseAuthenticationUI as String] as? String) == self.resolveSecurityUIFailValue()) + } + + @Test + func preflightQuery_executesWithoutInvalidUIPolicy() { + let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( + service: "codexbar.keychain.noui.\(UUID().uuidString)", + account: nil) + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + #expect(status == errSecItemNotFound || status == errSecInteractionNotAllowed) + } +} +#endif diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index e4c8a540..163e3a88 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -4,14 +4,12 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) struct StatusItemAnimationTests { private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } @Test @@ -45,6 +43,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -96,6 +95,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Enter loading state: no data, no stale error. store._setSnapshotForTesting(nil, provider: .codex) @@ -150,6 +150,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Primary used=10%. Bonus exhausted: used=100% (remaining=0%). let snapshot = UsageSnapshot( @@ -203,6 +204,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Bonus exists but is unused: used=0% (remaining=100%). let snapshot = UsageSnapshot( @@ -255,6 +257,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -294,6 +297,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 55fc217c..952978c8 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -4,19 +4,17 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) struct StatusMenuTests { private func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) } private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } private func makeSettings() -> SettingsStore { @@ -59,6 +57,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let claudeMenu = controller.makeMenu() controller.menuWillOpen(claudeMenu) @@ -105,6 +104,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -155,6 +155,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -183,6 +184,75 @@ struct StatusMenuTests { #expect(hasOpenAIWebSubmenus(menu) == false) } + @Test + func delayedMenuRefreshSkipsWhenRefreshDisabledDuringDelay() async { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + StatusItemController.setMenuOpenRefreshDelayForTesting(.milliseconds(50)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + var delayedRefreshWakeCount = 0 + + await withStatusItemControllerForTesting( + store: store, + settings: settings, + fetcher: fetcher, + statusBar: self.makeStatusBarForTesting()) + { controller in + controller.onDelayedMenuRefreshAttemptForTesting = { + delayedRefreshWakeCount += 1 + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + StatusItemController.setMenuRefreshEnabledForTesting(false) + try? await Task.sleep(for: .milliseconds(180)) + } + + #expect(delayedRefreshWakeCount == 0) + } + + @Test + func loginStateCallbacksDoNotAttachMenusAfterRelease() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.releaseStatusItemsForTesting() + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + + controller.activeLoginProvider = .codex + let loginTask = Task {} + controller.loginTask = loginTask + loginTask.cancel() + controller.loginTask = nil + controller.activeLoginProvider = nil + + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + } + @Test func providerToggleUpdatesStatusItemVisibility() { self.disableMenuCardsForTesting() @@ -212,6 +282,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect(controller.statusItems[.claude]?.isVisible == true) @@ -260,6 +331,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -321,6 +393,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -390,6 +463,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -468,6 +542,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -523,6 +598,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 7d8e2749..1eda9218 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -1,6 +1,9 @@ import CodexBarCore import Foundation @testable import CodexBar +#if os(macOS) +import AppKit +#endif final class InMemoryCookieHeaderStore: CookieHeaderStoring, @unchecked Sendable { var value: String? @@ -132,3 +135,45 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto } return CodexBarConfigStore(fileURL: url) } + +#if os(macOS) +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) throws -> T) rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try operation(controller) +} + +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) async throws -> T) async rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try await operation(controller) +} +#endif