import AppKit import CodexBarCore import Testing @testable import CodexBar @MainActor @Suite(.serialized) struct StatusItemControllerSplitLifecycleTests { private func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(false) } private func makeStatusBarForTesting() -> NSStatusBar { .system } private func makeSettings() -> SettingsStore { let suite = "StatusItemControllerSplitLifecycleTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) return SettingsStore( userDefaults: defaults, configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) } private func containsHostingView(_ view: NSView) -> Bool { if String(describing: type(of: view)).contains("NSHostingView") { return true } return view.subviews.contains { self.containsHostingView($0) } } private func makeSplitController() throws -> (SettingsStore, StatusItemController) { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false settings.providerDetectionCompleted = true let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { if let metadata = registry.metadata[provider] { settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: false) } } try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) try settings.setProviderEnabled( provider: .claude, metadata: #require(registry.metadata[.claude]), enabled: true) 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()) return (settings, controller) } @Test func `provider config notifications relay background work impact between settings stores`() { self.disableMenuCardsForTesting() let sourceSettings = self.makeSettings() let controllerSettings = self.makeSettings() controllerSettings.statusChecksEnabled = false controllerSettings.refreshFrequency = .manual let fetcher = UsageFetcher() let store = UsageStore( fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: controllerSettings) let controller = StatusItemController( store: store, settings: controllerSettings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting(), observeProviderConfigNotifications: true) defer { controller.releaseStatusItemsForTesting() } let initialBackgroundRevision = controllerSettings.backgroundWorkSettingsRevision let reorderedProviders = Array(sourceSettings.orderedProviders().reversed()) sourceSettings.setProviderOrder(reorderedProviders) #expect(controllerSettings.orderedProviders() == reorderedProviders) #expect(controllerSettings.backgroundWorkSettingsRevision == initialBackgroundRevision) sourceSettings.codexUsageDataSource = .cli #expect(controllerSettings.codexUsageDataSource == .cli) #expect(controllerSettings.backgroundWorkSettingsRevision == initialBackgroundRevision + 1) } @Test func `merged mode removes split provider status items`() throws { let (settings, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } #expect(controller.statusItems[.codex] != nil) #expect(controller.statusItems[.claude] != nil) #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-codex", "codexbar-claude"]) settings.mergeIcons = true controller.handleProviderConfigChange(reason: "test") #expect(controller.statusItem.isVisible == true) #expect(controller.statusItems.isEmpty) #expect(controller.expectedVisibleStatusItemAutosaveNames == ["codexbar-merged"]) } @Test func `removing split provider status items clears all menu lifecycle state`() throws { let (settings, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } let menus = try [UsageProvider.codex, .claude].map { provider in try #require(controller.providerMenus[provider]) } let keys = menus.map(ObjectIdentifier.init) for (menu, key) in zip(menus, keys) { controller.menuProviders[key] = .codex controller.menuReadinessSignatures[key] = "readiness" controller.menuIdentitySignatures[key] = "identity" controller.menuSession.markFresh(key) controller.menuSession.deferUntilNextOpen(key) controller.menuSession.deferParentRebuild(key) controller.openMenus[key] = menu controller.menuRefreshTasks[key] = Task { try? await Task.sleep(for: .seconds(60)) } controller.closedMenuRebuildTasks[key] = Task { try? await Task.sleep(for: .seconds(60)) } controller.openMenuRebuildTasks[key] = Task { try? await Task.sleep(for: .seconds(60)) } _ = controller.closedMenuRebuildRequests.replaceRequest(for: key) _ = controller.openMenuRebuildRequests.replaceRequest(for: key) controller.openMenuRebuildsClosingHostedSubviewMenus.insert(key) controller.highlightedMenuItems[key] = NSMenuItem(title: "Highlighted", action: nil, keyEquivalent: "") controller.nativeHighlightDeferredMenuRebuilds[key] = .init(provider: .codex) controller.pendingMenuBaselineResyncs.insert(key) } settings.mergeIcons = true controller.handleProviderConfigChange(reason: "test") for key in keys { #expect(controller.menuProviders[key] == nil) #expect(controller.menuReadinessSignatures[key] == nil) #expect(controller.menuIdentitySignatures[key] == nil) #expect(controller.menuSession.renderedVersion(for: key) == nil) #expect(!controller.menuSession.isDeferredUntilNextOpen(key)) #expect(!controller.menuSession.isParentRebuildDeferred(key)) #expect(controller.openMenus[key] == nil) #expect(controller.menuRefreshTasks[key] == nil) #expect(controller.closedMenuRebuildTasks[key] == nil) #expect(controller.openMenuRebuildTasks[key] == nil) #expect(controller.closedMenuRebuildRequests.tokens[key] == nil) #expect(controller.openMenuRebuildRequests.tokens[key] == nil) #expect(!controller.openMenuRebuildsClosingHostedSubviewMenus.contains(key)) #expect(controller.highlightedMenuItems[key] == nil) #expect(controller.nativeHighlightDeferredMenuRebuilds[key] == nil) #expect(!controller.pendingMenuBaselineResyncs.contains(key)) } } @Test func `menu bar icons stay appkit hosted`() throws { let (settings, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } let codexButton = try #require(controller.statusItems[.codex]?.button) #expect(codexButton.image != nil) #expect(!self.containsHostingView(codexButton)) settings.mergeIcons = true controller.handleProviderConfigChange(reason: "test") let mergedButton = try #require(controller.statusItem.button) #expect(mergedButton.image != nil) #expect(!self.containsHostingView(mergedButton)) } @Test func `status items publish stable manager identity`() throws { let (_, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } let codexButton = try #require(controller.statusItems[.codex]?.button) let claudeButton = try #require(controller.statusItems[.claude]?.button) #expect(controller.statusItem.autosaveName == "codexbar-merged") #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") #expect(controller.statusItems[.claude]?.autosaveName == "codexbar-claude") #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") #expect(codexButton.accessibilityIdentifier() == "CodexBar.StatusItem.codex") #expect(claudeButton.accessibilityIdentifier() == "CodexBar.StatusItem.claude") #expect(controller.statusItem.button?.accessibilityTitle() == "CodexBar") #expect(codexButton.accessibilityTitle() == "CodexBar") #expect(claudeButton.accessibilityTitle() == "CodexBar") } @Test func `status item identity returns stable autosave names`() { #expect(StatusItemController.StatusItemIdentity.merged.autosaveName == "codexbar-merged") #expect(StatusItemController.StatusItemIdentity.provider(.codex).autosaveName == "codexbar-codex") #expect(StatusItemController.StatusItemIdentity.provider(.claude).autosaveName == "codexbar-claude") } @Test func `status item placement preflight leaves fresh install placement unset`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-missing-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) #expect(defaults.object(forKey: key) == nil) } @Test func `status item placement preflight preserves missing new key when legacy item placement exists`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") #expect(!MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-merged", legacyDefaultItemIndex: 0)) #expect(defaults.object(forKey: key) == nil) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) } @Test func `status item placement preflight clears suspicious matching legacy placement`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-high-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-0") let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") #expect(MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-merged", legacyDefaultItemIndex: 0, maximumPreferredPosition: 3000)) #expect(defaults.object(forKey: key) == nil) #expect(defaults.object(forKey: "NSStatusItem Preferred Position Item-0") == nil) } @Test func `status item placement preflight preserves missing new key when mixed legacy placements exist`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-mixed-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") #expect(!MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-merged", legacyDefaultItemIndex: 0)) #expect(defaults.object(forKey: key) == nil) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 11298) } @Test func `status item placement preflight clears provider matching suspicious legacy placement`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-mixed-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") #expect(MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-codex", legacyDefaultItemIndex: 1, maximumPreferredPosition: 3000)) #expect(defaults.object(forKey: key) == nil) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) #expect(defaults.object(forKey: "NSStatusItem Preferred Position Item-1") == nil) } @Test func `status item placement preflight leaves provider key unset when only merged legacy placement exists`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-single-legacy-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") #expect(!MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-codex", legacyDefaultItemIndex: 1)) #expect(defaults.object(forKey: key) == nil) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) } @Test func `status item placement preflight preserves provider key with matching legacy placement`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-matching-legacy-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") defaults.set(58, forKey: "NSStatusItem Preferred Position Item-1") let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") #expect(!MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-codex", legacyDefaultItemIndex: 1)) #expect(defaults.object(forKey: key) == nil) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 58) } @Test func `status item placement preflight clears suspicious high position`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-high-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") defaults.set(11298, forKey: key) #expect(MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-merged", maximumPreferredPosition: 3000)) #expect(defaults.object(forKey: key) == nil) } @Test func `status item placement preflight clears old forced zero position`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-zero-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") defaults.set(0, forKey: key) #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) #expect(defaults.object(forKey: key) == nil) } @Test func `status item placement preflight clears malformed position`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-malformed-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") defaults.set("not-a-position", forKey: key) #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) #expect(defaults.object(forKey: key) == nil) } @Test func `status item placement preflight preserves reasonable position`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") defaults.set(42, forKey: key) #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) #expect(defaults.double(forKey: key) == 42) } @Test func `status item placement preflight preserves large display position`() throws { let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-large-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defer { defaults.removePersistentDomain(forName: suite) } let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") defaults.set(2500, forKey: key) #expect(!MenuBarStatusItemPlacementPreflight.prepare( defaults: defaults, autosaveName: "codexbar-merged", maximumPreferredPosition: 2560)) #expect(defaults.double(forKey: key) == 2500) } @Test func `status item defaults repair removes stale hidden Control Center keys once`() throws { let suite = "StatusItemControllerSplitLifecycleTests-repair-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defaults.set(false, forKey: "NSStatusItem VisibleCC Item-0") defaults.set(0, forKey: "NSStatusItem VisibleCC Item-12") defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-merged") defaults.set(true, forKey: "NSStatusItem VisibleCC Item-1") defaults.set(false, forKey: "NSStatusItem VisibleCC com.apple.clock") defer { defaults.removePersistentDomain(forName: suite) } let repairedKeys = MenuBarStatusItemDefaultsRepair.repairHiddenVisibilityDefaultsIfNeeded(defaults: defaults) #expect(repairedKeys == [ "NSStatusItem VisibleCC Item-0", "NSStatusItem VisibleCC Item-12", "NSStatusItem VisibleCC codexbar-merged", ]) #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-0") == nil) #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-12") == nil) #expect(defaults.object(forKey: "NSStatusItem VisibleCC codexbar-merged") == nil) #expect(defaults.bool(forKey: "NSStatusItem VisibleCC Item-1")) #expect(defaults.object(forKey: "NSStatusItem VisibleCC com.apple.clock") != nil) defaults.set(false, forKey: "NSStatusItem VisibleCC Item-2") #expect(MenuBarStatusItemDefaultsRepair.repairHiddenVisibilityDefaultsIfNeeded(defaults: defaults).isEmpty) #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-2") != nil) } @Test func `status item visibility default distinguishes enabled disabled and unset`() throws { let suite = "StatusItemControllerSplitLifecycleTests-visibility-default-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defaults.set(true, forKey: "NSStatusItem VisibleCC codexbar-merged") defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-claude") defer { defaults.removePersistentDomain(forName: suite) } #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( defaults: defaults, autosaveName: "codexbar-merged") == true) #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( defaults: defaults, autosaveName: "codexbar-claude") == false) #expect(MenuBarStatusItemDefaultsRepair.visibilityDefault( defaults: defaults, autosaveName: "codexbar-codex") == nil) } @Test func `non destructive visibility refresh preserves split provider status items`() throws { let (_, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } let oldCodexItem = try #require(controller.statusItems[.codex]) let oldClaudeItem = try #require(controller.statusItems[.claude]) let oldCodexButton = try #require(oldCodexItem.button) controller.refreshExistingStatusItemsForVisibilityRecovery() let newCodexItem = try #require(controller.statusItems[.codex]) let newClaudeItem = try #require(controller.statusItems[.claude]) #expect(newCodexItem === oldCodexItem) #expect(newClaudeItem === oldClaudeItem) #expect(newCodexItem.button === oldCodexButton) #expect(newCodexItem.autosaveName == "codexbar-codex") #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") } @Test func `non destructive visibility refresh preserves merged status item`() throws { let (settings, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } settings.mergeIcons = true controller.handleProviderConfigChange(reason: "test") let oldMergedItem = controller.statusItem let oldMergedButton = try #require(controller.statusItem.button) controller.refreshExistingStatusItemsForVisibilityRecovery() #expect(controller.statusItem === oldMergedItem) #expect(controller.statusItem.button === oldMergedButton) #expect(controller.statusItem.autosaveName == "codexbar-merged") #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") } @Test func `recreation produces immediately healthy snapshots for synchronous guidance check`() throws { // verifyScreenChangeRecoveryIfNeeded does a synchronous re-check immediately after // the single recreation to decide whether to show macOS 26 Allow-in-Menu-Bar guidance. // AppKit must materialise the button and window before returning from // recreateStatusItemsForVisibilityRecovery, so the item must not appear blocked at // that point. Only a genuine system-level block would leave it blocked — which is // exactly the case where guidance is useful. let (_, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } controller.recreateStatusItemsForVisibilityRecovery() let allItems = [controller.statusItem] + Array(controller.statusItems.values) let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(allItems) #expect(!MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(snapshots)) } @Test func `visibility recovery recreates split provider status items`() throws { let (_, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } let oldCodexItem = try #require(controller.statusItems[.codex]) controller.recreateStatusItemsForVisibilityRecovery() let newCodexItem = try #require(controller.statusItems[.codex]) #expect(newCodexItem !== oldCodexItem) #expect(newCodexItem.autosaveName == "codexbar-codex") #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") } @Test func `visibility recovery renders replacement merged status item`() throws { let (settings, controller) = try self.makeSplitController() defer { controller.releaseStatusItemsForTesting() } settings.mergeIcons = true controller.handleProviderConfigChange(reason: "test") let renderedSignature = try #require(controller.lastAppliedMergedIconRenderSignature) controller.lastAppliedMergedIconRenderSignature = renderedSignature controller.recreateStatusItemsForVisibilityRecovery() let mergedButton = try #require(controller.statusItem.button) #expect(mergedButton.image != nil) #expect(controller.statusItem.autosaveName == "codexbar-merged") #expect(mergedButton.accessibilityIdentifier() == "CodexBar.StatusItem") } }