Files
steipete--codexbar/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift
T
2026-07-13 12:22:33 +08:00

1703 lines
68 KiB
Swift

import AppKit
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar
extension StatusMenuTests {
@Test
func `opening fresh menu does not schedule deferred refresh`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
var providerRefreshCount = 0
var refreshInteractions: [ProviderInteraction] = []
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
refreshInteractions.append(ProviderInteractionContext.current)
providerRefreshCount += 1
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
StatusItemController.setClosedMenuPreparationDelayForTesting(.zero)
defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero)
defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
for _ in 0..<20 {
await Task.yield()
}
#expect(providerRefreshCount == 0)
#expect(!controller.deferredMenuInteractionRefreshPending)
controller.menuDidClose(menu)
for _ in 0..<40 {
await Task.yield()
}
#expect(providerRefreshCount == 0)
#expect(refreshInteractions.isEmpty)
}
@Test
func `non codex menu refresh all also defers codex dashboard refresh`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.refreshAllProvidersOnMenuOpen = true
// Enable several providers; only the available ones land in background work. The
// assertion below compares against that resolved set, so it stays robust regardless.
self.enableProvidersForInstantOpenTesting([.codex, .claude, .factory], settings: settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
// Give every enabled provider a fresh, non-stale snapshot so the ONLY reason to refresh on
// open is the new setting — not a stale/missing retry (which is the pre-existing behavior).
let now = Date()
for provider in store.enabledProvidersForBackgroundWork() {
store._setSnapshotForTesting(
UsageSnapshot(
primary: RateWindow(
usedPercent: 20,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(1800),
resetDescription: nil),
secondary: nil,
updatedAt: now),
provider: provider)
}
var refreshedProviders: Set<UsageProvider> = []
var refreshInteractions: [ProviderInteraction] = []
store._test_providerRefreshOverride = { provider in
refreshedProviders.insert(provider)
refreshInteractions.append(ProviderInteractionContext.current)
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
StatusItemController.setClosedMenuPreparationDelayForTesting(.zero)
defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero)
defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() }
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let expectedProviders = Set(store.enabledProvidersForBackgroundWork())
let menu = controller.makeMenu(for: .claude)
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
for _ in 0..<80 where refreshedProviders != expectedProviders {
await Task.yield()
}
// Every enabled provider is refreshed on open even though all snapshots were fresh.
#expect(refreshedProviders == expectedProviders && expectedProviders.contains(.codex))
#expect(!refreshInteractions.isEmpty)
#expect(refreshInteractions.allSatisfy { $0 == .background })
#expect(controller.deferredOpenAIDashboardRefreshReason != nil)
}
@Test
func `menu open leaves fresh provider untouched when refresh-all-on-open is disabled`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.refreshAllProvidersOnMenuOpen = false
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
var providerRefreshCount = 0
store._test_providerRefreshOverride = { _ in
providerRefreshCount += 1
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
StatusItemController.setClosedMenuPreparationDelayForTesting(.zero)
defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero)
defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() }
// Let the delayed open-refresh task actually fire; with the setting off and fresh data,
// it must still skip the refresh (today's stale/missing-only behavior).
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
for _ in 0..<40 {
await Task.yield()
}
#expect(providerRefreshCount == 0)
}
@Test
func `delayed open refresh does not rebuild fresh menu after unrelated data invalidation`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setMenuOpenRefreshDelayForTesting(.milliseconds(50))
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
let key = ObjectIdentifier(menu)
let openedVersion = controller.menuVersions[key]
var rebuildCount = 0
controller._test_openMenuRebuildObserver = { _ in
rebuildCount += 1
}
defer { controller._test_openMenuRebuildObserver = nil }
// Models first-open storage-footprint publication: menu content becomes stale, but no
// displayed provider was missing or failed when this menu opened.
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
try? await Task.sleep(for: .milliseconds(150))
#expect(controller.menuContentVersion != openedVersion)
#expect(controller.menuVersions[key] == openedVersion)
#expect(rebuildCount == 0)
}
@Test
func `menu open with missing data refreshes asynchronously while tracking`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
var providerRefreshCount = 0
var refreshInteractions: [ProviderInteraction] = []
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
refreshInteractions.append(ProviderInteractionContext.current)
providerRefreshCount += 1
store._setSnapshotForTesting(
UsageSnapshot(
primary: RateWindow(
usedPercent: 25,
windowMinutes: 300,
resetsAt: Date().addingTimeInterval(3600),
resetDescription: nil),
secondary: nil,
updatedAt: Date()),
provider: .codex)
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
StatusItemController.setClosedMenuPreparationDelayForTesting(.zero)
defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero)
defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() }
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
#expect(controller.deferredMenuInteractionRefreshPending)
for _ in 0..<40 where providerRefreshCount == 0 {
await Task.yield()
}
#expect(providerRefreshCount == 1)
#expect(refreshInteractions == [.background])
for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending {
await Task.yield()
}
#expect(!controller.deferredMenuInteractionRefreshPending)
controller.menuDidClose(menu)
#expect(!controller.deferredMenuInteractionRefreshPending)
}
@Test
func `menu open renders cached data immediately after data only invalidation`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
var providerRefreshCount = 0
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
providerRefreshCount += 1
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
let menu = controller.makeMenu()
controller.mergedMenu = menu
controller.statusItem.menu = menu
controller.populateMenu(menu, provider: nil)
controller.markMenuFresh(menu)
let key = ObjectIdentifier(menu)
let cachedItemCount = menu.items.count
let cachedVersion = controller.menuVersions[key]
controller.lastMenuAdjunctReadinessSignature = "stale-baseline"
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
let dataOnlyVersion = controller.menuContentVersion
var asyncRebuilds = 0
controller._test_openMenuRebuildObserver = { _ in
asyncRebuilds += 1
}
defer { controller._test_openMenuRebuildObserver = nil }
controller.menuWillOpen(menu)
#expect(cachedVersion != dataOnlyVersion)
#expect(menu.items.count == cachedItemCount)
#expect(controller.menuVersions[key] == cachedVersion)
#expect(asyncRebuilds == 0)
#expect(!controller.deferredMenuInteractionRefreshPending)
for _ in 0..<40 where asyncRebuilds == 0 {
await Task.yield()
}
#expect(asyncRebuilds == 1)
#expect(controller.menuVersions[key] == controller.menuContentVersion)
#expect(!controller.didMenuAdjunctReadinessChange())
controller.menuDidClose(menu)
for _ in 0..<40 {
await Task.yield()
}
#expect(providerRefreshCount == 0)
}
@Test
func `closing before cached menu rebuild keeps next open stale`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
let menu = controller.makeMenu()
controller.mergedMenu = menu
controller.statusItem.menu = menu
controller.populateMenu(menu, provider: nil)
controller.markMenuFresh(menu)
let key = ObjectIdentifier(menu)
let cachedVersion = controller.menuVersions[key]
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
var rebuildGateEntries = 0
var rebuildGate: CheckedContinuation<Void, Never>?
controller._test_openMenuRefreshYieldOverride = {
rebuildGateEntries += 1
await withCheckedContinuation { continuation in
rebuildGate = continuation
}
}
defer {
rebuildGate?.resume()
controller._test_openMenuRefreshYieldOverride = nil
}
controller.menuWillOpen(menu)
for _ in 0..<40 where rebuildGateEntries == 0 {
await Task.yield()
}
#expect(rebuildGateEntries == 1)
#expect(controller.menuVersions[key] == cachedVersion)
controller.menuDidClose(menu)
#expect(controller.menuNeedsRefresh(menu))
rebuildGate?.resume()
rebuildGate = nil
controller._test_openMenuRefreshYieldOverride = nil
for _ in 0..<20 {
await Task.yield()
}
controller.menuWillOpen(menu)
for _ in 0..<40 where controller.menuNeedsRefresh(menu) {
await Task.yield()
}
#expect(!controller.menuNeedsRefresh(menu))
controller.menuDidClose(menu)
}
@Test
func `menu open rebuilds synchronously after provider identity changes`() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "old@example.com"),
provider: .codex)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
let menu = controller.makeMenu()
controller.mergedMenu = menu
controller.statusItem.menu = menu
controller.populateMenu(menu, provider: nil)
controller.markMenuFresh(menu)
let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)]
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "new@example.com"),
provider: .codex)
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
#expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion)
#expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion)
}
@Test
func `overview menu rebuilds synchronously after secondary provider identity changes`() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
settings.mergedMenuLastSelectedWasOverview = true
self.enableProvidersForInstantOpenTesting([.codex, .claude], settings: settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "codex@example.com"),
provider: .codex)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "old@example.com", provider: .claude),
provider: .claude)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
controller.selectedMenuProvider = .codex
let menu = controller.makeMenu()
controller.mergedMenu = menu
controller.statusItem.menu = menu
controller.populateMenu(menu, provider: nil)
controller.markMenuFresh(menu)
let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)]
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "new@example.com", provider: .claude),
provider: .claude)
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
#expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion)
#expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion)
}
@Test
func `stacked Codex menu rebuilds synchronously after secondary account identity changes`() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
settings.multiAccountMenuLayout = .stacked
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "selected@example.com"),
provider: .codex)
let selectedAccount = CodexVisibleAccount(
id: "selected",
email: "selected@example.com",
workspaceLabel: nil,
workspaceAccountID: nil,
authFingerprint: nil,
storedAccountID: nil,
selectionSource: .liveSystem,
isActive: true,
isLive: true,
canReauthenticate: false,
canRemove: false)
let secondaryAccount = CodexVisibleAccount(
id: "secondary",
email: "secondary@example.com",
workspaceLabel: nil,
workspaceAccountID: nil,
authFingerprint: nil,
storedAccountID: nil,
selectionSource: .liveSystem,
isActive: false,
isLive: false,
canReauthenticate: false,
canRemove: false)
store.codexAccountSnapshots = [
CodexAccountUsageSnapshot(
account: selectedAccount,
snapshot: self.instantOpenSnapshot(email: "selected@example.com"),
error: nil,
sourceLabel: "test"),
CodexAccountUsageSnapshot(
account: secondaryAccount,
snapshot: self.instantOpenSnapshot(email: "old@example.com"),
error: nil,
sourceLabel: "test"),
]
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
let menu = controller.makeMenu()
controller.mergedMenu = menu
controller.statusItem.menu = menu
controller.populateMenu(menu, provider: nil)
controller.markMenuFresh(menu)
let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)]
store.codexAccountSnapshots[1] = CodexAccountUsageSnapshot(
account: secondaryAccount,
snapshot: self.instantOpenSnapshot(email: "new@example.com"),
error: nil,
sourceLabel: "test")
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
#expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion)
#expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion)
}
@Test
func `cache preserving structural invalidation rebuilds synchronously on open`() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
let menu = controller.makeMenu()
controller.mergedMenu = menu
controller.statusItem.menu = menu
controller.populateMenu(menu, provider: nil)
controller.markMenuFresh(menu)
let key = ObjectIdentifier(menu)
let cachedVersion = controller.menuVersions[key]
controller.preservingMergedSwitcherContentCachesDuringInvalidation {
controller.invalidateMenus()
}
#expect(controller.menuVersions[key] == cachedVersion)
#expect(controller.menuContentVersion != controller.latestDataOnlyMenuContentVersion)
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
#expect(controller.menuVersions[key] == controller.menuContentVersion)
#expect(controller.openMenuRebuildTasks[key] == nil)
}
@Test
func `data invalidation after cache preserving structural invalidation still rebuilds synchronously`() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
let menu = controller.makeMenu()
controller.mergedMenu = menu
controller.statusItem.menu = menu
controller.populateMenu(menu, provider: nil)
controller.markMenuFresh(menu)
let key = ObjectIdentifier(menu)
let cachedVersion = controller.menuVersions[key]
controller.preservingMergedSwitcherContentCachesDuringInvalidation {
controller.invalidateMenus()
}
let structuralVersion = controller.menuContentVersion
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
#expect(controller.menuVersions[key] == cachedVersion)
#expect(controller.latestStructuralMenuContentVersion == structuralVersion)
#expect(controller.menuContentVersion == controller.latestDataOnlyMenuContentVersion)
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
#expect(controller.menuVersions[key] == controller.menuContentVersion)
#expect(controller.openMenuRebuildTasks[key] == nil)
}
@Test
func `menu open does not overlap provider specific refresh`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
let refreshGate = BlockingInstantOpenProviderRefresh()
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
await refreshGate.run()
store._setSnapshotForTesting(self.instantOpenSnapshot(email: "refreshed@example.com"), provider: .codex)
}
defer { store._test_providerRefreshOverride = nil }
let existingRefreshTask = Task {
await store.refreshProvider(.codex)
}
await refreshGate.waitUntilStarted(count: 1)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
for _ in 0..<40 {
await Task.yield()
}
#expect(await refreshGate.startCount == 1)
#expect(store.refreshingProviders.contains(.codex))
await refreshGate.releaseFirst()
await existingRefreshTask.value
for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending {
await Task.yield()
}
#expect(!store.isRefreshing)
#expect(!controller.deferredMenuInteractionRefreshPending)
}
@Test
func `cached menu rebuilds after active provider refresh completes`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
let refreshGate = BlockingInstantOpenProviderRefresh()
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
await refreshGate.run()
store._setSnapshotForTesting(self.instantOpenSnapshot(email: "refreshed@example.com"), provider: .codex)
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.mergedMenu = menu
controller.statusItem.menu = menu
controller.populateMenu(menu, provider: nil)
controller.markMenuFresh(menu)
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
let existingRefreshTask = Task {
await store.refreshProvider(.codex)
}
await refreshGate.waitUntilStarted(count: 1)
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
#expect(controller.menuNeedsRefresh(menu))
await refreshGate.releaseFirst()
await existingRefreshTask.value
for _ in 0..<80 where controller.menuNeedsRefresh(menu) {
await Task.yield()
}
#expect(await refreshGate.startCount == 1)
#expect(!controller.menuNeedsRefresh(menu))
}
@Test
func `menu rebuilds after displayed provider completes while another provider refreshes`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "old@example.com"),
provider: .codex)
store.refreshingProviders = [.claude]
defer { store.refreshingProviders = [] }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu(for: .codex)
controller.populateMenu(menu, provider: .codex)
controller.markMenuFresh(menu)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "new@example.com"),
provider: .codex)
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
for _ in 0..<80 where controller.menuNeedsRefresh(menu) {
await Task.yield()
}
#expect(!controller.menuNeedsRefresh(menu))
}
@Test
func `user refresh supersedes background provider refresh`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let refreshGate = BlockingInstantOpenProviderRefresh()
var refreshInteractions: [ProviderInteraction] = []
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
refreshInteractions.append(ProviderInteractionContext.current)
await refreshGate.run()
}
defer { store._test_providerRefreshOverride = nil }
let backgroundRefreshTask = Task {
await ProviderInteractionContext.$current.withValue(.background) {
await store.refreshProvider(.codex, coalesceIfRefreshing: true)
}
}
await refreshGate.waitUntilStarted(count: 1)
let userRefreshTask = Task {
await ProviderInteractionContext.$current.withValue(.userInitiated) {
await store.refresh()
}
}
for _ in 0..<40 {
await Task.yield()
}
#expect(await refreshGate.startCount == 1)
await refreshGate.releaseFirst()
await refreshGate.waitUntilStarted(count: 2)
await userRefreshTask.value
await backgroundRefreshTask.value
#expect(await refreshGate.startCount == 2)
#expect(refreshInteractions == [.background, .userInitiated])
}
@Test
func `settings refresh supersedes background provider refresh without becoming user initiated`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let refreshGate = BlockingInstantOpenProviderRefresh()
var refreshInteractions: [ProviderInteraction] = []
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
refreshInteractions.append(ProviderInteractionContext.current)
await refreshGate.run()
}
defer { store._test_providerRefreshOverride = nil }
let backgroundRefreshTask = Task {
await store.refreshProvider(.codex, coalesceIfRefreshing: true)
}
await refreshGate.waitUntilStarted(count: 1)
let settingsRefreshTask = Task {
await store.refreshForSettingsChange()
}
for _ in 0..<40 {
await Task.yield()
}
#expect(await refreshGate.startCount == 1)
await refreshGate.releaseFirst()
await refreshGate.waitUntilStarted(count: 2)
await settingsRefreshTask.value
await backgroundRefreshTask.value
#expect(await refreshGate.startCount == 2)
#expect(refreshInteractions == [.background, .background])
}
@Test
func `superseded provider refresh drains before newer result`() async throws {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableProvidersForInstantOpenTesting([.claude], settings: settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let refreshes = OrderedInstantOpenProviderRefresh()
let baseSpec = try #require(store.providerSpecs[.claude])
let baseDescriptor = baseSpec.descriptor
let strategy = InstantOpenProviderFetchStrategy {
await refreshes.awaitSnapshot()
}
store.providerSpecs[.claude] = ProviderSpec(
style: baseSpec.style,
isEnabled: baseSpec.isEnabled,
descriptor: ProviderDescriptor(
id: .claude,
metadata: baseDescriptor.metadata,
branding: baseDescriptor.branding,
tokenCost: baseDescriptor.tokenCost,
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .cli, .oauth],
pipeline: ProviderFetchPipeline { _ in [strategy] }),
cli: baseDescriptor.cli),
makeFetchContext: baseSpec.makeFetchContext)
let olderTask = Task {
await store.refreshProvider(.claude)
}
await refreshes.waitUntilStarted(count: 1)
let newerTask = Task {
await store.refreshProvider(.claude)
}
for _ in 0..<40 {
await Task.yield()
}
#expect(await refreshes.startCount == 1)
await refreshes.resume(
call: 1,
snapshot: self.instantOpenSnapshot(
email: "old@example.com",
provider: .claude,
percent: 10))
await refreshes.waitUntilStarted(count: 2)
await refreshes.resume(
call: 2,
snapshot: self.instantOpenSnapshot(
email: "new@example.com",
provider: .claude,
percent: 80))
await newerTask.value
await olderTask.value
#expect(store.snapshot(for: .claude)?.primary?.usedPercent == 80)
#expect(store.snapshot(for: .claude)?.accountEmail(for: .claude) == "new@example.com")
}
@Test
func `superseded provider refresh cannot overwrite manually changed token`() async throws {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings)
settings.stepfunToken = "initial-token"
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let refreshes = OrderedInstantOpenProviderMutation()
let baseSpec = try #require(store.providerSpecs[.stepfun])
let baseDescriptor = baseSpec.descriptor
let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes)
store.providerSpecs[.stepfun] = ProviderSpec(
style: baseSpec.style,
isEnabled: baseSpec.isEnabled,
descriptor: ProviderDescriptor(
id: .stepfun,
metadata: baseDescriptor.metadata,
branding: baseDescriptor.branding,
tokenCost: baseDescriptor.tokenCost,
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .web],
pipeline: ProviderFetchPipeline { _ in [strategy] }),
cli: baseDescriptor.cli),
makeFetchContext: baseSpec.makeFetchContext)
let olderTask = Task {
await store.refreshProvider(.stepfun)
}
await refreshes.waitUntilStarted(count: 1)
let newerTask = Task {
await store.refreshProvider(.stepfun)
}
for _ in 0..<40 {
await Task.yield()
}
#expect(await refreshes.startCount == 1)
settings.stepfunToken = "user-token"
await refreshes.resume(call: 1, token: "old-token")
await refreshes.waitUntilStarted(count: 2)
#expect(settings.stepfunToken == "user-token")
await refreshes.resume(call: 2, token: "new-token")
await newerTask.value
await olderTask.value
#expect(settings.stepfunToken == "new-token")
}
@Test
func `superseded provider refresh preserves rotated token when credential is unchanged`() async throws {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings)
settings.stepfunToken = "initial-token"
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let refreshes = OrderedInstantOpenProviderMutation()
let baseSpec = try #require(store.providerSpecs[.stepfun])
let baseDescriptor = baseSpec.descriptor
let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes)
store.providerSpecs[.stepfun] = ProviderSpec(
style: baseSpec.style,
isEnabled: baseSpec.isEnabled,
descriptor: ProviderDescriptor(
id: .stepfun,
metadata: baseDescriptor.metadata,
branding: baseDescriptor.branding,
tokenCost: baseDescriptor.tokenCost,
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .web],
pipeline: ProviderFetchPipeline { _ in [strategy] }),
cli: baseDescriptor.cli),
makeFetchContext: baseSpec.makeFetchContext)
let olderTask = Task {
await store.refreshProvider(.stepfun)
}
await refreshes.waitUntilStarted(count: 1)
let newerTask = Task {
await store.refreshProvider(.stepfun)
}
await refreshes.resume(call: 1, token: "rotated-token")
await refreshes.waitUntilStarted(count: 2)
#expect(settings.stepfunToken == "rotated-token")
await refreshes.resume(call: 2, token: "newer-token")
await newerTask.value
await olderTask.value
#expect(settings.stepfunToken == "newer-token")
}
@Test
func `canceling provider refresh cancels its owned probe task`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let refreshGate = BlockingInstantOpenProviderRefresh()
var refreshWasCancelled = false
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
await refreshGate.run()
refreshWasCancelled = Task.isCancelled
}
defer { store._test_providerRefreshOverride = nil }
let refreshTask = Task {
await store.refreshProvider(.codex)
}
await refreshGate.waitUntilStarted(count: 1)
refreshTask.cancel()
await refreshGate.releaseFirst()
await refreshTask.value
#expect(refreshWasCancelled)
}
@Test
func `canceling refresh owner keeps shared provider probe alive`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
let refreshGate = BlockingInstantOpenProviderRefresh()
var refreshWasCancelled = false
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
await refreshGate.run()
refreshWasCancelled = Task.isCancelled
}
defer { store._test_providerRefreshOverride = nil }
let ownerTask = Task {
await store.refreshProvider(.codex)
}
await refreshGate.waitUntilStarted(count: 1)
let sharedWaiterTask = Task {
await store.refreshProvider(.codex, coalesceIfRefreshing: true)
}
for _ in 0..<40 {
await Task.yield()
}
ownerTask.cancel()
await refreshGate.releaseFirst()
await ownerTask.value
await sharedWaiterTask.value
#expect(!refreshWasCancelled)
#expect(await refreshGate.startCount == 1)
}
@Test
func `background refresh retries canceled provider probe with cached data`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "cached@example.com"),
provider: .codex)
let refreshGate = BlockingInstantOpenProviderRefresh()
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
await refreshGate.run()
}
defer { store._test_providerRefreshOverride = nil }
let ownerTask = Task {
await store.refreshProvider(.codex)
}
await refreshGate.waitUntilStarted(count: 1)
ownerTask.cancel()
let backgroundTask = Task {
await store.refreshProvider(.codex, coalesceIfRefreshing: true)
}
for _ in 0..<40 {
await Task.yield()
}
await refreshGate.releaseFirst()
await ownerTask.value
await backgroundTask.value
#expect(await refreshGate.startCount == 2)
}
@Test
func `menu open refresh only retries the displayed provider`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
store.refreshingProviders.insert(.claude)
defer { store.refreshingProviders.remove(.claude) }
var refreshedProviders: [UsageProvider] = []
store._test_providerRefreshOverride = { provider in
refreshedProviders.append(provider)
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuProviders[ObjectIdentifier(menu)] = .codex
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
for _ in 0..<40 where refreshedProviders.isEmpty {
await Task.yield()
}
#expect(refreshedProviders == [.codex])
}
@Test
func `opening fresh split menu preserves another provider deferred retry`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "claude@example.com", provider: .claude),
provider: .claude)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60))
StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60))
defer {
StatusItemController.resetMenuOpenRefreshDelayForTesting()
StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting()
}
let codexMenu = controller.makeMenu(for: .codex)
controller.menuWillOpen(codexMenu)
controller.menuDidClose(codexMenu)
#expect(controller.deferredMenuInteractionRefreshProviders == [.codex])
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
let claudeMenu = controller.makeMenu(for: .claude)
controller.menuWillOpen(claudeMenu)
defer { controller.menuDidClose(claudeMenu) }
for _ in 0..<40 {
await Task.yield()
}
#expect(controller.deferredMenuInteractionRefreshProviders == [.codex])
#expect(controller.deferredMenuInteractionRefreshPending)
}
@Test
func `overview defers only providers that need retry`() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
settings.mergedMenuLastSelectedWasOverview = true
self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
store._setSnapshotForTesting(
self.instantOpenSnapshot(email: "claude@example.com", provider: .claude),
provider: .claude)
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60))
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
defer { controller.menuDidClose(menu) }
#expect(controller.deferredMenuInteractionRefreshProviders == [.codex])
}
@Test
func `closing overview menu stops before refreshing another provider`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
settings.mergedMenuLastSelectedWasOverview = true
self.enableProvidersForInstantOpenTesting([.codex, .openai], settings: settings)
settings.updateProviderConfig(provider: .openai) { config in
config.apiKey = "test-openai-key"
}
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
store._setSnapshotForTesting(nil, provider: .openai)
let refreshGate = BlockingInstantOpenProviderRefresh()
store._test_providerRefreshOverride = { _ in
await refreshGate.run()
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60))
defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
await refreshGate.waitUntilStarted(count: 1)
controller.menuDidClose(menu)
await refreshGate.releaseFirst()
for _ in 0..<80 {
await Task.yield()
}
#expect(await refreshGate.startCount == 1)
#expect(controller.deferredMenuInteractionRefreshPending)
}
@Test
func `closing menu during missing data refresh preserves deferred retry`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
let refreshGate = BlockingInstantOpenProviderRefresh()
var refreshInteractions: [ProviderInteraction] = []
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
refreshInteractions.append(ProviderInteractionContext.current)
await refreshGate.run()
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero)
defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() }
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
await refreshGate.waitUntilStarted(count: 1)
#expect(controller.deferredMenuInteractionRefreshPending)
#expect(store.refreshingProviders.contains(.codex))
let periodicRefreshTask = Task {
await store.refresh()
}
for _ in 0..<40 {
await Task.yield()
}
#expect(await refreshGate.startCount == 1)
controller.menuDidClose(menu)
#expect(controller.deferredMenuInteractionRefreshPending)
await refreshGate.releaseFirst()
await periodicRefreshTask.value
for _ in 0..<40 where store.isRefreshing {
await Task.yield()
}
for _ in 0..<40 {
await Task.yield()
}
#expect(controller.deferredMenuInteractionRefreshPending)
controller.scheduleDeferredMenuInteractionRefreshIfNeeded(delay: .zero)
await refreshGate.waitUntilStarted(count: 2)
for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending {
await Task.yield()
}
#expect(await refreshGate.startCount == 2)
#expect(refreshInteractions == [.background, .background])
#expect(!controller.deferredMenuInteractionRefreshPending)
}
@Test
func `closing menu during successful missing data refresh clears deferred retry`() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableOnlyCodexForInstantOpenTesting(settings)
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
store._setSnapshotForTesting(nil, provider: .codex)
let refreshGate = BlockingInstantOpenProviderRefresh()
store._test_providerRefreshOverride = { provider in
guard provider == .codex else { return }
await refreshGate.run()
store._setSnapshotForTesting(
UsageSnapshot(
primary: RateWindow(
usedPercent: 25,
windowMinutes: 300,
resetsAt: Date().addingTimeInterval(3600),
resetDescription: nil),
secondary: nil,
updatedAt: Date()),
provider: .codex)
}
defer { store._test_providerRefreshOverride = nil }
let controller = StatusItemController(
store: store,
settings: settings,
account: UsageFetcher().loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: self.makeStatusBarForTesting())
defer { controller.releaseStatusItemsForTesting() }
controller.menuRefreshEnabledOverrideForTesting = true
StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero)
defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() }
StatusItemController.setMenuOpenRefreshDelayForTesting(.zero)
defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() }
var deferredRefreshCount = 0
controller.onDeferredMenuInteractionRefreshForTesting = {
deferredRefreshCount += 1
}
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
await refreshGate.waitUntilStarted(count: 1)
controller.menuDidClose(menu)
for _ in 0..<80 {
await Task.yield()
}
#expect(deferredRefreshCount == 0)
await refreshGate.releaseFirst()
for _ in 0..<80 where controller.deferredMenuInteractionRefreshPending {
await Task.yield()
}
for _ in 0..<40 {
await Task.yield()
}
#expect(await refreshGate.startCount == 1)
#expect(deferredRefreshCount == 0)
#expect(!controller.deferredMenuInteractionRefreshPending)
}
private func enableOnlyCodexForInstantOpenTesting(_ settings: SettingsStore) {
self.enableProvidersForInstantOpenTesting([.codex], settings: settings)
}
private func instantOpenSnapshot(
email: String,
provider: UsageProvider = .codex,
percent: Double = 25) -> UsageSnapshot
{
UsageSnapshot(
primary: RateWindow(
usedPercent: percent,
windowMinutes: 300,
resetsAt: Date().addingTimeInterval(3600),
resetDescription: nil),
secondary: nil,
updatedAt: Date(),
identity: ProviderIdentitySnapshot(
providerID: provider,
accountEmail: email,
accountOrganization: nil,
loginMethod: "ChatGPT"))
}
private func enableProvidersForInstantOpenTesting(
_ enabledProviders: Set<UsageProvider>,
settings: SettingsStore)
{
let registry = ProviderRegistry.shared
for provider in UsageProvider.allCases {
guard let metadata = registry.metadata[provider] else { continue }
settings.setProviderEnabled(
provider: provider,
metadata: metadata,
enabled: enabledProviders.contains(provider))
}
}
}
private struct InstantOpenProviderFetchStrategy: ProviderFetchStrategy {
let loader: @Sendable () async -> UsageSnapshot
var id: String {
"instant-open-provider-refresh-test"
}
var kind: ProviderFetchKind {
.cli
}
func isAvailable(_: ProviderFetchContext) async -> Bool {
true
}
func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
let usage = await self.loader()
return self.makeResult(usage: usage, sourceLabel: self.id)
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
private struct InstantOpenProviderMutationFetchStrategy: ProviderFetchStrategy {
let mutations: OrderedInstantOpenProviderMutation
let id = "instant-open-provider-mutation-test"
let kind: ProviderFetchKind = .web
func isAvailable(_: ProviderFetchContext) async -> Bool {
true
}
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
let token = await self.mutations.awaitToken()
await context.providerManualTokenUpdater?(.stepfun, token)
let usage = UsageSnapshot(
primary: RateWindow(
usedPercent: 25,
windowMinutes: 300,
resetsAt: Date().addingTimeInterval(3600),
resetDescription: nil),
secondary: nil,
updatedAt: Date())
return self.makeResult(usage: usage, sourceLabel: self.id)
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
private actor OrderedInstantOpenProviderRefresh {
private var started = 0
private var startWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
private var continuations: [Int: CheckedContinuation<UsageSnapshot, Never>] = [:]
var startCount: Int {
self.started
}
func awaitSnapshot() async -> UsageSnapshot {
self.started += 1
let call = self.started
self.resumeReadyStartWaiters()
return await withCheckedContinuation { continuation in
self.continuations[call] = continuation
}
}
func waitUntilStarted(count: Int) async {
if self.started >= count { return }
await withCheckedContinuation { continuation in
self.startWaiters.append((count: count, continuation: continuation))
}
}
func resume(call: Int, snapshot: UsageSnapshot) {
self.continuations.removeValue(forKey: call)?.resume(returning: snapshot)
}
private func resumeReadyStartWaiters() {
var remaining: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
for waiter in self.startWaiters {
if self.started >= waiter.count {
waiter.continuation.resume()
} else {
remaining.append(waiter)
}
}
self.startWaiters = remaining
}
}
private actor OrderedInstantOpenProviderMutation {
private var started = 0
private var startWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
private var continuations: [Int: CheckedContinuation<String, Never>] = [:]
var startCount: Int {
self.started
}
func awaitToken() async -> String {
self.started += 1
let call = self.started
self.resumeReadyStartWaiters()
return await withCheckedContinuation { continuation in
self.continuations[call] = continuation
}
}
func waitUntilStarted(count: Int) async {
if self.started >= count { return }
await withCheckedContinuation { continuation in
self.startWaiters.append((count: count, continuation: continuation))
}
}
func resume(call: Int, token: String) {
self.continuations.removeValue(forKey: call)?.resume(returning: token)
}
private func resumeReadyStartWaiters() {
var remaining: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
for waiter in self.startWaiters {
if self.started >= waiter.count {
waiter.continuation.resume()
} else {
remaining.append(waiter)
}
}
self.startWaiters = remaining
}
}
private actor BlockingInstantOpenProviderRefresh {
private var started = 0
private var startWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
private var firstReleaseWaiters: [CheckedContinuation<Void, Never>] = []
private var firstReleased = false
var startCount: Int {
self.started
}
func run() async {
self.started += 1
self.resumeReadyStartWaiters()
guard self.started == 1, !self.firstReleased else { return }
await withCheckedContinuation { continuation in
self.firstReleaseWaiters.append(continuation)
}
}
func waitUntilStarted(count: Int) async {
if self.started >= count { return }
await withCheckedContinuation { continuation in
self.startWaiters.append((count: count, continuation: continuation))
}
}
func releaseFirst() {
self.firstReleased = true
let waiters = self.firstReleaseWaiters
self.firstReleaseWaiters.removeAll()
waiters.forEach { $0.resume() }
}
private func resumeReadyStartWaiters() {
var remaining: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
for waiter in self.startWaiters {
if self.started >= waiter.count {
waiter.continuation.resume()
} else {
remaining.append(waiter)
}
}
self.startWaiters = remaining
}
}