1703 lines
68 KiB
Swift
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
|
|
}
|
|
}
|