diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 83e7f31038..8d54d68c22 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -54,6 +54,8 @@ struct SpendDashboardPane: View { self.store = store self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }, cachedLoader: { request in + await SpendDashboardSource.loadCached(request) })) } @@ -136,11 +138,12 @@ struct SpendDashboardPane: View { .frame(maxWidth: .infinity, minHeight: 220) } } else if self.controller.model.groups.isEmpty { + let emptyState = SpendDashboardEmptyState.make(isRefreshing: self.controller.isRefreshing) SpendDashboardPanel { ContentUnavailableView { - Label(L("No local cost history yet"), systemImage: "chart.bar.xaxis") + Label(emptyState.title, systemImage: "chart.bar.xaxis") } description: { - Text(L("Turn on cost tracking or refresh after using a supported provider.")) + Text(emptyState.message) } .frame(maxWidth: .infinity, minHeight: 220) } @@ -226,6 +229,22 @@ struct SpendDashboardPane: View { } } +struct SpendDashboardEmptyState: Equatable { + let title: String + let message: String + + static func make(isRefreshing: Bool) -> Self { + if isRefreshing { + return Self( + title: L("Refreshing"), + message: L("Local estimated cost history across supported providers.")) + } + return Self( + title: L("No local cost history yet"), + message: L("Turn on cost tracking or refresh after using a supported provider.")) + } +} + private struct SpendCurrencySection: View { let group: SpendDashboardModel.CurrencyGroup let requestedDays: Int diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 6f9ec8c361..875b07a5a3 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -117,6 +117,9 @@ struct CodexSpendSnapshotLoadContext: Sendable { enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + typealias CachedCodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async + -> CostUsageTokenSnapshot? + typealias CodexCacheRootResolver = @Sendable (CodexSpendScanRequest) -> URL static let scanDays = 30 @@ -251,6 +254,70 @@ enum SpendDashboardSource { }) } + static func loadCached(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + await self.loadCached(request, cacheRootResolver: { self.codexCacheRoot(for: $0) }) + } + + static func loadCached( + _ request: SpendDashboardLoadRequest, + cacheRootResolver: @escaping CodexCacheRootResolver) async -> SpendDashboardLoadResult + { + await self.loadCached( + request, + cacheRootResolver: cacheRootResolver, + cachedCodexSnapshotLoader: { context in + await CostUsageFetcher(cacheRoot: context.cacheRoot).loadCachedCodexTokenSnapshotForScopedHome( + now: context.now, + codexHomePath: context.account.homePath, + historyDays: context.historyDays, + includePiSessions: false, + includeProjectAndSessionBreakdowns: false) + }) + } + + static func loadCached( + _ request: SpendDashboardLoadRequest, + cachedCodexSnapshotLoader: CachedCodexSnapshotLoader) async -> SpendDashboardLoadResult + { + await self.loadCached( + request, + cacheRootResolver: { self.codexCacheRoot(for: $0) }, + cachedCodexSnapshotLoader: cachedCodexSnapshotLoader) + } + + private static func loadCached( + _ request: SpendDashboardLoadRequest, + cacheRootResolver: CodexCacheRootResolver, + cachedCodexSnapshotLoader: CachedCodexSnapshotLoader) async -> SpendDashboardLoadResult + { + var inputs = request.capturedInputs + for account in request.codexRequests { + guard !Task.isCancelled, + self.currentAuthFingerprint(for: account) == account.authFingerprint + else { continue } + let snapshot = await cachedCodexSnapshotLoader(CodexSpendSnapshotLoadContext( + account: account, + cacheRoot: cacheRootResolver(account), + now: request.now, + force: false, + historyDays: Self.scanDays, + refreshPricingInBackground: false, + includePiSessions: false)) + guard !Task.isCancelled, + let snapshot, + self.currentAuthFingerprint(for: account) == account.authFingerprint + else { continue } + inputs.append(SpendDashboardModel.ProviderInput( + id: "codex:\(account.id)", + provider: .codex, + displayName: account.displayName, + modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, + snapshot: snapshot)) + } + // A cache miss is still pending fresh validation, not a provider failure. + return SpendDashboardLoadResult(inputs: inputs, failedSourceIDs: []) + } + static func load( _ request: SpendDashboardLoadRequest, codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult @@ -266,9 +333,7 @@ enum SpendDashboardSource { invalidatedSourceIDs.insert(sourceID) continue } - let cacheRoot = UsageStore.costUsageCacheDirectory() - .appendingPathComponent("accounts", isDirectory: true) - .appendingPathComponent(account.cacheIdentity, isDirectory: true) + let cacheRoot = self.codexCacheRoot(for: account) let snapshot = try await codexSnapshotLoader(CodexSpendSnapshotLoadContext( account: account, cacheRoot: cacheRoot, @@ -313,6 +378,18 @@ enum SpendDashboardSource { invalidatedSourceIDs: invalidatedSourceIDs) } + private static func codexCacheRoot(for account: CodexSpendScanRequest) -> URL { + let costUsageDirectory = UsageStore.costUsageCacheDirectory() + if account.source == .liveSystem { + // The live account reads the same local-home telemetry as UsageStore's ambient scanner. + // Reuse that cache instead of indexing the identical session corpus a second time. + return costUsageDirectory.deletingLastPathComponent() + } + return costUsageDirectory + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent(account.cacheIdentity, isDirectory: true) + } + private static func loadCodexSnapshot( _ context: CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot { @@ -560,6 +637,7 @@ final class SpendDashboardController { typealias RequestBuilder = @MainActor @Sendable (SpendDashboardRequestBuildMode) async -> SpendDashboardLoadRequest typealias Loader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult + typealias CachedLoader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult private enum ReconciliationObservation: Sendable { case confirmedEmpty @@ -627,12 +705,14 @@ final class SpendDashboardController { } private enum LoadPhase: Sendable { + case priming case ordinary case forcing case reconciling(ForcedOutcome) var buildMode: SpendDashboardRequestBuildMode { switch self { + case .priming: .captureOnly case .ordinary: .refreshMissing case .forcing: .forceRefresh case .reconciling: .captureOnly @@ -641,7 +721,7 @@ final class SpendDashboardController { var manualRefreshOutstanding: Bool { switch self { - case .ordinary: false + case .priming, .ordinary: false case .forcing, .reconciling: true } } @@ -657,6 +737,7 @@ final class SpendDashboardController { private static let daysDefaultsKey = "settingsSpendDashboardDays" private let userDefaults: UserDefaults private let requestBuilder: RequestBuilder + private let cachedLoader: CachedLoader? private let loader: Loader private let nowProvider: @Sendable () -> Date private var loadTask: Task? @@ -668,11 +749,13 @@ final class SpendDashboardController { init( userDefaults: UserDefaults = .standard, requestBuilder: @escaping RequestBuilder, + cachedLoader: CachedLoader? = nil, loader: @escaping Loader = SpendDashboardSource.load, nowProvider: @escaping @Sendable () -> Date = { Date() }) { self.userDefaults = userDefaults self.requestBuilder = requestBuilder + self.cachedLoader = cachedLoader self.loader = loader self.nowProvider = nowProvider self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) @@ -694,7 +777,18 @@ final class SpendDashboardController { { return } - let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary + let ownershipChanged = previousConfiguration.map { + !Self.sameSourceOwnership($0, configuration) + } ?? false + let shouldPrime = self.cachedLoader != nil && + (self.lastSuccessfulConfiguration == nil || ownershipChanged) + let nextPhase: LoadPhase = if self.phase.manualRefreshOutstanding { + .forcing + } else if shouldPrime { + .priming + } else { + .ordinary + } self.startLoad(configuration: configuration, phase: nextPhase) } @@ -707,7 +801,7 @@ final class SpendDashboardController { self.loadTask?.cancel() let invalidatedSourceIDs = switch phase { case let .reconciling(outcome): outcome.invalidatedSourceIDs - case .ordinary, .forcing: + case .priming, .ordinary, .forcing: Self.invalidatedSourceIDs( previous: self.lastSuccessfulConfiguration, current: configuration) @@ -799,6 +893,27 @@ final class SpendDashboardController { } switch phase { + case .priming: + guard let cachedLoader = self.cachedLoader else { + self.startLoad(configuration: request.configuration, phase: .ordinary) + return + } + let result = await cachedLoader(request) + guard !Task.isCancelled, + generation == self.generation, + let latestConfiguration = self.configuration + else { return } + guard request.configuration == latestConfiguration else { + self.startLoad(configuration: latestConfiguration, phase: .ordinary) + return + } + self.apply( + request: request, + result: result, + invalidatedSourceIDs: invalidatedSourceIDs, + confirmedEmptySourceIDs: []) + self.startLoad(configuration: request.configuration, phase: .ordinary) + case .ordinary: let result = await self.loader(request) guard !Task.isCancelled, @@ -850,6 +965,7 @@ final class SpendDashboardController { { self.configuration = configuration let nextPhase: LoadPhase = switch phase { + case .priming: .priming case .ordinary: .ordinary case .forcing: .forcing case let .reconciling(outcome): @@ -948,7 +1064,11 @@ final class SpendDashboardController { self.loadedAt = now ?? self.nowProvider() self.rebuildModel() guard let configuration else { return } - let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary + let nextPhase: LoadPhase = switch self.phase { + case .priming: .priming + case .ordinary: .ordinary + case .forcing, .reconciling: .forcing + } self.startLoad(configuration: configuration, phase: nextPhase) } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 295fa75e06..d8f6968843 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -42,30 +42,6 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions = scannerOptions } - public func loadCachedCodexTokenSnapshot( - now: Date = Date(), - codexHomePath: String? = nil, - historyDays: Int = 30) async -> CostUsageTokenSnapshot? - { - await Self.loadCachedCodexTokenSnapshot( - now: now, - codexHomePath: codexHomePath, - historyDays: historyDays, - scannerOptions: self.scannerOptionsOverride()) - } - - package func loadCachedCodexTokenSnapshotResult( - now: Date = Date(), - codexHomePath: String? = nil, - historyDays: Int = 30) async -> CachedCodexTokenSnapshotResult? - { - await Self.loadCachedCodexTokenSnapshotResult( - now: now, - codexHomePath: codexHomePath, - historyDays: historyDays, - scannerOptions: self.scannerOptionsOverride()) - } - public func loadTokenSnapshot( provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -444,12 +420,18 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), codexHomePath: String? = nil, historyDays: Int = 30, + allowScopedCodexHome: Bool = false, + includePiSessions: Bool = true, + includeProjectAndSessionBreakdowns: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot? { await self.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: codexHomePath, historyDays: historyDays, + allowScopedCodexHome: allowScopedCodexHome, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, scannerOptions: overrideScannerOptions)?.snapshot } @@ -457,12 +439,14 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), codexHomePath: String? = nil, historyDays: Int = 30, + allowScopedCodexHome: Bool = false, + includePiSessions: Bool = true, + includeProjectAndSessionBreakdowns: Bool = true, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CachedCodexTokenSnapshotResult? { - if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), - !codexHomePath.isEmpty - { + let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) + if scopedCodexHomePath?.isEmpty == false, !allowScopedCodexHome { return nil } @@ -473,7 +457,11 @@ public struct CostUsageFetcher: Sendable { let until = now let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) - let options = overrideScannerOptions ?? CostUsageScanner.Options() + let options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: .codex, + codexHomePath: codexHomePath) + let shouldMergePiUsage = scopedCodexHomePath?.isEmpty != false let roots = CostUsageScanner.codexSessionsRoots(options: options) let cache = CostUsageScanner.codexCache( CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot), @@ -502,26 +490,30 @@ public struct CostUsageFetcher: Sendable { nativeScanAt = scanAt scanTimes.append(scanAt) } - sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( - cache: cache, - range: range, - modelsDevCacheRoot: options.cacheRoot, - sessionRoots: roots) - if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { - projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( + if includeProjectAndSessionBreakdowns { + sessions = CostUsageScanner.buildCodexSessionBreakdownsFromCache( cache: cache, range: range, - modelsDevCacheRoot: options.cacheRoot)) + modelsDevCacheRoot: options.cacheRoot, + sessionRoots: roots) + if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion { + projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot)) + } } } } - if let piResult = PiSessionCostScanner.loadCachedDailyReportResult( - provider: .codex, - since: since, - until: until, - now: now, - cacheRoot: options.cacheRoot) + if includePiSessions, + shouldMergePiUsage, + let piResult = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .codex, + since: since, + until: until, + now: now, + cacheRoot: options.cacheRoot) { reports.append(piResult.report) piMerged = true @@ -890,6 +882,57 @@ public struct CostUsageFetcher: Sendable { } extension CostUsageFetcher { + public func loadCachedCodexTokenSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30, + includePiSessions: Bool = true, + includeProjectAndSessionBreakdowns: Bool = true) async -> CostUsageTokenSnapshot? + { + await Self.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + allowScopedCodexHome: false, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, + scannerOptions: self.scannerOptionsOverride()) + } + + package func loadCachedCodexTokenSnapshotForScopedHome( + now: Date = Date(), + codexHomePath: String, + historyDays: Int = 30, + includePiSessions: Bool = false, + includeProjectAndSessionBreakdowns: Bool = true) async -> CostUsageTokenSnapshot? + { + await Self.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + allowScopedCodexHome: true, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, + scannerOptions: self.scannerOptionsOverride()) + } + + package func loadCachedCodexTokenSnapshotResult( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30, + includePiSessions: Bool = true, + includeProjectAndSessionBreakdowns: Bool = true) async -> CachedCodexTokenSnapshotResult? + { + await Self.loadCachedCodexTokenSnapshotResult( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + allowScopedCodexHome: false, + includePiSessions: includePiSessions, + includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, + scannerOptions: self.scannerOptionsOverride()) + } + fileprivate static func loadRemoteTokenSnapshot( provider: UsageProvider, environment: [String: String], diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index bcf9d25777..e11f9f0891 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -114,11 +114,27 @@ struct CostUsageFetcherCacheSnapshotTests { now: hydratedAt, historyDays: 1, scannerOptions: options) + let nativeOnly = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: hydratedAt, + historyDays: 1, + includePiSessions: false, + includeProjectAndSessionBreakdowns: false, + scannerOptions: options) + let scoped = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: hydratedAt, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + allowScopedCodexHome: true, + scannerOptions: options) #expect(cached?.snapshot.sessionTokens == 207) #expect(cached?.snapshot.updatedAt == oldestScanTime) #expect(cached?.snapshot.updatedAt != hydratedAt) #expect(cached?.lastRefreshAt == nil) + #expect(nativeOnly?.snapshot.sessionTokens == 42) + #expect(nativeOnly?.snapshot.projects.isEmpty == true) + #expect(nativeOnly?.snapshot.sessions.isEmpty == true) + #expect(scoped?.snapshot.sessionTokens == 42) } @Test @@ -208,7 +224,7 @@ struct CostUsageFetcherCacheSnapshotTests { } @Test - func `cached codex token snapshot refuses expanded or managed scopes`() async throws { + func `cached codex token snapshot keeps scoped homes opt in and validates their roots`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -238,9 +254,27 @@ struct CostUsageFetcherCacheSnapshotTests { codexHomePath: env.codexHomeRoot.path, historyDays: 1, scannerOptions: options) + let optedIn = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + allowScopedCodexHome: true, + scannerOptions: options) + let mismatchedHome = env.root.appendingPathComponent("other-codex-home", isDirectory: true) + try FileManager.default.createDirectory( + at: mismatchedHome.appendingPathComponent("sessions", isDirectory: true), + withIntermediateDirectories: true) + let mismatched = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + codexHomePath: mismatchedHome.path, + historyDays: 1, + allowScopedCodexHome: true, + scannerOptions: options) #expect(expanded == nil) #expect(managed == nil) + #expect(optedIn?.sessionTokens == 42) + #expect(mismatched == nil) } @Test diff --git a/Tests/CodexBarTests/SpendDashboardCachedRefreshTestSupport.swift b/Tests/CodexBarTests/SpendDashboardCachedRefreshTestSupport.swift new file mode 100644 index 0000000000..395c43d5be --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardCachedRefreshTestSupport.swift @@ -0,0 +1,81 @@ +import CodexBarCore +import Foundation +@testable import CodexBar + +@MainActor +final class CachedRefreshControllerBox { + var controller: SpendDashboardController? +} + +actor CachedRefreshModeRecorder { + private(set) var values: [SpendDashboardRequestBuildMode] = [] + + func append(_ mode: SpendDashboardRequestBuildMode) { + self.values.append(mode) + } +} + +actor CachedRefreshCodexLoadRecorder { + private(set) var contexts: [CodexSpendSnapshotLoadContext] = [] + + func record(_ context: CodexSpendSnapshotLoadContext) { + self.contexts.append(context) + } +} + +actor CachedRefreshRequestGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func suspend() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } +} + +actor CachedRefreshResultGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} + +actor CachedRefreshLoaderGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardCachedRefreshTests.swift b/Tests/CodexBarTests/SpendDashboardCachedRefreshTests.swift new file mode 100644 index 0000000000..61b624730c --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardCachedRefreshTests.swift @@ -0,0 +1,262 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardCachedRefreshTests { + @Test + func `cached dashboard data renders before request building and fresh loading finish`() async { + let loaderGate = CachedRefreshLoaderGate() + let requestGate = CachedRefreshRequestGate() + let configuration = Self.configuration(account: "cached") + let cachedInput = Self.input(cost: 3) + let controller = SpendDashboardController( + requestBuilder: { mode in + if mode == .refreshMissing { + await requestGate.suspend() + } + return Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult(inputs: [cachedInput], failedSourceIDs: []) + }, + loader: { request in await loaderGate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForRequestGate(requestGate) + + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.isRefreshing) + #expect(await loaderGate.pendingCount == 0) + + await requestGate.resume() + await Self.waitForPendingCount(1, gate: loaderGate) + await loaderGate.resume(at: 0, result: .init(inputs: [Self.input(cost: 5)], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 5) + } + + @Test + func `replacement generation rejects stale priming cache completion`() async { + let cachedGate = CachedRefreshResultGate() + let loaderGate = CachedRefreshLoaderGate() + let controllerBox = CachedRefreshControllerBox() + let firstConfiguration = Self.configuration(account: "first") + let secondConfiguration = Self.configuration(account: "second") + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request( + configuration: controllerBox.controller?.configuration ?? firstConfiguration, + force: mode.forcesLoader) + }, + cachedLoader: { request in await cachedGate.load(request) }, + loader: { request in await loaderGate.load(request) }) + controllerBox.controller = controller + + controller.update(configuration: firstConfiguration) + await Self.waitForCachedPendingCount(1, gate: cachedGate) + controller.update(configuration: secondConfiguration) + await Self.waitForCachedPendingCount(2, gate: cachedGate) + + await cachedGate.resume( + at: 1, + result: .init(inputs: [Self.input(cost: 2)], failedSourceIDs: [])) + await Self.waitForPendingCount(1, gate: loaderGate) + await loaderGate.resume( + at: 0, + result: .init(inputs: [Self.input(cost: 3)], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + await cachedGate.resume( + at: 0, + result: .init(inputs: [Self.input(cost: 1)], failedSourceIDs: [])) + await Task.yield() + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.generation == 3) + } + + @Test + func `cache miss stays refreshing through one fresh validation pass`() async { + let loaderGate = CachedRefreshLoaderGate() + let modeRecorder = CachedRefreshModeRecorder() + let configuration = Self.configuration(account: "missing") + let controller = SpendDashboardController( + requestBuilder: { mode in + await modeRecorder.append(mode) + return Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in SpendDashboardLoadResult(inputs: [], failedSourceIDs: []) }, + loader: { request in await loaderGate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: loaderGate) + + #expect(controller.model.groups.isEmpty) + #expect(controller.isRefreshing) + #expect(await modeRecorder.values == [.captureOnly, .refreshMissing]) + + await loaderGate.resume(at: 0, result: .init(inputs: [], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.isEmpty) + #expect(await modeRecorder.values == [.captureOnly, .refreshMissing]) + } + + @Test + func `cached dashboard data rejects auth rotation during hydration`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardCachedRefreshTests-auth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let authURL = CodexAuthFingerprint.authFileURL(homePath: home.path) + let initialAuth = Data("{\"profile\":\"owner-one\"}".utf8) + try initialAuth.write(to: authURL, options: .atomic) + let account = CodexSpendScanRequest( + id: "account", + displayName: "Codex", + source: .profileHome(path: home.path), + homePath: home.path, + authFingerprint: CodexAuthFingerprint.fingerprint(data: initialAuth), + authFileWasReadable: true, + cacheIdentity: "cached-auth") + let request = SpendDashboardLoadRequest( + configuration: Self.configuration(account: "account|cached-auth"), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: false) + let cachedSnapshot = Self.input(cost: 3).snapshot + + let result = await SpendDashboardSource.loadCached(request, cachedCodexSnapshotLoader: { _ in + try? Data("{\"profile\":\"owner-two\"}".utf8).write(to: authURL, options: .atomic) + return cachedSnapshot + }) + + #expect(result.inputs.isEmpty) + } + + @Test + func `cached dashboard reuses ambient root only for live system`() async { + let liveAccount = CodexSpendScanRequest( + id: "live", + displayName: "Codex", + source: .liveSystem, + homePath: "/synthetic/live-codex-home", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "live-cache") + let profileAccount = CodexSpendScanRequest( + id: "profile", + displayName: "Codex profile", + source: .profileHome(path: "/synthetic/profile-codex-home"), + homePath: "/synthetic/profile-codex-home", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "profile-cache") + let request = SpendDashboardLoadRequest( + configuration: Self.configuration(account: "live|live-cache,profile|profile-cache"), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [liveAccount, profileAccount], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: false) + let recorder = CachedRefreshCodexLoadRecorder() + + let result = await SpendDashboardSource.loadCached(request, cachedCodexSnapshotLoader: { context in + await recorder.record(context) + return nil + }) + let contexts = await recorder.contexts + + #expect(result.inputs.isEmpty) + #expect(contexts.map(\.cacheRoot) == [ + UsageStore.costUsageCacheDirectory().deletingLastPathComponent(), + UsageStore.costUsageCacheDirectory() + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent("profile-cache", isDirectory: true), + ]) + } + + private static func configuration(account: String) -> SpendDashboardConfiguration { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [account]) + } + + private static func request( + configuration: SpendDashboardConfiguration, + force: Bool) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: force) + } + + private static func input(cost: Double) -> SpendDashboardModel.ProviderInput { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + return SpendDashboardModel.ProviderInput( + provider: .codex, + displayName: UsageProvider.codex.rawValue, + snapshot: snapshot) + } + + private static func waitForRequestGate(_ gate: CachedRefreshRequestGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for request builder") + } + + private static func waitForCachedPendingCount(_ count: Int, gate: CachedRefreshResultGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) cached results") + } + + private static func waitForPendingCount(_ count: Int, gate: CachedRefreshLoaderGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} diff --git a/Tests/CodexBarTests/SpendDashboardPresentationTests.swift b/Tests/CodexBarTests/SpendDashboardPresentationTests.swift new file mode 100644 index 0000000000..0986ec0c2d --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardPresentationTests.swift @@ -0,0 +1,10 @@ +import Testing +@testable import CodexBar + +struct SpendDashboardPresentationTests { + @Test + func `empty dashboard reports refresh until validation finishes`() { + #expect(SpendDashboardEmptyState.make(isRefreshing: true).title == L("Refreshing")) + #expect(SpendDashboardEmptyState.make(isRefreshing: false).title == L("No local cost history yet")) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardScopedCacheTests.swift b/Tests/CodexBarTests/SpendDashboardScopedCacheTests.swift new file mode 100644 index 0000000000..66dab27f48 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardScopedCacheTests.swift @@ -0,0 +1,72 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardScopedCacheTests { + @Test + func `production cached dashboard loader reads a validated scoped cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 15) + let model = "openai/gpt-5.4" + _ = try env.writeCodexSessionFile( + day: day, + filename: "dashboard-cached.jsonl", + contents: env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 42, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ])) + _ = try await CostUsageFetcher(cacheRoot: env.cacheRoot).loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: env.codexHomeRoot.path, + historyDays: SpendDashboardSource.scanDays, + includePiSessions: false) + let account = CodexSpendScanRequest( + id: "profile", + displayName: "Codex profile", + source: .profileHome(path: env.codexHomeRoot.path), + homePath: env.codexHomeRoot.path, + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "profile-cache") + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["profile|profile-cache"]), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: day, + force: false) + let cacheRoot = env.cacheRoot + + let result = await SpendDashboardSource.loadCached(request, cacheRootResolver: { _ in cacheRoot }) + + #expect(result.inputs.count == 1) + #expect(result.inputs.first?.snapshot.sessionTokens == 42) + #expect(result.inputs.first?.snapshot.projects.isEmpty == true) + #expect(result.inputs.first?.snapshot.sessions.isEmpty == true) + } +}