diff --git a/CHANGELOG.md b/CHANGELOG.md index b1171dc206..42bbfafc6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.49.1 — Unreleased ### Fixed +- Codex: publish refreshed core quota immediately while optional Credits and OpenAI Web enrichment continues, without unfreezing cards whose layout still needs reconciliation (#2799). Thanks @Yuxin-Qiao! - Menu bar: keep DeepSeek balances compact and consistent between saved custom layouts and their live editor preview (#2638). Thanks @Yuxin-Qiao! - Settings: show CodexBar in the Dock while Settings or an update dialog is open, so Check for Updates and new-version prompts reliably appear in front. - Claude CLI: let explicit CLI usage and Auto fallback delegate authentication to the installed Claude executable, so an unavailable browser session no longer masks usable reduced-fidelity CLI usage. diff --git a/Sources/CodexBar/MenuCardRefreshMonitor.swift b/Sources/CodexBar/MenuCardRefreshMonitor.swift index 9c443be602..65492bce5a 100644 --- a/Sources/CodexBar/MenuCardRefreshMonitor.swift +++ b/Sources/CodexBar/MenuCardRefreshMonitor.swift @@ -22,6 +22,9 @@ final class MenuCardRefreshMonitor { /// refreshing one provider does not stall or unfreeze another. private var manualRefreshProviders: Set = [] private var frozenManualRefreshModels: [ProviderInstanceID: UsageMenuCardView.Model] = [:] + /// Core models published before optional enrichment finishes. These keep an already-hosted card on the + /// refreshed quota if a later enrichment step temporarily changes the model's tracked layout. + private var publishedManualRefreshModels: [ProviderInstanceID: UsageMenuCardView.Model] = [:] /// True while any manual refresh (global or per-provider) is running. var isManualRefreshInFlight: Bool { @@ -55,16 +58,38 @@ final class MenuCardRefreshMonitor { if let provider { self.manualRefreshProviders.remove(provider.instanceID) self.frozenManualRefreshModels[provider.instanceID] = nil + self.publishedManualRefreshModels[provider.instanceID] = nil } else { self.globalManualRefreshInFlight = false self.frozenManualRefreshModels.removeAll(keepingCapacity: true) + self.publishedManualRefreshModels.removeAll(keepingCapacity: true) } } + /// Ends a provider-scoped refresh only when the hosted card can adopt the resolved model without a rebuild. + /// The published model stays pinned as a compatible fallback until the caller reaches its final reconciliation. + @discardableResult + func publishResolvedModelIfCompatible(for provider: UsageProvider) -> Bool { + let instanceID = provider.instanceID + guard self.manualRefreshProviders.contains(instanceID), + let frozen = self.frozenManualRefreshModels[instanceID], + let resolved = self.resolveModel(provider), + frozen.hasCompatibleTrackedLayout(with: resolved) + else { + return false + } + + self.manualRefreshProviders.remove(instanceID) + self.frozenManualRefreshModels[instanceID] = nil + self.publishedManualRefreshModels[instanceID] = resolved + return true + } + func resetManualRefresh() { self.globalManualRefreshInFlight = false self.manualRefreshProviders.removeAll(keepingCapacity: true) self.frozenManualRefreshModels.removeAll(keepingCapacity: true) + self.publishedManualRefreshModels.removeAll(keepingCapacity: true) } func isManualRefreshInFlight(for provider: UsageProvider) -> Bool { @@ -91,12 +116,17 @@ final class MenuCardRefreshMonitor { return fallback } - guard let resolved = self.resolveModel(provider), - fallback.hasCompatibleTrackedLayout(with: resolved) - else { - return fallback + if let resolved = self.resolveModel(provider), + fallback.hasCompatibleTrackedLayout(with: resolved) + { + return resolved + } + if let published = self.publishedManualRefreshModels[provider.instanceID], + fallback.hasCompatibleTrackedLayout(with: published) + { + return published } - return resolved + return fallback } func subtitle( diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index bed5829d66..adaaf5788b 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -79,6 +79,12 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { let refreshStartedAt = Date() await self.store.refreshProvider(provider) guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } + // Provider-specific by design: Codex publishes a compatible core quota model before its optional + // credits and OpenAI Web enrichment stages below. Incompatible cards remain frozen until the final + // menu reconciliation, so they never lose their loading state while still showing the old layout. + if provider == .codex { + self.menuCardRefreshMonitor.publishResolvedModelIfCompatible(for: provider) + } await self.store.refreshProviderStatus(provider) guard !Task.isCancelled, !self.hasPreparedForAppShutdown else { return } await self.store.refreshTokenUsageNow(for: provider, force: true) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index a00abeba1c..21c0036f40 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2363,7 +2363,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Actions.swift", - line: 371, + line: 377, anchor: "if provider == .qoder {", expectedProviderIDs: ["claude", "qoder"], expectedReferenceCount: 3, @@ -2371,7 +2371,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Actions.swift", - line: 441, + line: 447, anchor: "?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledFirstPartyProviders().first)", expectedProviderIDs: ["codex"], expectedReferenceCount: 4, @@ -2379,7 +2379,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Actions.swift", - line: 462, + line: 468, anchor: "?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledFirstPartyProviders().first)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 4, @@ -2387,7 +2387,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Actions.swift", - line: 535, + line: 541, anchor: "?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2395,7 +2395,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Actions.swift", - line: 594, + line: 600, anchor: "self.lazyStatusItem(for: provider ?? .codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2403,7 +2403,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Actions.swift", - line: 698, + line: 704, anchor: "return .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift index 10ccb10bf3..ceb3ed687e 100644 --- a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -433,6 +433,149 @@ struct StatusMenuPersistentRefreshTests { #expect(refreshed.metrics.map(\.percent) != fallback.metrics.map(\.percent)) } + @Test + func `refresh monitor publishes compatible core and pins it until final reconciliation`() throws { + let settings = self.makeSettings() + self.enableOnly([.codex], settings: settings) + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let monitor = controller.menuCardRefreshMonitor + let now = Date(timeIntervalSince1970: 1_700_000_000) + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh(frozenModels: [.codex: frozen], provider: .codex) + defer { monitor.endManualRefresh(for: .codex) } + + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(1)) + let core = try #require(controller.menuCardModel(for: .codex)) + + #expect(monitor.publishResolvedModelIfCompatible(for: .codex)) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + #expect(monitor.model(for: .codex, fallback: frozen).metrics.map(\.percent) == core.metrics.map(\.percent)) + + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 70, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now.addingTimeInterval(2)) + let enriched = try #require(controller.menuCardModel(for: .codex)) + let visibleBeforeReconciliation = monitor.model(for: .codex, fallback: frozen) + let visibleAfterReconciliation = monitor.model(for: .codex, fallback: enriched) + + #expect(visibleBeforeReconciliation.metrics.map(\.percent) == core.metrics.map(\.percent)) + #expect(visibleAfterReconciliation.metrics.map(\.percent) == enriched.metrics.map(\.percent)) + if ProcessInfo.processInfo.environment["CODEXBAR_REFRESH_PROBE"] == "1" { + print( + "CODEXBAR_REFRESH_PROBE compatible-layout-shift refreshing=false pinned=" + + "\(visibleBeforeReconciliation.metrics.first?.percentLabel ?? "none") " + + "reconciledRows=\(visibleAfterReconciliation.metrics.count)") + } + } + + @Test + func `refresh monitor keeps incompatible core frozen until reconciliation`() throws { + let settings = self.makeSettings() + self.enableOnly([.codex], settings: settings) + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let monitor = controller.menuCardRefreshMonitor + let now = Date(timeIntervalSince1970: 1_700_000_000) + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 15, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh(frozenModels: [.codex: frozen], provider: .codex) + defer { monitor.endManualRefresh(for: .codex) } + + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 45, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 65, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now.addingTimeInterval(1)) + let refreshed = try #require(controller.menuCardModel(for: .codex)) + + #expect(!monitor.publishResolvedModelIfCompatible(for: .codex)) + #expect(monitor.isManualRefreshInFlight(for: .codex)) + #expect(monitor.subtitle( + for: .codex, + fallback: MenuCardLiveSubtitle(text: "old", style: .info)).style == .loading) + #expect(monitor.model(for: .codex, fallback: frozen).metrics.map(\.percent) == frozen.metrics.map(\.percent)) + + monitor.endManualRefresh(for: .codex) + let reconciled = monitor.model(for: .codex, fallback: refreshed) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + #expect(reconciled.metrics.map(\.percent) == refreshed.metrics.map(\.percent)) + if ProcessInfo.processInfo.environment["CODEXBAR_REFRESH_PROBE"] == "1" { + print( + "CODEXBAR_REFRESH_PROBE incompatible coreRows=\(refreshed.metrics.count) " + + "blockedState=refreshing reconciledState=published") + } + } + + @Test + func `refresh monitor publishes compatible core error honestly`() throws { + let settings = self.makeSettings() + self.enableOnly([.codex], settings: settings) + let controller = self.makeController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let monitor = controller.menuCardRefreshMonitor + let now = Date(timeIntervalSince1970: 1_700_000_000) + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .codex)) + monitor.beginManualRefresh(frozenModels: [.codex: frozen], provider: .codex) + defer { monitor.endManualRefresh(for: .codex) } + + controller.store.errors[.codex] = "Synthetic core refresh failure" + + #expect(monitor.publishResolvedModelIfCompatible(for: .codex)) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + let subtitle = monitor.subtitle( + for: .codex, + fallback: MenuCardLiveSubtitle(text: "old", style: .info)) + #expect(subtitle.style == .error) + #expect(monitor.model(for: .codex, fallback: frozen).metrics.map(\.percent) == frozen.metrics.map(\.percent)) + } + @Test func `manual refresh keeps frozen quota even if menu rebuilds before completion`() throws { let settings = self.makeSettings() diff --git a/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift b/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift index 532ec780cd..fbfb88e28c 100644 --- a/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift @@ -3,9 +3,172 @@ import Foundation import Testing @testable import CodexBar +@MainActor +private final class ScopedRefreshGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + if self.isOpen { + self.isOpen = false + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + if let continuation = self.continuation { + continuation.resume() + self.continuation = nil + } else { + self.isOpen = true + } + } + + func waitUntilSignaled(timeout: Duration = .seconds(5)) async -> Bool { + let deadline = ContinuousClock.now + timeout + while !self.isOpen { + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } + self.isOpen = false + return true + } +} + @MainActor @Suite(.serialized) struct StatusMenuScopedCodexRefreshTests { + @Test + func `scoped refresh publishes compatible quota before dashboard enrichment completes`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = false + settings.showOptionalCreditsAndExtraUsage = true + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .manual + settings.codexCookieHeader = "session=fixture" + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "fixture@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "fixture@example.com")) + settings.codexActiveSource = .liveSystem + self.enableOnlyCodex(settings) + defer { settings._test_liveSystemCodexAccount = nil } + + let account = AccountInfo(email: "fixture@example.com", plan: "pro") + let environment = Self.isolatedEnvironment() + let fetcher = UsageFetcher(environment: environment) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + store.accountInfoCache[.codex] = UsageStore.AccountInfoCacheEntry( + account: account, + configRevision: settings.configRevision, + expiresAt: .distantFuture) + let initialUpdatedAt = Date(timeIntervalSince1970: 1_700_000_000) + store.snapshots[.codex] = Self.snapshot(usedPercent: 12, updatedAt: initialUpdatedAt) + + try await withStatusItemControllerForTesting( + store: store, + settings: settings, + fetcher: fetcher, + account: account) + { controller in + let creditsStarted = ScopedRefreshGate() + let releaseCredits = ScopedRefreshGate() + let monitor = controller.menuCardRefreshMonitor + let frozen = try #require(controller.menuCardModel(for: .codex)) + var coreModel: UsageMenuCardView.Model? + var creditsLoaderCalls = 0 + var dashboardLoaderCalls = 0 + + store._test_providerRefreshOverride = { provider in + #expect(provider == .codex) + store.snapshots[.codex] = Self.snapshot( + usedPercent: 37, + updatedAt: initialUpdatedAt.addingTimeInterval(60)) + store.errors[.codex] = nil + coreModel = controller.menuCardModel(for: .codex) + } + store._test_codexCreditsLoaderOverride = { + creditsLoaderCalls += 1 + if creditsLoaderCalls == 1 { + creditsStarted.resume() + await releaseCredits.wait() + } + return CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + dashboardLoaderCalls += 1 + return OpenAIDashboardSnapshot( + signedInEmail: account.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()) + } + defer { + releaseCredits.resume() + monitor.endManualRefresh(for: .codex) + store._test_providerRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_openAIDashboardLoaderOverride = nil + } + + monitor.beginManualRefresh(frozenModels: [.codex: frozen], provider: .codex) + let refreshTask = Task { @MainActor in + await controller.performStoreRefresh( + for: .codex, + refreshOpenMenusWhenComplete: false, + interaction: .userInitiated) + } + let enrichmentDidStart = await creditsStarted.waitUntilSignaled() + #expect(enrichmentDidStart) + guard enrichmentDidStart else { + await refreshTask.value + return + } + + let expectedCore = try #require(coreModel) + let visibleWhileBlocked = monitor.model(for: .codex, fallback: frozen) + #expect(!monitor.isManualRefreshInFlight(for: .codex)) + #expect(visibleWhileBlocked.metrics.map(\.percent) == expectedCore.metrics.map(\.percent)) + #expect(visibleWhileBlocked.metrics.map(\.percent) != frozen.metrics.map(\.percent)) + self.emitProbe( + "compatible enrichment=blocked refreshing=false before=" + + "\(frozen.metrics.first?.percentLabel ?? "none") core=" + + "\(visibleWhileBlocked.metrics.first?.percentLabel ?? "none")") + + releaseCredits.resume() + await refreshTask.value + + let finalModel = try #require(controller.menuCardModel(for: .codex)) + let visibleAfterEnrichment = monitor.model(for: .codex, fallback: finalModel) + #expect(store.credits?.remaining == 25) + #expect(store.openAIDashboard?.creditsRemaining == 25) + #expect(creditsLoaderCalls >= 1) + #expect(dashboardLoaderCalls >= 1) + #expect(visibleAfterEnrichment.hasCompatibleTrackedLayout(with: finalModel)) + self.emitProbe( + "compatible enrichment=complete credits=\(store.credits?.remaining ?? -1) " + + "dashboardCredits=\(store.openAIDashboard?.creditsRemaining ?? -1)") + } + } + @Test func `scoped refresh reconciles usage after dashboard login expires`() async { let settings = self.makeSettings() @@ -22,52 +185,51 @@ struct StatusMenuScopedCodexRefreshTests { account: account, configRevision: settings.configRevision, expiresAt: .distantFuture) - let controller = StatusItemController( + await withStatusItemControllerForTesting( store: store, settings: settings, - account: account, - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: .system) - - var providerRefreshes = 0 - store._test_providerRefreshOverride = { provider in - #expect(provider == .codex) - providerRefreshes += 1 - } - store._test_tokenUsageRefreshOverride = { _, _ in } - store._test_codexCreditsLoaderOverride = { - CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) - } - store._test_openAIDashboardLoaderOverride = { _, _, _, _ in - throw OpenAIDashboardFetcher.FetchError.loginRequired - } - store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in - OpenAIDashboardBrowserCookieImporter.ImportResult( - sourceLabel: "Chrome", - cookieCount: 2, - signedInEmail: targetEmail, - matchesCodexEmail: true) - } + fetcher: fetcher, + account: account) + { controller in + var providerRefreshes = 0 + store._test_providerRefreshOverride = { provider in + #expect(provider == .codex) + providerRefreshes += 1 + } + store._test_tokenUsageRefreshOverride = { _, _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + throw OpenAIDashboardFetcher.FetchError.loginRequired + } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { + store._test_providerRefreshOverride = nil + store._test_tokenUsageRefreshOverride = nil + store._test_codexCreditsLoaderOverride = nil + store._test_openAIDashboardLoaderOverride = nil + store._test_openAIDashboardCookieImportOverride = nil + } - await controller.performStoreRefresh( - for: .codex, - refreshOpenMenusWhenComplete: false, - interaction: .userInitiated) + await controller.performStoreRefresh( + for: .codex, + refreshOpenMenusWhenComplete: false, + interaction: .userInitiated) - #expect(store.openAIDashboardRequiresLogin) - #expect(providerRefreshes == 2) + #expect(store.openAIDashboardRequiresLogin) + #expect(providerRefreshes == 2) + } } private func makeSettings() -> SettingsStore { - let suite = "StatusMenuScopedCodexRefreshTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - return SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + testSettingsStore(suiteName: "StatusMenuScopedCodexRefreshTests") } private func enableOnlyCodex(_ settings: SettingsStore) { @@ -76,4 +238,42 @@ struct StatusMenuScopedCodexRefreshTests { settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) } } + + private static func isolatedEnvironment() -> [String: String] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return [ + "HOME": root.path, + "CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path, + "XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path, + ] + } + + private static func snapshot( + usedPercent: Double, + secondaryUsedPercent: Double? = nil, + updatedAt: Date) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(3600), + resetDescription: nil), + secondary: secondaryUsedPercent.map { percent in + RateWindow( + usedPercent: percent, + windowMinutes: 10080, + resetsAt: updatedAt.addingTimeInterval(7200), + resetDescription: nil) + }, + updatedAt: updatedAt) + } + + private func emitProbe(_ line: String) { + guard ProcessInfo.processInfo.environment["CODEXBAR_REFRESH_PROBE"] == "1" else { return } + let data = Data("CODEXBAR_REFRESH_PROBE \(line)\n".utf8) + FileHandle.standardError.write(data) + } } diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index bfce8d16c0..5fa99984e3 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -171,13 +171,14 @@ func withStatusItemControllerForTesting( store: UsageStore, settings: SettingsStore, fetcher: UsageFetcher, + account: AccountInfo? = nil, statusBar: NSStatusBar = .system, operation: (StatusItemController) throws -> T) rethrows -> T { let controller = StatusItemController( store: store, settings: settings, - account: fetcher.loadAccountInfo(), + account: account ?? fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: statusBar) @@ -191,13 +192,14 @@ func withStatusItemControllerForTesting( store: UsageStore, settings: SettingsStore, fetcher: UsageFetcher, + account: AccountInfo? = nil, statusBar: NSStatusBar = .system, operation: (StatusItemController) async throws -> T) async rethrows -> T { let controller = StatusItemController( store: store, settings: settings, - account: fetcher.loadAccountInfo(), + account: account ?? fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: statusBar)