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

375 lines
13 KiB
Swift

import CodexBarCore
import Foundation
import Testing
@testable import CodexBar
private actor TokenRefreshGate {
private var didStart = false
private var didFinish = false
private var released = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
private var finishWaiters: [CheckedContinuation<Void, Never>] = []
private(set) var calls: [(provider: UsageProvider, force: Bool)] = []
func start(provider: UsageProvider, force: Bool) {
self.didStart = true
self.calls.append((provider, force))
let waiters = self.startWaiters
self.startWaiters.removeAll()
waiters.forEach { $0.resume() }
}
func waitForStart() async {
if self.didStart { return }
await withCheckedContinuation { continuation in
self.startWaiters.append(continuation)
}
}
func waitForRelease() async {
if self.released { return }
await withCheckedContinuation { continuation in
self.releaseWaiters.append(continuation)
}
}
func release() {
self.released = true
let waiters = self.releaseWaiters
self.releaseWaiters.removeAll()
waiters.forEach { $0.resume() }
}
func finish() {
self.didFinish = true
let waiters = self.finishWaiters
self.finishWaiters.removeAll()
waiters.forEach { $0.resume() }
}
func hasFinished() -> Bool {
self.didFinish
}
func waitForFinish() async {
if self.didFinish { return }
await withCheckedContinuation { continuation in
self.finishWaiters.append(continuation)
}
}
}
private actor CompletionFlag {
private var completed = false
func markCompleted() {
self.completed = true
}
func isCompleted() -> Bool {
self.completed
}
}
private actor TokenRefreshRecorder {
private(set) var calls: [(provider: UsageProvider, force: Bool)] = []
func record(provider: UsageProvider, force: Bool) {
self.calls.append((provider, force))
}
func waitForCallCount(_ count: Int, timeout: Duration = .seconds(5)) async -> Bool {
let deadline = ContinuousClock.now + timeout
while self.calls.count < count {
if ContinuousClock.now >= deadline {
return false
}
try? await Task.sleep(for: .milliseconds(10))
}
return true
}
}
@MainActor
@Suite(.serialized)
struct UsageStoreManualTokenRefreshTests {
@Test
func `manual refresh waits for token-cost refresh before completing`() async {
let store = Self.makeStore()
let gate = TokenRefreshGate()
let completion = CompletionFlag()
store._test_providerRefreshOverride = { _ in }
store._test_tokenUsageRefreshOverride = { provider, force in
await gate.start(provider: provider, force: force)
await gate.waitForRelease()
await gate.finish()
}
let task = Task { @MainActor in
await store.refresh(forceTokenUsage: true)
await completion.markCompleted()
}
await gate.waitForStart()
#expect(await completion.isCompleted() == false)
#expect(await gate.hasFinished() == false)
await gate.release()
await task.value
#expect(await completion.isCompleted())
#expect(await gate.hasFinished())
#expect(await gate.calls.map(\.provider) == [.codex])
#expect(await gate.calls.map(\.force) == [true])
}
@Test
func `manual refresh drains scheduled token-cost refresh before forced pass`() async {
let store = Self.makeStore()
let scheduledGate = TokenRefreshGate()
let forcedGate = TokenRefreshGate()
let recorder = TokenRefreshRecorder()
let completion = CompletionFlag()
store._test_providerRefreshOverride = { _ in }
store._test_tokenUsageRefreshOverride = { provider, force in
await recorder.record(provider: provider, force: force)
if force {
await forcedGate.start(provider: provider, force: force)
await forcedGate.waitForRelease()
await forcedGate.finish()
} else {
await scheduledGate.start(provider: provider, force: force)
await scheduledGate.waitForRelease()
await scheduledGate.finish()
}
}
await store.refresh(forceTokenUsage: false)
await scheduledGate.waitForStart()
let task = Task { @MainActor in
await store.refresh(forceTokenUsage: true)
await completion.markCompleted()
}
try? await Task.sleep(for: .milliseconds(50))
#expect(await completion.isCompleted() == false)
await scheduledGate.release()
await forcedGate.waitForStart()
#expect(await completion.isCompleted() == false)
await forcedGate.release()
await task.value
#expect(await completion.isCompleted())
#expect(await scheduledGate.hasFinished())
#expect(await forcedGate.hasFinished())
#expect(await recorder.calls.map(\.provider) == [.codex, .codex])
#expect(await recorder.calls.map(\.force) == [false, true])
}
@Test
func `scoped manual refresh drains scheduled token-cost refresh before forced pass`() async {
let store = Self.makeStore()
let scheduledGate = TokenRefreshGate()
let forcedGate = TokenRefreshGate()
let recorder = TokenRefreshRecorder()
let completion = CompletionFlag()
store._test_providerRefreshOverride = { _ in }
store._test_tokenUsageRefreshOverride = { provider, force in
await recorder.record(provider: provider, force: force)
if force {
await forcedGate.start(provider: provider, force: force)
await forcedGate.waitForRelease()
await forcedGate.finish()
} else {
await scheduledGate.start(provider: provider, force: force)
await scheduledGate.waitForRelease()
await scheduledGate.finish()
}
}
await store.refresh(forceTokenUsage: false)
await scheduledGate.waitForStart()
let task = Task { @MainActor in
await store.refreshTokenUsageNow(for: .codex, force: true)
await completion.markCompleted()
}
try? await Task.sleep(for: .milliseconds(50))
#expect(await completion.isCompleted() == false)
await scheduledGate.release()
await forcedGate.waitForStart()
#expect(await completion.isCompleted() == false)
await forcedGate.release()
await task.value
#expect(await completion.isCompleted())
#expect(await scheduledGate.hasFinished())
#expect(await forcedGate.hasFinished())
#expect(await recorder.calls.map(\.provider) == [.codex, .codex])
#expect(await recorder.calls.map(\.force) == [false, true])
}
@Test
func `scoped manual refresh leaves unrelated scheduled token-cost refresh running`() async {
let store = Self.makeStore(enabledProviders: [.claude, .codex])
let scheduledGate = TokenRefreshGate()
let forcedGate = TokenRefreshGate()
let recorder = TokenRefreshRecorder()
let completion = CompletionFlag()
store._test_providerRefreshOverride = { _ in }
store._test_tokenUsageRefreshOverride = { provider, force in
await recorder.record(provider: provider, force: force)
if force {
await forcedGate.start(provider: provider, force: force)
await forcedGate.waitForRelease()
await forcedGate.finish()
} else {
await scheduledGate.start(provider: provider, force: force)
await scheduledGate.waitForRelease()
await scheduledGate.finish()
}
}
await store.refresh(forceTokenUsage: false)
await scheduledGate.waitForStart()
let task = Task { @MainActor in
await store.refreshTokenUsageNow(for: .claude, force: true)
await completion.markCompleted()
}
await forcedGate.waitForStart()
#expect(await scheduledGate.hasFinished() == false)
#expect(await completion.isCompleted() == false)
#expect(await recorder.calls.map(\.provider) == [.codex, .claude])
#expect(await recorder.calls.map(\.force) == [false, true])
await forcedGate.release()
await task.value
#expect(await completion.isCompleted())
#expect(await scheduledGate.hasFinished() == false)
await scheduledGate.release()
await scheduledGate.waitForFinish()
}
@Test
func `scoped manual refresh preserves an unrelated token sequence before it starts`() async {
let store = Self.makeStore(enabledProviders: [.claude, .codex])
let recorder = TokenRefreshRecorder()
store._test_tokenUsageRefreshOverride = { provider, force in
await recorder.record(provider: provider, force: force)
}
// Do not yield between installing the scheduled slot and starting the scoped refresh. This
// exercises the window before the scheduled task receives its first MainActor turn.
store.scheduleTokenRefreshForTesting()
await store.refreshTokenUsageNow(for: .claude, force: true)
let recordedBothRefreshes = await recorder.waitForCallCount(2)
#expect(recordedBothRefreshes)
let scheduledTask = store.tokenRefreshSequenceTask
await scheduledTask?.value
let calls = await recorder.calls
#expect(calls.contains { $0.provider == .codex && !$0.force })
#expect(calls.contains { $0.provider == .claude && $0.force })
}
@Test
func `regular refresh schedules token-cost refresh without waiting`() async {
let store = Self.makeStore()
let gate = TokenRefreshGate()
store._test_providerRefreshOverride = { _ in }
store._test_tokenUsageRefreshOverride = { provider, force in
await gate.start(provider: provider, force: force)
await gate.waitForRelease()
await gate.finish()
}
await store.refresh(forceTokenUsage: false)
#expect(await gate.hasFinished() == false)
await gate.release()
try? await Task.sleep(for: .milliseconds(50))
let calls = await gate.calls
if !calls.isEmpty {
#expect(calls.map(\.provider) == [.codex])
#expect(calls.map(\.force) == [false])
#expect(await gate.hasFinished())
}
}
@Test
func `forced background refresh bypasses a fresh token cache`() async {
let store = Self.makeStore()
let recorder = TokenRefreshRecorder()
store._test_providerRefreshOverride = { _ in }
store._test_codexCreditsLoaderOverride = {
CreditsSnapshot(remaining: 25, events: [], updatedAt: Date())
}
store._test_tokenUsageRefreshOverride = { provider, force in
await recorder.record(provider: provider, force: force)
}
defer {
store._test_providerRefreshOverride = nil
store._test_codexCreditsLoaderOverride = nil
store._test_tokenUsageRefreshOverride = nil
}
await store.refresh(forceTokenUsage: false)
let didRecordScheduledRefresh = await recorder.waitForCallCount(1)
#expect(didRecordScheduledRefresh)
guard didRecordScheduledRefresh else {
store.cancelForcedRefreshEnrichment()
return
}
await store.refresh(enrichmentMode: .forcedBackground)
await store.awaitForcedRefreshEnrichment()
#expect(await recorder.calls.map(\.provider) == [.codex, .codex])
#expect(await recorder.calls.map(\.force) == [false, true])
}
private static func makeStore(enabledProviders: Set<UsageProvider> = [.codex]) -> UsageStore {
let settings = testSettingsStore(suiteName: "UsageStoreManualTokenRefreshTests")
settings.refreshFrequency = .manual
settings.statusChecksEnabled = false
settings.costUsageEnabled = true
settings.openAIWebAccessEnabled = false
settings.codexCookieSource = .off
settings.providerDetectionCompleted = true
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))
}
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("codexbar-tests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let environment = [
"HOME": root.path,
"CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path,
"XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path,
]
return UsageStore(
fetcher: UsageFetcher(environment: environment),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
startupBehavior: .testing,
environmentBase: environment)
}
}