chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import AdaptiveReplayKit
|
||||
import Testing
|
||||
@testable import AdaptiveReplayCLI
|
||||
|
||||
struct CLIArgumentsTests {
|
||||
@Test(arguments: [
|
||||
"fixed-0m",
|
||||
"fixed--1m",
|
||||
"fixed-3m",
|
||||
"fixed-9223372036854775807m",
|
||||
])
|
||||
func `rejects invalid fixed interval names before policy construction`(rawPolicyName: String) {
|
||||
let arguments = CLIArguments.parse(["trace.jsonl", "--policy", rawPolicyName])
|
||||
|
||||
guard case let .invalid(message) = arguments else {
|
||||
Issue.record("Expected \(rawPolicyName) to be rejected")
|
||||
return
|
||||
}
|
||||
#expect(message.contains(rawPolicyName))
|
||||
#expect(message.contains(ReplayPolicyName.expectedValues))
|
||||
}
|
||||
|
||||
@Test(arguments: ReplayPolicyName.allCases)
|
||||
func `accepts every documented policy name`(policyName: ReplayPolicyName) {
|
||||
let arguments = CLIArguments.parse(["trace.jsonl", "--policy", policyName.rawValue])
|
||||
|
||||
guard case let .run(tracePath, policyNames, jsonOutput, _) = arguments else {
|
||||
Issue.record("Expected \(policyName.rawValue) to be accepted")
|
||||
return
|
||||
}
|
||||
#expect(tracePath == "trace.jsonl")
|
||||
#expect(policyNames == [policyName])
|
||||
#expect(policyNames.map(\.policy.name) == [policyName.rawValue])
|
||||
#expect(!jsonOutput)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omitting policy selects every documented policy`() {
|
||||
let arguments = CLIArguments.parse(["trace.jsonl"])
|
||||
|
||||
guard case let .run(_, policyNames, _, _) = arguments else {
|
||||
Issue.record("Expected the default policy set")
|
||||
return
|
||||
}
|
||||
#expect(policyNames == ReplayPolicyName.allCases)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// Purely informational trace-level stats surfaced by `AdaptiveReplayCLI` — computed directly from
|
||||
/// raw `decision` records, independent of any `ReplayPolicy` or `ReplayEngine` simulation.
|
||||
struct ActivityCoverageStatsTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private static func decision(codex: TimeInterval?, claude: TimeInterval?) -> AdaptiveRefreshTraceRecord {
|
||||
.decision(
|
||||
timestamp: self.referenceNow,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "warm",
|
||||
delaySeconds: 300,
|
||||
codexActivitySeconds: codex,
|
||||
claudeActivitySeconds: claude)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `an empty trace reports zero decisions and zero fractions`() {
|
||||
let stats = ActivityCoverageStats.compute(from: [])
|
||||
#expect(stats.decisionCount == 0)
|
||||
#expect(stats.sampledCount == 0)
|
||||
#expect(stats.activeCount == 0)
|
||||
#expect(stats.sampledFraction == 0)
|
||||
#expect(stats.activeFraction == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `non decision records are ignored entirely`() {
|
||||
let records: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: Self.referenceNow),
|
||||
.refreshCompleted(timestamp: Self.referenceNow),
|
||||
]
|
||||
let stats = ActivityCoverageStats.compute(from: records)
|
||||
#expect(stats.decisionCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a decision with neither activity field set counts toward decisionCount but not sampledCount`() {
|
||||
let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: nil, claude: nil)])
|
||||
#expect(stats.decisionCount == 1)
|
||||
#expect(stats.sampledCount == 0)
|
||||
#expect(stats.activeCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a decision with only one activity field set still counts as sampled`() {
|
||||
let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: 500, claude: nil)])
|
||||
#expect(stats.sampledCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a sampled decision under the active threshold on either CLI counts as active`() {
|
||||
let codexActive = ActivityCoverageStats.compute(from: [Self.decision(codex: 100, claude: nil)])
|
||||
#expect(codexActive.activeCount == 1)
|
||||
|
||||
let claudeActive = ActivityCoverageStats.compute(from: [Self.decision(codex: nil, claude: 100)])
|
||||
#expect(claudeActive.activeCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a sampled decision at or above the active threshold on both CLIs does not count as active`() {
|
||||
let stats = ActivityCoverageStats.compute(from: [Self.decision(codex: 500, claude: 400)])
|
||||
#expect(stats.sampledCount == 1)
|
||||
#expect(stats.activeCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fractions are computed against decisionCount and sampledCount respectively`() {
|
||||
let records: [AdaptiveRefreshTraceRecord] = [
|
||||
Self.decision(codex: 100, claude: nil), // sampled, active
|
||||
Self.decision(codex: 500, claude: 400), // sampled, not active
|
||||
Self.decision(codex: nil, claude: nil), // not sampled
|
||||
Self.decision(codex: nil, claude: nil), // not sampled
|
||||
]
|
||||
let stats = ActivityCoverageStats.compute(from: records)
|
||||
#expect(stats.decisionCount == 4)
|
||||
#expect(stats.sampledCount == 2)
|
||||
#expect(stats.activeCount == 1)
|
||||
#expect(stats.sampledFraction == 0.5)
|
||||
#expect(stats.activeFraction == 0.5)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a custom active threshold changes the active classification`() {
|
||||
let stats = ActivityCoverageStats.compute(
|
||||
from: [Self.decision(codex: 250, claude: nil)],
|
||||
activeThresholdSeconds: 60)
|
||||
#expect(stats.sampledCount == 1)
|
||||
#expect(stats.activeCount == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import AdaptiveRefreshCore
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AdaptiveRefreshPolicyCoreTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private func input(
|
||||
ageSeconds: TimeInterval?,
|
||||
lowPowerModeEnabled: Bool = false,
|
||||
thermalPressure: AdaptiveRefreshPolicyCore.ThermalPressure = .nominal)
|
||||
-> AdaptiveRefreshPolicyCore.Input
|
||||
{
|
||||
AdaptiveRefreshPolicyCore.Input(
|
||||
now: Self.referenceNow,
|
||||
lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) },
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalPressure: thermalPressure)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(-600.0, AdaptiveRefreshPolicyCore.Reason.recentInteraction, 120),
|
||||
(0.0, .recentInteraction, 120),
|
||||
(299.0, .recentInteraction, 120),
|
||||
(300.0, .recentInteraction, 120),
|
||||
(301.0, .warm, 300),
|
||||
(3599.0, .warm, 300),
|
||||
(3600.0, .warm, 300),
|
||||
(3601.0, .idle, 900),
|
||||
(14399.0, .idle, 900),
|
||||
(14400.0, .longIdle, 1800),
|
||||
(100_000.0, .longIdle, 1800),
|
||||
])
|
||||
func `age determines the canonical table boundary`(
|
||||
ageSeconds: TimeInterval,
|
||||
expectedReason: AdaptiveRefreshPolicyCore.Reason,
|
||||
expectedDelaySeconds: Int)
|
||||
{
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: ageSeconds))
|
||||
#expect(decision.reason == expectedReason)
|
||||
#expect(decision.delay == .seconds(expectedDelaySeconds))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `nil last menu open is long idle`() {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: nil))
|
||||
#expect(decision.reason == .longIdle)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `low power mode wins over recent interaction`() {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(
|
||||
ageSeconds: 0,
|
||||
lowPowerModeEnabled: true))
|
||||
#expect(decision.reason == .constrained)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `thermal pressure wins when no menu open is recorded`() {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(
|
||||
ageSeconds: nil,
|
||||
thermalPressure: .constrained))
|
||||
#expect(decision.reason == .constrained)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `future timestamps read as recent`() {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(ageSeconds: -1_000_000))
|
||||
#expect(decision.reason == .recentInteraction)
|
||||
#expect(decision.delay == .seconds(2 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `every decision stays within the two to thirty minute bounds`() {
|
||||
let ages: [TimeInterval?] = [nil, -1_000_000, 0, 300, 301, 3600, 3601, 14399, 14400, 1_000_000]
|
||||
for age in ages {
|
||||
for lowPowerModeEnabled in [false, true] {
|
||||
for thermalPressure in [
|
||||
AdaptiveRefreshPolicyCore.ThermalPressure.nominal,
|
||||
.constrained,
|
||||
] {
|
||||
let decision = AdaptiveRefreshPolicyCore().nextDelay(for: self.input(
|
||||
ageSeconds: age,
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalPressure: thermalPressure))
|
||||
#expect(decision.delay >= .seconds(2 * 60))
|
||||
#expect(decision.delay <= .seconds(30 * 60))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `nominal heuristic interval remains five minutes`() {
|
||||
#expect(AdaptiveRefreshPolicyCore.nominalIntervalForHeuristics == 5 * 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// Hand-computed metric checks against small synthetic traces, plus determinism and baseline
|
||||
/// (manual/fixed) sanity checks for `ReplayEngine`. Trace construction stays in-code (no fixture
|
||||
/// files): each trace is small enough that its expected metrics can be derived by hand in the
|
||||
/// comments beside it, which is the actual verification for requirement 4 ("metric math verified
|
||||
/// against hand-computed values").
|
||||
struct AdaptiveReplayEngineTests {
|
||||
private static let epoch = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
||||
private func at(_ seconds: TimeInterval) -> Date {
|
||||
Self.epoch.addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
/// A one-hour span (t=0...3600) pinned by two `decision` boundary records, `FixedIntervalPolicy`
|
||||
/// refreshing every 10 minutes, and four `menuOpen` events chosen so each falls a different,
|
||||
/// hand-computable number of seconds after the preceding simulated refresh.
|
||||
///
|
||||
/// Refreshes land at t=600,1200,...,3600 (6 total: cursor starts at 0, and 3600 <= end is still
|
||||
/// included). Staleness samples: menuOpen@50 -> 50-0=50 (no refresh yet, falls back to
|
||||
/// time-since-trace-start); @900 -> 900-600=300; @2200 -> 2200-1800=400; @3500 -> 3500-3000=500.
|
||||
/// mean=(50+300+400+500)/4=312.5, median (nearest-rank, sorted=[50,300,400,500])=sorted[1]=300,
|
||||
/// p95=sorted[3]=500.
|
||||
private func fixedCadenceTrace() -> [AdaptiveRefreshTraceRecord] {
|
||||
[
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.decision(
|
||||
timestamp: self.at(3600),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
.menuOpen(timestamp: self.at(900)),
|
||||
.menuOpen(timestamp: self.at(2200)),
|
||||
.menuOpen(timestamp: self.at(3500)),
|
||||
]
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fixed cadence refresh count and staleness match hand computation`() throws {
|
||||
let metrics = ReplayEngine.run(trace: self.fixedCadenceTrace(), policy: FixedIntervalPolicy(minutes: 10))
|
||||
|
||||
#expect(metrics.totalRefreshCount == 6)
|
||||
#expect(metrics.simulatedSpanSeconds == 3600.0)
|
||||
#expect(metrics.refreshCountPer24h == 144.0) // 6 refreshes/hour * 24h
|
||||
#expect(metrics.interactionAdvanceCount == 0) // fixed cadence never advances on interaction
|
||||
|
||||
let staleness = try #require(metrics.stalenessAtMenuOpen)
|
||||
#expect(staleness.sampleCount == 4)
|
||||
#expect(staleness.mean == 312.5)
|
||||
#expect(staleness.median == 300.0)
|
||||
#expect(staleness.p95 == 500.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replaying the same trace and policy twice is deterministic`() {
|
||||
let trace = self.fixedCadenceTrace()
|
||||
let first = ReplayEngine.run(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
let second = ReplayEngine.run(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `manual policy never schedules a refresh`() {
|
||||
let metrics = ReplayEngine.run(trace: self.fixedCadenceTrace(), policy: ManualPolicy())
|
||||
#expect(metrics.totalRefreshCount == 0)
|
||||
#expect(metrics.refreshCountPer24h == 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a trace with no menu-open events reports no staleness stats`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.refreshCompleted(timestamp: self.at(1800)),
|
||||
]
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: AdaptiveReplayPolicy())
|
||||
#expect(metrics.stalenessAtMenuOpen == nil)
|
||||
}
|
||||
|
||||
/// A single constrained (`lowPowerModeEnabled: true`) sample at t=0, held for the whole
|
||||
/// 0...1000 span (no later sample overrides it), replayed against `FixedIntervalPolicy(2m)`
|
||||
/// (120s, well under the 30-minute constrained floor).
|
||||
///
|
||||
/// decide() is called at cursor = 0,120,240,...,960 (9 calls: the call at 960 computes
|
||||
/// next=1080 > end=1000 and breaks before appending). All 9 calls see the constrained sample,
|
||||
/// and every one returns a 120s delay, so all 9 are violations. 8 of those calls' `next` landed
|
||||
/// at or before 1000 (120,240,...,960), so 8 refreshes were recorded.
|
||||
private func constrainedTrace() -> [AdaptiveRefreshTraceRecord] {
|
||||
[
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: true,
|
||||
thermalState: .nominal,
|
||||
reason: "constrained",
|
||||
delaySeconds: 1800),
|
||||
.menuOpen(timestamp: self.at(1000)),
|
||||
]
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a policy that ignores the constrained floor is flagged non-compliant`() {
|
||||
let metrics = ReplayEngine.run(trace: self.constrainedTrace(), policy: FixedIntervalPolicy(minutes: 2))
|
||||
|
||||
#expect(metrics.totalRefreshCount == 8)
|
||||
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 9)
|
||||
#expect(metrics.constrainedCompliance.violationCount == 9)
|
||||
#expect(!metrics.constrainedCompliance.isCompliant)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `the shared adaptive policy honors the constrained floor`() {
|
||||
let metrics = ReplayEngine.run(trace: self.constrainedTrace(), policy: AdaptiveReplayPolicy())
|
||||
|
||||
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 1)
|
||||
#expect(metrics.constrainedCompliance.violationCount == 0)
|
||||
#expect(metrics.constrainedCompliance.isCompliant)
|
||||
// The menu open at t=1000 is still under low-power, so the advance-check itself also
|
||||
// returns the constrained floor (candidate = 1000+1800 = 2800), which is later than the
|
||||
// already-scheduled t=1800 tick — no advance is taken. Mirrors the real
|
||||
// `noteMenuOpened(at:)` guard: opening the menu while constrained never shortens the timer.
|
||||
#expect(metrics.interactionAdvanceCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `an empty trace reports zero metrics without crashing`() {
|
||||
let metrics = ReplayEngine.run(trace: [], policy: AdaptiveReplayPolicy())
|
||||
#expect(metrics.totalRefreshCount == 0)
|
||||
#expect(metrics.simulatedSpanSeconds == 0.0)
|
||||
#expect(metrics.stalenessAtMenuOpen == nil)
|
||||
#expect(metrics.constrainedCompliance.constrainedDecisionCount == 0)
|
||||
#expect(metrics.interactionAdvanceCount == 0)
|
||||
}
|
||||
|
||||
// MARK: - Interaction-advance path (mirrors UsageStore.noteMenuOpened(at:))
|
||||
|
||||
/// A 300-second span with a single tick boundary at t=0 (which alone would schedule a longIdle
|
||||
/// refresh at t=1800, far past the trace's end) and one `menuOpen` at t=50 landing inside that
|
||||
/// tick's window.
|
||||
///
|
||||
/// Hand computation for `AdaptiveReplayPolicy` (`advancesOnInteraction == true`):
|
||||
/// - cursor=0: decide(now:0, lastMenuOpenAt: nil) -> longIdle, delay=1800 -> next=1800.
|
||||
/// menuOpen@50 falls in (0, 1800]: decide(now:50, lastMenuOpenAt:50) (age 0) ->
|
||||
/// recentInteraction, delay=120 -> candidate=170. 170 < 1800, so the schedule advances:
|
||||
/// next=170 (1 advance so far). next(170) <= end(300), so a refresh lands at t=170.
|
||||
/// - cursor=170: decide(now:170, lastMenuOpenAt:50) (age 120 <= 300 recentInteractionThreshold)
|
||||
/// -> recentInteraction, delay=120 -> next=290. No more menu opens to scan. 290 <= 300, so a
|
||||
/// refresh lands at t=290.
|
||||
/// - cursor=290: decide(now:290, lastMenuOpenAt:50) (age 240 <= 300) -> recentInteraction,
|
||||
/// delay=120 -> next=410. 410 > end(300), loop breaks without appending.
|
||||
///
|
||||
/// Total: 2 refreshes (170, 290), 1 interaction advance. Without the advance, the *only*
|
||||
/// schedulable event would be the t=1800 tick, which falls entirely outside this 300s span —
|
||||
/// i.e. `totalRefreshCount` would be 0. The non-zero count here is only possible because the
|
||||
/// engine reproduces the interaction-advance path.
|
||||
private func menuOpenAdvanceTrace() -> [AdaptiveRefreshTraceRecord] {
|
||||
[
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.decision(
|
||||
timestamp: self.at(300),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
]
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a menu open pulls the adaptive schedule forward, matching hand computation`() {
|
||||
let metrics = ReplayEngine.run(trace: self.menuOpenAdvanceTrace(), policy: AdaptiveReplayPolicy())
|
||||
|
||||
#expect(metrics.totalRefreshCount == 2)
|
||||
#expect(metrics.interactionAdvanceCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a policy that does not advance on interaction ignores the same menu open`() {
|
||||
// Same trace, but FixedIntervalPolicy(30m) never overrides `advancesOnInteraction` (stays
|
||||
// false), matching fixed-cadence refresh frequencies in the real app, which never wire
|
||||
// `noteMenuOpened(at:)`'s advance check at all. The t=1800 tick falls outside the 300s
|
||||
// span, so nothing is scheduled — the menu open at t=50 has zero scheduling effect.
|
||||
let metrics = ReplayEngine.run(trace: self.menuOpenAdvanceTrace(), policy: FixedIntervalPolicy(minutes: 30))
|
||||
|
||||
#expect(metrics.totalRefreshCount == 0)
|
||||
#expect(metrics.interactionAdvanceCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a recorded timerAdvanced ground-truth event agrees with the engine's own recomputation`() throws {
|
||||
// The menuOpen ground truth plus a timerAdvanced record for the accepted schedule change.
|
||||
// The offline audit checks that record against the policy's recomputed candidate.
|
||||
let menuOpenAt = self.at(50)
|
||||
let recordedCandidate = self.at(170) // menuOpenAt + recentInteractionDelay (120s)
|
||||
var trace = self.menuOpenAdvanceTrace()
|
||||
trace.append(.timerAdvanced(
|
||||
timestamp: menuOpenAt,
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: recordedCandidate,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120))
|
||||
|
||||
let policy = AdaptiveReplayPolicy()
|
||||
let recomputed = policy.decide(ReplayPolicyInput(
|
||||
now: menuOpenAt,
|
||||
lastMenuOpenAt: menuOpenAt,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal))
|
||||
let recomputedCandidate = try menuOpenAt.addingTimeInterval(#require(recomputed.delaySeconds))
|
||||
|
||||
#expect(recomputedCandidate == recordedCandidate)
|
||||
|
||||
// The recorded event doesn't change the metrics (the engine recomputes advances itself,
|
||||
// independent of any timerAdvanced lines in the trace); replaying still reproduces the
|
||||
// same two refreshes as the trace without the extra record.
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: policy)
|
||||
#expect(metrics.totalRefreshCount == 2)
|
||||
#expect(metrics.interactionAdvanceCount == 1)
|
||||
}
|
||||
|
||||
/// Two menu opens in the same tick window: the second one's candidate is compared against the
|
||||
/// *already-advanced* schedule from the first, not the original tick schedule — mirroring a
|
||||
/// real second `noteMenuOpened(at:)` call tightening an already-shortened sleep.
|
||||
///
|
||||
/// - cursor=0: decide -> longIdle, next=1800. menuOpen@50: candidate=170 < 1800 -> next=170
|
||||
/// (advance 1). menuOpen@100 also falls in (0, 170]? No — 100 <= 170 is true, so it's still
|
||||
/// scanned: decide(now:100, lastMenuOpenAt:100) -> recentInteraction, candidate=220. Is 220 <
|
||||
/// next(170)? No, so this second menu open does *not* further advance the schedule (it would
|
||||
/// move the refresh *later*, which `shouldAdvanceAdaptiveTimer` never does). next stays 170.
|
||||
/// - Total: 1 refresh (170), 1 advance (only the first menu open's candidate beat the schedule).
|
||||
private func twoMenuOpensSameWindowTrace() -> [AdaptiveRefreshTraceRecord] {
|
||||
[
|
||||
.decision(
|
||||
timestamp: self.at(0),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.decision(
|
||||
timestamp: self.at(170),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
.menuOpen(timestamp: self.at(100)),
|
||||
]
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a later menu open in the same window cannot postpone an earlier advance`() {
|
||||
let metrics = ReplayEngine.run(trace: self.twoMenuOpensSameWindowTrace(), policy: AdaptiveReplayPolicy())
|
||||
|
||||
#expect(metrics.totalRefreshCount == 1)
|
||||
#expect(metrics.interactionAdvanceCount == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AdaptiveReplayPolicyTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private func input(
|
||||
ageSeconds: TimeInterval?,
|
||||
lowPowerModeEnabled: Bool = false,
|
||||
thermalState: ReplayThermalState = .nominal) -> ReplayPolicyInput
|
||||
{
|
||||
ReplayPolicyInput(
|
||||
now: Self.referenceNow,
|
||||
lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) },
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalState: thermalState)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(0.0, "recentInteraction", 120.0),
|
||||
(301.0, "warm", 300.0),
|
||||
(3601.0, "idle", 900.0),
|
||||
(14400.0, "longIdle", 1800.0),
|
||||
])
|
||||
func `replay adapter preserves canonical decisions`(
|
||||
ageSeconds: TimeInterval,
|
||||
expectedReason: String,
|
||||
expectedDelaySeconds: TimeInterval)
|
||||
{
|
||||
let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: ageSeconds))
|
||||
#expect(decision.reason == expectedReason)
|
||||
#expect(decision.delaySeconds == expectedDelaySeconds)
|
||||
}
|
||||
|
||||
@Test(arguments: [ReplayThermalState.serious, .critical])
|
||||
func `replay adapter maps serious and critical thermal states to constrained`(
|
||||
thermalState: ReplayThermalState)
|
||||
{
|
||||
let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: 0, thermalState: thermalState))
|
||||
#expect(decision.reason == "constrained")
|
||||
#expect(decision.delaySeconds == TimeInterval(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replay adapter preserves low power precedence`() {
|
||||
let decision = AdaptiveReplayPolicy().decide(self.input(
|
||||
ageSeconds: 0,
|
||||
lowPowerModeEnabled: true,
|
||||
thermalState: .nominal))
|
||||
#expect(decision.reason == "constrained")
|
||||
#expect(decision.delaySeconds == TimeInterval(30 * 60))
|
||||
}
|
||||
|
||||
@Test(arguments: [ReplayThermalState.nominal, .fair])
|
||||
func `replay adapter maps nominal and fair thermal states to unconstrained`(
|
||||
thermalState: ReplayThermalState)
|
||||
{
|
||||
let decision = AdaptiveReplayPolicy().decide(self.input(ageSeconds: 0, thermalState: thermalState))
|
||||
#expect(decision.reason == "recentInteraction")
|
||||
#expect(decision.delaySeconds == TimeInterval(2 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `only adaptive replay advances on interaction`() {
|
||||
#expect(AdaptiveReplayPolicy().advancesOnInteraction)
|
||||
#expect(!FixedIntervalPolicy(minutes: 5).advancesOnInteraction)
|
||||
#expect(!ManualPolicy().advancesOnInteraction)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fixed interval conversion cannot overflow integer multiplication`() {
|
||||
let decision = FixedIntervalPolicy(minutes: Int.max).decide(self.input(ageSeconds: 0))
|
||||
#expect(decision.delaySeconds == TimeInterval(Int.max) * 60)
|
||||
#expect(decision.delaySeconds?.isFinite == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AdaptiveReplayTraceParserTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private func encode(_ record: AdaptiveRefreshTraceRecord) throws -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(record)
|
||||
return try #require(String(data: data, encoding: .utf8))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses a well-formed trace and preserves record order`() throws {
|
||||
let records: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: Self.referenceNow),
|
||||
.decision(
|
||||
timestamp: Self.referenceNow.addingTimeInterval(1),
|
||||
menuAgeSeconds: 1,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120),
|
||||
.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(121)),
|
||||
]
|
||||
let text = try records.map(self.encode).joined(separator: "\n")
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.count == 3)
|
||||
#expect(parsed[0].kind == .menuOpen)
|
||||
#expect(parsed[1].kind == .decision)
|
||||
#expect(parsed[1].reason == "recentInteraction")
|
||||
#expect(parsed[1].delaySeconds == 120.0)
|
||||
#expect(parsed[2].kind == .refreshCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ignores blank lines between records`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.menuOpen(timestamp: Self.referenceNow)
|
||||
let text = try "\n\(self.encode(record))\n\n"
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strict parsing accepts multiple CRLF records`() throws {
|
||||
let records: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: Self.referenceNow),
|
||||
.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(1)),
|
||||
]
|
||||
let text = try records.map(self.encode).joined(separator: "\r\n")
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.map(\.kind) == [.menuOpen, .refreshCompleted])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `empty trace parses to zero records`() throws {
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse("")
|
||||
#expect(parsed.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a malformed line fails the whole parse with a line number`() throws {
|
||||
let good = try self.encode(.menuOpen(timestamp: Self.referenceNow))
|
||||
let text = "\(good)\nnot json\n\(good)"
|
||||
|
||||
#expect(throws: AdaptiveRefreshTraceParseError.self) {
|
||||
try AdaptiveRefreshTraceParser.parse(text)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try AdaptiveRefreshTraceParser.parse(text)
|
||||
Issue.record("expected parse to throw")
|
||||
} catch let error as AdaptiveRefreshTraceParseError {
|
||||
#expect(error.lineNumber == 2)
|
||||
#expect(error.content == "not json")
|
||||
} catch {
|
||||
Issue.record("unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `tolerant parsing skips malformed lines instead of failing`() throws {
|
||||
let good = try self.encode(.menuOpen(timestamp: Self.referenceNow))
|
||||
let text = "\(good)\nnot json\n\(good)"
|
||||
|
||||
let parsed = AdaptiveRefreshTraceParser.parseTolerantly(text)
|
||||
|
||||
#expect(parsed.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `tolerant parsing accepts CRLF and skips only malformed records`() throws {
|
||||
let menuOpen = try self.encode(.menuOpen(timestamp: Self.referenceNow))
|
||||
let refresh = try self.encode(.refreshCompleted(timestamp: Self.referenceNow.addingTimeInterval(1)))
|
||||
let text = [menuOpen, "not json", refresh].joined(separator: "\r\n")
|
||||
|
||||
let parsed = AdaptiveRefreshTraceParser.parseTolerantly(text)
|
||||
|
||||
#expect(parsed.map(\.kind) == [.menuOpen, .refreshCompleted])
|
||||
}
|
||||
|
||||
/// `timerAdvanced` round-trips its two extra fields (`previousScheduledAt`,
|
||||
/// `candidateScheduledAt`) and leaves the signal fields (`menuAgeSeconds`,
|
||||
/// `lowPowerModeEnabled`, `thermalState`) nil, matching the type's field-presence contract.
|
||||
@Test
|
||||
func `parses a timerAdvanced record and preserves its schedule fields`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: Self.referenceNow,
|
||||
previousScheduledAt: Self.referenceNow.addingTimeInterval(1800),
|
||||
candidateScheduledAt: Self.referenceNow.addingTimeInterval(120),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let text = try self.encode(record)
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.count == 1)
|
||||
#expect(parsed[0].kind == .timerAdvanced)
|
||||
#expect(parsed[0].reason == "recentInteraction")
|
||||
#expect(parsed[0].delaySeconds == 120.0)
|
||||
#expect(parsed[0].previousScheduledAt == Self.referenceNow.addingTimeInterval(1800))
|
||||
#expect(parsed[0].candidateScheduledAt == Self.referenceNow.addingTimeInterval(120))
|
||||
#expect(parsed[0].menuAgeSeconds == nil)
|
||||
#expect(parsed[0].lowPowerModeEnabled == nil)
|
||||
#expect(parsed[0].thermalState == nil)
|
||||
}
|
||||
|
||||
/// A `timerAdvanced` record whose advance had no prior schedule (`previousScheduledAt == nil`)
|
||||
/// — the "always advance" case `UsageStore.shouldAdvanceAdaptiveTimer` returns for a nil
|
||||
/// `scheduledAt` — round-trips the nil correctly rather than defaulting to some sentinel date.
|
||||
@Test
|
||||
func `a timerAdvanced record with no previous schedule round-trips a nil previousScheduledAt`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: Self.referenceNow,
|
||||
previousScheduledAt: nil,
|
||||
candidateScheduledAt: Self.referenceNow.addingTimeInterval(120),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let text = try self.encode(record)
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed[0].previousScheduledAt == nil)
|
||||
}
|
||||
|
||||
/// Backward compatibility: the ~500 pre-existing lines in this machine's live trace were
|
||||
/// written before `codexActivitySeconds`/`claudeActivitySeconds` existed. A hand-written
|
||||
/// old-format `decision` line (no activity keys at all) must still decode, with both new
|
||||
/// fields nil rather than failing to parse.
|
||||
@Test
|
||||
func `an old-format decision line without activity fields decodes with nil activity signals`() throws {
|
||||
let oldFormatLine = """
|
||||
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","menuAgeSeconds":30,\
|
||||
"lowPowerModeEnabled":false,"thermalState":"nominal","reason":"longIdle","delaySeconds":1800}
|
||||
"""
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(oldFormatLine)
|
||||
|
||||
#expect(parsed.count == 1)
|
||||
#expect(parsed[0].reason == "longIdle")
|
||||
#expect(parsed[0].codexActivitySeconds == nil)
|
||||
#expect(parsed[0].claudeActivitySeconds == nil)
|
||||
}
|
||||
|
||||
/// A `decision` record carrying both activity signals round-trips them exactly.
|
||||
@Test
|
||||
func `a decision record with activity signals round-trips both values`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: 5,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
codexActivitySeconds: 42,
|
||||
claudeActivitySeconds: 99)
|
||||
let text = try self.encode(record)
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed[0].codexActivitySeconds == 42)
|
||||
#expect(parsed[0].claudeActivitySeconds == 99)
|
||||
}
|
||||
|
||||
/// Encoding must omit nil activity fields rather than emitting explicit `null`s, so old
|
||||
/// tooling and hand-inspection of a trace stay unsurprised by fields it doesn't expect.
|
||||
@Test
|
||||
func `encoding a decision with nil activity signals omits both keys entirely`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: 5,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let text = try self.encode(record)
|
||||
|
||||
#expect(!text.contains("codexActivitySeconds"))
|
||||
#expect(!text.contains("claudeActivitySeconds"))
|
||||
}
|
||||
|
||||
/// Backward compatibility for the "B layer" (session duration / transcript bytes /
|
||||
/// active-transcript count): an old-format line written before those three fields per CLI
|
||||
/// existed — including one already carrying the earlier "A layer" activity-seconds fields —
|
||||
/// must still decode, with all six new fields nil.
|
||||
@Test
|
||||
func `an old-format decision line without B-layer fields decodes with nil B-layer signals`() throws {
|
||||
let oldFormatLine = """
|
||||
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","menuAgeSeconds":30,\
|
||||
"lowPowerModeEnabled":false,"thermalState":"nominal","reason":"longIdle","delaySeconds":1800,\
|
||||
"codexActivitySeconds":42,"claudeActivitySeconds":99}
|
||||
"""
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(oldFormatLine)
|
||||
|
||||
#expect(parsed.count == 1)
|
||||
#expect(parsed[0].codexActivitySeconds == 42)
|
||||
#expect(parsed[0].claudeActivitySeconds == 99)
|
||||
#expect(parsed[0].codexSessionDurationSeconds == nil)
|
||||
#expect(parsed[0].claudeSessionDurationSeconds == nil)
|
||||
#expect(parsed[0].codexTranscriptBytes == nil)
|
||||
#expect(parsed[0].claudeTranscriptBytes == nil)
|
||||
#expect(parsed[0].codexActiveTranscriptCount == nil)
|
||||
#expect(parsed[0].claudeActiveTranscriptCount == nil)
|
||||
}
|
||||
|
||||
/// A trace mixing a pre-B-layer line, a pre-A-layer (original phase 1) line, and a full
|
||||
/// current-format line all parse together — the parser never requires every line in a trace
|
||||
/// to share the same schema vintage.
|
||||
@Test
|
||||
func `a trace mixing old and new format decision lines parses every line`() throws {
|
||||
let phase1Line = """
|
||||
{"kind":"decision","timestamp":"2026-01-01T00:00:00Z","reason":"longIdle","delaySeconds":1800}
|
||||
"""
|
||||
let aLayerOnlyLine = """
|
||||
{"kind":"decision","timestamp":"2026-01-02T00:00:00Z","reason":"warm","delaySeconds":300,\
|
||||
"codexActivitySeconds":10}
|
||||
"""
|
||||
let currentLine = try self.encode(.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
codexActivitySeconds: 1,
|
||||
claudeActivitySeconds: 2,
|
||||
codexSessionDurationSeconds: 3,
|
||||
claudeSessionDurationSeconds: 4,
|
||||
codexTranscriptBytes: 5,
|
||||
claudeTranscriptBytes: 6,
|
||||
codexActiveTranscriptCount: 7,
|
||||
claudeActiveTranscriptCount: 8))
|
||||
let text = [phase1Line, aLayerOnlyLine, currentLine].joined(separator: "\n")
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed.count == 3)
|
||||
#expect(parsed[0].codexActivitySeconds == nil)
|
||||
#expect(parsed[1].codexActivitySeconds == 10)
|
||||
#expect(parsed[1].codexSessionDurationSeconds == nil)
|
||||
#expect(parsed[2].codexSessionDurationSeconds == 3)
|
||||
#expect(parsed[2].claudeActiveTranscriptCount == 8)
|
||||
}
|
||||
|
||||
/// A `decision` record carrying all six B-layer fields round-trips them exactly.
|
||||
@Test
|
||||
func `a decision record with B-layer fields round-trips all six values`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: 5,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
codexSessionDurationSeconds: 600,
|
||||
claudeSessionDurationSeconds: 900,
|
||||
codexTranscriptBytes: 12345,
|
||||
claudeTranscriptBytes: 67890,
|
||||
codexActiveTranscriptCount: 2,
|
||||
claudeActiveTranscriptCount: 4)
|
||||
let text = try self.encode(record)
|
||||
|
||||
let parsed = try AdaptiveRefreshTraceParser.parse(text)
|
||||
|
||||
#expect(parsed[0].codexSessionDurationSeconds == 600)
|
||||
#expect(parsed[0].claudeSessionDurationSeconds == 900)
|
||||
#expect(parsed[0].codexTranscriptBytes == 12345)
|
||||
#expect(parsed[0].claudeTranscriptBytes == 67890)
|
||||
#expect(parsed[0].codexActiveTranscriptCount == 2)
|
||||
#expect(parsed[0].claudeActiveTranscriptCount == 4)
|
||||
}
|
||||
|
||||
/// Encoding must omit nil B-layer fields rather than emitting explicit `null`s, matching the
|
||||
/// A-layer's contract.
|
||||
@Test
|
||||
func `encoding a decision with nil B-layer fields omits all six keys entirely`() throws {
|
||||
let record = AdaptiveRefreshTraceRecord.decision(
|
||||
timestamp: Self.referenceNow,
|
||||
menuAgeSeconds: 5,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let text = try self.encode(record)
|
||||
|
||||
#expect(!text.contains("codexSessionDurationSeconds"))
|
||||
#expect(!text.contains("claudeSessionDurationSeconds"))
|
||||
#expect(!text.contains("codexTranscriptBytes"))
|
||||
#expect(!text.contains("claudeTranscriptBytes"))
|
||||
#expect(!text.contains("codexActiveTranscriptCount"))
|
||||
#expect(!text.contains("claudeActiveTranscriptCount"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct CodingActivityReplayPolicyTests {
|
||||
private static let now = Date(timeIntervalSinceReferenceDate: 10000)
|
||||
|
||||
private func input(
|
||||
menuAge: TimeInterval?,
|
||||
activityAge: TimeInterval?,
|
||||
constrained: Bool = false) -> ReplayPolicyInput
|
||||
{
|
||||
ReplayPolicyInput(
|
||||
now: Self.now,
|
||||
lastMenuOpenAt: menuAge.map { Self.now.addingTimeInterval(-$0) },
|
||||
lastCodingActivityAt: activityAge.map { Self.now.addingTimeInterval(-$0) },
|
||||
lowPowerModeEnabled: constrained,
|
||||
thermalState: .nominal)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `active coding caps idle and long-idle decisions at five minutes`() {
|
||||
let policy = CodingActivityAdaptivePolicy()
|
||||
|
||||
#expect(policy.name == "adaptive-activity")
|
||||
#expect(policy.decide(self.input(menuAge: 2 * 3600, activityAge: 10)).delaySeconds == 300)
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).delaySeconds == 300)
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: 10)).reason == "codingActivity")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `existing recent and warm decisions remain unchanged`() {
|
||||
let policy = CodingActivityAdaptivePolicy()
|
||||
|
||||
#expect(policy.decide(self.input(menuAge: 60, activityAge: 10)).delaySeconds == 120)
|
||||
#expect(policy.decide(self.input(menuAge: 600, activityAge: 10)).delaySeconds == 300)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `constrained state wins and exact threshold is inactive`() {
|
||||
let policy = CodingActivityAdaptivePolicy()
|
||||
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: 10, constrained: true)).delaySeconds == 1800)
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: 300)).delaySeconds == 1800)
|
||||
#expect(policy.decide(self.input(menuAge: nil, activityAge: nil)).delaySeconds == 1800)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `future activity samples never backfill an earlier replay decision`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.decision(
|
||||
timestamp: Self.now,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800),
|
||||
.decision(
|
||||
timestamp: Self.now.addingTimeInterval(600),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800,
|
||||
codexActivitySeconds: 0),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy())
|
||||
|
||||
#expect(metrics.codingActiveDecisionCount == 0)
|
||||
#expect(metrics.totalRefreshCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a newer unavailable observation invalidates older activity`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: Self.now),
|
||||
.decision(
|
||||
timestamp: Self.now,
|
||||
menuAgeSeconds: 0,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
codexActivitySeconds: 0),
|
||||
.decision(
|
||||
timestamp: Self.now.addingTimeInterval(100),
|
||||
menuAgeSeconds: 100,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120),
|
||||
.refreshCompleted(timestamp: Self.now.addingTimeInterval(240)),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy())
|
||||
|
||||
#expect(metrics.totalRefreshCount == 2)
|
||||
#expect(metrics.codingActiveDecisionCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `active compliance denominator excludes constrained decisions`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.decision(
|
||||
timestamp: Self.now,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: true,
|
||||
thermalState: .nominal,
|
||||
reason: "constrained",
|
||||
delaySeconds: 1800,
|
||||
codexActivitySeconds: 0),
|
||||
.refreshCompleted(timestamp: Self.now.addingTimeInterval(1800)),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: CodingActivityAdaptivePolicy())
|
||||
|
||||
#expect(metrics.codingActiveDecisionCount == 0)
|
||||
#expect(metrics.codingActiveDelayViolationCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `manual policy counts as slower than the active freshness cap`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.decision(
|
||||
timestamp: Self.now,
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: 1800,
|
||||
codexActivitySeconds: 0),
|
||||
.refreshCompleted(timestamp: Self.now.addingTimeInterval(600)),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.run(trace: trace, policy: ManualPolicy())
|
||||
|
||||
#expect(metrics.codingActiveDecisionCount == 1)
|
||||
#expect(metrics.codingActiveDelayViolationCount == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct RecordedScheduleAuditTests {
|
||||
private static let epoch = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
||||
private func at(_ seconds: TimeInterval) -> Date {
|
||||
Self.epoch.addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy recorded advance validates without evaluation records`() {
|
||||
let menu = self.at(50)
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: menu),
|
||||
.timerAdvanced(
|
||||
timestamp: menu,
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120),
|
||||
]
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit(trace)
|
||||
|
||||
#expect(audit.isValid)
|
||||
#expect(audit.recordedAdvanceCount == 1)
|
||||
#expect(audit.evaluatedCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `accepted and rejected live evaluations audit independently of replay`() {
|
||||
let accepted = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
let rejected = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(100),
|
||||
previousScheduledAt: self.at(170),
|
||||
candidateScheduledAt: self.at(220),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: false,
|
||||
refreshInFlight: true)
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
accepted,
|
||||
.timerAdvanced(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120),
|
||||
.menuOpen(timestamp: self.at(100)),
|
||||
rejected,
|
||||
]
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit(trace)
|
||||
|
||||
#expect(audit.isValid)
|
||||
#expect(audit.evaluatedCount == 2)
|
||||
#expect(audit.acceptedEvaluationCount == 1)
|
||||
#expect(audit.rejectedEvaluationCount == 1)
|
||||
#expect(audit.ambiguousComparisonCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `evaluation whose accepted flag disagrees with schedule comparison fails`() {
|
||||
let trace: [AdaptiveRefreshTraceRecord] = [
|
||||
.timerAdvanceEvaluated(
|
||||
timestamp: self.at(100),
|
||||
previousScheduledAt: self.at(170),
|
||||
candidateScheduledAt: self.at(220),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false),
|
||||
]
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit(trace)
|
||||
|
||||
#expect(!audit.isValid)
|
||||
#expect(audit.decisionMismatchCount == 1)
|
||||
#expect(audit.payloadMismatchCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `unequal schedule dates override a contradictory exact lead`() {
|
||||
let event = AdaptiveRefreshTraceRecord(
|
||||
kind: .timerAdvanceEvaluated,
|
||||
timestamp: self.at(50),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
previousScheduledAt: self.at(180),
|
||||
candidateScheduledAt: self.at(170),
|
||||
timerAdvanceAccepted: false,
|
||||
scheduleLeadSeconds: -10,
|
||||
refreshInFlight: false)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event])
|
||||
|
||||
#expect(audit.decisionMismatchCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `accepted evaluation without a previous schedule remains valid`() {
|
||||
let event = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: nil,
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
|
||||
let advanced = AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: nil,
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event, advanced])
|
||||
|
||||
#expect(audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fractional live lead survives whole-second date serialization`() throws {
|
||||
let timestamp = self.at(50.2)
|
||||
let candidate = self.at(170.2)
|
||||
let previous = self.at(170.8)
|
||||
let record = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: timestamp,
|
||||
previousScheduledAt: previous,
|
||||
candidateScheduledAt: candidate,
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let line = try #require(String(data: encoder.encode(record), encoding: .utf8))
|
||||
let parsed = try #require(AdaptiveRefreshTraceParser.parse(line).first)
|
||||
|
||||
#expect(parsed.previousScheduledAt == parsed.candidateScheduledAt)
|
||||
#expect(try abs(#require(parsed.scheduleLeadSeconds) - 0.6) < 0.001)
|
||||
let advanced = try AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: parsed.timestamp,
|
||||
previousScheduledAt: parsed.previousScheduledAt,
|
||||
candidateScheduledAt: #require(parsed.candidateScheduledAt),
|
||||
reason: #require(parsed.reason),
|
||||
delaySeconds: #require(parsed.delaySeconds))
|
||||
#expect(RecordedScheduleAuditor.audit([.menuOpen(timestamp: parsed.timestamp), parsed, advanced]).isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy equal timestamps are reported as ambiguous instead of mismatched`() {
|
||||
let event = AdaptiveRefreshTraceRecord(
|
||||
kind: .timerAdvanceEvaluated,
|
||||
timestamp: self.at(50),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
previousScheduledAt: self.at(170),
|
||||
candidateScheduledAt: self.at(170),
|
||||
timerAdvanceAccepted: true,
|
||||
refreshInFlight: false)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([.menuOpen(timestamp: self.at(50)), event])
|
||||
|
||||
#expect(audit.decisionMismatchCount == 0)
|
||||
#expect(audit.ambiguousComparisonCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `evaluation without a menu-open source fails linkage audit`() {
|
||||
let event = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([event])
|
||||
|
||||
#expect(audit.menuLinkMismatchCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `duplicate accepted evaluations require matching advance multiplicity`() {
|
||||
let evaluation = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120,
|
||||
accepted: true,
|
||||
refreshInFlight: false)
|
||||
let advance = AdaptiveRefreshTraceRecord.timerAdvanced(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(1800),
|
||||
candidateScheduledAt: self.at(170),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 120)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
evaluation,
|
||||
evaluation,
|
||||
advance,
|
||||
])
|
||||
|
||||
#expect(audit.payloadMismatchCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `duplicate rejected evaluations require distinct menu opens`() {
|
||||
let evaluation = AdaptiveRefreshTraceRecord.timerAdvanceEvaluated(
|
||||
timestamp: self.at(50),
|
||||
previousScheduledAt: self.at(170),
|
||||
candidateScheduledAt: self.at(220),
|
||||
reason: "recentInteraction",
|
||||
delaySeconds: 170,
|
||||
accepted: false,
|
||||
refreshInFlight: true)
|
||||
|
||||
let audit = RecordedScheduleAuditor.audit([
|
||||
.menuOpen(timestamp: self.at(50)),
|
||||
evaluation,
|
||||
evaluation,
|
||||
])
|
||||
|
||||
#expect(audit.menuLinkMismatchCount == 1)
|
||||
#expect(!audit.isValid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import AdaptiveReplayKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct ReplayTraceSegmentationTests {
|
||||
private static let epoch = Date(timeIntervalSinceReferenceDate: 0)
|
||||
|
||||
private func at(_ seconds: TimeInterval) -> Date {
|
||||
Self.epoch.addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
private func decision(_ seconds: TimeInterval, delay: TimeInterval = 600) -> AdaptiveRefreshTraceRecord {
|
||||
.decision(
|
||||
timestamp: self.at(seconds),
|
||||
menuAgeSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal,
|
||||
reason: "longIdle",
|
||||
delaySeconds: delay)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `segmentation excludes only time beyond the expected deadline`() {
|
||||
let firstRun = stride(from: 0.0, through: 3000.0, by: 600.0).map { self.decision($0) }
|
||||
let secondRun = stride(from: 68400.0, through: 71400.0, by: 600.0).map { self.decision($0) }
|
||||
let trace = firstRun + secondRun + [.refreshCompleted(timestamp: self.at(72000))]
|
||||
|
||||
let report = ReplayTraceSegmenter.automatic(trace)
|
||||
|
||||
#expect(report.segments.count == 2)
|
||||
#expect(report.segments[0].start == self.at(0))
|
||||
#expect(report.segments[0].end == self.at(3600))
|
||||
#expect(report.segments[1].start == self.at(68400))
|
||||
#expect(report.segments[1].end == self.at(72000))
|
||||
#expect(report.excludedGapSeconds == 18 * 60 * 60)
|
||||
#expect(report.includedSpanSeconds == 2 * 60 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `segmented rate uses summed span instead of averaging segment rates`() {
|
||||
let firstRun = stride(from: 0.0, through: 3000.0, by: 600.0).map { self.decision($0) }
|
||||
let secondRun = stride(from: 68400.0, through: 71400.0, by: 600.0).map { self.decision($0) }
|
||||
let trace = firstRun + secondRun + [.refreshCompleted(timestamp: self.at(72000))]
|
||||
|
||||
let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
|
||||
#expect(metrics.totalRefreshCount == 12)
|
||||
#expect(metrics.simulatedSpanSeconds == 7200)
|
||||
#expect(metrics.refreshCountPer24h == 144)
|
||||
#expect(metrics.segmentCount == 2)
|
||||
#expect(metrics.excludedGapSeconds == 18 * 60 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `a normal scheduled wait remains in the preceding segment`() {
|
||||
let trace = [
|
||||
self.decision(0, delay: 1800),
|
||||
.menuOpen(timestamp: self.at(1700)),
|
||||
self.decision(4000, delay: 1800),
|
||||
]
|
||||
|
||||
let report = ReplayTraceSegmenter.automatic(trace)
|
||||
|
||||
#expect(report.segments.count == 2)
|
||||
#expect(report.segments[0].end == self.at(1800))
|
||||
#expect(report.excludedGapSeconds == 2200)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `menu opens before the first recorded refresh are censored equally`() throws {
|
||||
let trace = [
|
||||
self.decision(0, delay: 600),
|
||||
.menuOpen(timestamp: self.at(100)),
|
||||
.refreshCompleted(timestamp: self.at(600)),
|
||||
self.decision(600, delay: 600),
|
||||
.menuOpen(timestamp: self.at(700)),
|
||||
.refreshCompleted(timestamp: self.at(1200)),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
|
||||
#expect(metrics.boundaryCensoredMenuOpenCount == 1)
|
||||
#expect(try #require(metrics.stalenessAtMenuOpen).sampleCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `recorded refresh anchors staleness before a policy refresh`() throws {
|
||||
let trace = [
|
||||
self.decision(0, delay: 600),
|
||||
.refreshCompleted(timestamp: self.at(600)),
|
||||
.menuOpen(timestamp: self.at(700)),
|
||||
self.decision(1200, delay: 600),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.runSegmented(trace: trace, policy: ManualPolicy())
|
||||
let staleness = try #require(metrics.stalenessAtMenuOpen)
|
||||
|
||||
#expect(staleness.sampleCount == 1)
|
||||
#expect(staleness.mean == 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `recorded refresh supersedes an earlier simulated refresh`() throws {
|
||||
let trace = [
|
||||
self.decision(0, delay: 650),
|
||||
.refreshCompleted(timestamp: self.at(650)),
|
||||
.menuOpen(timestamp: self.at(700)),
|
||||
self.decision(1200, delay: 600),
|
||||
]
|
||||
|
||||
let metrics = ReplayEngine.runSegmented(trace: trace, policy: FixedIntervalPolicy(minutes: 10))
|
||||
let staleness = try #require(metrics.stalenessAtMenuOpen)
|
||||
|
||||
#expect(staleness.sampleCount == 1)
|
||||
#expect(staleness.mean == 50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
private enum APITokenStrategyTestError: Error {
|
||||
case missingCredentials
|
||||
}
|
||||
|
||||
private struct APITokenStrategyStubClaudeFetcher: ClaudeUsageFetching {
|
||||
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
|
||||
throw APITokenStrategyTestError.missingCredentials
|
||||
}
|
||||
|
||||
func debugRawProbe(model _: String) async -> String {
|
||||
"stub"
|
||||
}
|
||||
|
||||
func detectVersion() -> String? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
struct APITokenFetchStrategyTests {
|
||||
@Test
|
||||
func `missing token is unavailable and preserves provider error`() async {
|
||||
let strategy = Self.makeStrategy()
|
||||
let context = Self.makeContext(environment: [:])
|
||||
|
||||
#expect(await strategy.isAvailable(context) == false)
|
||||
await #expect(throws: APITokenStrategyTestError.missingCredentials) {
|
||||
try await strategy.fetch(context)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolved token loads usage and stamps result metadata`() async throws {
|
||||
let strategy = Self.makeStrategy()
|
||||
let context = Self.makeContext(environment: ["TEST_API_KEY": "test-token"])
|
||||
|
||||
#expect(await strategy.isAvailable(context))
|
||||
let result = try await strategy.fetch(context)
|
||||
|
||||
#expect(result.strategyID == "test.api")
|
||||
#expect(result.strategyKind == .apiToken)
|
||||
#expect(result.sourceLabel == "test-source")
|
||||
#expect(result.usage.updatedAt == Date(timeIntervalSince1970: 42))
|
||||
#expect(strategy.shouldFallback(on: APITokenStrategyTestError.missingCredentials, context: context) == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `required token strategy surfaces its missing credential error`() async {
|
||||
let strategy = APITokenFetchStrategy(
|
||||
id: "test.required-api",
|
||||
reportsMissingCredentials: true,
|
||||
resolveToken: { $0["TEST_API_KEY"] },
|
||||
missingCredentialsError: { APITokenStrategyTestError.missingCredentials },
|
||||
loadUsage: { _, _ in UsageSnapshot(primary: nil, secondary: nil, updatedAt: .now) })
|
||||
let context = Self.makeContext(environment: [:])
|
||||
|
||||
#expect(await strategy.isAvailable(context))
|
||||
await #expect(throws: APITokenStrategyTestError.missingCredentials) {
|
||||
try await strategy.fetch(context)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeStrategy() -> APITokenFetchStrategy {
|
||||
APITokenFetchStrategy(
|
||||
id: "test.api",
|
||||
sourceLabel: "test-source",
|
||||
resolveToken: { $0["TEST_API_KEY"] },
|
||||
missingCredentialsError: { APITokenStrategyTestError.missingCredentials },
|
||||
loadUsage: { token, context in
|
||||
UsageSnapshot(
|
||||
primary: nil,
|
||||
secondary: nil,
|
||||
updatedAt: token == context.env["TEST_API_KEY"]
|
||||
? Date(timeIntervalSince1970: 42)
|
||||
: Date.distantFuture)
|
||||
})
|
||||
}
|
||||
|
||||
private static func makeContext(environment: [String: String]) -> ProviderFetchContext {
|
||||
let browserDetection = BrowserDetection(cacheTTL: 0)
|
||||
return ProviderFetchContext(
|
||||
runtime: .app,
|
||||
sourceMode: .api,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: environment,
|
||||
settings: nil,
|
||||
fetcher: UsageFetcher(environment: environment),
|
||||
claudeFetcher: APITokenStrategyStubClaudeFetcher(),
|
||||
browserDetection: browserDetection)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
// MARK: - Descriptor Tests
|
||||
|
||||
struct AbacusDescriptorTests {
|
||||
@Test
|
||||
func `descriptor has correct identity`() {
|
||||
let descriptor = AbacusProviderDescriptor.descriptor
|
||||
#expect(descriptor.id == .abacus)
|
||||
#expect(descriptor.metadata.displayName == "Abacus AI")
|
||||
#expect(descriptor.metadata.cliName == "abacusai")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `descriptor does not expose a separate credits panel`() {
|
||||
let meta = AbacusProviderDescriptor.descriptor.metadata
|
||||
#expect(meta.supportsCredits == false)
|
||||
#expect(meta.supportsOpus == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `descriptor is not primary provider`() {
|
||||
let meta = AbacusProviderDescriptor.descriptor.metadata
|
||||
#expect(meta.isPrimaryProvider == false)
|
||||
#expect(meta.defaultEnabled == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `descriptor supports auto and web source modes`() {
|
||||
let descriptor = AbacusProviderDescriptor.descriptor
|
||||
#expect(descriptor.fetchPlan.sourceModes.contains(.auto))
|
||||
#expect(descriptor.fetchPlan.sourceModes.contains(.web))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `descriptor has no version detector`() {
|
||||
let descriptor = AbacusProviderDescriptor.descriptor
|
||||
#expect(descriptor.cli.versionDetector == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `descriptor does not support token cost`() {
|
||||
let descriptor = AbacusProviderDescriptor.descriptor
|
||||
#expect(descriptor.tokenCost.supportsTokenCost == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli aliases include abacus-ai`() {
|
||||
let descriptor = AbacusProviderDescriptor.descriptor
|
||||
#expect(descriptor.cli.aliases.contains("abacus-ai"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `dashboard url points to compute points page`() {
|
||||
let meta = AbacusProviderDescriptor.descriptor.metadata
|
||||
#expect(meta.dashboardURL?.contains("compute-points") == true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Usage Snapshot Conversion Tests
|
||||
|
||||
struct AbacusUsageSnapshotTests {
|
||||
@Test
|
||||
func `converts full snapshot to usage snapshot`() throws {
|
||||
let resetDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 250,
|
||||
creditsTotal: 1000,
|
||||
resetsAt: resetDate,
|
||||
planName: "Pro")
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary != nil)
|
||||
#expect(abs((usage.primary?.usedPercent ?? 0) - 25.0) < 0.01)
|
||||
#expect(usage.primary?.resetDescription == "250 / 1,000 credits")
|
||||
#expect(usage.primary?.resetsAt == resetDate)
|
||||
// Window derived from actual billing cycle (1 calendar month before resetDate)
|
||||
let cycleStart = try #require(Calendar.current.date(byAdding: .month, value: -1, to: resetDate))
|
||||
let expectedMinutes = Int(resetDate.timeIntervalSince(cycleStart) / 60)
|
||||
#expect(usage.primary?.windowMinutes == expectedMinutes)
|
||||
#expect(usage.secondary == nil)
|
||||
#expect(usage.tertiary == nil)
|
||||
#expect(usage.identity?.providerID == .abacus)
|
||||
#expect(usage.identity?.loginMethod == "Pro")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `handles zero usage`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 0,
|
||||
creditsTotal: 500,
|
||||
resetsAt: nil,
|
||||
planName: "Basic")
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(usage.primary?.usedPercent == 0.0)
|
||||
#expect(usage.primary?.resetDescription == "0 / 500 credits")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `handles full usage`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 1000,
|
||||
creditsTotal: 1000,
|
||||
resetsAt: nil,
|
||||
planName: nil)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(abs((usage.primary?.usedPercent ?? 0) - 100.0) < 0.01)
|
||||
#expect(usage.primary?.resetDescription == "1,000 / 1,000 credits")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `handles nil credits gracefully`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: nil,
|
||||
creditsTotal: nil,
|
||||
resetsAt: nil,
|
||||
planName: nil)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(usage.primary?.usedPercent == 0.0)
|
||||
#expect(usage.primary?.resetDescription == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `handles nil total with non-nil used`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 100,
|
||||
creditsTotal: nil,
|
||||
resetsAt: nil,
|
||||
planName: nil)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(usage.primary?.usedPercent == 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `handles zero total credits`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 0,
|
||||
creditsTotal: 0,
|
||||
resetsAt: nil,
|
||||
planName: nil)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(usage.primary?.usedPercent == 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `formats large credit values with comma grouping`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 12345,
|
||||
creditsTotal: 50000,
|
||||
resetsAt: nil,
|
||||
planName: nil)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(usage.primary?.resetDescription == "12,345 / 50,000 credits")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `formats fractional credit values`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 42.5,
|
||||
creditsTotal: 100,
|
||||
resetsAt: nil,
|
||||
planName: nil)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(usage.primary?.resetDescription == "42.5 / 100 credits")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `window minutes represents monthly cycle`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 0,
|
||||
creditsTotal: 100,
|
||||
resetsAt: nil,
|
||||
planName: nil)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
// 30 days * 24 hours * 60 minutes = 43200
|
||||
#expect(usage.primary?.windowMinutes == 43200)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `identity has no email or organization`() {
|
||||
let snapshot = AbacusUsageSnapshot(
|
||||
creditsUsed: 0,
|
||||
creditsTotal: 100,
|
||||
resetsAt: nil,
|
||||
planName: "Pro")
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(usage.identity?.accountEmail == nil)
|
||||
#expect(usage.identity?.accountOrganization == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Description Tests
|
||||
|
||||
struct AbacusErrorTests {
|
||||
@Test
|
||||
func `noSessionCookie error mentions login`() {
|
||||
let error = AbacusUsageError.noSessionCookie
|
||||
#expect(error.errorDescription?.contains("log in") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `sessionExpired error mentions expired`() {
|
||||
let error = AbacusUsageError.sessionExpired
|
||||
#expect(error.errorDescription?.contains("expired") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `networkError includes message`() {
|
||||
let error = AbacusUsageError.networkError("HTTP 500")
|
||||
#expect(error.errorDescription?.contains("HTTP 500") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parseFailed includes message`() {
|
||||
let error = AbacusUsageError.parseFailed("Invalid JSON")
|
||||
#expect(error.errorDescription?.contains("Invalid JSON") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `unauthorized error mentions login`() {
|
||||
let error = AbacusUsageError.unauthorized
|
||||
#expect(error.errorDescription?.contains("log in") == true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Classification Tests
|
||||
|
||||
struct AbacusErrorClassificationTests {
|
||||
@Test
|
||||
func `unauthorized is recoverable and auth related`() {
|
||||
let error = AbacusUsageError.unauthorized
|
||||
#expect(error.isRecoverable == true)
|
||||
#expect(error.isAuthRelated == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `sessionExpired is recoverable and auth related`() {
|
||||
let error = AbacusUsageError.sessionExpired
|
||||
#expect(error.isRecoverable == true)
|
||||
#expect(error.isAuthRelated == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parseFailed is not recoverable`() {
|
||||
let error = AbacusUsageError.parseFailed("bad json")
|
||||
#expect(error.isRecoverable == false)
|
||||
#expect(error.isAuthRelated == false)
|
||||
#expect(error.shouldTryNextImportedSession == true)
|
||||
#expect(error.shouldClearCachedCookie == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `networkError is not recoverable`() {
|
||||
let error = AbacusUsageError.networkError("timeout")
|
||||
#expect(error.isRecoverable == false)
|
||||
#expect(error.isAuthRelated == false)
|
||||
#expect(error.shouldTryNextImportedSession == true)
|
||||
#expect(error.shouldClearCachedCookie == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `noSessionCookie is not recoverable`() {
|
||||
let error = AbacusUsageError.noSessionCookie
|
||||
#expect(error.isRecoverable == false)
|
||||
#expect(error.isAuthRelated == false)
|
||||
#expect(error.shouldTryNextImportedSession == false)
|
||||
#expect(error.shouldClearCachedCookie == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auth failures continue imported session scanning`() {
|
||||
#expect(AbacusUsageError.unauthorized.shouldTryNextImportedSession == true)
|
||||
#expect(AbacusUsageError.sessionExpired.shouldTryNextImportedSession == true)
|
||||
#expect(AbacusUsageError.unauthorized.shouldClearCachedCookie == true)
|
||||
#expect(AbacusUsageError.sessionExpired.shouldClearCachedCookie == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
/// Covers `normalRefreshIntervalForHeuristics()` and every consumer that previously read
|
||||
/// `RefreshFrequency.seconds` directly. That property is nil for both `.manual` and `.adaptive`,
|
||||
/// so without the helper the interval-derived heuristics (reset-boundary refresh, OpenAI web
|
||||
/// staleness, persistent-CLI-session idle windows) silently degrade to manual behavior the
|
||||
/// moment a user picks adaptive. Each consumer has a test here that goes red if its call site
|
||||
/// is reverted to `.seconds`.
|
||||
@MainActor
|
||||
struct AdaptiveRefreshHeuristicsTests {
|
||||
@Test
|
||||
func `manual keeps the heuristics interval nil`() {
|
||||
let store = Self.makeStore(suite: "heuristics-manual-nil", frequency: .manual)
|
||||
#expect(store.normalRefreshIntervalForHeuristics() == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(RefreshFrequency.oneMinute, 60.0),
|
||||
(.twoMinutes, 120.0),
|
||||
(.fiveMinutes, 300.0),
|
||||
(.fifteenMinutes, 900.0),
|
||||
(.thirtyMinutes, 1800.0)
|
||||
])
|
||||
func `fixed frequencies pass their configured seconds through`(
|
||||
frequency: RefreshFrequency,
|
||||
expectedSeconds: TimeInterval)
|
||||
{
|
||||
let store = Self.makeStore(suite: "heuristics-fixed-\(frequency.rawValue)", frequency: frequency)
|
||||
#expect(store.normalRefreshIntervalForHeuristics() == expectedSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `adaptive resolves to the live adaptive decision delay`() {
|
||||
let store = Self.makeStore(suite: "heuristics-adaptive-live", frequency: .adaptive)
|
||||
|
||||
// No recorded menu open: the decision is longIdle, or constrained on a low-power/hot
|
||||
// machine — both are 30 minutes, so this assertion is environment-independent.
|
||||
#expect(store.normalRefreshIntervalForHeuristics() == 1800.0)
|
||||
|
||||
store.noteMenuOpened()
|
||||
let expected = TimeInterval(UsageStore.adaptiveRefreshDecision(
|
||||
now: Date(),
|
||||
lastMenuOpenAt: store.lastMenuOpenAt,
|
||||
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled,
|
||||
thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds)
|
||||
#expect(store.normalRefreshIntervalForHeuristics() == expected)
|
||||
if Self.machineIsUnconstrained {
|
||||
#expect(store.normalRefreshIntervalForHeuristics() == 120.0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `adaptive cadence schedules a reset-boundary refresh through the refresh pipeline`() async {
|
||||
let store = Self.makeStoreWithStubbedCodex(suite: "heuristics-boundary-adaptive", frequency: .adaptive)
|
||||
|
||||
// Goes through the real end-of-refresh scheduling call, which must feed the adaptive
|
||||
// interval (30 min here — no menu open) rather than the nil `RefreshFrequency.seconds`.
|
||||
await store.refresh()
|
||||
defer { store.cancelResetBoundaryRefresh() }
|
||||
|
||||
#expect(store.scheduledResetBoundaryRefreshAt != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `manual cadence still never schedules a reset-boundary refresh through the refresh pipeline`() async {
|
||||
let store = Self.makeStoreWithStubbedCodex(suite: "heuristics-boundary-manual", frequency: .manual)
|
||||
|
||||
await store.refresh()
|
||||
defer { store.cancelResetBoundaryRefresh() }
|
||||
|
||||
#expect(store.scheduledResetBoundaryRefreshAt == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `adaptive mode lifts the openai web refresh interval off the manual floor`() {
|
||||
let adaptiveStore = Self.makeStore(suite: "heuristics-web-adaptive", frequency: .adaptive)
|
||||
let manualStore = Self.makeStore(suite: "heuristics-web-manual", frequency: .manual)
|
||||
|
||||
let adaptiveInterval = adaptiveStore.openAIWebRefreshIntervalSeconds()
|
||||
let manualInterval = manualStore.openAIWebRefreshIntervalSeconds()
|
||||
|
||||
// Manual hits the 120s fallback floor; adaptive with no menu open resolves to 1800s.
|
||||
// Comparing as a ratio keeps this independent of the web-refresh multiplier.
|
||||
#expect(manualInterval > 0)
|
||||
#expect(adaptiveInterval == manualInterval * 15)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `registry nominal interval maps adaptive to the policy nominal and keeps manual nil`() {
|
||||
#expect(ProviderRegistry.nominalRefreshInterval(for: .adaptive)
|
||||
== AdaptiveRefreshPolicy.nominalIntervalForHeuristics)
|
||||
#expect(ProviderRegistry.nominalRefreshInterval(for: .manual) == nil)
|
||||
#expect(ProviderRegistry.nominalRefreshInterval(for: .thirtyMinutes) == 1800.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider specs give adaptive a nominal cli session idle window instead of the floor`() {
|
||||
let adaptiveStore = Self.makeStore(suite: "heuristics-spec-adaptive", frequency: .adaptive)
|
||||
let manualStore = Self.makeStore(suite: "heuristics-spec-manual", frequency: .manual)
|
||||
|
||||
// Registry specs have no UsageStore to ask, so adaptive maps to the policy's nominal
|
||||
// 300s steady-state interval: max(180, 300 + 60) = 360.
|
||||
let adaptiveWindow = adaptiveStore.providerSpecs[.codex]?
|
||||
.makeFetchContext().persistentCLISessionIdleWindow
|
||||
let manualWindow = manualStore.providerSpecs[.codex]?
|
||||
.makeFetchContext().persistentCLISessionIdleWindow
|
||||
#expect(adaptiveWindow == 360)
|
||||
#expect(manualWindow == 180)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `account-scoped fetch contexts derive the idle window from the live adaptive interval`() {
|
||||
let adaptiveStore = Self.makeStore(suite: "heuristics-account-adaptive", frequency: .adaptive)
|
||||
let manualStore = Self.makeStore(suite: "heuristics-account-manual", frequency: .manual)
|
||||
|
||||
// Unlike registry specs, this path runs inside UsageStore, so adaptive uses the live
|
||||
// decision: 1800s with no menu open, giving max(180, 1800 + 60) = 1860.
|
||||
let adaptiveWindow = adaptiveStore
|
||||
.makeFetchContext(provider: .codex, override: nil).persistentCLISessionIdleWindow
|
||||
let manualWindow = manualStore
|
||||
.makeFetchContext(provider: .codex, override: nil).persistentCLISessionIdleWindow
|
||||
#expect(adaptiveWindow == 1860)
|
||||
#expect(manualWindow == 180)
|
||||
}
|
||||
|
||||
private static var machineIsUnconstrained: Bool {
|
||||
let thermalState = ProcessInfo.processInfo.thermalState
|
||||
return !ProcessInfo.processInfo.isLowPowerModeEnabled
|
||||
&& (thermalState == .nominal || thermalState == .fair)
|
||||
}
|
||||
|
||||
private static func makeStore(suite: String, frequency: RefreshFrequency) -> UsageStore {
|
||||
let settings = testSettingsStore(suiteName: "AdaptiveRefreshHeuristicsTests-\(suite)")
|
||||
settings.providerDetectionCompleted = true
|
||||
settings.refreshFrequency = frequency
|
||||
Self.disableAllProviders(settings: settings)
|
||||
return UsageStore(
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
startupBehavior: .testing,
|
||||
environmentBase: [:])
|
||||
}
|
||||
|
||||
/// The reset-boundary pipeline tests need `refresh()` to complete with a snapshot still in
|
||||
/// place, and `clearDisabledProviderRefreshState` wipes snapshots of disabled providers. So
|
||||
/// codex stays enabled but its fetch is stubbed to return a canned snapshot whose primary
|
||||
/// window resets 10 minutes out — inside a 30-minute normal-refresh window, outside nothing.
|
||||
/// The live-system account is pinned and the snapshot carries the same email, so the
|
||||
/// account-scoped apply guard resolves identically whether or not the machine running the
|
||||
/// tests has a real `~/.codex` login (CI runners do not).
|
||||
private static func makeStoreWithStubbedCodex(suite: String, frequency: RefreshFrequency) -> UsageStore {
|
||||
let store = Self.makeStore(suite: suite, frequency: frequency)
|
||||
let metadata = ProviderRegistry.shared.metadata[.codex]!
|
||||
store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
|
||||
email: Self.stubbedCodexEmail,
|
||||
codexHomePath: "/Users/test/.codex",
|
||||
observedAt: Date(),
|
||||
identity: .unresolved)
|
||||
store.settings.codexActiveSource = .liveSystem
|
||||
store.settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true)
|
||||
store.providerSpecs[.codex] = CodexAccountScopedRefreshTests.makeCodexProviderSpec(
|
||||
baseSpec: store.providerSpecs[.codex]!)
|
||||
{
|
||||
Self.snapshot(updatedAt: Date(), primaryResetsAt: Date().addingTimeInterval(10 * 60))
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
private nonisolated static let stubbedCodexEmail = "adaptive-heuristics@example.com"
|
||||
|
||||
/// Keeps `refresh()` cheap and deterministic: no provider fetch can replace the snapshot
|
||||
/// injected by the reset-boundary tests or slow the pipeline tests down.
|
||||
private static func disableAllProviders(settings: SettingsStore) {
|
||||
let metadata = ProviderRegistry.shared.metadata
|
||||
for provider in UsageProvider.allCases {
|
||||
guard let providerMetadata = metadata[provider] else { continue }
|
||||
settings.setProviderEnabled(provider: provider, metadata: providerMetadata, enabled: false)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func snapshot(updatedAt: Date, primaryResetsAt: Date) -> UsageSnapshot {
|
||||
UsageSnapshot(
|
||||
primary: RateWindow(
|
||||
usedPercent: 100,
|
||||
windowMinutes: 300,
|
||||
resetsAt: primaryResetsAt,
|
||||
resetDescription: nil),
|
||||
secondary: nil,
|
||||
tertiary: nil,
|
||||
updatedAt: updatedAt,
|
||||
identity: ProviderIdentitySnapshot(
|
||||
providerID: .codex,
|
||||
accountEmail: self.stubbedCodexEmail,
|
||||
accountOrganization: nil,
|
||||
loginMethod: "Pro"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
struct AdaptiveRefreshPolicyTests {
|
||||
private static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
|
||||
private func decision(
|
||||
ageSeconds: TimeInterval? = 0,
|
||||
lowPowerModeEnabled: Bool,
|
||||
thermalState: ProcessInfo.ThermalState) -> AdaptiveRefreshPolicy.Decision
|
||||
{
|
||||
UsageStore.adaptiveRefreshDecision(
|
||||
now: Self.referenceNow,
|
||||
lastMenuOpenAt: ageSeconds.map { Self.referenceNow.addingTimeInterval(-$0) },
|
||||
lowPowerModeEnabled: lowPowerModeEnabled,
|
||||
thermalState: thermalState)
|
||||
}
|
||||
|
||||
@Test(arguments: [ProcessInfo.ThermalState.nominal, .fair])
|
||||
func `app adapter maps nominal and fair thermal states to unconstrained`(
|
||||
thermalState: ProcessInfo.ThermalState)
|
||||
{
|
||||
let decision = self.decision(lowPowerModeEnabled: false, thermalState: thermalState)
|
||||
#expect(decision.reason == .recentInteraction)
|
||||
#expect(decision.delay == .seconds(2 * 60))
|
||||
}
|
||||
|
||||
@Test(arguments: [ProcessInfo.ThermalState.serious, .critical])
|
||||
func `app adapter maps serious and critical thermal states to constrained`(
|
||||
thermalState: ProcessInfo.ThermalState)
|
||||
{
|
||||
let decision = self.decision(lowPowerModeEnabled: false, thermalState: thermalState)
|
||||
#expect(decision.reason == .constrained)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app adapter preserves low power precedence`() {
|
||||
let decision = self.decision(lowPowerModeEnabled: true, thermalState: .nominal)
|
||||
#expect(decision.reason == .constrained)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app adapter forwards timestamps and nil history`() {
|
||||
let warm = self.decision(
|
||||
ageSeconds: 301,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal)
|
||||
#expect(warm.reason == .warm)
|
||||
#expect(warm.delay == .seconds(5 * 60))
|
||||
|
||||
let noHistory = self.decision(
|
||||
ageSeconds: nil,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal)
|
||||
#expect(noHistory.reason == .longIdle)
|
||||
#expect(noHistory.delay == .seconds(30 * 60))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
/// Covers the timer plumbing added on top of the pure `AdaptiveRefreshPolicy` (see
|
||||
/// `AdaptiveRefreshPolicyTests`): how `UsageStore.startTimer()` wires live signals into the
|
||||
/// policy, and how manual/fixed/adaptive modes drive (or don't drive) `refresh()` over time.
|
||||
@MainActor
|
||||
struct AdaptiveRefreshTimerTests {
|
||||
@Test
|
||||
func `launch with no menu history begins at thirty minutes`() {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-launch", frequency: .adaptive)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing)
|
||||
|
||||
#expect(store.lastMenuOpenAt == nil)
|
||||
let decision = UsageStore.adaptiveRefreshDecision(
|
||||
now: Date(),
|
||||
lastMenuOpenAt: store.lastMenuOpenAt,
|
||||
lowPowerModeEnabled: false,
|
||||
thermalState: .nominal)
|
||||
#expect(decision.reason == .longIdle)
|
||||
#expect(decision.delay == .seconds(30 * 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `menu-open signal changes the next adaptive decision`() {
|
||||
let now = Date(timeIntervalSinceReferenceDate: 900_000_000)
|
||||
|
||||
let beforeOpen = UsageStore.adaptiveRefreshDecision(
|
||||
now: now, lastMenuOpenAt: nil, lowPowerModeEnabled: false, thermalState: .nominal)
|
||||
#expect(beforeOpen.reason == .longIdle)
|
||||
|
||||
let afterOpen = UsageStore.adaptiveRefreshDecision(
|
||||
now: now, lastMenuOpenAt: now, lowPowerModeEnabled: false, thermalState: .nominal)
|
||||
#expect(afterOpen.reason == .recentInteraction)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `menu open advances a long idle timer during refresh without postponing an earlier tick`() async throws {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-advance", frequency: .adaptive)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing)
|
||||
store.restartTimerWithSleepOverrideForTesting(.seconds(10))
|
||||
try await Self.waitUntil { store.adaptiveRefreshScheduledAt != nil }
|
||||
|
||||
let longIdleSchedule = try #require(store.adaptiveRefreshScheduledAt)
|
||||
let now = Date()
|
||||
store._setSnapshotForTesting(
|
||||
UsageSnapshot(
|
||||
primary: RateWindow(
|
||||
usedPercent: 50,
|
||||
windowMinutes: 300,
|
||||
resetsAt: now.addingTimeInterval(30),
|
||||
resetDescription: nil),
|
||||
secondary: nil,
|
||||
updatedAt: now,
|
||||
identity: nil),
|
||||
provider: .codex)
|
||||
store.scheduleResetBoundaryRefreshIfNeeded(normalRefreshInterval: 30 * 60, now: now)
|
||||
defer { store.cancelResetBoundaryRefresh() }
|
||||
let resetBoundarySchedule = try #require(store.scheduledResetBoundaryRefreshAt)
|
||||
|
||||
store.isRefreshing = true
|
||||
defer { store.isRefreshing = false }
|
||||
store.noteMenuOpened()
|
||||
try await Self.waitUntil {
|
||||
guard let scheduledAt = store.adaptiveRefreshScheduledAt else { return false }
|
||||
return scheduledAt < longIdleSchedule
|
||||
}
|
||||
let interactionSchedule = try #require(store.adaptiveRefreshScheduledAt)
|
||||
#expect(store.isRefreshing)
|
||||
#expect(store.scheduledResetBoundaryRefreshAt == resetBoundarySchedule)
|
||||
|
||||
store.noteMenuOpened(at: Date().addingTimeInterval(30))
|
||||
#expect(store.adaptiveRefreshScheduledAt == interactionSchedule)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `noting a menu open records the signal without starting a refresh`() {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-noteMenuOpened", frequency: .manual)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing)
|
||||
|
||||
#expect(store.completedRefreshCountForTesting == 0)
|
||||
#expect(store.isRefreshing == false)
|
||||
|
||||
store.noteMenuOpened()
|
||||
|
||||
#expect(store.lastMenuOpenAt != nil)
|
||||
#expect(store.completedRefreshCountForTesting == 0)
|
||||
#expect(store.isRefreshing == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `opportunistic timer refresh is a no-op while another refresh is already in flight`() async {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-coalesce", frequency: .manual)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing)
|
||||
|
||||
store.isRefreshing = true
|
||||
await store.refresh(enrichmentMode: .automatic)
|
||||
|
||||
// The guard at the top of runRefresh() returned immediately: no completion was recorded and the
|
||||
// flag was left untouched by this call. This is the invariant every timer tick (fixed or
|
||||
// adaptive) relies on to avoid overlapping with a refresh already in flight.
|
||||
#expect(store.completedRefreshCountForTesting == 0)
|
||||
#expect(store.isRefreshing == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `manual mode performs the initial refresh but no recurring ticks`() async throws {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-manual", frequency: .manual)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .full)
|
||||
try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 }
|
||||
|
||||
// Manual mode never starts a timer, so nothing can push the count past the one launch refresh
|
||||
// no matter how long we wait; a short settle window is enough to catch a regression.
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
#expect(store.completedRefreshCountForTesting == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fixed mode ticks recur at the overridden cadence`() async throws {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-fixed", frequency: .oneMinute)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .full)
|
||||
store.restartTimerWithSleepOverrideForTesting(.milliseconds(20))
|
||||
|
||||
// 1 initial launch refresh plus at least one 20ms-cadence tick; proves the loop recurs
|
||||
// rather than sleeping once and stopping. Each refresh cycle here costs low single-digit
|
||||
// seconds of wall time even with every provider disabled, so the timeout is generous.
|
||||
try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting >= 2 }
|
||||
#expect(store.completedRefreshCountForTesting >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fixed cadence advances from scheduled tick instead of refresh completion`() {
|
||||
let interval = Duration.milliseconds(100)
|
||||
let start = ContinuousClock.now
|
||||
let firstScheduledAt = start + interval
|
||||
|
||||
let nextAfterExactTick = UsageStore.nextFixedTimerScheduledAt(
|
||||
previousScheduledAt: firstScheduledAt,
|
||||
completedAt: firstScheduledAt,
|
||||
interval: interval)
|
||||
#expect(nextAfterExactTick == start + .milliseconds(200))
|
||||
|
||||
let nextJustBeforeFollowingTick = UsageStore.nextFixedTimerScheduledAt(
|
||||
previousScheduledAt: firstScheduledAt,
|
||||
completedAt: firstScheduledAt + .milliseconds(100) - .nanoseconds(1),
|
||||
interval: interval)
|
||||
#expect(nextJustBeforeFollowingTick == start + .milliseconds(200))
|
||||
|
||||
let nextAtFollowingTick = UsageStore.nextFixedTimerScheduledAt(
|
||||
previousScheduledAt: firstScheduledAt,
|
||||
completedAt: firstScheduledAt + .milliseconds(100),
|
||||
interval: interval)
|
||||
#expect(nextAtFollowingTick == start + .milliseconds(300))
|
||||
|
||||
let nextAfterSlowRefresh = UsageStore.nextFixedTimerScheduledAt(
|
||||
previousScheduledAt: firstScheduledAt,
|
||||
completedAt: firstScheduledAt + .milliseconds(60),
|
||||
interval: interval)
|
||||
#expect(nextAfterSlowRefresh == start + .milliseconds(200))
|
||||
|
||||
let nextAfterMissedTicks = UsageStore.nextFixedTimerScheduledAt(
|
||||
previousScheduledAt: firstScheduledAt,
|
||||
completedAt: firstScheduledAt + .milliseconds(260),
|
||||
interval: interval)
|
||||
#expect(nextAfterMissedTicks == start + .milliseconds(400))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fixed timer loop stays interval aligned after a slow refresh`() async {
|
||||
let harness = FixedTimerLoopHarness()
|
||||
|
||||
await UsageStore.runFixedRefreshTimer(
|
||||
interval: .milliseconds(100),
|
||||
now: { await harness.now() },
|
||||
sleep: { duration in await harness.sleep(for: duration) },
|
||||
refresh: { await harness.refresh() })
|
||||
|
||||
#expect(await harness.recordedStarts() == [.milliseconds(100), .milliseconds(300)])
|
||||
#expect(await harness.maximumConcurrentRefreshes() == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `adaptive mode keeps recomputing and refreshing across menu-open changes`() async throws {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-adaptive", frequency: .adaptive)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .full)
|
||||
store.restartTimerWithSleepOverrideForTesting(.milliseconds(20))
|
||||
try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting >= 1 }
|
||||
let countBeforeMenuOpen = store.completedRefreshCountForTesting
|
||||
|
||||
store.noteMenuOpened()
|
||||
|
||||
// The loop kept looping (recomputing the decision from a fresh Input) after lastMenuOpenAt
|
||||
// changed, rather than sleeping once on a captured delay and stopping.
|
||||
try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting > countBeforeMenuOpen }
|
||||
#expect(store.completedRefreshCountForTesting > countBeforeMenuOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `changing frequency away from fixed cancels the pending tick without an extra refresh`() async throws {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel", frequency: .oneMinute)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .full)
|
||||
// Deliberately much longer than anything else in this test: the assertion only needs this
|
||||
// sleep to still be pending (uncompleted) when we switch away, not to time anything precisely.
|
||||
store.restartTimerWithSleepOverrideForTesting(.seconds(5))
|
||||
// Only the initial launch refresh can land this quickly; the fixed-mode timer's first tick
|
||||
// needs the full 5s override to elapse, so it cannot have fired yet.
|
||||
try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 }
|
||||
let countBeforeSwitch = store.completedRefreshCountForTesting
|
||||
|
||||
settings.refreshFrequency = .manual
|
||||
|
||||
// The settings-change path (outside adaptive-refresh scope) may fire its own refresh(es) for
|
||||
// reasons unrelated to the timer under test; wait for the count to stop moving rather than
|
||||
// assuming it fires exactly once. Windows are doubled from an earlier version that flaked once
|
||||
// under full parallel `make test` load.
|
||||
let countAfterSettling = try await Self.waitForStableCount(store: store, settleWindow: .milliseconds(800))
|
||||
#expect(countAfterSettling > countBeforeSwitch)
|
||||
|
||||
// Settle comfortably within the 5s override window. If the old fixed-mode timer had not been
|
||||
// canceled, its pending tick would eventually land and push the count past the settled value —
|
||||
// but not within this window, so any further increase here indicates a real cancellation bug,
|
||||
// not settings-change noise.
|
||||
try await Task.sleep(for: .milliseconds(1600))
|
||||
#expect(store.completedRefreshCountForTesting == countAfterSettling)
|
||||
}
|
||||
|
||||
// The test above goes through `settings.refreshFrequency = .manual`, which also triggers the
|
||||
// settings-observer's own `refreshForSettingsChange()` — a legitimate refresh unrelated to the
|
||||
// timer. That confound means it cannot, by itself, prove the `guard !Task.isCancelled else { return }`
|
||||
// after each branch's sleep is load-bearing (deleting either guard still leaves this test green,
|
||||
// since the settings-observer refresh already accounts for the "count increased" expectation).
|
||||
// The two tests below isolate `startTimer()`'s cancel-and-replace path directly, by calling
|
||||
// `restartTimerWithSleepOverrideForTesting` a second time at the *same* frequency — which goes
|
||||
// straight through `startTimer()` with no settings observation involved — so no refresh is
|
||||
// legitimately expected at all, and any extra one proves a canceled sleep still ran its body.
|
||||
|
||||
@Test
|
||||
func `restarting the timer cancels a pending fixed tick without an extra refresh`() async throws {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel-fixed", frequency: .oneMinute)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .full)
|
||||
store.restartTimerWithSleepOverrideForTesting(.seconds(5))
|
||||
try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 }
|
||||
let countBeforeRestart = store.completedRefreshCountForTesting
|
||||
|
||||
// Cancels the pending 5s sleep above and starts a fresh one, still at .oneMinute. No settings
|
||||
// mutation, so no settings-observer refresh is expected here at all.
|
||||
store.restartTimerWithSleepOverrideForTesting(.seconds(5))
|
||||
|
||||
// Neither the old (canceled) timer's tick nor the new timer's first tick can land within this
|
||||
// window — both need the full 5s override. Any refresh here can only be the canceled sleep's
|
||||
// body running anyway.
|
||||
try await Task.sleep(for: .milliseconds(800))
|
||||
#expect(store.completedRefreshCountForTesting == countBeforeRestart)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `restarting the timer cancels a pending adaptive tick without an extra refresh`() async throws {
|
||||
let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel-adaptive", frequency: .adaptive)
|
||||
let store = Self.makeUsageStore(settings: settings, startupBehavior: .full)
|
||||
store.restartTimerWithSleepOverrideForTesting(.seconds(5))
|
||||
try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 }
|
||||
let countBeforeRestart = store.completedRefreshCountForTesting
|
||||
|
||||
store.restartTimerWithSleepOverrideForTesting(.seconds(5))
|
||||
|
||||
try await Task.sleep(for: .milliseconds(800))
|
||||
#expect(store.completedRefreshCountForTesting == countBeforeRestart)
|
||||
}
|
||||
|
||||
/// Polls `condition` until it's true or `timeout` elapses, without assuming how long setup or
|
||||
/// scheduling takes. Throws `CancellationError` (surfaced as a test failure) on timeout.
|
||||
private static func waitUntil(
|
||||
timeout: Duration = .seconds(30),
|
||||
pollInterval: Duration = .milliseconds(20),
|
||||
_ condition: () -> Bool) async throws
|
||||
{
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
while !condition() {
|
||||
if ContinuousClock.now >= deadline {
|
||||
throw CancellationError()
|
||||
}
|
||||
try await Task.sleep(for: pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls `store.completedRefreshCountForTesting` until it stops changing for `settleWindow`,
|
||||
/// tolerating an unknown number of in-flight refreshes (e.g. settings-change side effects
|
||||
/// unrelated to the timer under test) before returning the final, stable count.
|
||||
private static func waitForStableCount(
|
||||
store: UsageStore,
|
||||
settleWindow: Duration,
|
||||
timeout: Duration = .seconds(30),
|
||||
pollInterval: Duration = .milliseconds(20)) async throws -> Int
|
||||
{
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
var lastCount = store.completedRefreshCountForTesting
|
||||
var lastChangedAt = ContinuousClock.now
|
||||
while true {
|
||||
try await Task.sleep(for: pollInterval)
|
||||
let current = store.completedRefreshCountForTesting
|
||||
let now = ContinuousClock.now
|
||||
if current != lastCount {
|
||||
lastCount = current
|
||||
lastChangedAt = now
|
||||
} else if now - lastChangedAt >= settleWindow {
|
||||
return lastCount
|
||||
}
|
||||
if now >= deadline {
|
||||
throw CancellationError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeSettingsStore(suite: String, frequency: RefreshFrequency) -> SettingsStore {
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configStore = testConfigStore(suiteName: suite)
|
||||
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: configStore,
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore(),
|
||||
codexCookieStore: InMemoryCookieHeaderStore(),
|
||||
claudeCookieStore: InMemoryCookieHeaderStore(),
|
||||
cursorCookieStore: InMemoryCookieHeaderStore(),
|
||||
opencodeCookieStore: InMemoryCookieHeaderStore(),
|
||||
factoryCookieStore: InMemoryCookieHeaderStore(),
|
||||
minimaxCookieStore: InMemoryMiniMaxCookieStore(),
|
||||
minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(),
|
||||
kimiTokenStore: InMemoryKimiTokenStore(),
|
||||
kimiK2TokenStore: InMemoryKimiK2TokenStore(),
|
||||
augmentCookieStore: InMemoryCookieHeaderStore(),
|
||||
ampCookieStore: InMemoryCookieHeaderStore(),
|
||||
copilotTokenStore: InMemoryCopilotTokenStore(),
|
||||
tokenAccountStore: InMemoryTokenAccountStore())
|
||||
settings.providerDetectionCompleted = true
|
||||
settings.refreshFrequency = frequency
|
||||
Self.disableAllProviders(settings: settings)
|
||||
return settings
|
||||
}
|
||||
|
||||
/// Codex is enabled by default; disabling every provider (including it) keeps `refresh()` cheap
|
||||
/// and deterministic in these tests, which care about tick cadence, not provider fetch results.
|
||||
private static func disableAllProviders(settings: SettingsStore) {
|
||||
let metadata = ProviderRegistry.shared.metadata
|
||||
for provider in UsageProvider.allCases {
|
||||
guard let providerMetadata = metadata[provider] else { continue }
|
||||
settings.setProviderEnabled(provider: provider, metadata: providerMetadata, enabled: false)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeUsageStore(
|
||||
settings: SettingsStore,
|
||||
startupBehavior: UsageStore.StartupBehavior) -> UsageStore
|
||||
{
|
||||
UsageStore(
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
startupBehavior: startupBehavior,
|
||||
environmentBase: [:])
|
||||
}
|
||||
}
|
||||
|
||||
private actor FixedTimerLoopHarness {
|
||||
private let origin = ContinuousClock.now
|
||||
private var elapsed = Duration.zero
|
||||
private var starts: [Duration] = []
|
||||
private var activeRefreshes = 0
|
||||
private var maximumActiveRefreshes = 0
|
||||
|
||||
func now() -> ContinuousClock.Instant {
|
||||
self.origin + self.elapsed
|
||||
}
|
||||
|
||||
func sleep(for duration: Duration) {
|
||||
self.elapsed += duration
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
self.activeRefreshes += 1
|
||||
self.maximumActiveRefreshes = max(self.maximumActiveRefreshes, self.activeRefreshes)
|
||||
self.starts.append(self.elapsed)
|
||||
if self.starts.count == 1 {
|
||||
self.elapsed += .milliseconds(160)
|
||||
}
|
||||
self.activeRefreshes -= 1
|
||||
if self.starts.count == 2 {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
func recordedStarts() -> [Duration] {
|
||||
self.starts
|
||||
}
|
||||
|
||||
func maximumConcurrentRefreshes() -> Int {
|
||||
self.maximumActiveRefreshes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AdminAPIUsageLocalDaySelectionTests {
|
||||
@Test
|
||||
func `OpenAI current day includes UTC bucket containing positive timezone morning`() throws {
|
||||
let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney")
|
||||
let now = try Self.date(year: 2026, month: 5, day: 18, hour: 8, timeZoneIdentifier: "Australia/Sydney")
|
||||
let staleUTCStart = try Self.date(year: 2026, month: 5, day: 16, hour: 0, timeZoneIdentifier: "UTC")
|
||||
let overlappingUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC")
|
||||
let usage = OpenAIAPIUsageSnapshot(
|
||||
daily: [
|
||||
OpenAIAPIUsageSnapshot.DailyBucket(
|
||||
day: "2026-05-16",
|
||||
startTime: staleUTCStart,
|
||||
endTime: staleUTCStart.addingTimeInterval(86400),
|
||||
costUSD: 9,
|
||||
requests: 9,
|
||||
inputTokens: 900,
|
||||
cachedInputTokens: 90,
|
||||
outputTokens: 90,
|
||||
totalTokens: 990,
|
||||
lineItems: [],
|
||||
models: []),
|
||||
OpenAIAPIUsageSnapshot.DailyBucket(
|
||||
day: "2026-05-17",
|
||||
startTime: overlappingUTCStart,
|
||||
endTime: overlappingUTCStart.addingTimeInterval(86400),
|
||||
costUSD: 2.5,
|
||||
requests: 3,
|
||||
inputTokens: 200,
|
||||
cachedInputTokens: 20,
|
||||
outputTokens: 30,
|
||||
totalTokens: 250,
|
||||
lineItems: [],
|
||||
models: []),
|
||||
],
|
||||
updatedAt: now)
|
||||
|
||||
let today = usage.summary(forLocalDayContaining: now, calendar: calendar)
|
||||
|
||||
#expect(today.costUSD == 2.5)
|
||||
#expect(today.requests == 3)
|
||||
#expect(today.totalTokens == 250)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `OpenAI current day does not sum adjacent UTC buckets after positive timezone UTC rollover`() throws {
|
||||
let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney")
|
||||
let now = try Self.date(year: 2026, month: 5, day: 18, hour: 16, timeZoneIdentifier: "Australia/Sydney")
|
||||
let previousUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC")
|
||||
let currentUTCStart = try Self.date(year: 2026, month: 5, day: 18, hour: 0, timeZoneIdentifier: "UTC")
|
||||
let usage = OpenAIAPIUsageSnapshot(
|
||||
daily: [
|
||||
OpenAIAPIUsageSnapshot.DailyBucket(
|
||||
day: "2026-05-17",
|
||||
startTime: previousUTCStart,
|
||||
endTime: previousUTCStart.addingTimeInterval(86400),
|
||||
costUSD: 2.5,
|
||||
requests: 3,
|
||||
inputTokens: 200,
|
||||
cachedInputTokens: 20,
|
||||
outputTokens: 30,
|
||||
totalTokens: 250,
|
||||
lineItems: [],
|
||||
models: []),
|
||||
OpenAIAPIUsageSnapshot.DailyBucket(
|
||||
day: "2026-05-18",
|
||||
startTime: currentUTCStart,
|
||||
endTime: currentUTCStart.addingTimeInterval(86400),
|
||||
costUSD: 4.5,
|
||||
requests: 5,
|
||||
inputTokens: 400,
|
||||
cachedInputTokens: 40,
|
||||
outputTokens: 50,
|
||||
totalTokens: 490,
|
||||
lineItems: [],
|
||||
models: []),
|
||||
],
|
||||
updatedAt: now)
|
||||
|
||||
let today = usage.summary(forLocalDayContaining: now, calendar: calendar)
|
||||
|
||||
#expect(today.costUSD == 4.5)
|
||||
#expect(today.requests == 5)
|
||||
#expect(today.totalTokens == 490)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Claude Admin current day includes UTC bucket containing positive timezone morning`() throws {
|
||||
let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney")
|
||||
let now = try Self.date(year: 2026, month: 5, day: 18, hour: 8, timeZoneIdentifier: "Australia/Sydney")
|
||||
let staleUTCStart = try Self.date(year: 2026, month: 5, day: 16, hour: 0, timeZoneIdentifier: "UTC")
|
||||
let overlappingUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC")
|
||||
let usage = ClaudeAdminAPIUsageSnapshot(
|
||||
daily: [
|
||||
ClaudeAdminAPIUsageSnapshot.DailyBucket(
|
||||
day: "2026-05-16",
|
||||
startTime: staleUTCStart,
|
||||
endTime: staleUTCStart.addingTimeInterval(86400),
|
||||
costUSD: 9,
|
||||
inputTokens: 900,
|
||||
cacheCreationInputTokens: 90,
|
||||
cacheReadInputTokens: 45,
|
||||
outputTokens: 90,
|
||||
totalTokens: 1125,
|
||||
costItems: [],
|
||||
models: []),
|
||||
ClaudeAdminAPIUsageSnapshot.DailyBucket(
|
||||
day: "2026-05-17",
|
||||
startTime: overlappingUTCStart,
|
||||
endTime: overlappingUTCStart.addingTimeInterval(86400),
|
||||
costUSD: 2.5,
|
||||
inputTokens: 200,
|
||||
cacheCreationInputTokens: 20,
|
||||
cacheReadInputTokens: 10,
|
||||
outputTokens: 30,
|
||||
totalTokens: 260,
|
||||
costItems: [],
|
||||
models: []),
|
||||
],
|
||||
updatedAt: now)
|
||||
|
||||
let today = usage.summary(forLocalDayContaining: now, calendar: calendar)
|
||||
|
||||
#expect(today.costUSD == 2.5)
|
||||
#expect(today.inputTokens == 200)
|
||||
#expect(today.totalTokens == 260)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Claude Admin current day does not sum adjacent UTC buckets after negative timezone UTC rollover`() throws {
|
||||
let calendar = try Self.calendar(timeZoneIdentifier: "America/Los_Angeles")
|
||||
let now = try Self.date(year: 2026, month: 6, day: 22, hour: 20, timeZoneIdentifier: "America/Los_Angeles")
|
||||
let previousUTCStart = try Self.date(year: 2026, month: 6, day: 22, hour: 0, timeZoneIdentifier: "UTC")
|
||||
let currentUTCStart = try Self.date(year: 2026, month: 6, day: 23, hour: 0, timeZoneIdentifier: "UTC")
|
||||
let usage = ClaudeAdminAPIUsageSnapshot(
|
||||
daily: [
|
||||
ClaudeAdminAPIUsageSnapshot.DailyBucket(
|
||||
day: "2026-06-22",
|
||||
startTime: previousUTCStart,
|
||||
endTime: previousUTCStart.addingTimeInterval(86400),
|
||||
costUSD: 2.5,
|
||||
inputTokens: 200,
|
||||
cacheCreationInputTokens: 20,
|
||||
cacheReadInputTokens: 10,
|
||||
outputTokens: 30,
|
||||
totalTokens: 260,
|
||||
costItems: [],
|
||||
models: []),
|
||||
ClaudeAdminAPIUsageSnapshot.DailyBucket(
|
||||
day: "2026-06-23",
|
||||
startTime: currentUTCStart,
|
||||
endTime: currentUTCStart.addingTimeInterval(86400),
|
||||
costUSD: 4.5,
|
||||
inputTokens: 400,
|
||||
cacheCreationInputTokens: 40,
|
||||
cacheReadInputTokens: 20,
|
||||
outputTokens: 50,
|
||||
totalTokens: 510,
|
||||
costItems: [],
|
||||
models: []),
|
||||
],
|
||||
updatedAt: now)
|
||||
|
||||
let today = usage.summary(forLocalDayContaining: now, calendar: calendar)
|
||||
|
||||
#expect(today.costUSD == 4.5)
|
||||
#expect(today.inputTokens == 400)
|
||||
#expect(today.totalTokens == 510)
|
||||
}
|
||||
|
||||
private static func calendar(timeZoneIdentifier: String) throws -> Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: timeZoneIdentifier))
|
||||
return calendar
|
||||
}
|
||||
|
||||
private static func date(
|
||||
year: Int,
|
||||
month: Int,
|
||||
day: Int,
|
||||
hour: Int,
|
||||
timeZoneIdentifier: String) throws -> Date
|
||||
{
|
||||
var components = DateComponents()
|
||||
components.calendar = Calendar(identifier: .gregorian)
|
||||
components.timeZone = TimeZone(identifier: timeZoneIdentifier)
|
||||
components.year = year
|
||||
components.month = month
|
||||
components.day = day
|
||||
components.hour = hour
|
||||
return try #require(components.date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AgentSessionJSONTests {
|
||||
@Test
|
||||
func `sessions json round trip preserves stable schema`() throws {
|
||||
let session = AgentSession(
|
||||
id: "fixture-session",
|
||||
provider: .codex,
|
||||
source: .ide,
|
||||
state: .active,
|
||||
pid: 42,
|
||||
cwd: "/tmp/project",
|
||||
projectName: "project",
|
||||
startedAt: Date(timeIntervalSince1970: 100),
|
||||
lastActivityAt: Date(timeIntervalSince1970: 200),
|
||||
transcriptPath: "/tmp/rollout.jsonl",
|
||||
host: "local-mac")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode([session])
|
||||
let object = try #require(JSONSerialization.jsonObject(with: data) as? [[String: Any]])
|
||||
let keys = try #require(object.first).keys
|
||||
#expect(Set(keys) == [
|
||||
"id", "provider", "source", "state", "pid", "cwd", "projectName", "startedAt",
|
||||
"lastActivityAt", "transcriptPath", "host",
|
||||
])
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
#expect(try decoder.decode([AgentSession].self, from: data) == [session])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
@MainActor
|
||||
struct AgentSessionMenuDescriptorTests {
|
||||
@Test
|
||||
func `fresh settings omit agent sessions until explicitly enabled`() {
|
||||
let settings = testSettingsStore(suiteName: "AgentSessionMenuDescriptorTests-default-off")
|
||||
settings.statusChecksEnabled = false
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
let session = Self.session(id: "local", host: "local-mac", activity: Date())
|
||||
|
||||
let buildDescriptor = {
|
||||
MenuDescriptor.build(
|
||||
provider: .codex,
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
updateReady: false,
|
||||
agentSessionsEnabled: settings.agentSessionsEnabled,
|
||||
localAgentSessions: [session])
|
||||
}
|
||||
|
||||
let disabledEntries = buildDescriptor().sections.flatMap(\.entries)
|
||||
#expect(!Self.containsAgentSessions(in: disabledEntries))
|
||||
|
||||
settings.agentSessionsEnabled = true
|
||||
|
||||
let enabledEntries = buildDescriptor().sections.flatMap(\.entries)
|
||||
#expect(Self.containsAgentSessions(in: enabledEntries))
|
||||
#expect(enabledEntries.contains { entry in
|
||||
guard case .action(_, .focusAgentSession) = entry else { return false }
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
func `session section counts groups and renders unreachable hosts`() {
|
||||
let now = Date(timeIntervalSince1970: 1000)
|
||||
let local = Self.session(id: "local", host: "local-mac", activity: now.addingTimeInterval(-60))
|
||||
let remote = Self.session(id: "remote", host: "clawmac", activity: now.addingTimeInterval(-720))
|
||||
let section = MenuDescriptor.agentSessionsSection(
|
||||
localSessions: [local],
|
||||
remoteHosts: [
|
||||
RemoteSessionHostResult(host: "clawmac", sessions: [remote], error: nil),
|
||||
RemoteSessionHostResult(host: "offline", sessions: [], error: "Connection timed out"),
|
||||
],
|
||||
now: now)
|
||||
|
||||
guard case let .text(header, .headline) = section.entries[0] else {
|
||||
Issue.record("Expected session headline")
|
||||
return
|
||||
}
|
||||
#expect(header == "Agent Sessions (2)")
|
||||
guard case let .action(localTitle, .focusAgentSession(_, remoteHost)) = section.entries[1] else {
|
||||
Issue.record("Expected local session action")
|
||||
return
|
||||
}
|
||||
#expect(localTitle.contains("alpha — codex · cli · 1m"))
|
||||
#expect(remoteHost == nil)
|
||||
guard case let .text(remoteGroup, .secondary) = section.entries[2] else {
|
||||
Issue.record("Expected remote group")
|
||||
return
|
||||
}
|
||||
#expect(remoteGroup == "clawmac — 1")
|
||||
guard case let .unavailable(title, tooltip) = section.entries[4] else {
|
||||
Issue.record("Expected unreachable host")
|
||||
return
|
||||
}
|
||||
#expect(title == "offline — unreachable")
|
||||
#expect(tooltip == "Connection timed out")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `reachable empty remote host keeps zero count section actionable`() {
|
||||
let section = MenuDescriptor.agentSessionsSection(
|
||||
localSessions: [],
|
||||
remoteHosts: [RemoteSessionHostResult(host: "clawmac", sessions: [], error: nil)])
|
||||
|
||||
#expect(section.entries.contains { entry in
|
||||
guard case let .unavailable(title, _) = entry else { return false }
|
||||
return title == "No agent sessions found"
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
func `remote refresh gate retries changed settings and rejects stale result`() throws {
|
||||
var gate = AgentSessionRemoteRefreshGate()
|
||||
let initialGenerationCandidate = gate.begin()
|
||||
let initialGeneration = try #require(initialGenerationCandidate)
|
||||
gate.settingsDidChange()
|
||||
#expect(gate.begin() == nil)
|
||||
|
||||
let staleOutcome = gate.finish(generation: initialGeneration)
|
||||
#expect(!staleOutcome.shouldPublish)
|
||||
#expect(staleOutcome.shouldRetry)
|
||||
|
||||
let currentGenerationCandidate = gate.begin()
|
||||
let currentGeneration = try #require(currentGenerationCandidate)
|
||||
let currentOutcome = gate.finish(generation: currentGeneration)
|
||||
#expect(currentOutcome.shouldPublish)
|
||||
#expect(!currentOutcome.shouldRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `remote refresh gate coalesces ordinary overlaps without retry`() throws {
|
||||
var gate = AgentSessionRemoteRefreshGate()
|
||||
let generationCandidate = gate.begin()
|
||||
let generation = try #require(generationCandidate)
|
||||
#expect(gate.begin() == nil)
|
||||
|
||||
let outcome = gate.finish(generation: generation)
|
||||
#expect(outcome.shouldPublish)
|
||||
#expect(!outcome.shouldRetry)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `remote refresh gate coalesces multiple ordinary overlaps into one pass`() throws {
|
||||
var gate = AgentSessionRemoteRefreshGate()
|
||||
let generationCandidate = gate.begin()
|
||||
let generation = try #require(generationCandidate)
|
||||
for _ in 0..<5 {
|
||||
#expect(gate.begin() == nil)
|
||||
}
|
||||
|
||||
let outcome = gate.finish(generation: generation)
|
||||
#expect(outcome.shouldPublish)
|
||||
#expect(!outcome.shouldRetry)
|
||||
#expect(Self.remotePassCount(for: .ordinaryOverlaps(count: 5)) == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `remote refresh gate still retries after ordinary overlap then settings change`() throws {
|
||||
var gate = AgentSessionRemoteRefreshGate()
|
||||
let staleGenerationCandidate = gate.begin()
|
||||
let staleGeneration = try #require(staleGenerationCandidate)
|
||||
#expect(gate.begin() == nil)
|
||||
gate.settingsDidChange()
|
||||
|
||||
let staleOutcome = gate.finish(generation: staleGeneration)
|
||||
#expect(!staleOutcome.shouldPublish)
|
||||
#expect(staleOutcome.shouldRetry)
|
||||
|
||||
let currentGenerationCandidate = gate.begin()
|
||||
let currentGeneration = try #require(currentGenerationCandidate)
|
||||
let currentOutcome = gate.finish(generation: currentGeneration)
|
||||
#expect(currentOutcome.shouldPublish)
|
||||
#expect(!currentOutcome.shouldRetry)
|
||||
#expect(Self.remotePassCount(for: .ordinaryOverlapThenSettingsChange) == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `remote refresh gate pass counts stay at one for overlap and two for settings change`() {
|
||||
#expect(Self.remotePassCount(for: .ordinaryOverlaps(count: 1)) == 1)
|
||||
#expect(Self.remotePassCount(for: .settingsChangeDuringFlight) == 2)
|
||||
}
|
||||
|
||||
private static func session(id: String, host: String, activity: Date) -> AgentSession {
|
||||
AgentSession(
|
||||
id: id,
|
||||
provider: .codex,
|
||||
source: .cli,
|
||||
state: .active,
|
||||
pid: 42,
|
||||
cwd: "/Users/test/alpha",
|
||||
projectName: "alpha",
|
||||
startedAt: nil,
|
||||
lastActivityAt: activity,
|
||||
transcriptPath: nil,
|
||||
host: host)
|
||||
}
|
||||
|
||||
private static func containsAgentSessions(in entries: [MenuDescriptor.Entry]) -> Bool {
|
||||
entries.contains { entry in
|
||||
guard case let .text(title, .headline) = entry else { return false }
|
||||
return title.hasPrefix("Agent Sessions (")
|
||||
}
|
||||
}
|
||||
|
||||
private enum RemoteRefreshScenario {
|
||||
case ordinaryOverlaps(count: Int)
|
||||
case settingsChangeDuringFlight
|
||||
case ordinaryOverlapThenSettingsChange
|
||||
}
|
||||
|
||||
/// Pure state-machine pass counter: each successful `begin()`/`finish()` pair is one remote pass.
|
||||
private static func remotePassCount(for scenario: RemoteRefreshScenario) -> Int {
|
||||
var gate = AgentSessionRemoteRefreshGate()
|
||||
var passes = 0
|
||||
|
||||
guard let generation = gate.begin() else { return 0 }
|
||||
passes += 1
|
||||
|
||||
switch scenario {
|
||||
case let .ordinaryOverlaps(count):
|
||||
for _ in 0..<count {
|
||||
_ = gate.begin()
|
||||
}
|
||||
case .settingsChangeDuringFlight:
|
||||
gate.settingsDidChange()
|
||||
case .ordinaryOverlapThenSettingsChange:
|
||||
_ = gate.begin()
|
||||
gate.settingsDidChange()
|
||||
}
|
||||
|
||||
let outcome = gate.finish(generation: generation)
|
||||
guard outcome.shouldRetry, let nextGeneration = gate.begin() else {
|
||||
return passes
|
||||
}
|
||||
passes += 1
|
||||
_ = gate.finish(generation: nextGeneration)
|
||||
return passes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AgentSessionParserTests {
|
||||
@Test
|
||||
func `ps parser deduplicates desktop wrapper and excludes app server and helpers`() throws {
|
||||
let output = try Self.fixtureString("agent-sessions-ps", extension: "txt")
|
||||
let records = AgentPSOutputParser.parse(output)
|
||||
let agents = AgentPSOutputParser.agentProcesses(from: records)
|
||||
|
||||
#expect(records.count == 9)
|
||||
#expect(agents.map(\ .pid) == [102, 201])
|
||||
#expect(AgentPSOutputParser.provider(for: agents[0]) == .claude)
|
||||
#expect(AgentPSOutputParser.source(for: agents[0]) == .desktopApp)
|
||||
#expect(AgentPSOutputParser.provider(for: agents[1]) == .codex)
|
||||
#expect(agents[1].command.hasSuffix("strange argv here"))
|
||||
#expect(AgentPSOutputParser.hasCodexAppServer(in: records))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lsof parser maps batched cwd records`() throws {
|
||||
let output = try Self.fixtureString("agent-sessions-lsof", extension: "txt")
|
||||
let paths = LSOFCWDOutputParser.parse(output)
|
||||
|
||||
#expect(paths[102] == "/Users/test/Projects/alpha")
|
||||
#expect(paths[201] == "/Users/test/Projects/project with spaces")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `same cwd processes remain uncorrelated when ownership is ambiguous`() {
|
||||
let olderStart = Date(timeIntervalSince1970: 100)
|
||||
let newerStart = Date(timeIntervalSince1970: 200)
|
||||
let older = AgentProcessRecord(pid: 10, ppid: 1, startedAt: olderStart, command: "claude")
|
||||
let newer = AgentProcessRecord(pid: 20, ppid: 1, startedAt: newerStart, command: "claude")
|
||||
let olderTranscript = ClaudeSessionProjectMapper.Transcript(
|
||||
url: URL(fileURLWithPath: "/tmp/older.jsonl"),
|
||||
modifiedAt: Date(timeIntervalSince1970: 150))
|
||||
let newerTranscript = ClaudeSessionProjectMapper.Transcript(
|
||||
url: URL(fileURLWithPath: "/tmp/newer.jsonl"),
|
||||
modifiedAt: Date(timeIntervalSince1970: 250))
|
||||
|
||||
let assignments = AgentSessionCorrelation.assignClaudeTranscripts(
|
||||
processes: [older, newer],
|
||||
cwdByPID: [10: "/project", 20: "/project"],
|
||||
transcriptsByCWD: ["/project": [newerTranscript, olderTranscript]])
|
||||
|
||||
#expect(assignments.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `newest process sorts first for rollout correlation`() {
|
||||
let older = AgentProcessRecord(
|
||||
pid: 10,
|
||||
ppid: 1,
|
||||
startedAt: Date(timeIntervalSince1970: 100),
|
||||
command: "codex exec")
|
||||
let newer = AgentProcessRecord(
|
||||
pid: 20,
|
||||
ppid: 1,
|
||||
startedAt: Date(timeIntervalSince1970: 200),
|
||||
command: "codex exec")
|
||||
|
||||
#expect(AgentSessionCorrelation.newestProcessesFirst([older, newer]).map(\ .pid) == [20, 10])
|
||||
}
|
||||
|
||||
static func fixtureURL(_ name: String, extension fileExtension: String) throws -> URL {
|
||||
try #require(Bundle.module.url(forResource: name, withExtension: fileExtension, subdirectory: "Fixtures"))
|
||||
}
|
||||
|
||||
static func fixtureString(_ name: String, extension fileExtension: String) throws -> String {
|
||||
try String(contentsOf: self.fixtureURL(name, extension: fileExtension), encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import os.lock
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
import SweetCookieKit
|
||||
|
||||
@Suite(.serialized)
|
||||
struct AlibabaCodingPlanCookieImporterTests {
|
||||
@Test(.disabled(
|
||||
if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1",
|
||||
"Default-home cookie access is explicitly enabled for this test run."))
|
||||
func `default home import is suppressed before profile and keychain access`() throws {
|
||||
let profileProbeCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
let keychainProbeCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
let defaultHome = try #require(BrowserCookieClient.defaultHomeDirectories().first)
|
||||
let detection = BrowserDetection(
|
||||
homeDirectory: defaultHome.path,
|
||||
cacheTTL: 0,
|
||||
fileExists: { _ in
|
||||
profileProbeCount.withLock { $0 += 1 }
|
||||
return true
|
||||
},
|
||||
directoryContents: { _ in
|
||||
profileProbeCount.withLock { $0 += 1 }
|
||||
return ["Default"]
|
||||
})
|
||||
|
||||
_ = KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
|
||||
keychainProbeCount.withLock { $0 += 1 }
|
||||
return .allowed
|
||||
} operation: {
|
||||
#expect(throws: AlibabaCodingPlanSettingsError.self) {
|
||||
_ = try AlibabaCodingPlanCookieImporter.importSession(browserDetection: detection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(profileProbeCount.withLock { $0 } == 0)
|
||||
#expect(keychainProbeCount.withLock { $0 } == 0)
|
||||
}
|
||||
|
||||
@Test(.disabled(
|
||||
if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1",
|
||||
"Default-home cookie access is explicitly enabled for this test run."))
|
||||
func `chromium fallback rejects default client before keychain access`() {
|
||||
let keychainProbeCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
_ = KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
|
||||
keychainProbeCount.withLock { $0 += 1 }
|
||||
return .allowed
|
||||
} operation: {
|
||||
#expect(throws: BrowserCookieStoreAccessSuppressedError.self) {
|
||||
_ = try AlibabaChromiumCookieFallbackImporter.importSession(
|
||||
browser: .chrome,
|
||||
domains: ["example.com"])
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(keychainProbeCount.withLock { $0 } == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `domain matching requires exact or label bounded suffix`() {
|
||||
#expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("console.aliyun.com"))
|
||||
#expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain(".modelstudio.console.alibabacloud.com"))
|
||||
#expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("foo.aliyun.com"))
|
||||
#expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("evilaliyun.com") == false)
|
||||
#expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("notalibabacloud.com") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cookie import candidates honor provided browser order`() throws {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let firefoxProfile = temp
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Application Support")
|
||||
.appendingPathComponent("Firefox")
|
||||
.appendingPathComponent("Profiles")
|
||||
.appendingPathComponent("abc.default-release")
|
||||
try FileManager.default.createDirectory(at: firefoxProfile, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(
|
||||
atPath: firefoxProfile.appendingPathComponent("cookies.sqlite").path,
|
||||
contents: Data())
|
||||
|
||||
let detection = BrowserDetection(
|
||||
homeDirectory: temp.path,
|
||||
cacheTTL: 0,
|
||||
fileExists: { path in
|
||||
path == "/Applications/Firefox.app" || FileManager.default.fileExists(atPath: path)
|
||||
})
|
||||
let importOrder: BrowserCookieImportOrder = [.firefox, .safari, .chrome]
|
||||
|
||||
let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates(
|
||||
browserDetection: detection,
|
||||
importOrder: importOrder)
|
||||
|
||||
let expected: [Browser] = [.firefox, .safari]
|
||||
#expect(candidates == expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `default cookie import candidates skip keychain browsers during tests`() throws {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let chromeProfile = temp
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Application Support")
|
||||
.appendingPathComponent("Google")
|
||||
.appendingPathComponent("Chrome")
|
||||
.appendingPathComponent("Default")
|
||||
try FileManager.default.createDirectory(at: chromeProfile, withIntermediateDirectories: true)
|
||||
let cookiesDir = chromeProfile.appendingPathComponent("Network")
|
||||
try FileManager.default.createDirectory(at: cookiesDir, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(
|
||||
atPath: cookiesDir.appendingPathComponent("Cookies").path,
|
||||
contents: Data())
|
||||
|
||||
let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0)
|
||||
let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates(browserDetection: detection)
|
||||
|
||||
#expect(candidates.first == .safari)
|
||||
#expect(candidates.contains(.chrome) == false)
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
struct AlibabaCodingPlanCookieImporterTests {
|
||||
@Test
|
||||
func `non mac OS placeholder`() {
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,128 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
struct AlibabaCodingPlanMenuCardModelTests {
|
||||
@Test
|
||||
func `monthly quota shows deficit and run out details`() throws {
|
||||
let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z
|
||||
let reset = now.addingTimeInterval(6 * 24 * 3600)
|
||||
let snapshot = UsageSnapshot(
|
||||
primary: RateWindow(
|
||||
usedPercent: 25,
|
||||
windowMinutes: 5 * 60,
|
||||
resetsAt: now.addingTimeInterval(2 * 3600),
|
||||
resetDescription: "250 / 1000 used"),
|
||||
secondary: RateWindow(
|
||||
usedPercent: 40,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: now.addingTimeInterval(4 * 24 * 3600),
|
||||
resetDescription: "400 / 1000 used"),
|
||||
tertiary: RateWindow(
|
||||
usedPercent: 90,
|
||||
windowMinutes: 30 * 24 * 60,
|
||||
resetsAt: reset,
|
||||
resetDescription: "900 / 1000 used"),
|
||||
updatedAt: now,
|
||||
identity: nil)
|
||||
let metadata = try #require(ProviderDefaults.metadata[.alibaba])
|
||||
|
||||
let model = UsageMenuCardView.Model.make(.init(
|
||||
provider: .alibaba,
|
||||
metadata: metadata,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
creditsError: nil,
|
||||
dashboard: nil,
|
||||
dashboardError: nil,
|
||||
tokenSnapshot: nil,
|
||||
tokenError: nil,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
isRefreshing: false,
|
||||
lastError: nil,
|
||||
usageBarsShowUsed: false,
|
||||
resetTimeDisplayStyle: .countdown,
|
||||
tokenCostUsageEnabled: false,
|
||||
showOptionalCreditsAndExtraUsage: true,
|
||||
hidePersonalInfo: false,
|
||||
now: now))
|
||||
|
||||
#expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "Monthly"])
|
||||
let monthly = try #require(model.metrics.first { $0.id == "tertiary" })
|
||||
#expect(monthly.percentLabel == "10% left")
|
||||
#expect(monthly.detailText == "900 / 1000 used")
|
||||
#expect(monthly.detailLeftText == "10% in deficit")
|
||||
#expect(monthly.detailRightText == "Runs out in 2d 16h")
|
||||
#expect(monthly.pacePercent == 20)
|
||||
#expect(monthly.paceOnTop == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `monthly pace uses thirty one day reset window`() throws {
|
||||
let now = try Self.date("2026-07-01T23:00:00Z")
|
||||
let reset = try Self.date("2026-08-01T00:00:00Z")
|
||||
let model = try Self.model(
|
||||
now: now,
|
||||
monthly: RateWindow(
|
||||
usedPercent: 10,
|
||||
windowMinutes: 30 * 24 * 60,
|
||||
resetsAt: reset,
|
||||
resetDescription: nil))
|
||||
|
||||
let monthly = try #require(model.metrics.first { $0.id == "tertiary" })
|
||||
#expect(monthly.detailLeftText == "7% in deficit")
|
||||
#expect(monthly.detailRightText == "Runs out in 8d 15h")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `monthly pace uses twenty eight day reset window`() throws {
|
||||
let now = try Self.date("2026-02-02T00:00:00Z")
|
||||
let reset = try Self.date("2026-03-01T00:00:00Z")
|
||||
let model = try Self.model(
|
||||
now: now,
|
||||
monthly: RateWindow(
|
||||
usedPercent: 0,
|
||||
windowMinutes: 30 * 24 * 60,
|
||||
resetsAt: reset,
|
||||
resetDescription: nil))
|
||||
|
||||
let monthly = try #require(model.metrics.first { $0.id == "tertiary" })
|
||||
#expect(monthly.detailLeftText == "4% in reserve")
|
||||
#expect(monthly.detailRightText == "Lasts until reset")
|
||||
}
|
||||
|
||||
private static func model(now: Date, monthly: RateWindow) throws -> UsageMenuCardView.Model {
|
||||
let snapshot = UsageSnapshot(
|
||||
primary: nil,
|
||||
secondary: nil,
|
||||
tertiary: monthly,
|
||||
updatedAt: now,
|
||||
identity: nil)
|
||||
let metadata = try #require(ProviderDefaults.metadata[.alibaba])
|
||||
|
||||
return UsageMenuCardView.Model.make(.init(
|
||||
provider: .alibaba,
|
||||
metadata: metadata,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
creditsError: nil,
|
||||
dashboard: nil,
|
||||
dashboardError: nil,
|
||||
tokenSnapshot: nil,
|
||||
tokenError: nil,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
isRefreshing: false,
|
||||
lastError: nil,
|
||||
usageBarsShowUsed: false,
|
||||
resetTimeDisplayStyle: .countdown,
|
||||
tokenCostUsageEnabled: false,
|
||||
showOptionalCreditsAndExtraUsage: true,
|
||||
hidePersonalInfo: false,
|
||||
now: now))
|
||||
}
|
||||
|
||||
private static func date(_ value: String) throws -> Date {
|
||||
try #require(ISO8601DateFormatter().date(from: value))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import CodexBarCore
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct AlibabaTokenPlanDashboardActionTests {
|
||||
@Test
|
||||
func `dashboard action follows selected region`() {
|
||||
let settings = testSettingsStore(suiteName: "AlibabaTokenPlanDashboardActionTests")
|
||||
settings.statusChecksEnabled = false
|
||||
settings.refreshFrequency = .manual
|
||||
settings.mergeIcons = false
|
||||
settings.alibabaTokenPlanAPIRegion = .chinaMainland
|
||||
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(
|
||||
fetcher: fetcher,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
|
||||
withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in
|
||||
#expect(controller.dashboardURL(for: .alibabatokenplan) ==
|
||||
AlibabaTokenPlanAPIRegion.chinaMainland.dashboardURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
struct AlibabaTokenPlanMenuCardModelTests {
|
||||
@Test
|
||||
func `monthly quota shows deficit and run out details`() throws {
|
||||
let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z
|
||||
let snapshot = AlibabaTokenPlanUsageSnapshot(
|
||||
planName: "TOKEN PLAN",
|
||||
usedQuota: 900,
|
||||
totalQuota: 1000,
|
||||
remainingQuota: nil,
|
||||
resetsAt: now.addingTimeInterval(6 * 24 * 3600),
|
||||
updatedAt: now)
|
||||
.toUsageSnapshot()
|
||||
let metadata = try #require(ProviderDefaults.metadata[.alibabatokenplan])
|
||||
|
||||
let model = UsageMenuCardView.Model.make(.init(
|
||||
provider: .alibabatokenplan,
|
||||
metadata: metadata,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
creditsError: nil,
|
||||
dashboard: nil,
|
||||
dashboardError: nil,
|
||||
tokenSnapshot: nil,
|
||||
tokenError: nil,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
isRefreshing: false,
|
||||
lastError: nil,
|
||||
usageBarsShowUsed: false,
|
||||
resetTimeDisplayStyle: .countdown,
|
||||
tokenCostUsageEnabled: false,
|
||||
showOptionalCreditsAndExtraUsage: true,
|
||||
hidePersonalInfo: false,
|
||||
now: now))
|
||||
|
||||
#expect(model.metrics.map(\.title) == ["Credits"])
|
||||
let monthly = try #require(model.metrics.first { $0.id == "primary" })
|
||||
#expect(monthly.percentLabel == "10% left")
|
||||
#expect(monthly.detailText == "900 / 1,000 credits used")
|
||||
#expect(monthly.detailLeftText == "10% in deficit")
|
||||
#expect(monthly.detailRightText == "Runs out in 2d 16h")
|
||||
#expect(monthly.pacePercent == 20)
|
||||
#expect(monthly.paceOnTop == false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AlibabaTokenPlanSettingsReaderTests {
|
||||
@Test
|
||||
func `cookie reads from environment`() {
|
||||
let cookie = AlibabaTokenPlanSettingsReader.cookieHeader(environment: [
|
||||
AlibabaTokenPlanSettingsReader.cookieHeaderKey: "\"login_aliyunid_ticket=ticket\"",
|
||||
])
|
||||
#expect(cookie == "login_aliyunid_ticket=ticket")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `quota URL infers HTTPS scheme`() {
|
||||
let url = AlibabaTokenPlanSettingsReader.quotaURL(environment: [
|
||||
AlibabaTokenPlanSettingsReader.quotaURLKey: "quota.token-plan.test/data/api.json",
|
||||
])
|
||||
|
||||
#expect(url?.scheme == "https")
|
||||
#expect(url?.host == "quota.token-plan.test")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `quota URL rejects non HTTPS schemes`() {
|
||||
let httpURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: [
|
||||
AlibabaTokenPlanSettingsReader.quotaURLKey: "http://quota.token-plan.test/data/api.json",
|
||||
])
|
||||
let ftpURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: [
|
||||
AlibabaTokenPlanSettingsReader.quotaURLKey: "ftp://quota.token-plan.test/data/api.json",
|
||||
])
|
||||
|
||||
#expect(httpURL == nil)
|
||||
#expect(ftpURL == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `host override rejects non HTTPS schemes`() {
|
||||
let httpHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [
|
||||
AlibabaTokenPlanSettingsReader.hostKey: "http://dashboard.token-plan.test",
|
||||
])
|
||||
let httpsHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [
|
||||
AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test",
|
||||
])
|
||||
let bareHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [
|
||||
AlibabaTokenPlanSettingsReader.hostKey: "dashboard.token-plan.test",
|
||||
])
|
||||
|
||||
#expect(httpHost == nil)
|
||||
#expect(httpsHost == "dashboard.token-plan.test")
|
||||
#expect(bareHost == "dashboard.token-plan.test")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `default quota URL targets subscription summary API`() {
|
||||
let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL
|
||||
#expect(url.host == "modelstudio.console.alibabacloud.com")
|
||||
#expect(url.absoluteString.contains("GetSubscriptionSummary"))
|
||||
#expect(url.absoluteString.contains("BssOpenAPI-V3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `default quota URL for china mainland targets bailian`() {
|
||||
let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainland)
|
||||
#expect(url.host == "bailian.console.aliyun.com")
|
||||
#expect(url.absoluteString.contains("GetSubscriptionSummary"))
|
||||
#expect(url.absoluteString.contains("BssOpenAPI-V3"))
|
||||
}
|
||||
}
|
||||
|
||||
struct AlibabaTokenPlanCookieHeaderTests {
|
||||
@Test
|
||||
func `builds URL scoped headers for API and dashboard`() throws {
|
||||
let cookies = [
|
||||
self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"),
|
||||
self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"),
|
||||
self.cookie(name: "sec_token", value: "shared", domain: ".console.alibabacloud.com"),
|
||||
self.cookie(name: "sec_token", value: "dashboard", domain: "modelstudio.console.alibabacloud.com"),
|
||||
self.cookie(name: "bailian_only", value: "bailian", domain: "bailian.console.aliyun.com"),
|
||||
]
|
||||
|
||||
let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies))
|
||||
|
||||
#expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket"))
|
||||
#expect(headers.apiCookieHeader.contains("login_current_pk=account"))
|
||||
#expect(headers.apiCookieHeader.contains("sec_token=dashboard"))
|
||||
#expect(!headers.apiCookieHeader.contains("bailian_only=bailian"))
|
||||
#expect(headers.dashboardCookieHeader.contains("sec_token=dashboard"))
|
||||
#expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `builds URL scoped headers for china mainland region`() throws {
|
||||
let cookies = [
|
||||
self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"),
|
||||
self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"),
|
||||
self.cookie(name: "sec_token", value: "shared", domain: ".console.aliyun.com"),
|
||||
self.cookie(name: "sec_token", value: "dashboard", domain: "bailian.console.aliyun.com"),
|
||||
self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.alibabacloud.com"),
|
||||
]
|
||||
|
||||
let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies, region: .chinaMainland))
|
||||
|
||||
#expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket"))
|
||||
#expect(headers.apiCookieHeader.contains("login_current_pk=account"))
|
||||
#expect(headers.apiCookieHeader.contains("sec_token=dashboard"))
|
||||
#expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio"))
|
||||
#expect(headers.dashboardCookieHeader.contains("sec_token=dashboard"))
|
||||
#expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached token plan headers preserve URL scoping`() throws {
|
||||
let headers = AlibabaTokenPlanCookieHeaders(
|
||||
apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api",
|
||||
dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard")
|
||||
|
||||
let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader))
|
||||
|
||||
#expect(cached.apiCookieHeader.contains("api_only=api"))
|
||||
#expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard"))
|
||||
#expect(cached.dashboardCookieHeader.contains("dashboard_only=dashboard"))
|
||||
#expect(!cached.dashboardCookieHeader.contains("api_only=api"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `builds headers from environment scoped URLs`() throws {
|
||||
let cookies = [
|
||||
self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"),
|
||||
self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"),
|
||||
self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"),
|
||||
self.cookie(name: "prod_api_only", value: "prod-api", domain: "modelstudio.console.alibabacloud.com"),
|
||||
self.cookie(
|
||||
name: "prod_dashboard_only",
|
||||
value: "prod-dashboard",
|
||||
domain: "modelstudio.console.alibabacloud.com"),
|
||||
]
|
||||
|
||||
let headers = try #require(AlibabaTokenPlanCookieHeader.headers(
|
||||
from: cookies,
|
||||
environment: [
|
||||
AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json",
|
||||
AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test",
|
||||
]))
|
||||
|
||||
#expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket"))
|
||||
#expect(headers.apiCookieHeader.contains("api_only=api"))
|
||||
#expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api"))
|
||||
#expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard"))
|
||||
#expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard"))
|
||||
}
|
||||
|
||||
private func cookie(
|
||||
name: String,
|
||||
value: String,
|
||||
domain: String,
|
||||
path: String = "/",
|
||||
expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie
|
||||
{
|
||||
HTTPCookie(properties: [
|
||||
.domain: domain,
|
||||
.path: path,
|
||||
.name: name,
|
||||
.value: value,
|
||||
.expires: expires,
|
||||
.secure: true,
|
||||
])!
|
||||
}
|
||||
}
|
||||
|
||||
struct AlibabaTokenPlanUsageSnapshotTests {
|
||||
@Test
|
||||
func `maps used and total quota to primary window`() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let reset = Date(timeIntervalSince1970: 1_700_100_000)
|
||||
let snapshot = AlibabaTokenPlanUsageSnapshot(
|
||||
planName: "TOKEN PLAN",
|
||||
usedQuota: 250,
|
||||
totalQuota: 1000,
|
||||
remainingQuota: nil,
|
||||
resetsAt: reset,
|
||||
updatedAt: now)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary?.usedPercent == 25)
|
||||
#expect(usage.primary?.resetsAt == reset)
|
||||
#expect(usage.primary?.resetDescription == "250 / 1,000 credits used")
|
||||
#expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `does not create primary window from balance only`() {
|
||||
let snapshot = AlibabaTokenPlanUsageSnapshot(
|
||||
planName: "TOKEN PLAN",
|
||||
usedQuota: nil,
|
||||
totalQuota: nil,
|
||||
remainingQuota: 700,
|
||||
resetsAt: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary == nil)
|
||||
#expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct AlibabaTokenPlanUsageParsingTests {
|
||||
@Test
|
||||
func `parses subscription summary payload`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let json = """
|
||||
{
|
||||
"Success": true,
|
||||
"Data": {
|
||||
"TotalCount": 1,
|
||||
"TotalValue": 1000,
|
||||
"TotalSurplusValue": 875,
|
||||
"NearestExpireDate": 1701000000000
|
||||
},
|
||||
"Code": "200"
|
||||
}
|
||||
"""
|
||||
|
||||
let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now)
|
||||
|
||||
#expect(snapshot.planName == "TOKEN PLAN")
|
||||
#expect(snapshot.usedQuota == 125)
|
||||
#expect(snapshot.totalQuota == 1000)
|
||||
#expect(snapshot.remainingQuota == 875)
|
||||
#expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_701_000_000))
|
||||
#expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses nested subscription summary body`() throws {
|
||||
let body = """
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalCount": 1,
|
||||
"totalSurplusValue": 750,
|
||||
"totalValue": 1000
|
||||
}
|
||||
}
|
||||
"""
|
||||
let payload = ["successResponse": ["body": body]]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
|
||||
let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data)
|
||||
|
||||
#expect(snapshot.planName == "TOKEN PLAN")
|
||||
#expect(snapshot.usedQuota == 250)
|
||||
#expect(snapshot.remainingQuota == 750)
|
||||
#expect(snapshot.totalQuota == 1000)
|
||||
#expect(snapshot.toUsageSnapshot().primary?.usedPercent == 25)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `empty subscription summary stays visible without quota window`() throws {
|
||||
let json = """
|
||||
{
|
||||
"Success": true,
|
||||
"Data": {
|
||||
"TotalCount": 0
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8))
|
||||
|
||||
#expect(snapshot.planName == nil)
|
||||
#expect(snapshot.totalQuota == nil)
|
||||
#expect(snapshot.toUsageSnapshot().primary == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `login payload maps to login required`() {
|
||||
let json = """
|
||||
{
|
||||
"code": "ConsoleNeedLogin",
|
||||
"message": "You need to log in.",
|
||||
"successResponse": false
|
||||
}
|
||||
"""
|
||||
|
||||
#expect(throws: AlibabaTokenPlanUsageError.loginRequired) {
|
||||
try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `post only token payload maps to login required`() {
|
||||
let json = """
|
||||
{
|
||||
"code": "PostonlyOrTokenError",
|
||||
"message": "Your request has expired. Please refresh the page.",
|
||||
"successResponse": false
|
||||
}
|
||||
"""
|
||||
|
||||
#expect(throws: AlibabaTokenPlanUsageError.loginRequired) {
|
||||
try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `nested unsuccessful subscription summary maps to API error`() throws {
|
||||
let body = """
|
||||
{
|
||||
"success": false,
|
||||
"message": "Subscription lookup failed"
|
||||
}
|
||||
"""
|
||||
let payload = ["successResponse": ["body": body]]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
|
||||
#expect(throws: AlibabaTokenPlanUsageError.apiError("Subscription lookup failed")) {
|
||||
try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `forbidden payload maps to invalid credentials`() {
|
||||
let json = """
|
||||
{
|
||||
"statusCode": 403,
|
||||
"message": "Forbidden"
|
||||
}
|
||||
"""
|
||||
|
||||
#expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) {
|
||||
try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `failed forbidden payload maps to invalid credentials`() {
|
||||
let json = """
|
||||
{
|
||||
"successResponse": false,
|
||||
"statusCode": 403,
|
||||
"message": "Forbidden"
|
||||
}
|
||||
"""
|
||||
|
||||
#expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) {
|
||||
try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `html login payload maps to login required`() {
|
||||
let html = """
|
||||
<html>
|
||||
<body>Please login to Alibaba Cloud</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
#expect(throws: AlibabaTokenPlanUsageError.loginRequired) {
|
||||
try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(html.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `non json payload maps to parse failed`() {
|
||||
#expect(throws: AlibabaTokenPlanUsageError.parseFailed("Invalid JSON response")) {
|
||||
try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data("not-json".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `SEC token preflight falls back to user info`() async throws {
|
||||
defer {
|
||||
AlibabaTokenPlanStubURLProtocol.handler = nil
|
||||
}
|
||||
|
||||
let hostOverride = "https://alibaba-token-plan.test:9443"
|
||||
let environment: [String: String] = [
|
||||
AlibabaTokenPlanSettingsReader.hostKey: hostOverride,
|
||||
]
|
||||
let expectedReferer = AlibabaTokenPlanUsageFetcher.dashboardURL(
|
||||
region: .international,
|
||||
environment: environment).absoluteString
|
||||
|
||||
AlibabaTokenPlanStubURLProtocol.handler = { request in
|
||||
guard let url = request.url else { throw URLError(.badURL) }
|
||||
|
||||
if url.host == "alibaba-token-plan.test",
|
||||
url.path == "/ap-southeast-1/",
|
||||
request.httpMethod == "GET"
|
||||
{
|
||||
#expect(url.port == 9443)
|
||||
return Self.makeResponse(url: url, body: "<html></html>", statusCode: 200)
|
||||
}
|
||||
|
||||
if url.host == "alibaba-token-plan.test",
|
||||
url.path == "/tool/user/info.json",
|
||||
request.httpMethod == "GET"
|
||||
{
|
||||
#expect(url.port == 9443)
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep")
|
||||
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json, text/plain, */*")
|
||||
let json = """
|
||||
{
|
||||
"code": "200",
|
||||
"data": {
|
||||
"secToken": "user-info-token"
|
||||
},
|
||||
"successResponse": true
|
||||
}
|
||||
"""
|
||||
return Self.makeResponse(url: url, body: json, statusCode: 200)
|
||||
}
|
||||
|
||||
if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" {
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == "https://modelstudio.console.alibabacloud.com")
|
||||
#expect(request.value(forHTTPHeaderField: "Referer") == expectedReferer)
|
||||
let body = Self.requestBodyString(from: request)
|
||||
#expect(body.contains("sec_token=user-info-token"))
|
||||
#expect(body.contains("GetSubscriptionSummary"))
|
||||
#expect(body.contains("BssOpenAPI-V3"))
|
||||
#expect(body.contains("ProductCode"))
|
||||
#expect(body.contains("sfm_tokenplanteams_dp_intl"))
|
||||
let json = """
|
||||
{
|
||||
"Success": true,
|
||||
"Data": {
|
||||
"TotalCount": 1,
|
||||
"TotalValue": 1000,
|
||||
"TotalSurplusValue": 900
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Self.makeResponse(url: url, body: json, statusCode: 200)
|
||||
}
|
||||
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self]
|
||||
let session = URLSession(configuration: configuration)
|
||||
let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage(
|
||||
apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep",
|
||||
dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep",
|
||||
environment: environment,
|
||||
session: session)
|
||||
|
||||
#expect(snapshot.planName == "TOKEN PLAN")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `SEC token preflight uses injected session`() async throws {
|
||||
AlibabaTokenPlanStubURLProtocol.handler = { request in
|
||||
guard let url = request.url else { throw URLError(.badURL) }
|
||||
|
||||
if url.host == "session-token.test", request.httpMethod == "GET" {
|
||||
return Self.makeResponse(
|
||||
url: url,
|
||||
body: "<html><script>sec_token = \"session-html-token\";</script></html>",
|
||||
statusCode: 200)
|
||||
}
|
||||
|
||||
if url.host == "session-token.test", request.httpMethod == "POST" {
|
||||
let body = Self.requestBodyString(from: request)
|
||||
#expect(body.contains("sec_token=session-html-token"))
|
||||
let json = """
|
||||
{
|
||||
"Success": true,
|
||||
"Data": {
|
||||
"TotalCount": 1,
|
||||
"TotalValue": 1000,
|
||||
"TotalSurplusValue": 900
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Self.makeResponse(url: url, body: json, statusCode: 200)
|
||||
}
|
||||
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
defer {
|
||||
AlibabaTokenPlanStubURLProtocol.handler = nil
|
||||
}
|
||||
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self]
|
||||
let session = URLSession(configuration: configuration)
|
||||
let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage(
|
||||
apiCookieHeader: "login_aliyunid_ticket=ticket",
|
||||
dashboardCookieHeader: "login_aliyunid_ticket=ticket",
|
||||
environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://session-token.test"],
|
||||
session: session)
|
||||
|
||||
#expect(snapshot.planName == "TOKEN PLAN")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `redirect preserves cookie only for same host HTTPS requests`() throws {
|
||||
let sourceURL = try #require(URL(string: "https://bailian.console.aliyun.com/data/api.json"))
|
||||
let sameHostURL = try #require(URL(string: "https://bailian.console.aliyun.com/redirected"))
|
||||
let crossHostURL = try #require(URL(string: "https://signin.aliyun.com/login"))
|
||||
let insecureURL = try #require(URL(string: "http://bailian.console.aliyun.com/redirected"))
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: sourceURL,
|
||||
statusCode: 302,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil))
|
||||
|
||||
var sameHostRequest = URLRequest(url: sameHostURL)
|
||||
sameHostRequest.setValue("old=value", forHTTPHeaderField: "Cookie")
|
||||
let sameHostRedirect = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest(
|
||||
response: response,
|
||||
request: sameHostRequest,
|
||||
cookieHeader: "login_aliyunid_ticket=ticket"))
|
||||
#expect(sameHostRedirect.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket")
|
||||
|
||||
var crossHostRequest = URLRequest(url: crossHostURL)
|
||||
crossHostRequest.setValue("old=value", forHTTPHeaderField: "Cookie")
|
||||
let crossHostRedirect = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest(
|
||||
response: response,
|
||||
request: crossHostRequest,
|
||||
cookieHeader: "login_aliyunid_ticket=ticket"))
|
||||
#expect(crossHostRedirect.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
|
||||
let insecureRedirect = AlibabaTokenPlanUsageFetcher.redirectedRequest(
|
||||
response: response,
|
||||
request: URLRequest(url: insecureURL),
|
||||
cookieHeader: "login_aliyunid_ticket=ticket")
|
||||
#expect(insecureRedirect == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `dashboard redirect preserves dashboard cookie header`() throws {
|
||||
let sourceURL = try #require(URL(string: "https://bailian.console.aliyun.com/cn-beijing"))
|
||||
let targetURL = try #require(URL(string: "https://bailian.console.aliyun.com/redirected"))
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: sourceURL,
|
||||
statusCode: 302,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil))
|
||||
var request = URLRequest(url: targetURL)
|
||||
request.setValue("api_only=wrong", forHTTPHeaderField: "Cookie")
|
||||
|
||||
let redirected = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest(
|
||||
response: response,
|
||||
request: request,
|
||||
cookieHeader: "dashboard_only=keep"))
|
||||
|
||||
#expect(redirected.value(forHTTPHeaderField: "Cookie") == "dashboard_only=keep")
|
||||
}
|
||||
|
||||
private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) {
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: statusCode,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
return (response, Data(body.utf8))
|
||||
}
|
||||
|
||||
private static func requestBodyString(from request: URLRequest) -> String {
|
||||
if let data = request.httpBody {
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
if let stream = request.httpBodyStream {
|
||||
stream.open()
|
||||
defer {
|
||||
stream.close()
|
||||
}
|
||||
var data = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 1024)
|
||||
while stream.hasBytesAvailable {
|
||||
let count = stream.read(&buffer, maxLength: buffer.count)
|
||||
if count <= 0 {
|
||||
break
|
||||
}
|
||||
data.append(buffer, count: count)
|
||||
}
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct AlibabaTokenPlanWebStrategyTests {
|
||||
private struct StubClaudeFetcher: ClaudeUsageFetching {
|
||||
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
|
||||
throw ClaudeUsageError.parseFailed("stub")
|
||||
}
|
||||
|
||||
func debugRawProbe(model _: String) async -> String {
|
||||
"stub"
|
||||
}
|
||||
|
||||
func detectVersion() -> String? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func clearCookieCaches() {
|
||||
CookieHeaderCache.clear(provider: .alibabatokenplan)
|
||||
for region in AlibabaTokenPlanAPIRegion.allCases {
|
||||
CookieHeaderCache.clear(provider: .alibabatokenplan, scope: region.cookieCacheScope)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto web strategy surfaces cookie import errors`() async throws {
|
||||
let strategy = AlibabaTokenPlanWebFetchStrategy()
|
||||
let settings = ProviderSettingsSnapshot.make(
|
||||
alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings(
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil))
|
||||
let context = ProviderFetchContext(
|
||||
runtime: .cli,
|
||||
sourceMode: .web,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: [:],
|
||||
settings: settings,
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
claudeFetcher: StubClaudeFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0))
|
||||
|
||||
self.clearCookieCaches()
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
|
||||
throw AlibabaCodingPlanSettingsError.missingCookie(
|
||||
details: "macOS Keychain denied access to Chrome Safe Storage.")
|
||||
}
|
||||
defer {
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil
|
||||
self.clearCookieCaches()
|
||||
}
|
||||
|
||||
#expect(await strategy.isAvailable(context))
|
||||
|
||||
do {
|
||||
_ = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false)
|
||||
Issue.record("Expected cookie import failure to be surfaced")
|
||||
} catch let error as AlibabaTokenPlanSettingsError {
|
||||
guard case let .missingCookie(details) = error else {
|
||||
Issue.record("Expected missingCookie, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(details == "macOS Keychain denied access to Chrome Safe Storage.")
|
||||
#expect(error.localizedDescription.contains("Alibaba Token Plan"))
|
||||
#expect(!error.localizedDescription.contains("Alibaba Coding Plan"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto web strategy imports subscription scoped token plan cookies`() throws {
|
||||
let strategy = AlibabaTokenPlanWebFetchStrategy()
|
||||
let settings = ProviderSettingsSnapshot.make(
|
||||
alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings(
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil))
|
||||
let context = ProviderFetchContext(
|
||||
runtime: .cli,
|
||||
sourceMode: .web,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: [:],
|
||||
settings: settings,
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
claudeFetcher: StubClaudeFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0))
|
||||
|
||||
self.clearCookieCaches()
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
|
||||
AlibabaCodingPlanCookieImporter.SessionInfo(
|
||||
cookies: [
|
||||
self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"),
|
||||
self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"),
|
||||
self.cookie(
|
||||
name: "dashboard_only",
|
||||
value: "dashboard",
|
||||
domain: "modelstudio.console.alibabacloud.com"),
|
||||
self.cookie(
|
||||
name: "bailian_only",
|
||||
value: "bailian",
|
||||
domain: "bailian.console.aliyun.com"),
|
||||
self.cookie(name: "aliyun_only", value: "aliyun", domain: ".aliyun.com"),
|
||||
],
|
||||
sourceLabel: "Chrome Default")
|
||||
}
|
||||
defer {
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil
|
||||
self.clearCookieCaches()
|
||||
}
|
||||
|
||||
let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false)
|
||||
|
||||
#expect(headers.apiCookieHeader == headers.dashboardCookieHeader)
|
||||
#expect(headers.apiCookieHeader.contains("dashboard_only=dashboard"))
|
||||
#expect(!headers.apiCookieHeader.contains("bailian_only=bailian"))
|
||||
#expect(!headers.apiCookieHeader.contains("aliyun_only=aliyun"))
|
||||
#expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard"))
|
||||
#expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian"))
|
||||
#expect(!headers.dashboardCookieHeader.contains("aliyun_only=aliyun"))
|
||||
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
|
||||
throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import")
|
||||
}
|
||||
let cachedHeaders = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
|
||||
context: context,
|
||||
allowCached: true)
|
||||
#expect(cachedHeaders.apiCookieHeader == headers.apiCookieHeader)
|
||||
#expect(cachedHeaders.dashboardCookieHeader == headers.dashboardCookieHeader)
|
||||
#expect(strategy.id == "alibaba-token-plan.web")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto web strategy scopes imported cookies to environment overrides`() throws {
|
||||
let settings = ProviderSettingsSnapshot.make(
|
||||
alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings(
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil))
|
||||
let environment = [
|
||||
AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json",
|
||||
AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test",
|
||||
]
|
||||
let context = ProviderFetchContext(
|
||||
runtime: .cli,
|
||||
sourceMode: .web,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: environment,
|
||||
settings: settings,
|
||||
fetcher: UsageFetcher(environment: environment),
|
||||
claudeFetcher: StubClaudeFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0))
|
||||
|
||||
self.clearCookieCaches()
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
|
||||
AlibabaCodingPlanCookieImporter.SessionInfo(
|
||||
cookies: [
|
||||
self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"),
|
||||
self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"),
|
||||
self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"),
|
||||
self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"),
|
||||
self.cookie(
|
||||
name: "prod_dashboard_only",
|
||||
value: "prod-dashboard",
|
||||
domain: "bailian.console.aliyun.com"),
|
||||
],
|
||||
sourceLabel: "Chrome Default")
|
||||
}
|
||||
defer {
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil
|
||||
self.clearCookieCaches()
|
||||
}
|
||||
|
||||
let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false)
|
||||
|
||||
#expect(headers.apiCookieHeader.contains("api_only=api"))
|
||||
#expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api"))
|
||||
#expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard"))
|
||||
#expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached browser cookies stay isolated by gateway region`() throws {
|
||||
self.clearCookieCaches()
|
||||
defer { self.clearCookieCaches() }
|
||||
CookieHeaderCache.store(
|
||||
provider: .alibabatokenplan,
|
||||
scope: AlibabaTokenPlanAPIRegion.international.cookieCacheScope,
|
||||
cookieHeader: "login_aliyunid_ticket=intl-ticket; gateway=intl",
|
||||
sourceLabel: "International fixture")
|
||||
CookieHeaderCache.store(
|
||||
provider: .alibabatokenplan,
|
||||
scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope,
|
||||
cookieHeader: "login_aliyunid_ticket=cn-ticket; gateway=cn",
|
||||
sourceLabel: "China fixture")
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
|
||||
throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import")
|
||||
}
|
||||
defer { AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil }
|
||||
|
||||
let context = self.context(region: .international)
|
||||
let international = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
|
||||
context: context,
|
||||
allowCached: true,
|
||||
region: .international)
|
||||
let china = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
|
||||
context: context,
|
||||
allowCached: true,
|
||||
region: .chinaMainland)
|
||||
|
||||
#expect(international.apiCookieHeader.contains("gateway=intl"))
|
||||
#expect(!international.apiCookieHeader.contains("gateway=cn"))
|
||||
#expect(china.apiCookieHeader.contains("gateway=cn"))
|
||||
#expect(!china.apiCookieHeader.contains("gateway=intl"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy unscoped cache migrates only to China gateway`() throws {
|
||||
self.clearCookieCaches()
|
||||
defer {
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil
|
||||
self.clearCookieCaches()
|
||||
}
|
||||
CookieHeaderCache.store(
|
||||
provider: .alibabatokenplan,
|
||||
cookieHeader: "login_aliyunid_ticket=legacy; gateway=legacy-cn",
|
||||
sourceLabel: "Legacy fixture")
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
|
||||
AlibabaCodingPlanCookieImporter.SessionInfo(
|
||||
cookies: [
|
||||
self.cookie(
|
||||
name: "login_aliyunid_ticket",
|
||||
value: "intl",
|
||||
domain: ".alibabacloud.com"),
|
||||
self.cookie(
|
||||
name: "gateway",
|
||||
value: "intl",
|
||||
domain: "modelstudio.console.alibabacloud.com"),
|
||||
],
|
||||
sourceLabel: "International fixture")
|
||||
}
|
||||
let context = self.context(region: .international)
|
||||
|
||||
let international = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
|
||||
context: context,
|
||||
allowCached: true,
|
||||
region: .international)
|
||||
AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in
|
||||
throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected China import")
|
||||
}
|
||||
let china = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(
|
||||
context: context,
|
||||
allowCached: true,
|
||||
region: .chinaMainland)
|
||||
|
||||
#expect(international.apiCookieHeader.contains("gateway=intl"))
|
||||
#expect(!international.apiCookieHeader.contains("gateway=legacy-cn"))
|
||||
#expect(china.apiCookieHeader.contains("gateway=legacy-cn"))
|
||||
#expect(CookieHeaderCache.load(provider: .alibabatokenplan) == nil)
|
||||
#expect(CookieHeaderCache.load(
|
||||
provider: .alibabatokenplan,
|
||||
scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope) != nil)
|
||||
}
|
||||
|
||||
private func context(region: AlibabaTokenPlanAPIRegion) -> ProviderFetchContext {
|
||||
let settings = ProviderSettingsSnapshot.make(
|
||||
alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings(
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil,
|
||||
apiRegion: region))
|
||||
return ProviderFetchContext(
|
||||
runtime: .cli,
|
||||
sourceMode: .web,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: [:],
|
||||
settings: settings,
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
claudeFetcher: StubClaudeFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0))
|
||||
}
|
||||
|
||||
private func cookie(
|
||||
name: String,
|
||||
value: String,
|
||||
domain: String,
|
||||
path: String = "/",
|
||||
expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie
|
||||
{
|
||||
HTTPCookie(properties: [
|
||||
.domain: domain,
|
||||
.path: path,
|
||||
.name: name,
|
||||
.value: value,
|
||||
.expires: expires,
|
||||
.secure: true,
|
||||
])!
|
||||
}
|
||||
}
|
||||
|
||||
final class AlibabaTokenPlanStubURLProtocol: URLProtocol {
|
||||
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
override static func canInit(with request: URLRequest) -> Bool {
|
||||
guard let host = request.url?.host else { return false }
|
||||
return host == "bailian.console.aliyun.com" ||
|
||||
host == "alibaba-token-plan.test" ||
|
||||
host == "session-token.test"
|
||||
}
|
||||
|
||||
override static func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard let handler = Self.handler else {
|
||||
self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (response, data) = try handler(self.request)
|
||||
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
self.client?.urlProtocol(self, didLoad: data)
|
||||
self.client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
self.client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AmpUsageFetcherTests {
|
||||
private func makeContext(
|
||||
sourceMode: ProviderSourceMode,
|
||||
env: [String: String] = [:]) -> ProviderFetchContext
|
||||
{
|
||||
let browserDetection = BrowserDetection(cacheTTL: 0)
|
||||
return ProviderFetchContext(
|
||||
runtime: .app,
|
||||
sourceMode: sourceMode,
|
||||
includeCredits: true,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: env,
|
||||
settings: nil,
|
||||
fetcher: UsageFetcher(),
|
||||
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
||||
browserDetection: browserDetection)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `uses amp internal usage endpoint`() {
|
||||
#expect(
|
||||
AmpUsageFetcher.usageURL.absoluteString ==
|
||||
"https://ampcode.com/api/internal?userDisplayBalanceInfo")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider dashboard points to current usage page`() {
|
||||
#expect(AmpProviderDescriptor.descriptor.metadata.dashboardURL == "https://ampcode.com/settings/usage")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `web fallback requires browser import or a manual session cookie`() {
|
||||
let disabled = ProviderSettingsSnapshot.AmpProviderSettings(cookieSource: .off, manualCookieHeader: nil)
|
||||
let invalidManual = ProviderSettingsSnapshot.AmpProviderSettings(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "other=value")
|
||||
let validManual = ProviderSettingsSnapshot.AmpProviderSettings(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "session=test")
|
||||
|
||||
#expect(AmpStatusFetchStrategy.canUseWebFallback(
|
||||
settings: nil,
|
||||
canImportBrowserCookies: false) == false)
|
||||
#expect(AmpStatusFetchStrategy.canUseWebFallback(
|
||||
settings: nil,
|
||||
canImportBrowserCookies: true))
|
||||
#expect(AmpStatusFetchStrategy.canUseWebFallback(
|
||||
settings: disabled,
|
||||
canImportBrowserCookies: true) == false)
|
||||
#expect(AmpStatusFetchStrategy.canUseWebFallback(
|
||||
settings: invalidManual,
|
||||
canImportBrowserCookies: false) == false)
|
||||
#expect(AmpStatusFetchStrategy.canUseWebFallback(
|
||||
settings: validManual,
|
||||
canImportBrowserCookies: false))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli cancellation does not fall back to web`() {
|
||||
let strategy = AmpCLIFetchStrategy()
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
|
||||
#expect(!strategy.shouldFallback(on: CancellationError(), context: context))
|
||||
#expect(!strategy.shouldFallback(on: URLError(.cancelled), context: context))
|
||||
#expect(strategy.shouldFallback(on: AmpUsageError.parseFailed("missing"), context: context))
|
||||
#expect(!strategy.shouldFallback(
|
||||
on: AmpUsageError.parseFailed("missing"),
|
||||
context: self.makeContext(sourceMode: .cli)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `api request uses bearer token without cookies`() throws {
|
||||
let request = try AmpUsageFetcher.makeUsageAPIRequest(apiToken: "sgamp_test")
|
||||
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sgamp_test")
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `api strategy falls back only from auto mode and preserves cancellation`() {
|
||||
let strategy = AmpAPIFetchStrategy()
|
||||
let auto = self.makeContext(sourceMode: .auto)
|
||||
|
||||
#expect(strategy.shouldFallback(on: AmpUsageError.missingAPIToken, context: auto))
|
||||
#expect(strategy.shouldFallback(on: AmpUsageError.invalidAPIToken, context: auto))
|
||||
#expect(strategy.shouldFallback(on: URLError(.timedOut), context: auto))
|
||||
#expect(!strategy.shouldFallback(on: CancellationError(), context: auto))
|
||||
#expect(!strategy.shouldFallback(on: URLError(.cancelled), context: auto))
|
||||
#expect(!strategy.shouldFallback(
|
||||
on: AmpUsageError.invalidAPIToken,
|
||||
context: self.makeContext(sourceMode: .api)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `amp config token resolves through environment`() {
|
||||
let env = [AmpSettingsReader.apiTokenKey: " 'sgamp_test' "]
|
||||
|
||||
#expect(ProviderTokenResolver.ampToken(environment: env) == "sgamp_test")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `attaches cookie for amp hosts`() {
|
||||
#expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com/settings")))
|
||||
#expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ampcode.com")))
|
||||
#expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ampcode.com/path")))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `rejects non amp hosts`() {
|
||||
#expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com")))
|
||||
#expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com.evil.com")))
|
||||
#expect(!AmpUsageFetcher.shouldAttachCookie(to: nil))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `rejects non https amp urls`() {
|
||||
#expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://ampcode.com/settings")))
|
||||
#expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ampcode.com")))
|
||||
#expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ampcode.com/path")))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `detects login redirects`() throws {
|
||||
let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings"))
|
||||
#expect(AmpUsageFetcher.isLoginRedirect(signIn))
|
||||
|
||||
let downgradedSignIn = try #require(URL(string: "http://ampcode.com/auth/sign-in?returnTo=%2Fsettings"))
|
||||
#expect(AmpUsageFetcher.isLoginRedirect(downgradedSignIn))
|
||||
#expect(!AmpUsageFetcher.shouldAttachCookie(to: downgradedSignIn))
|
||||
|
||||
let sso = try #require(URL(string: "https://ampcode.com/auth/sso?returnTo=%2Fsettings"))
|
||||
#expect(AmpUsageFetcher.isLoginRedirect(sso))
|
||||
|
||||
let login = try #require(URL(string: "https://ampcode.com/login"))
|
||||
#expect(AmpUsageFetcher.isLoginRedirect(login))
|
||||
|
||||
let signin = try #require(URL(string: "https://www.ampcode.com/signin"))
|
||||
#expect(AmpUsageFetcher.isLoginRedirect(signin))
|
||||
|
||||
let hostedAuth = try #require(URL(
|
||||
string: "https://auth.ampcode.com/?client_id=test&redirect_uri=https%3A%2F%2Fampcode.com%2Fauth%2Fcallback"))
|
||||
#expect(AmpUsageFetcher.isLoginRedirect(hostedAuth))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ignores non login UR ls`() throws {
|
||||
let settings = try #require(URL(string: "https://ampcode.com/settings"))
|
||||
#expect(!AmpUsageFetcher.isLoginRedirect(settings))
|
||||
|
||||
let signOut = try #require(URL(string: "https://ampcode.com/auth/sign-out"))
|
||||
#expect(!AmpUsageFetcher.isLoginRedirect(signOut))
|
||||
|
||||
let evil = try #require(URL(string: "https://ampcode.com.evil.com/auth/sign-in"))
|
||||
#expect(!AmpUsageFetcher.isLoginRedirect(evil))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AmpUsageParserTests {
|
||||
@Test
|
||||
func `amp cli probe runs usage and parses balances`() async throws {
|
||||
let script = """
|
||||
[ "$1" = "usage" ] || exit 2
|
||||
cat <<'EOF'
|
||||
Signed in as cli@example.com (team)
|
||||
Amp Free: $6/$10 remaining (replenishes +$0.5/hour)
|
||||
Individual credits: $12.50 remaining
|
||||
Workspace Test Team: $7.25 remaining
|
||||
EOF
|
||||
"""
|
||||
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let snapshot = try await AmpCLIProbe(arguments: ["-c", script, "amp", "usage"]).fetch(
|
||||
environment: ["AMP_CLI_PATH": "/bin/sh"],
|
||||
now: now)
|
||||
|
||||
#expect(snapshot.freeUsed == 4)
|
||||
#expect(snapshot.individualCredits == 12.5)
|
||||
#expect(snapshot.workspaceBalances == [AmpWorkspaceBalance(name: "Test Team", remaining: 7.25)])
|
||||
#expect(snapshot.accountEmail == "cli@example.com")
|
||||
#expect(snapshot.updatedAt == now)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses current amp usage display text`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let output = """
|
||||
\u{1B}[2mSigned in as ampcode@3kh0.net (echo)\u{1B}[0m
|
||||
Amp Free: $4.71/$10 remaining (replenishes +$0.42/hour) - https://ampcode.com/settings#amp-free
|
||||
Individual credits: $25.64 remaining (set up automatic top-up to avoid running out) - https://ampcode.com/settings
|
||||
Workspace meow: $10.22 remaining (set up automatic top-up to avoid running out) - https://ampcode.com/workspaces/meow
|
||||
"""
|
||||
|
||||
let snapshot = try AmpUsageParser.parse(displayText: output, now: now)
|
||||
|
||||
#expect(snapshot.freeQuota == 10)
|
||||
#expect(try abs(#require(snapshot.freeUsed) - 5.29) < 0.001)
|
||||
#expect(snapshot.hourlyReplenishment == 0.42)
|
||||
#expect(snapshot.windowHours == 24)
|
||||
#expect(snapshot.individualCredits == 25.64)
|
||||
#expect(snapshot.workspaceBalances == [AmpWorkspaceBalance(name: "meow", remaining: 10.22)])
|
||||
#expect(snapshot.accountEmail == "ampcode@3kh0.net")
|
||||
#expect(snapshot.accountOrganization == "echo")
|
||||
#expect(snapshot.toUsageSnapshot(now: now).ampUsage == AmpUsageDetails(
|
||||
individualCredits: 25.64,
|
||||
workspaceBalances: [AmpWorkspaceBalance(name: "meow", remaining: 10.22)]))
|
||||
|
||||
let encoded = try JSONEncoder().encode(snapshot.toUsageSnapshot(now: now))
|
||||
let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded)
|
||||
#expect(decoded.ampUsage == AmpUsageDetails(
|
||||
individualCredits: 25.64,
|
||||
workspaceBalances: [AmpWorkspaceBalance(name: "meow", remaining: 10.22)]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses percentage based amp free usage`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let output = """
|
||||
Signed in as user@example.com (example)
|
||||
Amp Free: 61% remaining today (resets daily) - https://ampcode.com/settings#amp-free
|
||||
Individual credits: $9.86 remaining (set up automatic top-up to avoid running out)
|
||||
Workspace example: $5.33 remaining (set up automatic top-up to avoid running out)
|
||||
"""
|
||||
|
||||
let snapshot = try AmpUsageParser.parse(displayText: output, now: now)
|
||||
let usage = snapshot.toUsageSnapshot(now: now)
|
||||
|
||||
#expect(snapshot.freeQuota == 100)
|
||||
#expect(snapshot.freeUsed == 39)
|
||||
#expect(snapshot.hourlyReplenishment == 0)
|
||||
#expect(snapshot.windowHours == 24)
|
||||
#expect(snapshot.individualCredits == 9.86)
|
||||
#expect(snapshot.workspaceBalances == [AmpWorkspaceBalance(name: "example", remaining: 5.33)])
|
||||
#expect(snapshot.accountEmail == "user@example.com")
|
||||
#expect(snapshot.accountOrganization == "example")
|
||||
#expect(usage.primary?.usedPercent == 39)
|
||||
#expect(usage.primary?.windowMinutes == 1440)
|
||||
#expect(usage.primary?.resetsAt == nil)
|
||||
#expect(usage.primary?.resetDescription == "resets daily")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy amp free usage keeps replenishment reset when percentage text also exists`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let output = """
|
||||
Signed in as user@example.com
|
||||
Amp Free: $6/$10 remaining (replenishes +$0.5/hour)
|
||||
Amp Free: 61% remaining today (resets daily)
|
||||
"""
|
||||
|
||||
let snapshot = try AmpUsageParser.parse(displayText: output, now: now)
|
||||
let usage = snapshot.toUsageSnapshot(now: now)
|
||||
|
||||
#expect(snapshot.freeUsed == 4)
|
||||
#expect(snapshot.freeResetDescription == nil)
|
||||
#expect(usage.primary?.resetsAt == now.addingTimeInterval(8 * 3600))
|
||||
#expect(usage.primary?.resetDescription == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `daily amp usage rejects cached rolling reset`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let legacy = try AmpUsageParser.parse(
|
||||
displayText: "Signed in as user@example.com\nAmp Free: $6/$10 remaining (replenishes +$0.5/hour)",
|
||||
now: now).toUsageSnapshot(now: now)
|
||||
let daily = try AmpUsageParser.parse(
|
||||
displayText: "Signed in as user@example.com\nAmp Free: 61% remaining today (resets daily)",
|
||||
now: now).toUsageSnapshot(now: now)
|
||||
|
||||
let published = daily.backfillingResetTimes(from: legacy, now: now)
|
||||
|
||||
#expect(legacy.primary?.resetsAt == now.addingTimeInterval(8 * 3600))
|
||||
#expect(published.primary?.resetsAt == nil)
|
||||
#expect(published.primary?.resetDescription == "resets daily")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses individual credits without free tier usage`() throws {
|
||||
let output = """
|
||||
Signed in as paid@example.com
|
||||
Individual credits: $25.64 remaining
|
||||
"""
|
||||
|
||||
let snapshot = try AmpUsageParser.parse(displayText: output)
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(snapshot.freeQuota == nil)
|
||||
#expect(snapshot.freeUsed == nil)
|
||||
#expect(snapshot.individualCredits == 25.64)
|
||||
#expect(usage.primary == nil)
|
||||
#expect(usage.ampUsage == AmpUsageDetails(individualCredits: 25.64, workspaceBalances: []))
|
||||
#expect(usage.identity?.loginMethod == "Amp")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses workspace credits without free tier usage`() throws {
|
||||
let output = """
|
||||
Signed in as workspace@example.com (team)
|
||||
Workspace Alpha Team: $1,234.56 remaining
|
||||
Workspace Beta: $7 remaining
|
||||
"""
|
||||
|
||||
let snapshot = try AmpUsageParser.parse(displayText: output)
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(snapshot.freeQuota == nil)
|
||||
#expect(snapshot.workspaceBalances == [
|
||||
AmpWorkspaceBalance(name: "Alpha Team", remaining: 1234.56),
|
||||
AmpWorkspaceBalance(name: "Beta", remaining: 7),
|
||||
])
|
||||
#expect(usage.primary == nil)
|
||||
#expect(usage.ampUsage == AmpUsageDetails(
|
||||
individualCredits: nil,
|
||||
workspaceBalances: snapshot.workspaceBalances))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `signed in identity can contain login`() throws {
|
||||
let output = """
|
||||
Signed in as login@example.com (login-team)
|
||||
Amp Free: $6/$10 remaining (replenishes +$0.5/hour)
|
||||
"""
|
||||
|
||||
let snapshot = try AmpUsageParser.parse(displayText: output)
|
||||
|
||||
#expect(snapshot.accountEmail == "login@example.com")
|
||||
#expect(snapshot.accountOrganization == "login-team")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses current usage api response`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_005_000)
|
||||
let displayText = """
|
||||
Signed in as user@example.com (team)
|
||||
Amp Free: $8/$10 remaining (replenishes +$0.5/hour)
|
||||
Individual credits: $12.50 remaining
|
||||
Workspace Alpha Team: $1,234.56 remaining
|
||||
Workspace Beta: $7 remaining
|
||||
"""
|
||||
let data = try JSONSerialization.data(withJSONObject: [
|
||||
"ok": true,
|
||||
"result": ["displayText": displayText],
|
||||
])
|
||||
|
||||
let snapshot = try AmpUsageFetcher.parseUsageAPIResponse(data, now: now)
|
||||
|
||||
#expect(snapshot.freeUsed == 2)
|
||||
#expect(snapshot.individualCredits == 12.5)
|
||||
#expect(snapshot.workspaceBalances == [
|
||||
AmpWorkspaceBalance(name: "Alpha Team", remaining: 1234.56),
|
||||
AmpWorkspaceBalance(name: "Beta", remaining: 7),
|
||||
])
|
||||
#expect(snapshot.accountEmail == "user@example.com")
|
||||
#expect(snapshot.accountOrganization == "team")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `usage api auth error is invalid API token`() {
|
||||
let data = Data(#"{"ok":false,"error":{"code":"auth-required","message":"Sign in"}}"#.utf8)
|
||||
|
||||
#expect {
|
||||
try AmpUsageFetcher.parseUsageAPIResponse(data)
|
||||
} throws: { error in
|
||||
guard case AmpUsageError.invalidAPIToken = error else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses free tier usage from settings HTML`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let html = """
|
||||
<script>
|
||||
__sveltekit_x.data = {user:{},
|
||||
freeTierUsage:{bucket:"ubi",quota:1000,hourlyReplenishment:42,windowHours:24,used:338.5}};
|
||||
</script>
|
||||
"""
|
||||
|
||||
let snapshot = try AmpUsageParser.parse(html: html, now: now)
|
||||
|
||||
#expect(snapshot.freeQuota == 1000)
|
||||
#expect(snapshot.freeUsed == 338.5)
|
||||
#expect(snapshot.hourlyReplenishment == 42)
|
||||
#expect(snapshot.windowHours == 24)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot(now: now)
|
||||
let expectedPercent = (338.5 / 1000) * 100
|
||||
#expect(abs((usage.primary?.usedPercent ?? 0) - expectedPercent) < 0.001)
|
||||
#expect(usage.primary?.windowMinutes == 1440)
|
||||
|
||||
let expectedHoursToFull = 338.5 / 42
|
||||
let expectedReset = now.addingTimeInterval(expectedHoursToFull * 3600)
|
||||
#expect(usage.primary?.resetsAt == expectedReset)
|
||||
#expect(usage.identity?.loginMethod == "Amp Free")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses free tier usage from prefetched key`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_010_000)
|
||||
let html = """
|
||||
<script>
|
||||
__sveltekit_x.data = {
|
||||
"w6b2h6/getFreeTierUsage/":{bucket:"ubi",quota:1000,hourlyReplenishment:42,windowHours:24,used:0}
|
||||
};
|
||||
</script>
|
||||
"""
|
||||
|
||||
let snapshot = try AmpUsageParser.parse(html: html, now: now)
|
||||
#expect(snapshot.freeUsed == 0)
|
||||
#expect(snapshot.freeQuota == 1000)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `missing usage throws parse failed`() {
|
||||
let html = "<html><body>No usage here.</body></html>"
|
||||
|
||||
#expect {
|
||||
try AmpUsageParser.parse(html: html)
|
||||
} throws: { error in
|
||||
guard case let AmpUsageError.parseFailed(message) = error else { return false }
|
||||
return message.contains("Missing Amp Free usage data")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `signed out throws not logged in`() {
|
||||
let html = "<html><body>Please sign in to Amp.</body></html>"
|
||||
|
||||
#expect {
|
||||
try AmpUsageParser.parse(html: html)
|
||||
} throws: { error in
|
||||
guard case AmpUsageError.notLoggedIn = error else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `usage snapshot clamps percent and window`() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_020_000)
|
||||
let snapshot = AmpUsageSnapshot(
|
||||
freeQuota: 100,
|
||||
freeUsed: 150,
|
||||
hourlyReplenishment: 10,
|
||||
windowHours: nil,
|
||||
updatedAt: now)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot(now: now)
|
||||
#expect(usage.primary?.usedPercent == 100)
|
||||
#expect(usage.primary?.windowMinutes == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `usage snapshot omits reset when hourly replenishment is zero`() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_030_000)
|
||||
let snapshot = AmpUsageSnapshot(
|
||||
freeQuota: 100,
|
||||
freeUsed: 20,
|
||||
hourlyReplenishment: 0,
|
||||
windowHours: 24,
|
||||
updatedAt: now)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot(now: now)
|
||||
#expect(usage.primary?.resetsAt == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
private func antigravityBlockingSleep(_ interval: TimeInterval) {
|
||||
Thread.sleep(forTimeInterval: interval)
|
||||
}
|
||||
|
||||
private final class AntigravityCLICounter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
@discardableResult
|
||||
func increment() -> Int {
|
||||
self.lock.lock()
|
||||
self.count += 1
|
||||
let value = self.count
|
||||
self.lock.unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
var value: Int {
|
||||
self.lock.lock()
|
||||
let value = self.count
|
||||
self.lock.unlock()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private final class AntigravityCLIPortRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var ports: [[Int]] = []
|
||||
|
||||
func append(_ value: [Int]) {
|
||||
self.lock.lock()
|
||||
self.ports.append(value)
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [[Int]] {
|
||||
self.lock.lock()
|
||||
let value = self.ports
|
||||
self.lock.unlock()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private final class AntigravityCLITimeoutRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var timeouts: [TimeInterval] = []
|
||||
|
||||
func append(_ value: TimeInterval) {
|
||||
self.lock.lock()
|
||||
self.timeouts.append(value)
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [TimeInterval] {
|
||||
self.lock.lock()
|
||||
let value = self.timeouts
|
||||
self.lock.unlock()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private final class AntigravityCLITestClock: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var date: Date
|
||||
|
||||
init(date: Date) {
|
||||
self.date = date
|
||||
}
|
||||
|
||||
func now() -> Date {
|
||||
self.lock.lock()
|
||||
let value = self.date
|
||||
self.date = self.date.addingTimeInterval(1)
|
||||
self.lock.unlock()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private final class AntigravityCLIOutputSequence: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var values: [Data]
|
||||
|
||||
init(_ values: [Data]) {
|
||||
self.values = values
|
||||
}
|
||||
|
||||
func next() -> Data {
|
||||
self.lock.lock()
|
||||
let value = self.values.isEmpty ? Data() : self.values.removeFirst()
|
||||
self.lock.unlock()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
struct AntigravityCLIHTTPSFetchStrategyTests {
|
||||
@Test
|
||||
func `local strategy falls back to cli HTTPS in cli source mode`() {
|
||||
let strategy = AntigravityStatusFetchStrategy()
|
||||
let context = self.makeFetchContext(sourceMode: .cli)
|
||||
|
||||
#expect(strategy.shouldFallback(on: AntigravityStatusProbeError.notRunning, context: context))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `local strategy falls back to cli HTTPS in auto source mode`() {
|
||||
let strategy = AntigravityStatusFetchStrategy()
|
||||
let context = self.makeFetchContext(sourceMode: .auto)
|
||||
|
||||
#expect(strategy.shouldFallback(on: AntigravityStatusProbeError.notRunning, context: context))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `local strategy does not fallback for unrelated source modes`() {
|
||||
let strategy = AntigravityStatusFetchStrategy()
|
||||
|
||||
#expect(!strategy.shouldFallback(
|
||||
on: AntigravityStatusProbeError.notRunning,
|
||||
context: self.makeFetchContext(sourceMode: .oauth)))
|
||||
#expect(!strategy.shouldFallback(
|
||||
on: AntigravityStatusProbeError.notRunning,
|
||||
context: self.makeFetchContext(sourceMode: .web)))
|
||||
#expect(!strategy.shouldFallback(
|
||||
on: AntigravityStatusProbeError.notRunning,
|
||||
context: self.makeFetchContext(sourceMode: .api)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strategy pipeline includes cli HTTPS fallback in cli and auto modes`() async {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity)
|
||||
|
||||
let cliStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies(
|
||||
self.makeFetchContext(sourceMode: .cli))
|
||||
#expect(cliStrategies.map(\.id) == [
|
||||
"antigravity.app-local",
|
||||
"antigravity.cli-https",
|
||||
"antigravity.ide-local",
|
||||
])
|
||||
|
||||
let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies(
|
||||
self.makeFetchContext(sourceMode: .auto))
|
||||
#expect(autoStrategies.map(\.id) == [
|
||||
"antigravity.app-local",
|
||||
"antigravity.cli-https",
|
||||
"antigravity.ide-local",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strategy pipeline keeps source mode authoritative with selected token account`() async {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity)
|
||||
|
||||
let accountID = UUID()
|
||||
let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies(
|
||||
self.makeFetchContext(sourceMode: .auto, selectedTokenAccountID: accountID))
|
||||
let cliStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies(
|
||||
self.makeFetchContext(sourceMode: .cli, selectedTokenAccountID: accountID))
|
||||
let oauthStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies(
|
||||
self.makeFetchContext(sourceMode: .oauth, selectedTokenAccountID: accountID))
|
||||
|
||||
#expect(autoStrategies.map(\.id) == [
|
||||
"antigravity.app-local",
|
||||
"antigravity.cli-https",
|
||||
"antigravity.ide-local",
|
||||
"antigravity.oauth",
|
||||
])
|
||||
#expect(cliStrategies.map(\.id) == [
|
||||
"antigravity.app-local",
|
||||
"antigravity.cli-https",
|
||||
"antigravity.ide-local",
|
||||
])
|
||||
#expect(oauthStrategies.map(\.id) == ["antigravity.oauth"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto strategy pipeline includes oauth when credentials are injected`() async {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity)
|
||||
|
||||
let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies(
|
||||
self.makeFetchContext(
|
||||
sourceMode: .auto,
|
||||
env: self.accountEnv(email: "selected@example.com")))
|
||||
|
||||
#expect(autoStrategies.map(\.id) == [
|
||||
"antigravity.app-local",
|
||||
"antigravity.cli-https",
|
||||
"antigravity.ide-local",
|
||||
"antigravity.oauth",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto strategy pipeline preserves oauth fallback for shared credentials file`() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("antigravity-shared-auto-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = AntigravityOAuthCredentialsStore(
|
||||
fileURL: AntigravityOAuthCredentialsStore.defaultURL(home: root))
|
||||
try store.save(AntigravityOAuthCredentials(
|
||||
accessToken: "access",
|
||||
refreshToken: "refresh",
|
||||
expiryDate: Date().addingTimeInterval(3600),
|
||||
email: "legacy@example.com"))
|
||||
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity)
|
||||
let autoStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies(
|
||||
self.makeFetchContext(sourceMode: .auto, env: ["HOME": root.path]))
|
||||
|
||||
#expect(autoStrategies.map(\.id) == [
|
||||
"antigravity.app-local",
|
||||
"antigravity.cli-https",
|
||||
"antigravity.ide-local",
|
||||
"antigravity.oauth",
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Selected-account guard
|
||||
|
||||
@Test
|
||||
func `account guard ignores fetches without a selected account`() throws {
|
||||
let usage = self.makeUsage(accountEmail: "ambient@example.com")
|
||||
let context = self.makeFetchContext(
|
||||
sourceMode: .auto,
|
||||
env: self.accountEnv(email: "selected@example.com"))
|
||||
|
||||
try AntigravitySelectedAccountGuard.validate(usage, context: context)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `account guard accepts matching ambient snapshot in auto mode`() throws {
|
||||
let usage = self.makeUsage(accountEmail: "Selected@Example.com")
|
||||
let context = self.makeFetchContext(
|
||||
sourceMode: .auto,
|
||||
selectedTokenAccountID: UUID(),
|
||||
env: self.accountEnv(email: "selected@example.com"))
|
||||
|
||||
try AntigravitySelectedAccountGuard.validate(usage, context: context)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `account guard rejects mismatched ambient snapshot in auto mode`() {
|
||||
let usage = self.makeUsage(accountEmail: "ambient@example.com")
|
||||
let context = self.makeFetchContext(
|
||||
sourceMode: .auto,
|
||||
selectedTokenAccountID: UUID(),
|
||||
env: self.accountEnv(email: "selected@example.com"))
|
||||
|
||||
#expect(throws: AntigravityStatusProbeError.accountMismatch(
|
||||
expected: "selected@example.com",
|
||||
found: "ambient@example.com"))
|
||||
{
|
||||
try AntigravitySelectedAccountGuard.validate(usage, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `account guard rejects snapshot without an identity email`() {
|
||||
let usage = self.makeUsage(accountEmail: nil)
|
||||
let context = self.makeFetchContext(
|
||||
sourceMode: .auto,
|
||||
selectedTokenAccountID: UUID(),
|
||||
env: self.accountEnv(email: "selected@example.com"))
|
||||
|
||||
#expect(throws: AntigravityStatusProbeError.accountMismatch(
|
||||
expected: "selected@example.com",
|
||||
found: nil))
|
||||
{
|
||||
try AntigravitySelectedAccountGuard.validate(usage, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `account guard rejects when selected account email cannot be resolved`() {
|
||||
let usage = self.makeUsage(accountEmail: "ambient@example.com")
|
||||
let context = self.makeFetchContext(
|
||||
sourceMode: .auto,
|
||||
selectedTokenAccountID: UUID())
|
||||
|
||||
#expect(throws: AntigravityStatusProbeError.accountMismatch(
|
||||
expected: nil,
|
||||
found: "ambient@example.com"))
|
||||
{
|
||||
try AntigravitySelectedAccountGuard.validate(usage, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `account guard leaves explicit cli source mode authoritative`() throws {
|
||||
let usage = self.makeUsage(accountEmail: "ambient@example.com")
|
||||
let context = self.makeFetchContext(
|
||||
sourceMode: .cli,
|
||||
selectedTokenAccountID: UUID(),
|
||||
env: self.accountEnv(email: "selected@example.com"))
|
||||
|
||||
try AntigravitySelectedAccountGuard.validate(usage, context: context)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `selected account email resolves from id_token when email field missing`() {
|
||||
let idToken = Self.makeIDToken(email: "jwt@example.com")
|
||||
let context = self.makeFetchContext(
|
||||
sourceMode: .auto,
|
||||
selectedTokenAccountID: UUID(),
|
||||
env: self.accountEnv(email: nil, idToken: idToken))
|
||||
|
||||
#expect(AntigravitySelectedAccountGuard.selectedAccountEmail(context: context) == "jwt@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `selected account email prefers id_token over stored email field`() {
|
||||
let idToken = Self.makeIDToken(email: "jwt@example.com")
|
||||
let context = self.makeFetchContext(
|
||||
sourceMode: .auto,
|
||||
selectedTokenAccountID: UUID(),
|
||||
env: self.accountEnv(email: "stored@example.com", idToken: idToken))
|
||||
|
||||
#expect(AntigravitySelectedAccountGuard.selectedAccountEmail(context: context) == "jwt@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS resets session only for one-shot CLI runtime`() {
|
||||
// One-shot CLI invocation: reset after fetch.
|
||||
#expect(AntigravityCLIHTTPSFetchStrategy.shouldResetSessionAfterFetch(self.makeFetchContext(runtime: .cli)))
|
||||
// App runtime keeps the warm session.
|
||||
#expect(!AntigravityCLIHTTPSFetchStrategy.shouldResetSessionAfterFetch(self.makeFetchContext(runtime: .app)))
|
||||
// Long-lived CLI host (codexbar serve) keeps the warm session even at .cli runtime.
|
||||
#expect(!AntigravityCLIHTTPSFetchStrategy.shouldResetSessionAfterFetch(
|
||||
self.makeFetchContext(runtime: .cli, persistsCLISessions: true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS reports public source as cli`() {
|
||||
#expect(AntigravityCLIHTTPSFetchStrategy.sourceLabel == "cli")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli local strategy availability requires binary`() async throws {
|
||||
let binaryURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codexbar-antigravity-\(UUID().uuidString)")
|
||||
try Data("#!/bin/sh\n".utf8).write(to: binaryURL)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755],
|
||||
ofItemAtPath: binaryURL.path)
|
||||
defer { try? FileManager.default.removeItem(at: binaryURL) }
|
||||
|
||||
let strategy = AntigravityCLIHTTPSFetchStrategy()
|
||||
let context = self.makeFetchContext(env: ["ANTIGRAVITY_CLI_PATH": binaryURL.path])
|
||||
let isAvailable = await strategy.isAvailable(context)
|
||||
|
||||
#expect(isAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli local endpoints remain HTTPS only on macOS`() {
|
||||
#expect(
|
||||
AntigravityStatusProbe.cliEndpoints(ports: [55624]) == [
|
||||
AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 55624,
|
||||
csrfToken: "",
|
||||
source: .cliHTTPS),
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS falls back to command model configs when quota summary and user status fail`() async throws {
|
||||
let endpoints = [
|
||||
AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 50080,
|
||||
csrfToken: "",
|
||||
source: .cliHTTPS),
|
||||
]
|
||||
let attempts = AntigravityCLICounter()
|
||||
|
||||
let snapshot = try await AntigravityStatusProbe.fetchSnapshot(
|
||||
context: AntigravityStatusProbe.RequestContext(
|
||||
endpoints: endpoints,
|
||||
timeout: 1,
|
||||
deadline: Date().addingTimeInterval(2)),
|
||||
send: { payload, _, _ in
|
||||
let attempt = attempts.increment()
|
||||
if attempt == 1 {
|
||||
#expect(payload.path == "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary")
|
||||
throw AntigravityStatusProbeError.apiError("quota summary unavailable")
|
||||
}
|
||||
if attempt == 2 {
|
||||
#expect(payload.path == "/exa.language_server_pb.LanguageServerService/GetUserStatus")
|
||||
throw AntigravityStatusProbeError.apiError("user status unavailable")
|
||||
}
|
||||
#expect(payload.path == "/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs")
|
||||
return Data("""
|
||||
{
|
||||
"clientModelConfigs": [
|
||||
{
|
||||
"label": "Claude Sonnet",
|
||||
"modelOrAlias": { "model": "claude-sonnet" },
|
||||
"quotaInfo": { "remainingFraction": 0.5 }
|
||||
}
|
||||
]
|
||||
}
|
||||
""".utf8)
|
||||
})
|
||||
|
||||
#expect(snapshot.modelQuotas.first?.label == "Claude Sonnet")
|
||||
#expect(attempts.value == 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS waits for user status after ports appear`() async throws {
|
||||
let fetchAttempts = AntigravityCLICounter()
|
||||
let drainAttempts = AntigravityCLICounter()
|
||||
let fetchedPorts = AntigravityCLIPortRecorder()
|
||||
let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: Date().addingTimeInterval(5),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in [50080, 50081] },
|
||||
drainOutput: {
|
||||
drainAttempts.increment()
|
||||
return Data()
|
||||
},
|
||||
fetchSnapshot: { ports in
|
||||
fetchedPorts.append(ports)
|
||||
if fetchAttempts.increment() == 1 {
|
||||
throw AntigravityStatusProbeError.apiError("HTTP 500: GetCascadeModelConfigData() is nil")
|
||||
}
|
||||
return AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Claude Opus 4.6 (Thinking)",
|
||||
modelId: "claude-opus-4.6-thinking",
|
||||
remainingFraction: 1,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: "user@example.com",
|
||||
accountPlan: "Pro",
|
||||
source: .local)
|
||||
}))
|
||||
|
||||
#expect(snapshot.accountEmail == "user@example.com")
|
||||
#expect(fetchAttempts.value == 2)
|
||||
#expect(fetchedPorts.snapshot() == [[50080, 50081], [50080, 50081]])
|
||||
#expect(drainAttempts.value == 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS retries empty quota snapshots until usage is parseable`() async throws {
|
||||
let fetchAttempts = AntigravityCLICounter()
|
||||
|
||||
let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: Date().addingTimeInterval(5),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in [50080] },
|
||||
drainOutput: { Data() },
|
||||
fetchSnapshot: { _ in
|
||||
if fetchAttempts.increment() == 1 {
|
||||
return AntigravityStatusSnapshot(
|
||||
modelQuotas: [],
|
||||
accountEmail: nil,
|
||||
accountPlan: nil,
|
||||
source: .local)
|
||||
}
|
||||
return AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Claude Sonnet",
|
||||
modelId: "claude-sonnet",
|
||||
remainingFraction: 0.5,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: "user@example.com",
|
||||
accountPlan: "Pro",
|
||||
source: .local)
|
||||
}))
|
||||
|
||||
#expect(fetchAttempts.value == 2)
|
||||
#expect(snapshot.modelQuotas.first?.modelId == "claude-sonnet")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS drains output before ports appear`() async throws {
|
||||
let portPolls = AntigravityCLICounter()
|
||||
let drainAttempts = AntigravityCLICounter()
|
||||
let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: Date().addingTimeInterval(5),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in
|
||||
portPolls.increment() == 1 ? [] : [50080]
|
||||
},
|
||||
drainOutput: {
|
||||
drainAttempts.increment()
|
||||
return Data()
|
||||
},
|
||||
fetchSnapshot: { _ in
|
||||
AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Claude Sonnet",
|
||||
modelId: "claude-sonnet",
|
||||
remainingFraction: 1,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: "user@example.com",
|
||||
accountPlan: "Pro",
|
||||
source: .local)
|
||||
}))
|
||||
|
||||
#expect(snapshot.accountEmail == "user@example.com")
|
||||
#expect(portPolls.value == 2)
|
||||
#expect(drainAttempts.value == 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS stops before probing when signed out prompt spans output chunks`() async {
|
||||
let output = AntigravityCLIOutputSequence([
|
||||
Data("Welcome. You are currently ".utf8),
|
||||
Data("Welcome. You are currently not signed in.\nSelect login method:".utf8),
|
||||
])
|
||||
let portPolls = AntigravityCLICounter()
|
||||
|
||||
do {
|
||||
_ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: Date().addingTimeInterval(2),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in
|
||||
portPolls.increment()
|
||||
return []
|
||||
},
|
||||
drainOutput: {
|
||||
output.next()
|
||||
},
|
||||
fetchSnapshot: { _ in
|
||||
Issue.record("Signed-out helper should not fetch a snapshot")
|
||||
return AntigravityStatusSnapshot(
|
||||
modelQuotas: [],
|
||||
accountEmail: nil,
|
||||
accountPlan: nil,
|
||||
source: .local)
|
||||
}))
|
||||
Issue.record("Expected authentication failure")
|
||||
} catch AntigravityStatusProbeError.authenticationRequired {
|
||||
#expect(portPolls.value == 1)
|
||||
} catch {
|
||||
Issue.record("Expected authenticationRequired, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS allows transient automatic sign in banner`() async throws {
|
||||
let output = AntigravityCLIOutputSequence([
|
||||
Data("Welcome. You are currently not signed in.\nSigning in...".utf8),
|
||||
Data("user@example.com\nGemini 3.1 Pro (High)".utf8),
|
||||
])
|
||||
|
||||
let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: Date().addingTimeInterval(2),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in [50080] },
|
||||
drainOutput: {
|
||||
output.next()
|
||||
},
|
||||
fetchSnapshot: { _ in
|
||||
AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Claude Sonnet",
|
||||
modelId: "claude-sonnet",
|
||||
remainingFraction: 1,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: "user@example.com",
|
||||
accountPlan: "Pro",
|
||||
source: .local)
|
||||
}))
|
||||
|
||||
#expect(snapshot.accountEmail == "user@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS rechecks signed out prompt after snapshot readiness`() async {
|
||||
let output = AntigravityCLIOutputSequence([
|
||||
Data(),
|
||||
Data("You are currently not signed in.\nSelect login method:".utf8),
|
||||
])
|
||||
|
||||
do {
|
||||
_ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: Date().addingTimeInterval(2),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in [50080] },
|
||||
drainOutput: {
|
||||
output.next()
|
||||
},
|
||||
fetchSnapshot: { _ in
|
||||
AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Claude Sonnet",
|
||||
modelId: "claude-sonnet",
|
||||
remainingFraction: 1,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: "user@example.com",
|
||||
accountPlan: "Pro",
|
||||
source: .local)
|
||||
}))
|
||||
Issue.record("Expected authentication failure")
|
||||
} catch AntigravityStatusProbeError.authenticationRequired {
|
||||
// Expected: the late prompt wins over the apparently ready API.
|
||||
} catch {
|
||||
Issue.record("Expected authenticationRequired, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS treats empty lsof exit as ports not ready`() async throws {
|
||||
let portPolls = AntigravityCLICounter()
|
||||
let snapshot = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: Date().addingTimeInterval(5),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in
|
||||
if portPolls.increment() == 1 {
|
||||
throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: "")
|
||||
}
|
||||
return [50080]
|
||||
},
|
||||
drainOutput: { Data() },
|
||||
fetchSnapshot: { _ in
|
||||
AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Claude Sonnet",
|
||||
modelId: "claude-sonnet",
|
||||
remainingFraction: 0.5,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: "user@example.com",
|
||||
accountPlan: "Pro",
|
||||
source: .local)
|
||||
}))
|
||||
|
||||
#expect(snapshot.accountEmail == "user@example.com")
|
||||
#expect(portPolls.value == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parsed requests recompute timeout from shared deadline between endpoints`() async throws {
|
||||
let timeoutRecorder = AntigravityCLITimeoutRecorder()
|
||||
let attempts = AntigravityCLICounter()
|
||||
let endpoints = [
|
||||
AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 50080,
|
||||
csrfToken: "",
|
||||
source: .cliHTTPS),
|
||||
AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 50081,
|
||||
csrfToken: "",
|
||||
source: .cliHTTPS),
|
||||
]
|
||||
|
||||
let result = try await AntigravityStatusProbe.makeParsedRequest(
|
||||
payload: AntigravityStatusProbe.RequestPayload(path: "/status", body: [:]),
|
||||
context: AntigravityStatusProbe.RequestContext(
|
||||
endpoints: endpoints,
|
||||
timeout: 10,
|
||||
deadline: Date().addingTimeInterval(10)),
|
||||
send: { _, _, timeout in
|
||||
timeoutRecorder.append(timeout)
|
||||
if attempts.increment() == 1 {
|
||||
antigravityBlockingSleep(0.1)
|
||||
throw AntigravityStatusProbeError.apiError("first endpoint failed")
|
||||
}
|
||||
return Data("ok".utf8)
|
||||
},
|
||||
parse: { data in
|
||||
guard let value = String(bytes: data, encoding: .utf8) else {
|
||||
throw AntigravityStatusProbeError.apiError("invalid test data")
|
||||
}
|
||||
return value
|
||||
})
|
||||
|
||||
let timeouts = timeoutRecorder.snapshot()
|
||||
#expect(result == "ok")
|
||||
#expect(timeouts.count == 2)
|
||||
#expect(timeouts.allSatisfy { $0 < 10 })
|
||||
#expect((timeouts.last ?? 10) < (timeouts.first ?? 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parsed request reports timeout when shared deadline is already expired`() async {
|
||||
do {
|
||||
_ = try await AntigravityStatusProbe.makeParsedRequest(
|
||||
payload: AntigravityStatusProbe.RequestPayload(path: "/status", body: [:]),
|
||||
context: AntigravityStatusProbe.RequestContext(
|
||||
endpoints: [
|
||||
AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 50080,
|
||||
csrfToken: "",
|
||||
source: .cliHTTPS),
|
||||
],
|
||||
timeout: 10,
|
||||
deadline: Date().addingTimeInterval(-1)),
|
||||
send: { _, _, _ in
|
||||
Issue.record("Expired deadline should not send a request")
|
||||
return Data()
|
||||
},
|
||||
parse: { _ in "ok" })
|
||||
Issue.record("Expected timeout")
|
||||
} catch AntigravityStatusProbeError.timedOut {
|
||||
} catch {
|
||||
Issue.record("Expected timedOut, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS reports last readiness error when ports never become usable`() async {
|
||||
let fetchAttempts = AntigravityCLICounter()
|
||||
let start = Date(timeIntervalSinceReferenceDate: 0)
|
||||
let clock = AntigravityCLITestClock(date: start)
|
||||
|
||||
do {
|
||||
_ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: start.addingTimeInterval(5),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in [50080] },
|
||||
drainOutput: { Data() },
|
||||
fetchSnapshot: { _ in
|
||||
let attempt = fetchAttempts.increment()
|
||||
throw AntigravityStatusProbeError.apiError("HTTP 500: warming attempt \(attempt)")
|
||||
},
|
||||
now: { clock.now() }))
|
||||
Issue.record("Expected readiness polling to throw")
|
||||
} catch let AntigravityStatusProbeError.apiError(message) {
|
||||
#expect(fetchAttempts.value == 2)
|
||||
#expect(message == "HTTP 500: warming attempt 2")
|
||||
} catch {
|
||||
Issue.record("Expected apiError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS preserves non transient port detection errors`() async {
|
||||
do {
|
||||
_ = try await AntigravityCLIHTTPSFetchStrategy.waitForSnapshot(
|
||||
pid: 123,
|
||||
deadline: Date().addingTimeInterval(2),
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.SnapshotWaitDependencies(
|
||||
pollIntervalNanoseconds: 0,
|
||||
listeningPorts: { _, _ in
|
||||
throw AntigravityStatusProbeError.portDetectionFailed("lsof not available")
|
||||
},
|
||||
drainOutput: { Data() },
|
||||
fetchSnapshot: { _ in
|
||||
Issue.record("Port detection failure should not fetch a snapshot")
|
||||
return AntigravityStatusSnapshot(
|
||||
modelQuotas: [],
|
||||
accountEmail: nil,
|
||||
accountPlan: nil,
|
||||
source: .local)
|
||||
}))
|
||||
Issue.record("Expected port detection failure")
|
||||
} catch let AntigravityStatusProbeError.portDetectionFailed(message) {
|
||||
#expect(message == "lsof not available")
|
||||
} catch {
|
||||
Issue.record("Expected portDetectionFailed, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli HTTPS endpoint does not require CSRF token`() {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 55624,
|
||||
csrfToken: "ignored-by-cli",
|
||||
source: .cliHTTPS)
|
||||
#expect(!endpoint.requiresCSRFToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `languageServer endpoint requires CSRF token`() {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64440,
|
||||
csrfToken: "",
|
||||
source: .languageServer)
|
||||
#expect(endpoint.requiresCSRFToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `extensionServer endpoint requires CSRF token`() {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "http",
|
||||
port: 64432,
|
||||
csrfToken: "",
|
||||
source: .extensionServer)
|
||||
#expect(endpoint.requiresCSRFToken)
|
||||
}
|
||||
|
||||
private func makeFetchContext(
|
||||
runtime: ProviderRuntime = .app,
|
||||
sourceMode: ProviderSourceMode = .auto,
|
||||
selectedTokenAccountID: UUID? = nil,
|
||||
persistsCLISessions: Bool = false,
|
||||
env: [String: String] = [:]) -> ProviderFetchContext
|
||||
{
|
||||
var effectiveEnv = env
|
||||
effectiveEnv["HOME"] = effectiveEnv["HOME"] ??
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codexbar-antigravity-empty-home-\(UUID().uuidString)", isDirectory: true)
|
||||
.path
|
||||
return ProviderFetchContext(
|
||||
runtime: runtime,
|
||||
sourceMode: sourceMode,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: effectiveEnv,
|
||||
settings: nil,
|
||||
fetcher: UsageFetcher(environment: effectiveEnv),
|
||||
claudeFetcher: StubClaudeFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
selectedTokenAccountID: selectedTokenAccountID,
|
||||
persistsCLISessions: persistsCLISessions)
|
||||
}
|
||||
|
||||
private func makeUsage(accountEmail: String?) -> UsageSnapshot {
|
||||
UsageSnapshot(
|
||||
primary: nil,
|
||||
secondary: nil,
|
||||
updatedAt: Date(),
|
||||
identity: ProviderIdentitySnapshot(
|
||||
providerID: .antigravity,
|
||||
accountEmail: accountEmail,
|
||||
accountOrganization: nil,
|
||||
loginMethod: nil))
|
||||
}
|
||||
|
||||
private func accountEnv(email: String?, idToken: String? = nil) -> [String: String] {
|
||||
let credentials = AntigravityOAuthCredentials(
|
||||
accessToken: "access",
|
||||
refreshToken: "refresh",
|
||||
expiryDate: Date().addingTimeInterval(3600),
|
||||
idToken: idToken,
|
||||
email: email)
|
||||
guard let value = try? AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) else {
|
||||
return [:]
|
||||
}
|
||||
return [AntigravityOAuthCredentialsStore.environmentCredentialsKey: value]
|
||||
}
|
||||
|
||||
private static func makeIDToken(email: String) -> String {
|
||||
let payload = Data("{\"email\":\"\(email)\"}".utf8)
|
||||
let encodedPayload = payload.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
return "header.\(encodedPayload).signature"
|
||||
}
|
||||
|
||||
private struct StubClaudeFetcher: ClaudeUsageFetching {
|
||||
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
|
||||
throw ClaudeUsageError.parseFailed("stub")
|
||||
}
|
||||
|
||||
func debugRawProbe(model _: String) async -> String {
|
||||
"stub"
|
||||
}
|
||||
|
||||
func detectVersion() -> String? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AntigravityCompactFallbackTests {
|
||||
@Test
|
||||
func `model quota reset proximity does not imply window duration`() throws {
|
||||
let resetTime = Date().addingTimeInterval(2 * 60 * 60)
|
||||
let snapshot = AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Gemini 3.1 Pro (Low)",
|
||||
modelId: "MODEL_PLACEHOLDER_M36",
|
||||
remainingFraction: 0.5,
|
||||
resetTime: resetTime,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: nil,
|
||||
accountPlan: nil)
|
||||
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary?.windowMinutes == nil)
|
||||
#expect(usage.primary?.resetsAt == resetTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `local unclassified model remains available as compact fallback`() throws {
|
||||
let snapshot = AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Experimental Model",
|
||||
modelId: "MODEL_PLACEHOLDER_NEW",
|
||||
remainingFraction: 0.36,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: nil,
|
||||
accountPlan: nil,
|
||||
source: .local)
|
||||
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary == nil)
|
||||
#expect(usage.secondary == nil)
|
||||
#expect(usage.tertiary == nil)
|
||||
#expect(usage.extraRateWindows?.map(\.id) == ["antigravity-compact-fallback-MODEL_PLACEHOLDER_NEW"])
|
||||
#expect(usage.extraRateWindows?.map(\.title) == ["Experimental Model"])
|
||||
#expect(usage.extraRateWindows?.map(\.window.usedPercent) == [64])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `remote unclassified model remains detail only`() throws {
|
||||
let snapshot = AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Experimental Model",
|
||||
modelId: "MODEL_PLACEHOLDER_NEW",
|
||||
remainingFraction: 0.36,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: nil,
|
||||
accountPlan: nil,
|
||||
source: .remote)
|
||||
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary == nil)
|
||||
#expect(usage.secondary == nil)
|
||||
#expect(usage.extraRateWindows?.map(\.id) == ["MODEL_PLACEHOLDER_NEW"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fully unused local model remains available as compact fallback`() throws {
|
||||
let snapshot = AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Experimental Model",
|
||||
modelId: "MODEL_PLACEHOLDER_NEW",
|
||||
remainingFraction: 1,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: nil,
|
||||
accountPlan: nil,
|
||||
source: .local)
|
||||
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.extraRateWindows?.map(\.id) == ["antigravity-compact-fallback-MODEL_PLACEHOLDER_NEW"])
|
||||
#expect(usage.extraRateWindows?.map(\.window.usedPercent) == [0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
private final class AntigravityTimeoutRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var timeouts: [TimeInterval] = []
|
||||
|
||||
func append(_ timeout: TimeInterval) {
|
||||
self.lock.withLock {
|
||||
self.timeouts.append(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot() -> [TimeInterval] {
|
||||
self.lock.withLock {
|
||||
self.timeouts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class AntigravityConcurrencyRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var activeCount = 0
|
||||
private var maximumActiveCount = 0
|
||||
|
||||
func begin() {
|
||||
self.lock.withLock {
|
||||
self.activeCount += 1
|
||||
self.maximumActiveCount = max(self.maximumActiveCount, self.activeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func end() {
|
||||
self.lock.withLock {
|
||||
self.activeCount -= 1
|
||||
}
|
||||
}
|
||||
|
||||
func maximum() -> Int {
|
||||
self.lock.withLock {
|
||||
self.maximumActiveCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AntigravityDeadlineTests {
|
||||
@Test
|
||||
func `process candidates probe concurrently while preserving result order`() async throws {
|
||||
let processInfos = [
|
||||
AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: 1,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "first",
|
||||
commandLine: "first"),
|
||||
AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: 2,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "second",
|
||||
commandLine: "second"),
|
||||
]
|
||||
let concurrency = AntigravityConcurrencyRecorder()
|
||||
|
||||
let result = try await AntigravityStatusProbe.fetchProcessSnapshots(
|
||||
processInfos: processInfos)
|
||||
{ processInfo in
|
||||
concurrency.begin()
|
||||
defer { concurrency.end() }
|
||||
if processInfo.pid == 1 {
|
||||
try await Task.sleep(for: .milliseconds(120))
|
||||
} else {
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
return AntigravityStatusSnapshot(
|
||||
modelQuotas: [],
|
||||
accountEmail: "\(processInfo.pid)@example.com",
|
||||
accountPlan: nil)
|
||||
}
|
||||
|
||||
#expect(concurrency.maximum() == 2)
|
||||
#expect(result.snapshots.map(\.accountEmail) == ["1@example.com", "2@example.com"])
|
||||
#expect(result.lastError == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `process candidate transport error preserves url error identity`() async throws {
|
||||
let processInfo = AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: 1,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "token",
|
||||
commandLine: "command")
|
||||
|
||||
let result = try await AntigravityStatusProbe.fetchProcessSnapshots(processInfos: [processInfo]) { _ in
|
||||
throw URLError(.cannotConnectToHost)
|
||||
}
|
||||
|
||||
#expect((result.lastError as? URLError)?.code == .cannotConnectToHost)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `process candidate cancellation rejects partial success`() async {
|
||||
let processInfos = [
|
||||
AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: 1,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "first",
|
||||
commandLine: "first"),
|
||||
AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: 2,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "second",
|
||||
commandLine: "second"),
|
||||
]
|
||||
|
||||
await #expect(throws: CancellationError.self) {
|
||||
try await AntigravityStatusProbe.fetchProcessSnapshots(processInfos: processInfos) { processInfo in
|
||||
if processInfo.pid == 2 {
|
||||
throw CancellationError()
|
||||
}
|
||||
return AntigravityStatusSnapshot(
|
||||
modelQuotas: [],
|
||||
accountEmail: "partial@example.com",
|
||||
accountPlan: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cancelled process request rejects partial success`() async {
|
||||
let processInfos = [
|
||||
AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: 1,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "first",
|
||||
commandLine: "first"),
|
||||
AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: 2,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "second",
|
||||
commandLine: "second"),
|
||||
]
|
||||
|
||||
await #expect(throws: CancellationError.self) {
|
||||
try await AntigravityStatusProbe.fetchProcessSnapshots(processInfos: processInfos) { processInfo in
|
||||
if processInfo.pid == 2 {
|
||||
throw URLError(.cancelled)
|
||||
}
|
||||
return AntigravityStatusSnapshot(
|
||||
modelQuotas: [],
|
||||
accountEmail: "partial@example.com",
|
||||
accountPlan: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `shared deadline reserves time for later endpoint probes`() async throws {
|
||||
let endpoints = [
|
||||
AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64001,
|
||||
csrfToken: "token",
|
||||
source: .languageServer),
|
||||
AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64002,
|
||||
csrfToken: "token",
|
||||
source: .languageServer),
|
||||
]
|
||||
let recorder = AntigravityTimeoutRecorder()
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
|
||||
let resolved = try await AntigravityStatusProbe.resolveWorkingEndpoint(
|
||||
candidateEndpoints: endpoints,
|
||||
timeout: 1,
|
||||
deadline: deadline,
|
||||
testConnectivity: { endpoint, timeout in
|
||||
recorder.append(timeout)
|
||||
if endpoint.port == 64001 {
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
let timeouts = recorder.snapshot()
|
||||
#expect(resolved.port == 64002)
|
||||
#expect(timeouts.count == 2)
|
||||
#expect(timeouts[0] < 1.1)
|
||||
#expect(timeouts[1] > 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AntigravityLocalSnapshotSelectionTests {
|
||||
@Test
|
||||
func `selected account wins before quota richness`() throws {
|
||||
let selectedAccount = AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Gemini 3 Pro Low",
|
||||
modelId: "gemini-3-pro-low",
|
||||
remainingFraction: 0.9,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: "selected@example.com",
|
||||
accountPlan: "Pro",
|
||||
source: .local)
|
||||
let richerOtherAccount = AntigravityStatusSnapshot(
|
||||
quotaSummary: AntigravityQuotaSummary(
|
||||
description: nil,
|
||||
groups: [
|
||||
AntigravityQuotaSummaryGroup(
|
||||
displayName: "Gemini Models",
|
||||
description: nil,
|
||||
buckets: [
|
||||
AntigravityQuotaSummaryBucket(
|
||||
bucketId: "gemini-5h",
|
||||
displayName: "Five Hour Limit",
|
||||
remainingFraction: 0.9,
|
||||
resetDescription: nil,
|
||||
disabled: false),
|
||||
]),
|
||||
]),
|
||||
accountEmail: "other@example.com",
|
||||
accountPlan: "Ultra",
|
||||
source: .local)
|
||||
|
||||
let selected = try #require(
|
||||
AntigravityStatusProbe.preferredLocalSnapshot(
|
||||
[richerOtherAccount, selectedAccount],
|
||||
matchingAccountEmail: " SELECTED@example.com "))
|
||||
|
||||
#expect(selected.accountEmail == "selected@example.com")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
struct AntigravityLoginAlertTests {
|
||||
@Test
|
||||
func `authorization URL asks Google to select an account`() throws {
|
||||
let redirectURL = try #require(URL(string: "http://127.0.0.1:54321/callback"))
|
||||
let url = try AntigravityLoginRunner.makeAuthorizationURL(
|
||||
redirectURL: redirectURL,
|
||||
state: "state",
|
||||
oauthClient: AntigravityOAuthClient(
|
||||
clientID: "client.apps.googleusercontent.com",
|
||||
clientSecret: "secret"))
|
||||
let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
|
||||
let prompt = components.queryItems?.first(where: { $0.name == "prompt" })?.value
|
||||
|
||||
#expect(prompt?.split(separator: " ").contains("select_account") == true)
|
||||
#expect(prompt?.split(separator: " ").contains("consent") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns alert for timeout`() {
|
||||
let result = AntigravityLoginRunner.Result(outcome: .timedOut)
|
||||
let info = StatusItemController.antigravityLoginAlertInfo(for: result)
|
||||
#expect(info?.title == "Antigravity login timed out")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns alert for launch failure`() {
|
||||
let result = AntigravityLoginRunner.Result(outcome: .launchFailed("https://example.com/login"))
|
||||
let info = StatusItemController.antigravityLoginAlertInfo(for: result)
|
||||
#expect(info?.title == "Could not open browser for Antigravity")
|
||||
#expect(info?.message.contains("https://example.com/login") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns alert for auth failure`() {
|
||||
let result = AntigravityLoginRunner.Result(outcome: .failed("permission denied"))
|
||||
let info = StatusItemController.antigravityLoginAlertInfo(for: result)
|
||||
#expect(info?.title == "Antigravity login failed")
|
||||
#expect(info?.message == "permission denied")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns nil on success`() {
|
||||
let result = AntigravityLoginRunner.Result(outcome: .success("user@example.com"))
|
||||
let info = StatusItemController.antigravityLoginAlertInfo(for: result)
|
||||
#expect(info == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AntigravityModelLabelTests {
|
||||
@Test
|
||||
func `humanizes raw model ids when label matches model id`() {
|
||||
#expect(AntigravityStatusSnapshot.humanizedModelID("gemini-3-pro-preview") == "Gemini 3 Pro Preview")
|
||||
#expect(AntigravityStatusSnapshot.humanizedModelID("gemini-2.5-flash") == "Gemini 2.5 Flash")
|
||||
#expect(AntigravityStatusSnapshot.humanizedModelID("example-3-1-pro-low") == "Example 3.1 Pro Low")
|
||||
#expect(AntigravityStatusSnapshot.humanizedModelID("gpt-api-oss") == "GPT API OSS")
|
||||
#expect(AntigravityStatusSnapshot.humanizedModelID("").isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `preserves custom model labels`() {
|
||||
let quota = AntigravityModelQuota(
|
||||
label: "Custom enterprise label",
|
||||
modelId: "gemini-3-pro-preview",
|
||||
remainingFraction: 1,
|
||||
resetTime: nil,
|
||||
resetDescription: nil)
|
||||
|
||||
#expect(AntigravityStatusSnapshot.quotaDisplayLabel(quota) == "Custom enterprise label")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AntigravityOAuthCredentialsStoreTests {
|
||||
@Test
|
||||
func `oauth client discovery reads renamed legacy bundle`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let legacyClient = AntigravityOAuthClient(
|
||||
clientID: self.googleClientID("legacy"),
|
||||
clientSecret: self.googleClientSecret(repeating: "a"))
|
||||
try self.writeAntigravityApp(
|
||||
named: "Antigravity 2.app",
|
||||
under: root,
|
||||
bundleIdentifier: "com.google.antigravity-ide",
|
||||
artifactRelativePath: "Contents/Resources/app/out/main.js",
|
||||
artifactData: Data("""
|
||||
out-build/vs/platform/cloudCode/common/oauthClient.js
|
||||
clientId="\(legacyClient.clientID)";
|
||||
clientSecret="\(legacyClient.clientSecret)";
|
||||
""".utf8))
|
||||
|
||||
#expect(
|
||||
AntigravityOAuthConfig.discoverClientFromInstalledApp(
|
||||
applicationRoots: [root]) == legacyClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `oauth client discovery reads standalone antigravity 2 bundle`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let standaloneClient = AntigravityOAuthClient(
|
||||
clientID: self.googleClientID("standalone"),
|
||||
clientSecret: self.googleClientSecret(repeating: "b"))
|
||||
let alternateClient = AntigravityOAuthClient(
|
||||
clientID: self.googleClientID("alternate"),
|
||||
clientSecret: self.googleClientSecret(repeating: "c"))
|
||||
var artifactData = Data([0xFF])
|
||||
artifactData.append(Data(
|
||||
"""
|
||||
\u{0}\(alternateClient.clientSecret)\u{0}\(standaloneClient.clientSecret)\
|
||||
\u{0}oauth_data\(standaloneClient.clientID)\u{0}\(alternateClient.clientID)\u{0}
|
||||
""".utf8))
|
||||
try self.writeAntigravityApp(
|
||||
named: "Antigravity.app",
|
||||
under: root,
|
||||
artifactRelativePath: "Contents/Resources/bin/language_server",
|
||||
artifactData: artifactData)
|
||||
|
||||
#expect(
|
||||
AntigravityOAuthConfig.discoverClientFromInstalledApp(
|
||||
applicationRoots: [root]) == standaloneClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `oauth client discovery reads antigravity extension language server`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let extensionClient = AntigravityOAuthClient(
|
||||
clientID: self.googleClientID("extension"),
|
||||
clientSecret: self.googleClientSecret(repeating: "d"))
|
||||
let staleClient = AntigravityOAuthClient(
|
||||
clientID: self.googleClientID("stale"),
|
||||
clientSecret: self.googleClientSecret(repeating: "e"))
|
||||
try self.writeAntigravityApp(
|
||||
named: "Antigravity IDE.app",
|
||||
under: root,
|
||||
bundleIdentifier: "com.google.antigravity-ide",
|
||||
artifactRelativePath: "Contents/Resources/app/out/main.js",
|
||||
artifactData: Data("""
|
||||
out-build/vs/platform/cloudCode/common/oauthClient.js
|
||||
clientId="\(staleClient.clientID)";
|
||||
clientSecret="\(staleClient.clientSecret)";
|
||||
""".utf8))
|
||||
var artifactData = Data([0xFF])
|
||||
artifactData.append(Data(
|
||||
"""
|
||||
\u{0}\(extensionClient.clientSecret)\u{0}oauth_data\u{0}\(extensionClient.clientID)\u{0}
|
||||
""".utf8))
|
||||
try self.writeAntigravityApp(
|
||||
named: "Antigravity IDE.app",
|
||||
under: root,
|
||||
bundleIdentifier: "com.google.antigravity-ide",
|
||||
artifactRelativePath: "Contents/Resources/app/extensions/antigravity/bin/language_server_macos_arm",
|
||||
artifactData: artifactData)
|
||||
|
||||
#expect(
|
||||
AntigravityOAuthConfig.discoverClientFromInstalledApp(
|
||||
applicationRoots: [root]) == extensionClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `oauth client discovery pairs lone binary secret with trailing client id`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let standaloneClient = AntigravityOAuthClient(
|
||||
clientID: self.googleClientID("standalone"),
|
||||
clientSecret: self.googleClientSecret(repeating: "b"))
|
||||
let alternateClientID = self.googleClientID("alternate")
|
||||
var artifactData = Data([0xFF])
|
||||
artifactData.append(Data(
|
||||
"""
|
||||
\u{0}\(standaloneClient.clientSecret)\u{0}oauth_data\
|
||||
\u{0}\(alternateClientID)\u{0}\(standaloneClient.clientID)\u{0}
|
||||
""".utf8))
|
||||
try self.writeAntigravityApp(
|
||||
named: "Antigravity.app",
|
||||
under: root,
|
||||
artifactRelativePath: "Contents/Resources/bin/language_server",
|
||||
artifactData: artifactData)
|
||||
|
||||
#expect(
|
||||
AntigravityOAuthConfig.discoverClientFromInstalledApp(
|
||||
applicationRoots: [root]) == standaloneClient)
|
||||
}
|
||||
|
||||
private func writeAntigravityApp(
|
||||
named name: String,
|
||||
under root: URL,
|
||||
bundleIdentifier: String = "com.google.antigravity",
|
||||
artifactRelativePath: String,
|
||||
artifactData: Data) throws
|
||||
{
|
||||
let appURL = root.appendingPathComponent(name, isDirectory: true)
|
||||
let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: contentsURL,
|
||||
withIntermediateDirectories: true)
|
||||
let infoURL = contentsURL.appendingPathComponent("Info.plist")
|
||||
let infoData = try PropertyListSerialization.data(
|
||||
fromPropertyList: ["CFBundleIdentifier": bundleIdentifier],
|
||||
format: .xml,
|
||||
options: 0)
|
||||
try infoData.write(to: infoURL)
|
||||
|
||||
let artifactURL = appURL.appendingPathComponent(artifactRelativePath)
|
||||
try FileManager.default.createDirectory(
|
||||
at: artifactURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try artifactData.write(to: artifactURL)
|
||||
}
|
||||
|
||||
private func googleClientID(_ name: String) -> String {
|
||||
"123456789012-" + name + ".apps" + ".googleusercontent.com"
|
||||
}
|
||||
|
||||
private func googleClientSecret(repeating character: Character) -> String {
|
||||
"GOC" + "SPX-" + String(repeating: character, count: 28)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
private final class AntigravityQuotaSummaryPathRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var paths: [String] = []
|
||||
|
||||
func append(_ path: String) {
|
||||
self.lock.lock()
|
||||
self.paths.append(path)
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [String] {
|
||||
self.lock.lock()
|
||||
let snapshot = self.paths
|
||||
self.lock.unlock()
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
struct AntigravityQuotaSummaryTests {
|
||||
@Test
|
||||
func `parses quota summary response into two model groups with session before weekly windows`() throws {
|
||||
let snapshot = try AntigravityStatusProbe.parseQuotaSummaryResponse(
|
||||
Data(antigravityQuotaSummaryJSON().utf8))
|
||||
|
||||
#expect(snapshot.modelQuotas.isEmpty)
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
let windows = try #require(usage.extraRateWindows)
|
||||
|
||||
#expect(windows.map(\.id) == [
|
||||
"antigravity-quota-summary-gemini-5h",
|
||||
"antigravity-quota-summary-gemini-weekly",
|
||||
"antigravity-quota-summary-3p-5h",
|
||||
"antigravity-quota-summary-3p-weekly",
|
||||
])
|
||||
#expect(windows.map(\.title) == [
|
||||
"Gemini 5-hour",
|
||||
"Gemini weekly",
|
||||
"Claude/GPT 5-hour",
|
||||
"Claude/GPT weekly",
|
||||
])
|
||||
#expect(windows.map(\.window.windowMinutes) == [300, 10080, 300, 10080])
|
||||
#expect(windows.map { $0.window.remainingPercent.rounded() } == [91, 82, 73, 64])
|
||||
#expect(windows.map(\.usageKnown) == [true, true, true, true])
|
||||
|
||||
let expectedDates = [
|
||||
ISO8601DateFormatter().date(from: "2026-06-15T11:39:34Z"),
|
||||
ISO8601DateFormatter().date(from: "2026-06-19T08:45:39Z"),
|
||||
ISO8601DateFormatter().date(from: "2026-06-15T12:52:10Z"),
|
||||
ISO8601DateFormatter().date(from: "2026-06-20T00:39:54Z"),
|
||||
]
|
||||
#expect(windows.map(\.window.resetsAt) == expectedDates)
|
||||
|
||||
#expect(usage.primary?.remainingPercent.rounded() == 82)
|
||||
#expect(usage.secondary?.remainingPercent.rounded() == 64)
|
||||
#expect(usage.tertiary == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses quota summary oneof remaining value shape`() throws {
|
||||
let json = """
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"displayName": "Gemini Models",
|
||||
"buckets": [
|
||||
{
|
||||
"bucketId": "gemini-weekly",
|
||||
"displayName": "Weekly Limit",
|
||||
"remaining": { "case": "remainingFraction", "value": 0.5 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
let snapshot = try AntigravityStatusProbe.parseQuotaSummaryResponse(Data(json.utf8))
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.extraRateWindows?.first?.window.remainingPercent == 50)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fetch snapshot prefers quota summary endpoint and merges identity`() async throws {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64440,
|
||||
csrfToken: "token",
|
||||
source: .languageServer)
|
||||
let paths = AntigravityQuotaSummaryPathRecorder()
|
||||
|
||||
let snapshot = try await AntigravityStatusProbe.fetchSnapshot(
|
||||
context: AntigravityStatusProbe.RequestContext(endpoints: [endpoint], timeout: 1),
|
||||
send: { payload, _, _ in
|
||||
paths.append(payload.path)
|
||||
if payload.path.contains("GetUserStatus") {
|
||||
return Data(antigravityUserStatusJSON().utf8)
|
||||
}
|
||||
return Data(antigravityQuotaSummaryJSON().utf8)
|
||||
})
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(paths.snapshot() == [
|
||||
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary",
|
||||
"/exa.language_server_pb.LanguageServerService/GetUserStatus",
|
||||
])
|
||||
#expect(usage.extraRateWindows?.count == 4)
|
||||
#expect(usage.identity?.accountEmail == "test@example.com")
|
||||
#expect(usage.identity?.loginMethod == "Pro")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fetch snapshot keeps quota summary when identity endpoint fails`() async throws {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64440,
|
||||
csrfToken: "token",
|
||||
source: .languageServer)
|
||||
let paths = AntigravityQuotaSummaryPathRecorder()
|
||||
|
||||
let snapshot = try await AntigravityStatusProbe.fetchSnapshot(
|
||||
context: AntigravityStatusProbe.RequestContext(endpoints: [endpoint], timeout: 1),
|
||||
send: { payload, _, _ in
|
||||
paths.append(payload.path)
|
||||
if payload.path.contains("GetUserStatus") {
|
||||
return Data(#"{"code":16}"#.utf8)
|
||||
}
|
||||
return Data(antigravityQuotaSummaryJSON().utf8)
|
||||
})
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(paths.snapshot() == [
|
||||
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary",
|
||||
"/exa.language_server_pb.LanguageServerService/GetUserStatus",
|
||||
])
|
||||
#expect(usage.extraRateWindows?.count == 4)
|
||||
#expect(usage.identity?.accountEmail == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fetch snapshot falls back to user status when quota summary is unavailable`() async throws {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64440,
|
||||
csrfToken: "token",
|
||||
source: .languageServer)
|
||||
let paths = AntigravityQuotaSummaryPathRecorder()
|
||||
|
||||
let snapshot = try await AntigravityStatusProbe.fetchSnapshot(
|
||||
context: AntigravityStatusProbe.RequestContext(endpoints: [endpoint], timeout: 1),
|
||||
send: { payload, _, _ in
|
||||
paths.append(payload.path)
|
||||
if payload.path.contains("RetrieveUserQuotaSummary") {
|
||||
return Data(#"{"code":16}"#.utf8)
|
||||
}
|
||||
return Data(antigravityUserStatusJSON().utf8)
|
||||
})
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(paths.snapshot() == [
|
||||
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary",
|
||||
"/exa.language_server_pb.LanguageServerService/GetUserStatus",
|
||||
])
|
||||
#expect(usage.primary?.remainingPercent.rounded() == 90)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fetch snapshot falls back when quota summary has no known usage buckets`() async throws {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64440,
|
||||
csrfToken: "token",
|
||||
source: .languageServer)
|
||||
let paths = AntigravityQuotaSummaryPathRecorder()
|
||||
|
||||
let snapshot = try await AntigravityStatusProbe.fetchSnapshot(
|
||||
context: AntigravityStatusProbe.RequestContext(endpoints: [endpoint], timeout: 1),
|
||||
send: { payload, _, _ in
|
||||
paths.append(payload.path)
|
||||
if payload.path.contains("RetrieveUserQuotaSummary") {
|
||||
return Data(antigravityQuotaSummaryWithoutKnownUsageJSON().utf8)
|
||||
}
|
||||
return Data(antigravityUserStatusJSON().utf8)
|
||||
})
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(paths.snapshot() == [
|
||||
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary",
|
||||
"/exa.language_server_pb.LanguageServerService/GetUserStatus",
|
||||
])
|
||||
#expect(usage.primary?.remainingPercent.rounded() == 90)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `quota summary timeout reserves deadline for legacy fallback`() async throws {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64440,
|
||||
csrfToken: "token",
|
||||
source: .languageServer)
|
||||
let paths = AntigravityQuotaSummaryPathRecorder()
|
||||
|
||||
let snapshot = try await AntigravityStatusProbe.fetchSnapshot(
|
||||
context: AntigravityStatusProbe.RequestContext(
|
||||
endpoints: [endpoint],
|
||||
timeout: 1,
|
||||
deadline: Date().addingTimeInterval(2)),
|
||||
send: { payload, _, timeout in
|
||||
paths.append(payload.path)
|
||||
if payload.path.contains("RetrieveUserQuotaSummary") {
|
||||
try await Task.sleep(for: .seconds(timeout))
|
||||
throw AntigravityStatusProbeError.timedOut
|
||||
}
|
||||
return Data(antigravityUserStatusJSON().utf8)
|
||||
})
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(paths.snapshot() == [
|
||||
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary",
|
||||
"/exa.language_server_pb.LanguageServerService/GetUserStatus",
|
||||
])
|
||||
#expect(usage.primary?.remainingPercent.rounded() == 90)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `user status timeout reserves deadline for command model fallback`() async throws {
|
||||
let endpoint = AntigravityStatusProbe.AntigravityConnectionEndpoint(
|
||||
scheme: "https",
|
||||
port: 64440,
|
||||
csrfToken: "token",
|
||||
source: .languageServer)
|
||||
let paths = AntigravityQuotaSummaryPathRecorder()
|
||||
|
||||
let snapshot = try await AntigravityStatusProbe.fetchSnapshot(
|
||||
context: AntigravityStatusProbe.RequestContext(
|
||||
endpoints: [endpoint],
|
||||
timeout: 1,
|
||||
deadline: Date().addingTimeInterval(2)),
|
||||
send: { payload, _, timeout in
|
||||
paths.append(payload.path)
|
||||
if payload.path.contains("RetrieveUserQuotaSummary") {
|
||||
throw AntigravityStatusProbeError.apiError("unsupported")
|
||||
}
|
||||
if payload.path.contains("GetUserStatus") {
|
||||
try await Task.sleep(for: .seconds(timeout))
|
||||
throw AntigravityStatusProbeError.timedOut
|
||||
}
|
||||
return Data(antigravityCommandModelConfigJSON().utf8)
|
||||
})
|
||||
let usage = try snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(paths.snapshot() == [
|
||||
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary",
|
||||
"/exa.language_server_pb.LanguageServerService/GetUserStatus",
|
||||
"/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs",
|
||||
])
|
||||
#expect(usage.primary?.remainingPercent.rounded() == 90)
|
||||
}
|
||||
}
|
||||
|
||||
private func antigravityQuotaSummaryJSON() -> String {
|
||||
"""
|
||||
{
|
||||
"response": {
|
||||
"description": "Within each group, models share a weekly limit and a 5-hour limit.",
|
||||
"groups": [
|
||||
{
|
||||
"displayName": "Gemini Models",
|
||||
"description": "Models within this group: Gemini Flash, Gemini Pro",
|
||||
"buckets": [
|
||||
{
|
||||
"bucketId": "gemini-weekly",
|
||||
"displayName": "Weekly Limit",
|
||||
"remaining": { "remainingFraction": 0.82 },
|
||||
"description": "You have used some of your weekly limit, it will fully refresh in 5 days, 11 hours.",
|
||||
"resetTime": "2026-06-19T08:45:39Z"
|
||||
},
|
||||
{
|
||||
"bucketId": "gemini-5h",
|
||||
"displayName": "Five Hour Limit",
|
||||
"remaining": { "remainingFraction": 0.91 },
|
||||
"description": "You have used some of your 5-hour limit, it will fully refresh in 4 hours.",
|
||||
"resetTime": "2026-06-15T11:39:34Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"displayName": "Claude and GPT models",
|
||||
"description": "Models within this group: Claude Opus, Claude Sonnet, GPT-OSS",
|
||||
"buckets": [
|
||||
{
|
||||
"bucketId": "3p-weekly",
|
||||
"displayName": "Weekly Limit",
|
||||
"remaining": { "remainingFraction": 0.64 },
|
||||
"description": "You have used some of your weekly limit, it will fully refresh in 6 days, 22 hours.",
|
||||
"resetTime": "2026-06-20T00:39:54Z"
|
||||
},
|
||||
{
|
||||
"bucketId": "3p-5h",
|
||||
"displayName": "Five Hour Limit",
|
||||
"remaining": { "remainingFraction": 0.73 },
|
||||
"description": "You have used some of your 5-hour limit, it will fully refresh in 3 hours, 38 minutes.",
|
||||
"resetTime": "2026-06-15T12:52:10Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private func antigravityQuotaSummaryWithoutKnownUsageJSON() -> String {
|
||||
"""
|
||||
{
|
||||
"response": {
|
||||
"groups": [
|
||||
{
|
||||
"displayName": "Gemini Models",
|
||||
"buckets": [
|
||||
{
|
||||
"bucketId": "gemini-weekly",
|
||||
"displayName": "Weekly Limit",
|
||||
"description": "Refreshes later."
|
||||
},
|
||||
{
|
||||
"bucketId": "gemini-5h",
|
||||
"displayName": "Five Hour Limit",
|
||||
"disabled": true,
|
||||
"remaining": { "remainingFraction": 0.5 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private func antigravityUserStatusJSON() -> String {
|
||||
"""
|
||||
{
|
||||
"code": 0,
|
||||
"userStatus": {
|
||||
"email": "test@example.com",
|
||||
"planStatus": {
|
||||
"planInfo": {
|
||||
"planName": "Pro"
|
||||
}
|
||||
},
|
||||
"cascadeModelConfigData": {
|
||||
"clientModelConfigs": [
|
||||
{
|
||||
"label": "Gemini 3 Pro Low",
|
||||
"modelOrAlias": { "model": "gemini-3-pro-low" },
|
||||
"quotaInfo": { "remainingFraction": 0.9, "resetTime": "2025-12-24T10:00:00Z" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private func antigravityCommandModelConfigJSON() -> String {
|
||||
"""
|
||||
{
|
||||
"clientModelConfigs": [
|
||||
{
|
||||
"label": "Gemini 3 Pro Low",
|
||||
"modelOrAlias": { "model": "gemini-3-pro-low" },
|
||||
"quotaInfo": { "remainingFraction": 0.9, "resetTime": "2025-12-24T10:00:00Z" }
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AntigravityWarmAgyReuseTests {
|
||||
// MARK: - Helper-seam tests (tryWarmAgyFetch)
|
||||
|
||||
@Test
|
||||
func `warm agy found reuses ports without spawn`() async throws {
|
||||
let listeningPortsCallCount = AntigravityWarmLockedCounter()
|
||||
let fetchSnapshotCallCount = AntigravityWarmLockedCounter()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [Self.cliProcessInfo(pid: 9901)] },
|
||||
listeningPorts: { pid, _ in
|
||||
listeningPortsCallCount.increment()
|
||||
#expect(pid == 9901)
|
||||
return [56789]
|
||||
},
|
||||
fetchSnapshot: { ports, _ in
|
||||
fetchSnapshotCallCount.increment()
|
||||
#expect(ports == [56789])
|
||||
return Self.usableSnapshot(email: "warm@example.com")
|
||||
}))
|
||||
|
||||
#expect(result?.accountEmail == "warm@example.com")
|
||||
#expect(result?.modelQuotas.first?.modelId == "gemini-pro")
|
||||
#expect(listeningPortsCallCount.value == 1)
|
||||
#expect(fetchSnapshotCallCount.value == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `no warm agy returns nil`() async throws {
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [] },
|
||||
listeningPorts: { _, _ in
|
||||
Issue.record("listeningPorts must not be called when no warm agy found")
|
||||
return []
|
||||
},
|
||||
fetchSnapshot: { _, _ in
|
||||
Issue.record("fetchSnapshot must not be called when no warm agy found")
|
||||
throw AntigravityStatusProbeError.notRunning
|
||||
}))
|
||||
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `process infos throws returns nil`() async throws {
|
||||
// detectProcessInfos throws (e.g. .missingCSRFToken / .notRunning) — the
|
||||
// fast path must swallow it and let the caller fall back to spawning.
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in throw AntigravityStatusProbeError.missingCSRFToken },
|
||||
listeningPorts: { _, _ in
|
||||
Issue.record("listeningPorts must not be called when discovery throws")
|
||||
return []
|
||||
},
|
||||
fetchSnapshot: { _, _ in
|
||||
Issue.record("fetchSnapshot must not be called when discovery throws")
|
||||
throw AntigravityStatusProbeError.notRunning
|
||||
}))
|
||||
|
||||
#expect(result == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `warm agy fetch fails returns nil`() async throws {
|
||||
let fetchSnapshotCallCount = AntigravityWarmLockedCounter()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [Self.cliProcessInfo(pid: 7701)] },
|
||||
listeningPorts: { _, _ in [55555] },
|
||||
fetchSnapshot: { _, _ in
|
||||
fetchSnapshotCallCount.increment()
|
||||
throw AntigravityStatusProbeError.portDetectionFailed("endpoint not ready")
|
||||
}))
|
||||
|
||||
// Fetch fails → warm reuse returns nil → caller falls back to spawn
|
||||
#expect(result == nil)
|
||||
#expect(fetchSnapshotCallCount.value == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ide process ignored not reuseable as warm CLI`() async throws {
|
||||
// An IDE language server requires a CSRF token — must NOT be reused via
|
||||
// the token-less warm path.
|
||||
let ideProcessInfo = AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: 8801,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "abc123",
|
||||
commandLine:
|
||||
"/Applications/Antigravity IDE.app/Contents/Resources/language_server " +
|
||||
"--csrf_token abc123 --app_data_dir antigravity-ide")
|
||||
let fetchSnapshotCallCount = AntigravityWarmLockedCounter()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [ideProcessInfo] },
|
||||
listeningPorts: { _, _ in [44444] },
|
||||
fetchSnapshot: { _, _ in
|
||||
fetchSnapshotCallCount.increment()
|
||||
return Self.usableSnapshot(email: "ide@example.com")
|
||||
}))
|
||||
|
||||
#expect(result == nil)
|
||||
#expect(fetchSnapshotCallCount.value == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `owned agy excluded falls back to spawn path`() async throws {
|
||||
// CodexBar's own managed `agy` (pid 4242) appears in the process scan.
|
||||
// It must NOT be reused through the warm path — doing so would bypass the
|
||||
// session lifecycle and let `stopIfIdle` tear it down mid-poll.
|
||||
let fetchSnapshotCallCount = AntigravityWarmLockedCounter()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [Self.cliProcessInfo(pid: 4242)] },
|
||||
listeningPorts: { _, _ in
|
||||
Issue.record("listeningPorts must not be called for a CodexBar-owned agy")
|
||||
return []
|
||||
},
|
||||
fetchSnapshot: { _, _ in
|
||||
fetchSnapshotCallCount.increment()
|
||||
return Self.usableSnapshot(email: "owned@example.com")
|
||||
},
|
||||
ownedPID: { 4242 }))
|
||||
|
||||
#expect(result == nil)
|
||||
#expect(fetchSnapshotCallCount.value == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `external agy reused when owned also present`() async throws {
|
||||
// With both an owned `agy` (pid 4242) and an external one (pid 7000), only
|
||||
// the external server is reused; the owned pid is filtered out.
|
||||
let listeningPortsCallCount = AntigravityWarmLockedCounter()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [Self.cliProcessInfo(pid: 4242), Self.cliProcessInfo(pid: 7000)] },
|
||||
listeningPorts: { pid, _ in
|
||||
listeningPortsCallCount.increment()
|
||||
#expect(pid == 7000)
|
||||
return [50050]
|
||||
},
|
||||
fetchSnapshot: { _, _ in Self.usableSnapshot(email: "external@example.com") },
|
||||
ownedPID: { 4242 }))
|
||||
|
||||
#expect(result?.accountEmail == "external@example.com")
|
||||
#expect(listeningPortsCallCount.value == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `other user agy is ignored`() async throws {
|
||||
let listeningPIDs = AntigravityWarmLockedValues<Int>()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [Self.cliProcessInfo(pid: 6001), Self.cliProcessInfo(pid: 6002)] },
|
||||
listeningPorts: { pid, _ in
|
||||
listeningPIDs.append(pid)
|
||||
return [pid]
|
||||
},
|
||||
fetchSnapshot: { _, _ in Self.usableSnapshot(email: "same-user@example.com") },
|
||||
processOwnerUserID: { pid in pid == 6001 ? 502 : 501 },
|
||||
currentUserID: { 501 }))
|
||||
|
||||
#expect(result?.accountEmail == "same-user@example.com")
|
||||
#expect(listeningPIDs.value == [6002])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `account mismatch tries next warm agy`() async throws {
|
||||
let listeningPIDs = AntigravityWarmLockedValues<Int>()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
expectedAccountEmail: "selected@example.com",
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [Self.cliProcessInfo(pid: 6101), Self.cliProcessInfo(pid: 6102)] },
|
||||
listeningPorts: { pid, _ in
|
||||
listeningPIDs.append(pid)
|
||||
return [pid]
|
||||
},
|
||||
fetchSnapshot: { ports, _ in
|
||||
let email = ports == [6101] ? "other@example.com" : "SELECTED@example.com"
|
||||
return Self.usableSnapshot(email: email)
|
||||
}))
|
||||
|
||||
#expect(result?.accountEmail == "SELECTED@example.com")
|
||||
#expect(listeningPIDs.value == [6101, 6102])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `binary mismatch tries next warm agy`() async throws {
|
||||
let listeningPIDs = AntigravityWarmLockedValues<Int>()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
expectedBinaryPath: "/selected/agy",
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in
|
||||
[
|
||||
Self.cliProcessInfo(pid: 6151, binaryPath: "/other/agy"),
|
||||
Self.cliProcessInfo(pid: 6152, binaryPath: "/selected/agy"),
|
||||
]
|
||||
},
|
||||
listeningPorts: { pid, _ in
|
||||
listeningPIDs.append(pid)
|
||||
return [pid]
|
||||
},
|
||||
fetchSnapshot: { _, _ in Self.usableSnapshot(email: "selected@example.com") }))
|
||||
|
||||
#expect(result?.accountEmail == "selected@example.com")
|
||||
#expect(listeningPIDs.value == [6152])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `warm probe deadline is shared across discovery and candidates`() async throws {
|
||||
let clock = AntigravityWarmTestClock(date: Date(timeIntervalSince1970: 100))
|
||||
let listeningPortsCallCount = AntigravityWarmLockedCounter()
|
||||
let fetchSnapshotCallCount = AntigravityWarmLockedCounter()
|
||||
|
||||
let result = try await AntigravityCLIHTTPSFetchStrategy.tryWarmAgyFetch(
|
||||
timeout: 2.0,
|
||||
dependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { timeout in
|
||||
#expect(timeout == 2.0)
|
||||
clock.advance(by: 1.5)
|
||||
return [Self.cliProcessInfo(pid: 6201), Self.cliProcessInfo(pid: 6202)]
|
||||
},
|
||||
listeningPorts: { _, timeout in
|
||||
listeningPortsCallCount.increment()
|
||||
#expect(timeout == 0.5)
|
||||
clock.advance(by: 0.6)
|
||||
return [62010]
|
||||
},
|
||||
fetchSnapshot: { _, _ in
|
||||
fetchSnapshotCallCount.increment()
|
||||
return Self.usableSnapshot(email: "late@example.com")
|
||||
},
|
||||
now: { clock.now() }))
|
||||
|
||||
#expect(result == nil)
|
||||
#expect(listeningPortsCallCount.value == 1)
|
||||
#expect(fetchSnapshotCallCount.value == 0)
|
||||
}
|
||||
|
||||
// MARK: - Integration: fetchUsingWarmSession fast-path branch
|
||||
|
||||
@Test
|
||||
func `warm reuse skips spawn path`() async throws {
|
||||
let spawnCallCount = AntigravityWarmLockedCounter()
|
||||
let strategy = AntigravityCLIHTTPSFetchStrategy()
|
||||
|
||||
let result = try await strategy.fetchUsingWarmSession(
|
||||
binary: "/usr/local/bin/agy",
|
||||
idleWindow: nil,
|
||||
resetAfterFetch: true,
|
||||
warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [Self.cliProcessInfo(pid: 1234)] },
|
||||
listeningPorts: { _, _ in [40000] },
|
||||
fetchSnapshot: { _, _ in Self.usableSnapshot(email: "warm@example.com") }),
|
||||
spawnFetch: { _, _, _ in
|
||||
spawnCallCount.increment()
|
||||
Issue.record("spawn path must not run when a warm agy is reused")
|
||||
throw AntigravityStatusProbeError.notRunning
|
||||
})
|
||||
|
||||
#expect(result.usage.identity?.accountEmail == "warm@example.com")
|
||||
#expect(result.sourceLabel == AntigravityCLIHTTPSFetchStrategy.sourceLabel)
|
||||
// The warm path never touches AntigravityCLISession: the spawn seam (the
|
||||
// only place beginProbe/finishProbe run) was never invoked.
|
||||
#expect(spawnCallCount.value == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `no warm agy falls back to spawn path`() async throws {
|
||||
let spawnCallCount = AntigravityWarmLockedCounter()
|
||||
let strategy = AntigravityCLIHTTPSFetchStrategy()
|
||||
|
||||
let result = try await strategy.fetchUsingWarmSession(
|
||||
binary: "/usr/local/bin/agy",
|
||||
idleWindow: nil,
|
||||
resetAfterFetch: true,
|
||||
warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in [] },
|
||||
listeningPorts: { _, _ in [] },
|
||||
fetchSnapshot: { _, _ in throw AntigravityStatusProbeError.notRunning }),
|
||||
spawnFetch: { binary, _, resetAfterFetch in
|
||||
spawnCallCount.increment()
|
||||
#expect(binary == "/usr/local/bin/agy")
|
||||
#expect(resetAfterFetch)
|
||||
return strategy.makeResult(
|
||||
usage: Self.usableUsage(email: "spawned@example.com"),
|
||||
sourceLabel: AntigravityCLIHTTPSFetchStrategy.sourceLabel)
|
||||
})
|
||||
|
||||
#expect(result.usage.identity?.accountEmail == "spawned@example.com")
|
||||
#expect(spawnCallCount.value == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `warm probe cancellation does not fall back to spawn`() async {
|
||||
let spawnCallCount = AntigravityWarmLockedCounter()
|
||||
let strategy = AntigravityCLIHTTPSFetchStrategy()
|
||||
|
||||
do {
|
||||
_ = try await strategy.fetchUsingWarmSession(
|
||||
binary: "/usr/local/bin/agy",
|
||||
idleWindow: nil,
|
||||
resetAfterFetch: true,
|
||||
warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in throw CancellationError() },
|
||||
listeningPorts: { _, _ in [] },
|
||||
fetchSnapshot: { _, _ in throw AntigravityStatusProbeError.notRunning }),
|
||||
spawnFetch: { _, _, _ in
|
||||
spawnCallCount.increment()
|
||||
return strategy.makeResult(
|
||||
usage: Self.usableUsage(email: "spawned@example.com"),
|
||||
sourceLabel: AntigravityCLIHTTPSFetchStrategy.sourceLabel)
|
||||
})
|
||||
Issue.record("cancellation must be rethrown")
|
||||
} catch is CancellationError {
|
||||
// Expected: cancellation must not be downgraded to a warm miss.
|
||||
} catch {
|
||||
Issue.record("unexpected error: \(error)")
|
||||
}
|
||||
|
||||
#expect(spawnCallCount.value == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `long lived session skips external warm scan`() async throws {
|
||||
let spawnCallCount = AntigravityWarmLockedCounter()
|
||||
let strategy = AntigravityCLIHTTPSFetchStrategy()
|
||||
|
||||
let result = try await strategy.fetchUsingWarmSession(
|
||||
binary: "/usr/local/bin/agy",
|
||||
idleWindow: 60,
|
||||
resetAfterFetch: false,
|
||||
warmDependencies: AntigravityCLIHTTPSFetchStrategy.WarmAgyDependencies(
|
||||
processInfos: { _ in
|
||||
Issue.record("long-lived hosts must use their managed session")
|
||||
return [Self.cliProcessInfo(pid: 6301)]
|
||||
},
|
||||
listeningPorts: { _, _ in [] },
|
||||
fetchSnapshot: { _, _ in throw AntigravityStatusProbeError.notRunning }),
|
||||
spawnFetch: { _, _, resetAfterFetch in
|
||||
spawnCallCount.increment()
|
||||
#expect(!resetAfterFetch)
|
||||
return strategy.makeResult(
|
||||
usage: Self.usableUsage(email: "managed@example.com"),
|
||||
sourceLabel: AntigravityCLIHTTPSFetchStrategy.sourceLabel)
|
||||
})
|
||||
|
||||
#expect(result.usage.identity?.accountEmail == "managed@example.com")
|
||||
#expect(spawnCallCount.value == 1)
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func cliProcessInfo(
|
||||
pid: Int,
|
||||
binaryPath: String = "/usr/local/bin/agy") -> AntigravityStatusProbe.ProcessInfoResult
|
||||
{
|
||||
AntigravityStatusProbe.ProcessInfoResult(
|
||||
pid: pid,
|
||||
extensionPort: nil,
|
||||
extensionServerCSRFToken: nil,
|
||||
csrfToken: "",
|
||||
commandLine: binaryPath)
|
||||
}
|
||||
|
||||
private static func usableSnapshot(email: String) -> AntigravityStatusSnapshot {
|
||||
AntigravityStatusSnapshot(
|
||||
modelQuotas: [
|
||||
AntigravityModelQuota(
|
||||
label: "Gemini Pro",
|
||||
modelId: "gemini-pro",
|
||||
remainingFraction: 0.8,
|
||||
resetTime: nil,
|
||||
resetDescription: nil),
|
||||
],
|
||||
accountEmail: email,
|
||||
accountPlan: "Pro",
|
||||
source: .local)
|
||||
}
|
||||
|
||||
private static func usableUsage(email: String) -> UsageSnapshot {
|
||||
(try? self.usableSnapshot(email: email).toUsageSnapshot())
|
||||
?? UsageSnapshot(
|
||||
primary: nil,
|
||||
secondary: nil,
|
||||
tertiary: nil,
|
||||
updatedAt: Date(),
|
||||
identity: ProviderIdentitySnapshot(
|
||||
providerID: .antigravity,
|
||||
accountEmail: email,
|
||||
accountOrganization: nil,
|
||||
loginMethod: nil))
|
||||
}
|
||||
}
|
||||
|
||||
private final class AntigravityWarmLockedCounter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
@discardableResult
|
||||
func increment() -> Int {
|
||||
self.lock.withLock {
|
||||
self.count += 1
|
||||
return self.count
|
||||
}
|
||||
}
|
||||
|
||||
var value: Int {
|
||||
self.lock.withLock { self.count }
|
||||
}
|
||||
}
|
||||
|
||||
private final class AntigravityWarmLockedValues<Value: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var values: [Value] = []
|
||||
|
||||
func append(_ value: Value) {
|
||||
self.lock.withLock {
|
||||
self.values.append(value)
|
||||
}
|
||||
}
|
||||
|
||||
var value: [Value] {
|
||||
self.lock.withLock { self.values }
|
||||
}
|
||||
}
|
||||
|
||||
private final class AntigravityWarmTestClock: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var date: Date
|
||||
|
||||
init(date: Date) {
|
||||
self.date = date
|
||||
}
|
||||
|
||||
func now() -> Date {
|
||||
self.lock.withLock { self.date }
|
||||
}
|
||||
|
||||
func advance(by interval: TimeInterval) {
|
||||
self.lock.withLock {
|
||||
self.date = self.date.addingTimeInterval(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
@MainActor
|
||||
struct AppDelegateTests {
|
||||
@Test
|
||||
func `builds status controller after launch`() {
|
||||
let appDelegate = AppDelegate()
|
||||
var factoryCalls = 0
|
||||
var ttyShutdowns = 0
|
||||
let dummyStatusController = DummyStatusController()
|
||||
let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator()
|
||||
|
||||
let settings = SettingsStore(
|
||||
configStore: testConfigStore(suiteName: "AppDelegateTests"),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
|
||||
let account = fetcher.loadAccountInfo()
|
||||
let promotionCoordinator = CodexAccountPromotionCoordinator(
|
||||
settingsStore: settings,
|
||||
usageStore: store,
|
||||
managedAccountCoordinator: managedCodexAccountCoordinator)
|
||||
appDelegate.terminateActiveProcessesForAppShutdown = {
|
||||
ttyShutdowns += 1
|
||||
}
|
||||
|
||||
// Install a test factory that records invocations without touching NSStatusBar.
|
||||
StatusItemController.factory = { _, _, _, _, _, receivedManagedCoordinator, receivedPromotionCoordinator in
|
||||
factoryCalls += 1
|
||||
#expect(receivedManagedCoordinator === managedCodexAccountCoordinator)
|
||||
#expect(receivedPromotionCoordinator === promotionCoordinator)
|
||||
return dummyStatusController
|
||||
}
|
||||
defer { StatusItemController.factory = StatusItemController.defaultFactory }
|
||||
|
||||
// configure should not eagerly construct the status controller
|
||||
appDelegate.configure(.init(
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: account,
|
||||
selection: PreferencesSelection(),
|
||||
managedCodexAccountCoordinator: managedCodexAccountCoordinator,
|
||||
codexAccountPromotionCoordinator: promotionCoordinator))
|
||||
#expect(factoryCalls == 0)
|
||||
|
||||
// construction happens once after launch
|
||||
appDelegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification))
|
||||
#expect(factoryCalls == 1)
|
||||
|
||||
// idempotent on subsequent calls
|
||||
appDelegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification))
|
||||
#expect(factoryCalls == 1)
|
||||
|
||||
// production termination should ask the status controller to detach AppKit status/menu state
|
||||
appDelegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification))
|
||||
#expect(dummyStatusController.shutdowns == 1)
|
||||
#expect(ttyShutdowns == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class DummyStatusController: StatusItemControlling {
|
||||
private(set) var shutdowns = 0
|
||||
|
||||
func openMenuFromShortcut() {}
|
||||
func runLoginFlowFromSettings(provider _: UsageProvider) async {}
|
||||
func prepareForAppShutdown() {
|
||||
self.shutdowns += 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct AppGroupSupportTests {
|
||||
@Test
|
||||
func `app group identifiers use resolved team-prefixed release and debug variants`() {
|
||||
#expect(
|
||||
AppGroupSupport.currentGroupID(teamID: "Y5PE65HELJ", bundleID: "com.steipete.codexbar")
|
||||
== "Y5PE65HELJ.com.steipete.codexbar")
|
||||
#expect(
|
||||
AppGroupSupport.currentGroupID(teamID: "ABCDE12345", bundleID: "com.steipete.codexbar.debug")
|
||||
== "ABCDE12345.com.steipete.codexbar.debug")
|
||||
#expect(
|
||||
AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar")
|
||||
== "group.com.steipete.codexbar")
|
||||
#expect(
|
||||
AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar.debug")
|
||||
== "group.com.steipete.codexbar.debug")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolved team id falls back to plist and then default`() {
|
||||
#expect(
|
||||
AppGroupSupport.resolvedTeamID(
|
||||
infoDictionaryOverride: [AppGroupSupport.teamIDInfoKey: "ABCDE12345"],
|
||||
bundleURLOverride: nil) == "ABCDE12345")
|
||||
#expect(
|
||||
AppGroupSupport.resolvedTeamID(
|
||||
infoDictionaryOverride: nil,
|
||||
bundleURLOverride: nil) == AppGroupSupport.defaultTeamID)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy migration copies snapshot once`() throws {
|
||||
let fileManager = FileManager.default
|
||||
let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: root) }
|
||||
|
||||
let standardSuite = "AppGroupSupportTests-standard-\(UUID().uuidString)"
|
||||
let currentSuite = "AppGroupSupportTests-current-\(UUID().uuidString)"
|
||||
let legacySuite = "AppGroupSupportTests-legacy-\(UUID().uuidString)"
|
||||
|
||||
let standardDefaults = try #require(UserDefaults(suiteName: standardSuite))
|
||||
let currentDefaults = try #require(UserDefaults(suiteName: currentSuite))
|
||||
let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite))
|
||||
standardDefaults.removePersistentDomain(forName: standardSuite)
|
||||
currentDefaults.removePersistentDomain(forName: currentSuite)
|
||||
legacyDefaults.removePersistentDomain(forName: legacySuite)
|
||||
|
||||
legacyDefaults.set(true, forKey: "debugDisableKeychainAccess")
|
||||
legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider")
|
||||
|
||||
let legacySnapshotURL = root.appendingPathComponent(
|
||||
"legacy/widget-snapshot.json",
|
||||
isDirectory: false)
|
||||
try fileManager.createDirectory(
|
||||
at: legacySnapshotURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try Data("legacy-snapshot".utf8).write(to: legacySnapshotURL)
|
||||
|
||||
let currentSnapshotURL = root.appendingPathComponent("current/widget-snapshot.json", isDirectory: false)
|
||||
let result = AppGroupSupport.migrateLegacyDataIfNeeded(
|
||||
bundleID: "com.steipete.codexbar",
|
||||
standardDefaults: standardDefaults,
|
||||
currentDefaultsOverride: currentDefaults,
|
||||
legacyDefaultsOverride: legacyDefaults,
|
||||
currentSnapshotURLOverride: currentSnapshotURL,
|
||||
legacySnapshotURLOverride: legacySnapshotURL)
|
||||
|
||||
#expect(result.status == .migrated)
|
||||
#expect(result.copiedSnapshot)
|
||||
#expect(result.copiedDefaults == 2)
|
||||
#expect(currentDefaults.bool(forKey: "debugDisableKeychainAccess"))
|
||||
#expect(currentDefaults.string(forKey: "widgetSelectedProvider") == UsageProvider.cursor.rawValue)
|
||||
#expect(fileManager.fileExists(atPath: currentSnapshotURL.path))
|
||||
#expect(
|
||||
standardDefaults.integer(forKey: AppGroupSupport.migrationVersionKey)
|
||||
== AppGroupSupport.migrationVersion)
|
||||
|
||||
let secondResult = AppGroupSupport.migrateLegacyDataIfNeeded(
|
||||
bundleID: "com.steipete.codexbar",
|
||||
standardDefaults: standardDefaults,
|
||||
currentDefaultsOverride: currentDefaults,
|
||||
legacyDefaultsOverride: legacyDefaults,
|
||||
currentSnapshotURLOverride: currentSnapshotURL,
|
||||
legacySnapshotURLOverride: legacySnapshotURL)
|
||||
#expect(secondResult.status == .alreadyCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy migration preserves existing target shared defaults`() throws {
|
||||
let standardSuite = "AppGroupSupportTests-standard-existing-\(UUID().uuidString)"
|
||||
let currentSuite = "AppGroupSupportTests-current-existing-\(UUID().uuidString)"
|
||||
let legacySuite = "AppGroupSupportTests-legacy-existing-\(UUID().uuidString)"
|
||||
|
||||
let standardDefaults = try #require(UserDefaults(suiteName: standardSuite))
|
||||
let currentDefaults = try #require(UserDefaults(suiteName: currentSuite))
|
||||
let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite))
|
||||
standardDefaults.removePersistentDomain(forName: standardSuite)
|
||||
currentDefaults.removePersistentDomain(forName: currentSuite)
|
||||
legacyDefaults.removePersistentDomain(forName: legacySuite)
|
||||
|
||||
currentDefaults.set(false, forKey: "debugDisableKeychainAccess")
|
||||
currentDefaults.set(UsageProvider.codex.rawValue, forKey: "widgetSelectedProvider")
|
||||
legacyDefaults.set(true, forKey: "debugDisableKeychainAccess")
|
||||
legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider")
|
||||
|
||||
let result = AppGroupSupport.migrateLegacyDataIfNeeded(
|
||||
bundleID: "com.steipete.codexbar",
|
||||
standardDefaults: standardDefaults,
|
||||
currentDefaultsOverride: currentDefaults,
|
||||
legacyDefaultsOverride: legacyDefaults)
|
||||
|
||||
#expect(result.status == .noChangesNeeded)
|
||||
#expect(result.copiedDefaults == 0)
|
||||
#expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess"))
|
||||
#expect(currentDefaults.string(forKey: "widgetSelectedProvider") == UsageProvider.codex.rawValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
struct AuggieCLIProbeParseTests {
|
||||
private let probe = AuggieCLIProbe()
|
||||
|
||||
@Test
|
||||
func `parses current auggie account status output`() throws {
|
||||
let output = """
|
||||
╭ Account ───────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ 319,054 credits remaining Max Plan │
|
||||
│ 450,000 credits / month │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────╯
|
||||
|
||||
9 days remaining in this billing cycle (ends 6/9/2026)
|
||||
For more detail, visit https://app.augmentcode.com/account
|
||||
"""
|
||||
|
||||
let snapshot = try probe.parse(output)
|
||||
|
||||
#expect(snapshot.creditsRemaining == 319_054)
|
||||
#expect(snapshot.creditsLimit == 450_000)
|
||||
#expect(snapshot.creditsUsed == 130_946)
|
||||
#expect(snapshot.accountPlan == "\(450_000.formatted()) credits/month")
|
||||
#expect(snapshot.billingCycleEnd != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses legacy auggie account status output`() throws {
|
||||
let output = """
|
||||
Max Plan 450,000 credits / month
|
||||
11,657 remaining · 953,170 / 964,827 credits used
|
||||
2 days remaining in this billing cycle (ends 1/8/2026)
|
||||
"""
|
||||
|
||||
let snapshot = try probe.parse(output)
|
||||
|
||||
#expect(snapshot.creditsRemaining == 11657)
|
||||
#expect(snapshot.creditsUsed == 953_170)
|
||||
#expect(snapshot.creditsLimit == 964_827)
|
||||
#expect(snapshot.accountPlan == "\(450_000.formatted()) credits/month")
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
/// Regression tests for #474: verify that CLI timeout errors trigger fallback
|
||||
/// to the web strategy instead of stalling the refresh cycle.
|
||||
struct AugmentCLIFetchStrategyFallbackTests {
|
||||
private struct StubClaudeFetcher: ClaudeUsageFetching {
|
||||
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
|
||||
throw ClaudeUsageError.parseFailed("stub")
|
||||
}
|
||||
|
||||
func debugRawProbe(model _: String) async -> String {
|
||||
"stub"
|
||||
}
|
||||
|
||||
func detectVersion() -> String? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext {
|
||||
let env: [String: String] = [:]
|
||||
return ProviderFetchContext(
|
||||
runtime: .app,
|
||||
sourceMode: sourceMode,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: env,
|
||||
settings: nil,
|
||||
fetcher: UsageFetcher(environment: env),
|
||||
claudeFetcher: StubClaudeFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0))
|
||||
}
|
||||
|
||||
// SubprocessRunnerError is not an AuggieCLIError, so it hits the default
|
||||
// fallback=true path — the desired behavior for infrastructure errors.
|
||||
|
||||
@Test
|
||||
func `timeout error falls back to web`() {
|
||||
let strategy = AugmentCLIFetchStrategy()
|
||||
let context = self.makeContext()
|
||||
let error = SubprocessRunnerError.timedOut("auggie-account-status")
|
||||
#expect(strategy.shouldFallback(on: error, context: context) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `binary not found falls back to web`() {
|
||||
let strategy = AugmentCLIFetchStrategy()
|
||||
let context = self.makeContext()
|
||||
let error = SubprocessRunnerError.binaryNotFound("/usr/local/bin/auggie")
|
||||
#expect(strategy.shouldFallback(on: error, context: context) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `launch failed falls back to web`() {
|
||||
let strategy = AugmentCLIFetchStrategy()
|
||||
let context = self.makeContext()
|
||||
let error = SubprocessRunnerError.launchFailed("permission denied")
|
||||
#expect(strategy.shouldFallback(on: error, context: context) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `not authenticated falls back to web`() {
|
||||
let strategy = AugmentCLIFetchStrategy()
|
||||
let context = self.makeContext()
|
||||
#expect(strategy.shouldFallback(on: AuggieCLIError.notAuthenticated, context: context) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `no output falls back to web`() {
|
||||
let strategy = AugmentCLIFetchStrategy()
|
||||
let context = self.makeContext()
|
||||
#expect(strategy.shouldFallback(on: AuggieCLIError.noOutput, context: context) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse error falls back to web`() {
|
||||
let strategy = AugmentCLIFetchStrategy()
|
||||
let context = self.makeContext()
|
||||
#expect(strategy.shouldFallback(on: AuggieCLIError.parseError("bad data"), context: context) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `non zero exit falls back to web`() {
|
||||
let strategy = AugmentCLIFetchStrategy()
|
||||
let context = self.makeContext()
|
||||
let error = SubprocessRunnerError.nonZeroExit(code: 1, stderr: "crash")
|
||||
#expect(strategy.shouldFallback(on: error, context: context) == true)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,42 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct AugmentProviderRuntimeTests {
|
||||
@Test
|
||||
func `repeated stop only reports a running keepalive once`() throws {
|
||||
let suite = "AugmentProviderRuntimeTests-\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: testConfigStore(suiteName: suite),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore(),
|
||||
augmentCookieStore: InMemoryCookieHeaderStore())
|
||||
let metadata = try #require(ProviderRegistry.shared.metadata[.augment])
|
||||
settings.setProviderEnabled(provider: .augment, metadata: metadata, enabled: true)
|
||||
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
startupBehavior: .testing)
|
||||
let runtime = AugmentProviderRuntime()
|
||||
let context = ProviderRuntimeContext(provider: .augment, settings: settings, store: store)
|
||||
defer { runtime.stop(context: context) }
|
||||
|
||||
runtime.start(context: context)
|
||||
#expect(runtime._test_isKeepaliveRunning)
|
||||
runtime.stop(context: context)
|
||||
settings.setProviderEnabled(provider: .augment, metadata: metadata, enabled: false)
|
||||
runtime.stop(context: context)
|
||||
runtime.settingsDidChange(context: context)
|
||||
|
||||
#expect(!runtime._test_isKeepaliveRunning)
|
||||
#expect(runtime._test_keepaliveStopCount == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import XCTest
|
||||
@testable import CodexBarCore
|
||||
|
||||
final class AugmentStatusProbeTests: XCTestCase {
|
||||
private func failingProbe() throws -> AugmentStatusProbe {
|
||||
try AugmentStatusProbe(baseURL: XCTUnwrap(URL(string: "http://127.0.0.1:1")), timeout: 0.1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func test_sessionKeepaliveStartLogsActualIntervals() {
|
||||
var messages: [String] = []
|
||||
let keepalive = AugmentSessionKeepalive { message in
|
||||
messages.append(message)
|
||||
}
|
||||
|
||||
keepalive.start()
|
||||
defer { keepalive.stop() }
|
||||
|
||||
XCTAssertTrue(messages.contains { $0.contains("Check interval: 60s (1 minute)") })
|
||||
XCTAssertTrue(messages.contains { $0.contains("Refresh buffer: 300s (5 minutes before expiry)") })
|
||||
XCTAssertTrue(messages.contains { $0.contains("Min refresh interval: 60s (1 minute)") })
|
||||
XCTAssertFalse(messages.contains { $0.contains("every 5 minutes") })
|
||||
XCTAssertFalse(messages.contains { $0.contains("2 minutes") })
|
||||
}
|
||||
|
||||
func test_debugRawProbe_returnsFormattedOutput() async throws {
|
||||
// Given: A probe instance
|
||||
let probe = try self.failingProbe()
|
||||
|
||||
// When: We call debugRawProbe
|
||||
let output = await probe.debugRawProbe(cookieHeaderOverride: "session=test")
|
||||
|
||||
// Then: The output should contain expected debug information
|
||||
XCTAssertTrue(output.contains("=== Augment Debug Probe @"), "Should contain debug header")
|
||||
XCTAssertTrue(
|
||||
output.contains("Probe Success") || output.contains("Probe Failed"),
|
||||
"Should contain probe result status")
|
||||
}
|
||||
|
||||
func test_latestDumps_initiallyEmpty() async {
|
||||
// Note: This test may fail if other tests have already run and captured dumps
|
||||
// The ring buffer is shared across all tests in the process
|
||||
// When: We request latest dumps
|
||||
let dumps = await AugmentStatusProbe.latestDumps()
|
||||
|
||||
// Then: Should either be empty or contain previous test dumps
|
||||
// We just verify it returns a non-empty string
|
||||
XCTAssertFalse(dumps.isEmpty, "Should return a string (either empty message or dumps)")
|
||||
}
|
||||
|
||||
func test_debugRawProbe_capturesFailureInDumps() async throws {
|
||||
// Given: A probe with an invalid base URL that will fail
|
||||
let invalidProbe = try self.failingProbe()
|
||||
|
||||
// When: We call debugRawProbe which should fail
|
||||
let output = await invalidProbe.debugRawProbe(cookieHeaderOverride: "session=test")
|
||||
|
||||
// Then: The output should indicate failure
|
||||
XCTAssertTrue(output.contains("Probe Failed"), "Should contain failure message")
|
||||
|
||||
// And: The failure should be captured in dumps
|
||||
let dumps = await AugmentStatusProbe.latestDumps()
|
||||
XCTAssertNotEqual(dumps, "No Augment probe dumps captured yet.", "Should have captured the failure")
|
||||
XCTAssertTrue(dumps.contains("Probe Failed"), "Dumps should contain the failure")
|
||||
}
|
||||
|
||||
func test_latestDumps_maintainsRingBuffer() async throws {
|
||||
// Given: Multiple failed probes to fill the ring buffer
|
||||
let invalidProbe = try self.failingProbe()
|
||||
|
||||
// When: We generate more than 5 dumps (the ring buffer size)
|
||||
for _ in 1...7 {
|
||||
_ = await invalidProbe.debugRawProbe(cookieHeaderOverride: "session=test")
|
||||
// Small delay to ensure different timestamps
|
||||
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
}
|
||||
|
||||
// Then: The dumps should only contain the most recent 5
|
||||
let dumps = await AugmentStatusProbe.latestDumps()
|
||||
let separatorCount = dumps.components(separatedBy: "\n\n---\n\n").count
|
||||
XCTAssertLessThanOrEqual(separatorCount, 5, "Should maintain at most 5 dumps in ring buffer")
|
||||
}
|
||||
|
||||
func test_debugRawProbe_includesTimestamp() async throws {
|
||||
// Given: A probe instance
|
||||
let probe = try self.failingProbe()
|
||||
|
||||
// When: We call debugRawProbe
|
||||
let output = await probe.debugRawProbe(cookieHeaderOverride: "session=test")
|
||||
|
||||
// Then: The output should include an ISO8601 timestamp
|
||||
XCTAssertTrue(output.contains("@"), "Should contain timestamp marker")
|
||||
XCTAssertTrue(output.contains("==="), "Should contain debug header markers")
|
||||
}
|
||||
|
||||
func test_debugRawProbe_includesCreditsBalance() async throws {
|
||||
// Given: A probe instance
|
||||
let probe = try self.failingProbe()
|
||||
|
||||
// When: We call debugRawProbe
|
||||
let output = await probe.debugRawProbe(cookieHeaderOverride: "session=test")
|
||||
|
||||
// Then: The output should mention credits balance (either in success or failure)
|
||||
XCTAssertTrue(
|
||||
output.contains("Credits Balance") || output.contains("Probe Failed"),
|
||||
"Should contain credits information or failure message")
|
||||
}
|
||||
|
||||
func test_creditsLimit_prefersUsageUnitsAvailable() throws {
|
||||
let response = try JSONDecoder().decode(AugmentCreditsResponse.self, from: Data("""
|
||||
{
|
||||
"usageUnitsRemaining": 15,
|
||||
"usageUnitsConsumedThisBillingCycle": 10,
|
||||
"usageUnitsAvailable": 100,
|
||||
"usageBalanceStatus": "active"
|
||||
}
|
||||
""".utf8))
|
||||
|
||||
XCTAssertEqual(response.creditsLimit, 100)
|
||||
}
|
||||
|
||||
func test_creditsLimit_fallsBackToRemainingPlusConsumedWhenAvailableMissing() throws {
|
||||
let response = try JSONDecoder().decode(AugmentCreditsResponse.self, from: Data("""
|
||||
{
|
||||
"usageUnitsRemaining": 15,
|
||||
"usageUnitsConsumedThisBillingCycle": 10,
|
||||
"usageBalanceStatus": "active"
|
||||
}
|
||||
""".utf8))
|
||||
|
||||
XCTAssertEqual(response.creditsLimit, 25)
|
||||
}
|
||||
|
||||
func test_creditsLimit_ignoresZeroAvailableValue() throws {
|
||||
let response = try JSONDecoder().decode(AugmentCreditsResponse.self, from: Data("""
|
||||
{
|
||||
"usageUnitsRemaining": 15,
|
||||
"usageUnitsConsumedThisBillingCycle": 10,
|
||||
"usageUnitsAvailable": 0,
|
||||
"usageBalanceStatus": "active"
|
||||
}
|
||||
""".utf8))
|
||||
|
||||
XCTAssertEqual(response.creditsLimit, 25)
|
||||
}
|
||||
|
||||
// MARK: - Cookie Domain Filtering Tests
|
||||
|
||||
func test_cookieDomainMatching_exactMatch() throws {
|
||||
// Given: A session with a cookie that has exact domain match
|
||||
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "app.augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "session",
|
||||
.value: "test123",
|
||||
]))
|
||||
let session = AugmentCookieImporter.SessionInfo(
|
||||
cookies: [cookie],
|
||||
sourceLabel: "Test")
|
||||
let targetURL = try XCTUnwrap(URL(string: "https://app.augmentcode.com/api/credits"))
|
||||
|
||||
// When: We get the cookie header for the target URL
|
||||
let cookieHeader = session.cookieHeader(for: targetURL)
|
||||
|
||||
// Then: It should include the cookie
|
||||
XCTAssertEqual(cookieHeader, "session=test123", "Cookie with exact domain should match")
|
||||
}
|
||||
|
||||
func test_cookieDomainMatching_parentDomain() throws {
|
||||
// Given: A session with a cookie that has parent domain
|
||||
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "session",
|
||||
.value: "test123",
|
||||
]))
|
||||
let session = AugmentCookieImporter.SessionInfo(
|
||||
cookies: [cookie],
|
||||
sourceLabel: "Test")
|
||||
let targetURL = try XCTUnwrap(URL(string: "https://app.augmentcode.com/api/credits"))
|
||||
|
||||
// When: We get the cookie header for the target URL
|
||||
let cookieHeader = session.cookieHeader(for: targetURL)
|
||||
|
||||
// Then: It should include the cookie (parent domain matches subdomain)
|
||||
XCTAssertEqual(cookieHeader, "session=test123", "Cookie with parent domain should match subdomain")
|
||||
}
|
||||
|
||||
func test_cookieDomainMatching_wildcardDomain() throws {
|
||||
// Given: A session with a cookie that has wildcard domain
|
||||
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: ".augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "session",
|
||||
.value: "test123",
|
||||
]))
|
||||
let session = AugmentCookieImporter.SessionInfo(
|
||||
cookies: [cookie],
|
||||
sourceLabel: "Test")
|
||||
let targetURL = try XCTUnwrap(URL(string: "https://app.augmentcode.com/api/credits"))
|
||||
|
||||
// When: We get the cookie header for the target URL
|
||||
let cookieHeader = session.cookieHeader(for: targetURL)
|
||||
|
||||
// Then: It should include the cookie
|
||||
XCTAssertEqual(cookieHeader, "session=test123", "Cookie with wildcard domain should match")
|
||||
}
|
||||
|
||||
func test_cookieDomainMatching_wrongDomain() throws {
|
||||
// Given: A session with a cookie from a different subdomain
|
||||
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "auth.augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "auth_token",
|
||||
.value: "test123",
|
||||
]))
|
||||
let session = AugmentCookieImporter.SessionInfo(
|
||||
cookies: [cookie],
|
||||
sourceLabel: "Test")
|
||||
let targetURL = try XCTUnwrap(URL(string: "https://app.augmentcode.com/api/credits"))
|
||||
|
||||
// When: We get the cookie header for the target URL
|
||||
let cookieHeader = session.cookieHeader(for: targetURL)
|
||||
|
||||
// Then: It should NOT include the cookie
|
||||
XCTAssertTrue(cookieHeader.isEmpty, "Cookie from different subdomain should not match")
|
||||
}
|
||||
|
||||
func test_cookieDomainMatching_differentBaseDomain() throws {
|
||||
// Given: A session with a cookie from a completely different domain
|
||||
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "example.com",
|
||||
.path: "/",
|
||||
.name: "session",
|
||||
.value: "test123",
|
||||
]))
|
||||
let session = AugmentCookieImporter.SessionInfo(
|
||||
cookies: [cookie],
|
||||
sourceLabel: "Test")
|
||||
let targetURL = try XCTUnwrap(URL(string: "https://app.augmentcode.com/api/credits"))
|
||||
|
||||
// When: We get the cookie header for the target URL
|
||||
let cookieHeader = session.cookieHeader(for: targetURL)
|
||||
|
||||
// Then: It should NOT include the cookie
|
||||
XCTAssertTrue(cookieHeader.isEmpty, "Cookie from different base domain should not match")
|
||||
}
|
||||
|
||||
func test_cookieHeader_filtersCorrectly() throws {
|
||||
// Given: A session with multiple cookies from different domains
|
||||
let cookies = try [
|
||||
XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "app.augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "session",
|
||||
.value: "valid1",
|
||||
])),
|
||||
XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: ".augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "_session",
|
||||
.value: "valid2",
|
||||
])),
|
||||
XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "auth.augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "auth_token",
|
||||
.value: "invalid1",
|
||||
])),
|
||||
XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "billing.augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "billing_session",
|
||||
.value: "invalid2",
|
||||
])),
|
||||
]
|
||||
|
||||
let session = AugmentCookieImporter.SessionInfo(
|
||||
cookies: cookies,
|
||||
sourceLabel: "Test")
|
||||
|
||||
let targetURL = try XCTUnwrap(URL(string: "https://app.augmentcode.com/api/credits"))
|
||||
|
||||
// When: We get the cookie header for the target URL
|
||||
let cookieHeader = session.cookieHeader(for: targetURL)
|
||||
|
||||
// Then: It should only include cookies valid for app.augmentcode.com
|
||||
XCTAssertTrue(cookieHeader.contains("session=valid1"), "Should include exact domain match")
|
||||
XCTAssertTrue(cookieHeader.contains("_session=valid2"), "Should include wildcard domain match")
|
||||
XCTAssertFalse(cookieHeader.contains("auth_token"), "Should NOT include auth subdomain cookie")
|
||||
XCTAssertFalse(cookieHeader.contains("billing_session"), "Should NOT include billing subdomain cookie")
|
||||
}
|
||||
|
||||
func test_cookieHeader_emptyWhenNoCookiesMatch() throws {
|
||||
// Given: A session with cookies that don't match the target domain
|
||||
let cookies = try [
|
||||
XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "auth.augmentcode.com",
|
||||
.path: "/",
|
||||
.name: "auth_token",
|
||||
.value: "test",
|
||||
])),
|
||||
XCTUnwrap(HTTPCookie(properties: [
|
||||
.domain: "example.com",
|
||||
.path: "/",
|
||||
.name: "other",
|
||||
.value: "test",
|
||||
])),
|
||||
]
|
||||
|
||||
let session = AugmentCookieImporter.SessionInfo(
|
||||
cookies: cookies,
|
||||
sourceLabel: "Test")
|
||||
|
||||
let targetURL = try XCTUnwrap(URL(string: "https://app.augmentcode.com/api/credits"))
|
||||
|
||||
// When: We get the cookie header for the target URL
|
||||
let cookieHeader = session.cookieHeader(for: targetURL)
|
||||
|
||||
// Then: It should be empty
|
||||
XCTAssertTrue(cookieHeader.isEmpty, "Should return empty string when no cookies match")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
struct AzureOpenAIUsageFetcherTests {
|
||||
private func makeContext(environment: [String: String]) -> ProviderFetchContext {
|
||||
let browserDetection = BrowserDetection(cacheTTL: 0)
|
||||
return ProviderFetchContext(
|
||||
runtime: .app,
|
||||
sourceMode: .auto,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: environment,
|
||||
settings: nil,
|
||||
fetcher: UsageFetcher(environment: environment),
|
||||
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
||||
browserDetection: browserDetection)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `settings reader trims env vars and normalizes endpoint`() {
|
||||
let environment = [
|
||||
AzureOpenAISettingsReader.apiKeyEnvironmentKey: " 'azure-key' ",
|
||||
AzureOpenAISettingsReader.endpointEnvironmentKey: "my-resource.openai.azure.com",
|
||||
AzureOpenAISettingsReader.deploymentNameEnvironmentKey: " \"chat-deployment\" ",
|
||||
]
|
||||
|
||||
#expect(AzureOpenAISettingsReader.apiKey(environment: environment) == "azure-key")
|
||||
#expect(
|
||||
AzureOpenAISettingsReader.endpoint(environment: environment)?.absoluteString ==
|
||||
"https://my-resource.openai.azure.com")
|
||||
#expect(AzureOpenAISettingsReader.deploymentName(environment: environment) == "chat-deployment")
|
||||
#expect(AzureOpenAISettingsReader.apiVersion(environment: [:]) == AzureOpenAISettingsReader.defaultAPIVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `missing deployment config returns precise provider error`() async {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .azureopenai)
|
||||
let outcome = await descriptor.fetchPlan.fetchOutcome(
|
||||
context: self.makeContext(environment: [
|
||||
AzureOpenAISettingsReader.apiKeyEnvironmentKey: "azure-key",
|
||||
AzureOpenAISettingsReader.endpointEnvironmentKey: "https://example-resource.openai.azure.com",
|
||||
]),
|
||||
provider: .azureopenai)
|
||||
|
||||
guard case let .failure(error) = outcome.result else {
|
||||
Issue.record("Expected missing deployment to fail")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(error as? AzureOpenAIUsageError == .missingDeploymentName)
|
||||
#expect(error.localizedDescription.contains("deployment not configured"))
|
||||
#expect(outcome.attempts.map(\.wasAvailable) == [true])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `invalid endpoint returns precise provider error before fetch`() async {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .azureopenai)
|
||||
let outcome = await descriptor.fetchPlan.fetchOutcome(
|
||||
context: self.makeContext(environment: [
|
||||
AzureOpenAISettingsReader.apiKeyEnvironmentKey: "AZURE_CANARY_KEY",
|
||||
AzureOpenAISettingsReader.endpointEnvironmentKey: "http://127.0.0.1:31337",
|
||||
AzureOpenAISettingsReader.deploymentNameEnvironmentKey: "canary-deployment",
|
||||
]),
|
||||
provider: .azureopenai)
|
||||
|
||||
guard case let .failure(error) = outcome.result else {
|
||||
Issue.record("Expected invalid endpoint override to fail")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(error as? AzureOpenAISettingsError == .invalidEndpointOverride(
|
||||
AzureOpenAISettingsReader.endpointEnvironmentKey))
|
||||
#expect(error.localizedDescription.contains("HTTPS endpoint"))
|
||||
#expect(outcome.attempts.map(\.wasAvailable) == [true])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fetcher validates deployment with chat completions request`() async throws {
|
||||
let endpoint = try #require(URL(string: "https://example-resource.openai.azure.com"))
|
||||
let updatedAt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let transport = ProviderHTTPTransportStub { request in
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url?.path == "/openai/deployments/chat-prod/chat/completions")
|
||||
#expect(request.url?.query == "api-version=2024-10-21")
|
||||
#expect(request.value(forHTTPHeaderField: "api-key") == "azure-key")
|
||||
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
|
||||
let body = try #require(request.httpBody)
|
||||
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(json["max_tokens"] as? Int == 1)
|
||||
#expect(json["temperature"] == nil)
|
||||
let messages = try #require(json["messages"] as? [[String: String]])
|
||||
#expect(messages.first?["content"] == "ping")
|
||||
|
||||
let response = try HTTPURLResponse(
|
||||
url: #require(request.url),
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
return (Data(#"{"id":"cmpl-1","model":"gpt-4o-mini"}"#.utf8), response)
|
||||
}
|
||||
|
||||
let snapshot = try await AzureOpenAIUsageFetcher.fetchUsage(
|
||||
apiKey: "azure-key",
|
||||
endpoint: endpoint,
|
||||
deploymentName: "chat-prod",
|
||||
transport: transport,
|
||||
updatedAt: updatedAt)
|
||||
|
||||
#expect(snapshot.endpointHost == "example-resource.openai.azure.com")
|
||||
#expect(snapshot.deploymentName == "chat-prod")
|
||||
#expect(snapshot.model == "gpt-4o-mini")
|
||||
#expect(snapshot.apiVersion == AzureOpenAISettingsReader.defaultAPIVersion)
|
||||
#expect(snapshot.updatedAt == updatedAt)
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
#expect(usage.identity?.providerID == .azureopenai)
|
||||
#expect(usage.identity?.accountOrganization == "example-resource.openai.azure.com")
|
||||
#expect(usage.identity?.loginMethod == "Deployment: chat-prod")
|
||||
#expect(usage.primary?.resetDescription == "Deployment: chat-prod · Model: gpt-4o-mini")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chat completions URL preserves endpoint path and deployment escaping`() throws {
|
||||
let endpoint = try #require(URL(string: "https://proxy.example.com/base"))
|
||||
let url = try AzureOpenAIUsageFetcher._chatCompletionsURLForTesting(
|
||||
endpoint: endpoint,
|
||||
deploymentName: "chat prod",
|
||||
apiVersion: "2024-10-21")
|
||||
|
||||
#expect(
|
||||
url.absoluteString ==
|
||||
"https://proxy.example.com/base/openai/deployments/chat%20prod/chat/completions?api-version=2024-10-21")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chat completions URL does not duplicate openai endpoint suffix`() throws {
|
||||
let endpoint = try #require(URL(string: "https://proxy.example.com/base/openai"))
|
||||
let url = try AzureOpenAIUsageFetcher._chatCompletionsURLForTesting(
|
||||
endpoint: endpoint,
|
||||
deploymentName: "chat-prod",
|
||||
apiVersion: "2024-10-21")
|
||||
|
||||
#expect(
|
||||
url.absoluteString ==
|
||||
"https://proxy.example.com/base/openai/deployments/chat-prod/chat/completions?api-version=2024-10-21")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `v1 API validates with OpenAI compatible path and model field`() async throws {
|
||||
let endpoint = try #require(URL(string: "https://example-resource.openai.azure.com"))
|
||||
let transport = ProviderHTTPTransportStub { request in
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(
|
||||
request.url?.absoluteString ==
|
||||
"https://example-resource.openai.azure.com/openai/v1/chat/completions")
|
||||
#expect(request.value(forHTTPHeaderField: "api-key") == "azure-key")
|
||||
|
||||
let body = try #require(request.httpBody)
|
||||
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(json["model"] as? String == "chat-prod")
|
||||
#expect(json["max_completion_tokens"] as? Int == 1)
|
||||
#expect(json["max_tokens"] == nil)
|
||||
#expect(json["temperature"] == nil)
|
||||
|
||||
let response = try HTTPURLResponse(
|
||||
url: #require(request.url),
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
return (Data(#"{"id":"cmpl-1","model":"gpt-4o-mini"}"#.utf8), response)
|
||||
}
|
||||
|
||||
let snapshot = try await AzureOpenAIUsageFetcher.fetchUsage(
|
||||
apiKey: "azure-key",
|
||||
endpoint: endpoint,
|
||||
deploymentName: "chat-prod",
|
||||
apiVersion: "v1",
|
||||
transport: transport)
|
||||
|
||||
#expect(snapshot.apiVersion == "v1")
|
||||
#expect(snapshot.deploymentName == "chat-prod")
|
||||
#expect(snapshot.model == "gpt-4o-mini")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `v1 API accepts documented openai v1 base URL`() throws {
|
||||
let endpoint = try #require(URL(string: "https://example-resource.openai.azure.com/openai/v1"))
|
||||
let url = try AzureOpenAIUsageFetcher._chatCompletionsURLForTesting(
|
||||
endpoint: endpoint,
|
||||
deploymentName: "chat-prod",
|
||||
apiVersion: "v1")
|
||||
|
||||
#expect(
|
||||
url.absoluteString ==
|
||||
"https://example-resource.openai.azure.com/openai/v1/chat/completions")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct AzureOpenAIProviderAvailabilityTests {
|
||||
@Test
|
||||
func `configured invalid endpoint remains visible for actionable error`() throws {
|
||||
let suite = "AzureOpenAIProviderAvailabilityTests-\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: testConfigStore(suiteName: suite),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
settings.azureOpenAIAPIKey = "AZURE_CANARY_KEY"
|
||||
settings.azureOpenAIEndpoint = "http://127.0.0.1:31337"
|
||||
settings.azureOpenAIDeploymentName = "canary-deployment"
|
||||
|
||||
let environment = ProviderRegistry.makeEnvironment(
|
||||
base: [:],
|
||||
provider: .azureopenai,
|
||||
settings: settings,
|
||||
tokenOverride: nil)
|
||||
let context = ProviderAvailabilityContext(
|
||||
provider: .azureopenai,
|
||||
settings: settings,
|
||||
environment: environment)
|
||||
|
||||
#expect(AzureOpenAISettingsReader.endpoint(environment: environment) == nil)
|
||||
#expect(AzureOpenAIProviderImplementation().isAvailable(context: context))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct AzureOpenAIMenuDescriptorTests {
|
||||
@Test
|
||||
func `azure openai deployment detail appears in menu`() throws {
|
||||
let suite = "AzureOpenAIMenuDescriptorTests-menu"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: testConfigStore(suiteName: suite),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
settings.statusChecksEnabled = false
|
||||
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
let snapshot = AzureOpenAIUsageSnapshot(
|
||||
endpointHost: "example-resource.openai.azure.com",
|
||||
deploymentName: "chat-prod",
|
||||
model: "gpt-4o-mini",
|
||||
apiVersion: "2024-10-21",
|
||||
updatedAt: Date(timeIntervalSince1970: 1_800_000_000))
|
||||
store._setSnapshotForTesting(snapshot.toUsageSnapshot(), provider: .azureopenai)
|
||||
|
||||
let descriptor = MenuDescriptor.build(
|
||||
provider: .azureopenai,
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
updateReady: false,
|
||||
includeContextualActions: false)
|
||||
let lines = descriptor.sections
|
||||
.flatMap(\.entries)
|
||||
.compactMap { entry -> String? in
|
||||
guard case let .text(text, _) = entry else { return nil }
|
||||
return text
|
||||
}
|
||||
|
||||
#expect(lines.contains("Azure OpenAI"))
|
||||
#expect(lines.contains("Deployment: chat-prod · Model: gpt-4o-mini"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import AppKit
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
/// Regression coverage for battery drain caused by fallback-provider animation.
|
||||
/// See GitHub issues #269, #139.
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct BatteryDrainDiagnosticTests {
|
||||
private func ensureAppKitInitialized() {
|
||||
_ = NSApplication.shared
|
||||
}
|
||||
|
||||
private func makeStatusBarForTesting() -> NSStatusBar {
|
||||
// Use the real system status bar in tests. Creating standalone NSStatusBar instances
|
||||
// has caused AppKit teardown crashes under swiftpm-testing-helper.
|
||||
.system
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Fallback provider should not animate when all providers are disabled`() {
|
||||
self.ensureAppKitInitialized()
|
||||
|
||||
let settings = SettingsStore(
|
||||
configStore: testConfigStore(suiteName: "BatteryDrain-AllDisabled"),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
|
||||
settings.statusChecksEnabled = false
|
||||
settings.refreshFrequency = .manual
|
||||
settings.mergeIcons = false
|
||||
|
||||
let registry = ProviderRegistry.shared
|
||||
for provider in UsageProvider.allCases {
|
||||
if let meta = registry.metadata[provider] {
|
||||
settings.setProviderEnabled(provider: provider, metadata: meta, enabled: false)
|
||||
}
|
||||
}
|
||||
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(
|
||||
fetcher: fetcher,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
|
||||
let controller = StatusItemController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: fetcher.loadAccountInfo(),
|
||||
updater: DisabledUpdaterController(),
|
||||
preferencesSelection: PreferencesSelection(),
|
||||
statusBar: self.makeStatusBarForTesting())
|
||||
defer { controller.releaseStatusItemsForTesting() }
|
||||
|
||||
#expect(
|
||||
controller.needsMenuBarIconAnimation() == false,
|
||||
"Should not animate when only fallback provider is visible")
|
||||
#expect(
|
||||
controller.animationDriver == nil,
|
||||
"Animation driver should not start for fallback provider")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Enabled provider with data should not animate`() {
|
||||
self.ensureAppKitInitialized()
|
||||
|
||||
let settings = SettingsStore(
|
||||
configStore: testConfigStore(suiteName: "BatteryDrain-HasData"),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
|
||||
settings.statusChecksEnabled = false
|
||||
settings.refreshFrequency = .manual
|
||||
settings.mergeIcons = true
|
||||
settings.selectedMenuProvider = .codex
|
||||
|
||||
let registry = ProviderRegistry.shared
|
||||
if let meta = registry.metadata[.codex] {
|
||||
settings.setProviderEnabled(provider: .codex, metadata: meta, enabled: true)
|
||||
}
|
||||
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(
|
||||
fetcher: fetcher,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
|
||||
let snapshot = UsageSnapshot(
|
||||
primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
|
||||
secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
|
||||
updatedAt: Date())
|
||||
store._setSnapshotForTesting(snapshot, provider: .codex)
|
||||
|
||||
let controller = StatusItemController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: fetcher.loadAccountInfo(),
|
||||
updater: DisabledUpdaterController(),
|
||||
preferencesSelection: PreferencesSelection(),
|
||||
statusBar: self.makeStatusBarForTesting())
|
||||
defer { controller.releaseStatusItemsForTesting() }
|
||||
|
||||
#expect(
|
||||
controller.needsMenuBarIconAnimation() == false,
|
||||
"Should not animate when provider has data")
|
||||
#expect(
|
||||
controller.animationDriver == nil,
|
||||
"Animation driver should be nil when data is present")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Enabled provider without data should animate`() {
|
||||
self.ensureAppKitInitialized()
|
||||
|
||||
let settings = SettingsStore(
|
||||
configStore: testConfigStore(suiteName: "BatteryDrain-NoData"),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
|
||||
settings.statusChecksEnabled = false
|
||||
settings.refreshFrequency = .manual
|
||||
settings.mergeIcons = false
|
||||
|
||||
let registry = ProviderRegistry.shared
|
||||
if let meta = registry.metadata[.codex] {
|
||||
settings.setProviderEnabled(provider: .codex, metadata: meta, enabled: true)
|
||||
}
|
||||
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(
|
||||
fetcher: fetcher,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
|
||||
let controller = StatusItemController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: fetcher.loadAccountInfo(),
|
||||
updater: DisabledUpdaterController(),
|
||||
preferencesSelection: PreferencesSelection(),
|
||||
statusBar: self.makeStatusBarForTesting())
|
||||
defer { controller.releaseStatusItemsForTesting() }
|
||||
|
||||
#expect(
|
||||
controller.needsMenuBarIconAnimation() == true,
|
||||
"Should animate when enabled provider has no data")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Enabled provider with error should not animate`() {
|
||||
self.ensureAppKitInitialized()
|
||||
|
||||
let settings = SettingsStore(
|
||||
configStore: testConfigStore(suiteName: "BatteryDrain-ErrorStops"),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
|
||||
settings.statusChecksEnabled = false
|
||||
settings.refreshFrequency = .manual
|
||||
settings.mergeIcons = false
|
||||
|
||||
let registry = ProviderRegistry.shared
|
||||
if let meta = registry.metadata[.codex] {
|
||||
settings.setProviderEnabled(provider: .codex, metadata: meta, enabled: true)
|
||||
}
|
||||
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(
|
||||
fetcher: fetcher,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
store._setErrorForTesting("simulated Codex RPC timeout", provider: .codex)
|
||||
|
||||
let controller = StatusItemController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
account: fetcher.loadAccountInfo(),
|
||||
updater: DisabledUpdaterController(),
|
||||
preferencesSelection: PreferencesSelection(),
|
||||
statusBar: self.makeStatusBarForTesting())
|
||||
|
||||
#expect(store.isStale(provider: .codex) == true)
|
||||
#expect(
|
||||
controller.needsMenuBarIconAnimation() == false,
|
||||
"Should not animate when provider has recorded an error")
|
||||
#expect(controller.animationDriver == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
private final class CapturedEnvironment: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var stored: [String: String] = [:]
|
||||
func record(_ environment: [String: String]) {
|
||||
self.lock.withLock { self.stored = environment }
|
||||
}
|
||||
|
||||
var value: [String: String] {
|
||||
self.lock.withLock { self.stored }
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct BedrockCredentialResolverTests {
|
||||
private static let credentialsJSON = #"""
|
||||
{"Version":1,"AccessKeyId":"AKIAPROFILE","SecretAccessKey":"profile-secret","SessionToken":"profile-token"}
|
||||
"""#
|
||||
|
||||
/// Fake AWS CLI runner: returns exported credentials and a profile region.
|
||||
private func profileProvider(region: String = "ap-southeast-2") -> BedrockProfileCredentialProvider {
|
||||
BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { arguments, _ in
|
||||
if arguments.contains("export-credentials") {
|
||||
return SubprocessResult(stdout: Self.credentialsJSON, stderr: "")
|
||||
}
|
||||
if arguments.contains("get") {
|
||||
return SubprocessResult(stdout: region + "\n", stderr: "")
|
||||
}
|
||||
return SubprocessResult(stdout: "", stderr: "")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `keys mode resolves static credentials and region`() async throws {
|
||||
let env = [
|
||||
BedrockSettingsReader.accessKeyIDKey: "AKIAKEYS",
|
||||
BedrockSettingsReader.secretAccessKeyKey: "keys-secret",
|
||||
BedrockSettingsReader.regionKeys[0]: "us-west-2",
|
||||
]
|
||||
let resolved = try await BedrockCredentialResolver.resolve(environment: env)
|
||||
#expect(resolved.credentials.accessKeyID == "AKIAKEYS")
|
||||
#expect(resolved.credentials.secretAccessKey == "keys-secret")
|
||||
#expect(resolved.region == "us-west-2")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `keys mode without credentials throws missingCredentials`() async {
|
||||
await #expect(throws: BedrockUsageError.missingCredentials) {
|
||||
try await BedrockCredentialResolver.resolve(environment: [:])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `profile mode resolves credentials via the AWS CLI`() async throws {
|
||||
let env = [
|
||||
BedrockSettingsReader.authModeKey: "profile",
|
||||
BedrockSettingsReader.profileKey: "work",
|
||||
]
|
||||
let resolved = try await BedrockCredentialResolver.resolve(
|
||||
environment: env,
|
||||
resolveAWSBinary: { _ in "/usr/bin/aws" },
|
||||
makeProvider: { _ in self.profileProvider() })
|
||||
#expect(resolved.credentials.accessKeyID == "AKIAPROFILE")
|
||||
#expect(resolved.credentials.sessionToken == "profile-token")
|
||||
// No explicit region in env, so it is derived from the profile.
|
||||
#expect(resolved.region == "ap-southeast-2")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `profile mode prefers explicit region over the profile region`() async throws {
|
||||
let env = [
|
||||
BedrockSettingsReader.authModeKey: "profile",
|
||||
BedrockSettingsReader.profileKey: "work",
|
||||
BedrockSettingsReader.regionKeys[0]: "eu-central-1",
|
||||
]
|
||||
let resolved = try await BedrockCredentialResolver.resolve(
|
||||
environment: env,
|
||||
resolveAWSBinary: { _ in "/usr/bin/aws" },
|
||||
makeProvider: { _ in self.profileProvider() })
|
||||
#expect(resolved.region == "eu-central-1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `profile mode without a profile name throws missingCredentials`() async {
|
||||
let env = [BedrockSettingsReader.authModeKey: "profile"]
|
||||
await #expect(throws: BedrockUsageError.missingCredentials) {
|
||||
try await BedrockCredentialResolver.resolve(
|
||||
environment: env,
|
||||
resolveAWSBinary: { _ in "/usr/bin/aws" },
|
||||
makeProvider: { _ in self.profileProvider() })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `profile mode preserves source credentials but removes AWS_PROFILE for AWS CLI`() async throws {
|
||||
let captured = CapturedEnvironment()
|
||||
let env = [
|
||||
BedrockSettingsReader.authModeKey: "profile",
|
||||
BedrockSettingsReader.profileKey: "work",
|
||||
BedrockSettingsReader.accessKeyIDKey: "AKIAINHERITED",
|
||||
BedrockSettingsReader.secretAccessKeyKey: "inherited-secret",
|
||||
BedrockSettingsReader.sessionTokenKey: "inherited-token",
|
||||
]
|
||||
_ = try await BedrockCredentialResolver.resolve(
|
||||
environment: env,
|
||||
resolveAWSBinary: { _ in "/usr/bin/aws" },
|
||||
makeProvider: { _ in
|
||||
BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { arguments, environment in
|
||||
captured.record(environment)
|
||||
if arguments.contains("export-credentials") {
|
||||
return SubprocessResult(stdout: Self.credentialsJSON, stderr: "")
|
||||
}
|
||||
return SubprocessResult(stdout: "us-east-1\n", stderr: "")
|
||||
}
|
||||
})
|
||||
let seen = captured.value
|
||||
#expect(seen[BedrockSettingsReader.accessKeyIDKey] == "AKIAINHERITED")
|
||||
#expect(seen[BedrockSettingsReader.secretAccessKeyKey] == "inherited-secret")
|
||||
#expect(seen[BedrockSettingsReader.sessionTokenKey] == "inherited-token")
|
||||
#expect(seen[BedrockSettingsReader.profileKey] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `profile mode without the AWS CLI throws awsCLINotFound`() async {
|
||||
let env = [
|
||||
BedrockSettingsReader.authModeKey: "profile",
|
||||
BedrockSettingsReader.profileKey: "work",
|
||||
]
|
||||
await #expect(throws: BedrockUsageError.awsCLINotFound) {
|
||||
try await BedrockCredentialResolver.resolve(
|
||||
environment: env,
|
||||
resolveAWSBinary: { _ in nil },
|
||||
makeProvider: { _ in self.profileProvider() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
struct BedrockMenuCardTests {
|
||||
@Test
|
||||
func `bedrock cost section labels latest billing day`() throws {
|
||||
let now = Date()
|
||||
let metadata = try #require(ProviderDefaults.metadata[.bedrock])
|
||||
let tokenSnapshot = CostUsageTokenSnapshot(
|
||||
sessionTokens: nil,
|
||||
sessionCostUSD: 12.34,
|
||||
last30DaysTokens: nil,
|
||||
last30DaysCostUSD: 56.78,
|
||||
historyDays: 7,
|
||||
daily: [
|
||||
CostUsageDailyReport.Entry(
|
||||
date: "2026-05-12",
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
costUSD: 12.34,
|
||||
modelsUsed: ["Amazon Bedrock"],
|
||||
modelBreakdowns: nil),
|
||||
],
|
||||
updatedAt: now)
|
||||
let model = UsageMenuCardView.Model.make(.init(
|
||||
provider: .bedrock,
|
||||
metadata: metadata,
|
||||
snapshot: nil,
|
||||
credits: nil,
|
||||
creditsError: nil,
|
||||
dashboard: nil,
|
||||
dashboardError: nil,
|
||||
tokenSnapshot: tokenSnapshot,
|
||||
tokenError: nil,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
isRefreshing: false,
|
||||
lastError: nil,
|
||||
usageBarsShowUsed: false,
|
||||
resetTimeDisplayStyle: .countdown,
|
||||
tokenCostUsageEnabled: true,
|
||||
showOptionalCreditsAndExtraUsage: true,
|
||||
hidePersonalInfo: false,
|
||||
now: now))
|
||||
|
||||
#expect(model.tokenUsage?.sessionLine == "Latest billing day (May 12): $12.34")
|
||||
#expect(model.tokenUsage?.sessionLine.contains("Today") == false)
|
||||
#expect(model.tokenUsage?.monthLine == "Last 7 days: $56.78")
|
||||
#expect(model.tokenUsage?.hintLine == "AWS Cost Explorer billing can lag.")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `bedrock cost section picks latest valid billing day`() throws {
|
||||
let now = Date()
|
||||
let metadata = try #require(ProviderDefaults.metadata[.bedrock])
|
||||
let tokenSnapshot = CostUsageTokenSnapshot(
|
||||
sessionTokens: nil,
|
||||
sessionCostUSD: 23.45,
|
||||
last30DaysTokens: nil,
|
||||
last30DaysCostUSD: 56.78,
|
||||
historyDays: 7,
|
||||
daily: [
|
||||
CostUsageDailyReport.Entry(
|
||||
date: "not-a-day",
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: 10,
|
||||
costUSD: 99,
|
||||
modelsUsed: ["Amazon Bedrock"],
|
||||
modelBreakdowns: nil),
|
||||
CostUsageDailyReport.Entry(
|
||||
date: "2026-06-31",
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: 40,
|
||||
costUSD: 99,
|
||||
modelsUsed: ["Amazon Bedrock"],
|
||||
modelBreakdowns: nil),
|
||||
CostUsageDailyReport.Entry(
|
||||
date: "2026-05-12",
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: 20,
|
||||
costUSD: 12.34,
|
||||
modelsUsed: ["Amazon Bedrock"],
|
||||
modelBreakdowns: nil),
|
||||
CostUsageDailyReport.Entry(
|
||||
date: "2026-05-13",
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: 30,
|
||||
costUSD: 23.45,
|
||||
modelsUsed: ["Amazon Bedrock"],
|
||||
modelBreakdowns: nil),
|
||||
],
|
||||
updatedAt: now)
|
||||
let model = UsageMenuCardView.Model.make(.init(
|
||||
provider: .bedrock,
|
||||
metadata: metadata,
|
||||
snapshot: nil,
|
||||
credits: nil,
|
||||
creditsError: nil,
|
||||
dashboard: nil,
|
||||
dashboardError: nil,
|
||||
tokenSnapshot: tokenSnapshot,
|
||||
tokenError: nil,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
isRefreshing: false,
|
||||
lastError: nil,
|
||||
usageBarsShowUsed: false,
|
||||
resetTimeDisplayStyle: .countdown,
|
||||
tokenCostUsageEnabled: true,
|
||||
showOptionalCreditsAndExtraUsage: true,
|
||||
hidePersonalInfo: false,
|
||||
now: now))
|
||||
|
||||
#expect(model.tokenUsage?.sessionLine == "Latest billing day (May 13): $23.45")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct BedrockProfileCredentialProviderTests {
|
||||
private func provider(
|
||||
stdout: String = "",
|
||||
stderr: String = "",
|
||||
throwsNonZero: Bool = false) -> BedrockProfileCredentialProvider
|
||||
{
|
||||
BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { _, _ in
|
||||
if throwsNonZero {
|
||||
throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: stderr)
|
||||
}
|
||||
return SubprocessResult(stdout: stdout, stderr: stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses export-credentials json with session token`() async throws {
|
||||
let json = """
|
||||
{"Version":1,"AccessKeyId":"AKIA","SecretAccessKey":"secret",\
|
||||
"SessionToken":"token","Expiration":"2026-05-27T12:00:00Z"}
|
||||
"""
|
||||
let creds = try await provider(stdout: json).exportCredentials(profile: "work")
|
||||
#expect(creds.accessKeyID == "AKIA")
|
||||
#expect(creds.secretAccessKey == "secret")
|
||||
#expect(creds.sessionToken == "token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses export-credentials json without session token`() async throws {
|
||||
let json = #"{"Version":1,"AccessKeyId":"AKIA","SecretAccessKey":"secret"}"#
|
||||
let creds = try await provider(stdout: json).exportCredentials(profile: "work")
|
||||
#expect(creds.accessKeyID == "AKIA")
|
||||
#expect(creds.sessionToken == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `maps expired SSO stderr to profileSessionExpired`() async {
|
||||
let stderr = "The SSO session associated with this profile has expired. " +
|
||||
"To refresh this SSO session run aws sso login with the corresponding profile."
|
||||
let sut = self.provider(stderr: stderr, throwsNonZero: true)
|
||||
await #expect(throws: BedrockUsageError.profileSessionExpired("work")) {
|
||||
try await sut.exportCredentials(profile: "work")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `maps other non-zero exit to apiError`() async {
|
||||
let sut = self.provider(stderr: "The config profile (work) could not be found", throwsNonZero: true)
|
||||
do {
|
||||
_ = try await sut.exportCredentials(profile: "work")
|
||||
Issue.record("expected an error")
|
||||
} catch let error as BedrockUsageError {
|
||||
if case .apiError = error { } else { Issue.record("expected apiError, got \(error)") }
|
||||
} catch {
|
||||
Issue.record("unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `malformed json throws parseFailed`() async {
|
||||
let sut = self.provider(stdout: "not json")
|
||||
do {
|
||||
_ = try await sut.exportCredentials(profile: "work")
|
||||
Issue.record("expected an error")
|
||||
} catch let error as BedrockUsageError {
|
||||
if case .parseFailed = error { } else { Issue.record("expected parseFailed, got \(error)") }
|
||||
} catch {
|
||||
Issue.record("unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolveRegion returns trimmed value`() async throws {
|
||||
let region = try await provider(stdout: "eu-west-1\n").resolveRegion(profile: "work")
|
||||
#expect(region == "eu-west-1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolveRegion returns nil when unset (non-zero exit)`() async throws {
|
||||
let region = try await provider(throwsNonZero: true).resolveRegion(profile: "work")
|
||||
#expect(region == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolveRegion returns nil for empty output`() async throws {
|
||||
let region = try await provider(stdout: "\n").resolveRegion(profile: "work")
|
||||
#expect(region == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct BedrockSettingsFlowTests {
|
||||
@Test
|
||||
func `settings store maps Bedrock credentials into provider environment`() throws {
|
||||
let suite = "BedrockSettingsFlowTests-settings-\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configStore = testConfigStore(suiteName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: configStore,
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
|
||||
settings.bedrockAccessKeyID = "AKIATEST"
|
||||
settings.bedrockSecretAccessKey = "secret"
|
||||
settings.bedrockRegion = "us-west-2"
|
||||
|
||||
let config = try #require(settings.providerConfig(for: .bedrock))
|
||||
#expect(config.sanitizedAPIKey == "AKIATEST")
|
||||
#expect(config.sanitizedSecretKey == "secret")
|
||||
#expect(config.sanitizedCookieHeader == nil)
|
||||
#expect(config.sanitizedRegion == "us-west-2")
|
||||
|
||||
let env = ProviderRegistry.makeEnvironment(
|
||||
base: [:],
|
||||
provider: .bedrock,
|
||||
settings: settings,
|
||||
tokenOverride: nil)
|
||||
|
||||
#expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST")
|
||||
#expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret")
|
||||
#expect(env[BedrockSettingsReader.regionKeys[0]] == "us-west-2")
|
||||
#expect(BedrockSettingsReader.hasCredentials(environment: env))
|
||||
#expect(BedrockProviderImplementation().isAvailable(context: ProviderAvailabilityContext(
|
||||
provider: .bedrock,
|
||||
settings: settings,
|
||||
environment: env)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `bedrock availability requires secret access key`() throws {
|
||||
let suite = "BedrockSettingsFlowTests-missing-secret-\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: testConfigStore(suiteName: suite),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
|
||||
settings.bedrockAccessKeyID = "AKIATEST"
|
||||
|
||||
let env = ProviderRegistry.makeEnvironment(
|
||||
base: [:],
|
||||
provider: .bedrock,
|
||||
settings: settings,
|
||||
tokenOverride: nil)
|
||||
|
||||
#expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST")
|
||||
#expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil)
|
||||
#expect(!BedrockProviderImplementation().isAvailable(context: ProviderAvailabilityContext(
|
||||
provider: .bedrock,
|
||||
settings: settings,
|
||||
environment: env)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `profile mode maps profile into provider environment and is available`() throws {
|
||||
let suite = "BedrockSettingsFlowTests-profile-\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: testConfigStore(suiteName: suite),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
|
||||
settings.bedrockAuthMode = BedrockAuthMode.profile.rawValue
|
||||
settings.bedrockProfile = "work"
|
||||
|
||||
let config = try #require(settings.providerConfig(for: .bedrock))
|
||||
#expect(config.sanitizedAWSAuthMode == "profile")
|
||||
#expect(config.sanitizedAWSProfile == "work")
|
||||
|
||||
let env = ProviderRegistry.makeEnvironment(
|
||||
base: [:],
|
||||
provider: .bedrock,
|
||||
settings: settings,
|
||||
tokenOverride: nil)
|
||||
|
||||
#expect(env[BedrockSettingsReader.authModeKey] == "profile")
|
||||
#expect(env[BedrockSettingsReader.profileKey] == "work")
|
||||
#expect(env[BedrockSettingsReader.accessKeyIDKey] == nil)
|
||||
#expect(BedrockSettingsReader.hasCredentials(environment: env))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct BedrockSettingsReaderTests {
|
||||
@Test
|
||||
func `default auth mode is keys`() {
|
||||
#expect(BedrockSettingsReader.authMode(environment: [:]) == .keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `explicit profile auth mode wins`() {
|
||||
let env = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile"]
|
||||
#expect(BedrockSettingsReader.authMode(environment: env) == .profile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `AWS_PROFILE without keys implies profile mode`() {
|
||||
let env = ["AWS_PROFILE": "work"]
|
||||
#expect(BedrockSettingsReader.authMode(environment: env) == .profile)
|
||||
#expect(BedrockSettingsReader.profile(environment: env) == "work")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `AWS_PROFILE alongside static keys keeps keys mode`() {
|
||||
let env = [
|
||||
"AWS_PROFILE": "work",
|
||||
"AWS_ACCESS_KEY_ID": "AKIA",
|
||||
"AWS_SECRET_ACCESS_KEY": "secret",
|
||||
]
|
||||
#expect(BedrockSettingsReader.authMode(environment: env) == .keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `hasCredentials in profile mode requires a profile name`() {
|
||||
let withProfile = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile", "AWS_PROFILE": "work"]
|
||||
let withoutProfile = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile"]
|
||||
#expect(BedrockSettingsReader.hasCredentials(environment: withProfile))
|
||||
#expect(!BedrockSettingsReader.hasCredentials(environment: withoutProfile))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `hasCredentials in keys mode requires both keys`() {
|
||||
let both = ["AWS_ACCESS_KEY_ID": "AKIA", "AWS_SECRET_ACCESS_KEY": "secret"]
|
||||
let onlyAccess = ["AWS_ACCESS_KEY_ID": "AKIA"]
|
||||
#expect(BedrockSettingsReader.hasCredentials(environment: both))
|
||||
#expect(!BedrockSettingsReader.hasCredentials(environment: onlyAccess))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct BedrockUsageStatsTests {
|
||||
@Test
|
||||
func `to usage snapshot with budget shows primary window`() {
|
||||
let snapshot = BedrockUsageSnapshot(
|
||||
monthlySpend: 50,
|
||||
monthlyBudget: 200,
|
||||
inputTokens: 1_500_000,
|
||||
outputTokens: 500_000,
|
||||
requestCount: 42,
|
||||
region: "us-east-1",
|
||||
updatedAt: Date(timeIntervalSince1970: 1_739_841_600))
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary?.usedPercent == 25)
|
||||
#expect(usage.primary?.resetDescription == "Monthly budget")
|
||||
#expect(usage.primary?.resetsAt != nil)
|
||||
#expect(usage.providerCost?.used == 50)
|
||||
#expect(usage.providerCost?.limit == 200)
|
||||
#expect(usage.providerCost?.currencyCode == "USD")
|
||||
#expect(usage.providerCost?.period == "Monthly")
|
||||
#expect(usage.identity?.providerID == .bedrock)
|
||||
#expect(usage.identity?.loginMethod?.contains("Spend: $50.00") == true)
|
||||
#expect(usage.identity?.loginMethod?.contains("Claude 14d: 2.0M tokens") == true)
|
||||
#expect(usage.identity?.loginMethod?.contains("Requests: 42") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `to usage snapshot without budget omits primary window`() {
|
||||
let snapshot = BedrockUsageSnapshot(
|
||||
monthlySpend: 75.5,
|
||||
monthlyBudget: nil,
|
||||
region: "us-west-2",
|
||||
updatedAt: Date(timeIntervalSince1970: 1_739_841_600))
|
||||
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary == nil)
|
||||
#expect(usage.providerCost?.used == 75.5)
|
||||
#expect(usage.providerCost?.limit == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `settings reader parses credentials from environment`() {
|
||||
let env = [
|
||||
"AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE",
|
||||
"AWS_SECRET_ACCESS_KEY": "secret",
|
||||
"AWS_REGION": "eu-west-1",
|
||||
"CODEXBAR_BEDROCK_BUDGET": "500",
|
||||
]
|
||||
|
||||
#expect(BedrockSettingsReader.accessKeyID(environment: env) == "AKIAIOSFODNN7EXAMPLE")
|
||||
#expect(BedrockSettingsReader.secretAccessKey(environment: env) == "secret")
|
||||
#expect(BedrockSettingsReader.region(environment: env) == "eu-west-1")
|
||||
#expect(BedrockSettingsReader.budget(environment: env) == 500)
|
||||
#expect(BedrockSettingsReader.hasCredentials(environment: env))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `settings reader requires both credential fields`() {
|
||||
#expect(!BedrockSettingsReader.hasCredentials(environment: [:]))
|
||||
#expect(!BedrockSettingsReader.hasCredentials(environment: [
|
||||
"AWS_ACCESS_KEY_ID": "AKIATEST",
|
||||
]))
|
||||
#expect(!BedrockSettingsReader.hasCredentials(environment: [
|
||||
"AWS_SECRET_ACCESS_KEY": "secret",
|
||||
]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost explorer response parsing extracts total`() async throws {
|
||||
let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self)
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(BedrockStubURLProtocol.self)
|
||||
}
|
||||
BedrockStubURLProtocol.handler = nil
|
||||
}
|
||||
|
||||
BedrockStubURLProtocol.handler = { request in
|
||||
guard let url = request.url else { throw URLError(.badURL) }
|
||||
let body = """
|
||||
{
|
||||
"ResultsByTime": [
|
||||
{
|
||||
"TimePeriod": {"Start": "2026-04-01", "End": "2026-04-06"},
|
||||
"Groups": [
|
||||
{
|
||||
"Keys": ["Claude Opus (Bedrock Edition)"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "30.00", "Unit": "USD"}}
|
||||
},
|
||||
{
|
||||
"Keys": ["Claude Sonnet (Bedrock Edition)"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "12.50", "Unit": "USD"}}
|
||||
},
|
||||
{
|
||||
"Keys": ["Amazon EC2"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "5.00", "Unit": "USD"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
return Self.makeResponse(url: url, body: body, statusCode: 200)
|
||||
}
|
||||
|
||||
let credentials = BedrockAWSSigner.Credentials(
|
||||
accessKeyID: "AKIATEST",
|
||||
secretAccessKey: "testSecret",
|
||||
sessionToken: nil)
|
||||
|
||||
let usage = try await BedrockUsageFetcher.fetchUsage(
|
||||
credentials: credentials,
|
||||
region: "us-east-1",
|
||||
budget: 100,
|
||||
environment: ["CODEXBAR_BEDROCK_API_URL": "https://bedrock.test"])
|
||||
|
||||
#expect(usage.monthlySpend == 42.50)
|
||||
#expect(usage.monthlyBudget == 100)
|
||||
#expect(usage.region == "us-east-1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost explorer data unavailable response returns zero usage`() async throws {
|
||||
let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self)
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(BedrockStubURLProtocol.self)
|
||||
}
|
||||
BedrockStubURLProtocol.handler = nil
|
||||
}
|
||||
|
||||
BedrockStubURLProtocol.handler = { request in
|
||||
guard let url = request.url else { throw URLError(.badURL) }
|
||||
return Self.makeResponse(
|
||||
url: url,
|
||||
body: #"{"__type":"com.amazonaws.ce#DataUnavailableException","message":"Data is not ready"}"#,
|
||||
statusCode: 400)
|
||||
}
|
||||
|
||||
let credentials = BedrockAWSSigner.Credentials(
|
||||
accessKeyID: "AKIATEST",
|
||||
secretAccessKey: "testSecret",
|
||||
sessionToken: nil)
|
||||
|
||||
let usage = try await BedrockUsageFetcher.fetchUsage(
|
||||
credentials: credentials,
|
||||
region: "us-east-1",
|
||||
budget: 100,
|
||||
environment: [BedrockSettingsReader.apiURLKey: "https://bedrock.test"])
|
||||
|
||||
#expect(usage.monthlySpend == 0)
|
||||
#expect(usage.monthlyBudget == 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost explorer unrelated bad request remains an API error`() async throws {
|
||||
let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self)
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(BedrockStubURLProtocol.self)
|
||||
}
|
||||
BedrockStubURLProtocol.handler = nil
|
||||
}
|
||||
|
||||
BedrockStubURLProtocol.handler = { request in
|
||||
guard let url = request.url else { throw URLError(.badURL) }
|
||||
return Self.makeResponse(
|
||||
url: url,
|
||||
body: #"{"__type":"ValidationException","message":"Invalid request"}"#,
|
||||
statusCode: 400)
|
||||
}
|
||||
|
||||
let credentials = BedrockAWSSigner.Credentials(
|
||||
accessKeyID: "AKIATEST",
|
||||
secretAccessKey: "testSecret",
|
||||
sessionToken: nil)
|
||||
|
||||
await #expect(throws: BedrockUsageError.apiError("HTTP 400")) {
|
||||
try await BedrockUsageFetcher.fetchUsage(
|
||||
credentials: credentials,
|
||||
region: "us-east-1",
|
||||
budget: nil,
|
||||
environment: [BedrockSettingsReader.apiURLKey: "https://bedrock.test"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost explorer rejects remote HTTP override before transport`() async throws {
|
||||
let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self)
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(BedrockStubURLProtocol.self)
|
||||
}
|
||||
BedrockStubURLProtocol.handler = nil
|
||||
}
|
||||
let capture = BedrockRequestCapture()
|
||||
BedrockStubURLProtocol.handler = { request in
|
||||
capture.append(request)
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
await #expect(throws: BedrockUsageError.parseFailed("invalid endpoint override")) {
|
||||
try await BedrockUsageFetcher.fetchUsage(
|
||||
credentials: Self.testCredentials,
|
||||
region: "us-east-1",
|
||||
budget: nil,
|
||||
environment: [BedrockSettingsReader.apiURLKey: "http://bedrock.test"])
|
||||
}
|
||||
#expect(capture.requests.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost explorer pagination aggregates monthly total`() async throws {
|
||||
let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self)
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(BedrockStubURLProtocol.self)
|
||||
}
|
||||
BedrockStubURLProtocol.handler = nil
|
||||
}
|
||||
|
||||
let responses = BedrockStubResponseQueue([
|
||||
"""
|
||||
{
|
||||
"NextPageToken": "page-2",
|
||||
"ResultsByTime": [
|
||||
{
|
||||
"TimePeriod": {"Start": "2026-04-01", "End": "2026-04-06"},
|
||||
"Groups": [
|
||||
{
|
||||
"Keys": ["Amazon EC2"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "5.00", "Unit": "USD"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
"""
|
||||
{
|
||||
"ResultsByTime": [
|
||||
{
|
||||
"TimePeriod": {"Start": "2026-04-01", "End": "2026-04-06"},
|
||||
"Groups": [
|
||||
{
|
||||
"Keys": ["Amazon Bedrock"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "12.00", "Unit": "USD"}}
|
||||
},
|
||||
{
|
||||
"Keys": ["Claude Sonnet (Bedrock Edition)"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "8.00", "Unit": "USD"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
])
|
||||
BedrockStubURLProtocol.handler = { request in
|
||||
guard let url = request.url else { throw URLError(.badURL) }
|
||||
return responses.next(url: url)
|
||||
}
|
||||
|
||||
let credentials = BedrockAWSSigner.Credentials(
|
||||
accessKeyID: "AKIATEST",
|
||||
secretAccessKey: "testSecret",
|
||||
sessionToken: nil)
|
||||
|
||||
let usage = try await BedrockUsageFetcher.fetchUsage(
|
||||
credentials: credentials,
|
||||
region: "us-east-1",
|
||||
budget: nil,
|
||||
environment: [BedrockSettingsReader.apiURLKey: "https://bedrock.test"])
|
||||
|
||||
#expect(usage.monthlySpend == 20)
|
||||
#expect(responses.remainingCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost usage fetcher uses provided bedrock environment`() async throws {
|
||||
let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self)
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(BedrockStubURLProtocol.self)
|
||||
}
|
||||
BedrockStubURLProtocol.handler = nil
|
||||
}
|
||||
|
||||
let responses = BedrockStubResponseQueue([
|
||||
"""
|
||||
{
|
||||
"NextPageToken": "daily-page-2",
|
||||
"ResultsByTime": [
|
||||
{
|
||||
"TimePeriod": {"Start": "2025-12-10", "End": "2025-12-11"},
|
||||
"Groups": [
|
||||
{
|
||||
"Keys": ["Amazon EC2"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "5.00", "Unit": "USD"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
"""
|
||||
{
|
||||
"ResultsByTime": [
|
||||
{
|
||||
"TimePeriod": {"Start": "2025-12-10", "End": "2025-12-11"},
|
||||
"Groups": [
|
||||
{
|
||||
"Keys": ["Amazon Bedrock"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "7.25", "Unit": "USD"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
])
|
||||
BedrockStubURLProtocol.handler = { request in
|
||||
guard let url = request.url else { throw URLError(.badURL) }
|
||||
return responses.next(url: url)
|
||||
}
|
||||
|
||||
let snapshot = try await CostUsageFetcher().loadTokenSnapshot(
|
||||
provider: .bedrock,
|
||||
environment: [
|
||||
BedrockSettingsReader.accessKeyIDKey: "AKIATEST",
|
||||
BedrockSettingsReader.secretAccessKeyKey: "testSecret",
|
||||
BedrockSettingsReader.apiURLKey: "https://bedrock.test",
|
||||
],
|
||||
now: Date(timeIntervalSince1970: 1_765_324_800))
|
||||
|
||||
#expect(snapshot.last30DaysCostUSD == 7.25)
|
||||
#expect(snapshot.sessionCostUSD == 7.25)
|
||||
#expect(snapshot.daily.map(\.date) == ["2025-12-10"])
|
||||
#expect(responses.remainingCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `current month range uses UTC calendar`() throws {
|
||||
let originalTimeZone = NSTimeZone.default
|
||||
NSTimeZone.default = TimeZone(secondsFromGMT: 14 * 60 * 60)!
|
||||
defer {
|
||||
NSTimeZone.default = originalTimeZone
|
||||
}
|
||||
|
||||
let now = try #require(ISO8601DateFormatter().date(from: "2026-05-10T12:00:00Z"))
|
||||
let range = BedrockUsageFetcher.currentMonthRange(now: now)
|
||||
|
||||
#expect(range.start == "2026-05-01")
|
||||
#expect(range.end == "2026-05-11")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cloudwatch fetch aggregates Claude activity with bounded signed query`() async throws {
|
||||
let now = try #require(ISO8601DateFormatter().date(from: "2026-06-19T12:00:00Z"))
|
||||
let capture = BedrockRequestCapture()
|
||||
let transport = ProviderHTTPTransportHandler { request in
|
||||
capture.append(request)
|
||||
let requestURL = try #require(request.url)
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: requestURL,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"]))
|
||||
let body = """
|
||||
{
|
||||
"MetricDataResults": [
|
||||
{"Id":"inputTokens","StatusCode":"Complete","Values":[1000,2500]},
|
||||
{"Id":"outputTokens","StatusCode":"Complete","Values":[400,600]},
|
||||
{"Id":"requests","StatusCode":"Complete","Values":[7,8]}
|
||||
]
|
||||
}
|
||||
"""
|
||||
return (Data(body.utf8), response)
|
||||
}
|
||||
|
||||
let activity = try await BedrockCloudWatchUsageFetcher.fetch(
|
||||
credentials: Self.testCredentials,
|
||||
region: "us-west-2",
|
||||
now: now,
|
||||
endpointOverride: "https://cloudwatch.test",
|
||||
transport: transport)
|
||||
|
||||
#expect(activity == BedrockClaudeActivity(inputTokens: 3500, outputTokens: 1000, requestCount: 15))
|
||||
let request = try #require(capture.requests.first)
|
||||
#expect(capture.requests.count == 1)
|
||||
#expect(request.value(forHTTPHeaderField: "X-Amz-Target") ==
|
||||
"GraniteServiceVersion20100801.GetMetricData")
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-amz-json-1.0")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization")?.contains(
|
||||
"/us-west-2/monitoring/aws4_request") == true)
|
||||
|
||||
let requestBody = try #require(request.httpBody)
|
||||
let payload = try #require(try JSONSerialization.jsonObject(with: requestBody) as? [String: Any])
|
||||
#expect(payload["StartTime"] as? Double == now.timeIntervalSince1970 - 14 * 24 * 60 * 60)
|
||||
#expect(payload["EndTime"] as? Double == now.timeIntervalSince1970)
|
||||
let queries = try #require(payload["MetricDataQueries"] as? [[String: Any]])
|
||||
#expect(queries.count == 3)
|
||||
#expect(queries.allSatisfy { query in
|
||||
guard let expression = query["Expression"] as? String else { return false }
|
||||
return expression.hasPrefix("SUM(SEARCH(") && expression.contains("claude") &&
|
||||
expression.contains("86400")
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cloudwatch pagination aggregates pages`() async throws {
|
||||
let transport = ProviderHTTPTransportHandler { request in
|
||||
let requestBody = try #require(request.httpBody)
|
||||
let payload = try #require(JSONSerialization.jsonObject(with: requestBody) as? [String: Any])
|
||||
let isSecondPage = payload["NextToken"] as? String == "page-2"
|
||||
let body = if isSecondPage {
|
||||
"""
|
||||
{"MetricDataResults":[{"Id":"inputTokens","StatusCode":"Complete","Values":[3]}]}
|
||||
"""
|
||||
} else {
|
||||
"""
|
||||
{
|
||||
"NextToken":"page-2",
|
||||
"MetricDataResults":[{"Id":"inputTokens","StatusCode":"Complete","Values":[2]}]
|
||||
}
|
||||
"""
|
||||
}
|
||||
let requestURL = try #require(request.url)
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: requestURL,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil))
|
||||
return (Data(body.utf8), response)
|
||||
}
|
||||
|
||||
let activity = try await BedrockCloudWatchUsageFetcher.fetch(
|
||||
credentials: Self.testCredentials,
|
||||
region: "us-east-1",
|
||||
now: Date(timeIntervalSince1970: 1_750_000_000),
|
||||
endpointOverride: "https://cloudwatch.test",
|
||||
transport: transport)
|
||||
|
||||
#expect(activity.inputTokens == 5)
|
||||
#expect(activity.outputTokens == 0)
|
||||
#expect(activity.requestCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cloudwatch rejects incomplete search results`() async throws {
|
||||
let transport = ProviderHTTPTransportHandler { request in
|
||||
let requestURL = try #require(request.url)
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: requestURL,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil))
|
||||
let body = #"{"Messages":[{"Code":"MaxQueryLimit","Value":"Maximum number exceeded"}]}"#
|
||||
return (Data(body.utf8), response)
|
||||
}
|
||||
|
||||
await #expect(throws: BedrockUsageError.cloudWatchParseFailed(
|
||||
"CloudWatch reported incomplete results"))
|
||||
{
|
||||
try await BedrockCloudWatchUsageFetcher.fetch(
|
||||
credentials: Self.testCredentials,
|
||||
region: "us-east-1",
|
||||
now: Date(timeIntervalSince1970: 1_750_000_000),
|
||||
endpointOverride: "https://cloudwatch.test",
|
||||
transport: transport)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cloudwatch permission failure preserves cost explorer usage`() async throws {
|
||||
let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self)
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(BedrockStubURLProtocol.self)
|
||||
}
|
||||
BedrockStubURLProtocol.handler = nil
|
||||
}
|
||||
BedrockStubURLProtocol.handler = { request in
|
||||
guard let url = request.url else { throw URLError(.badURL) }
|
||||
let body = """
|
||||
{
|
||||
"ResultsByTime": [{
|
||||
"Groups": [{
|
||||
"Keys": ["Amazon Bedrock"],
|
||||
"Metrics": {"UnblendedCost": {"Amount": "12.50"}}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
"""
|
||||
return Self.makeResponse(url: url, body: body)
|
||||
}
|
||||
let cloudWatchTransport = ProviderHTTPTransportHandler { request in
|
||||
let requestURL = try #require(request.url)
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: requestURL,
|
||||
statusCode: 403,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil))
|
||||
return (Data(), response)
|
||||
}
|
||||
let now = Date(timeIntervalSince1970: 1_750_000_000)
|
||||
|
||||
let usage = try await BedrockUsageFetcher.fetchUsage(
|
||||
credentials: Self.testCredentials,
|
||||
region: "us-east-1",
|
||||
budget: nil,
|
||||
environment: [
|
||||
BedrockSettingsReader.apiURLKey: "https://bedrock.test",
|
||||
BedrockSettingsReader.cloudWatchAPIURLKey: "https://cloudwatch.test",
|
||||
],
|
||||
now: now,
|
||||
cloudWatchTransport: cloudWatchTransport)
|
||||
|
||||
#expect(usage.monthlySpend == 12.5)
|
||||
#expect(usage.inputTokens == nil)
|
||||
#expect(usage.outputTokens == nil)
|
||||
#expect(usage.requestCount == nil)
|
||||
#expect(usage.updatedAt == now)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cloudwatch invalid override fails closed without transport`() async throws {
|
||||
let capture = BedrockRequestCapture()
|
||||
let transport = ProviderHTTPTransportHandler { request in
|
||||
capture.append(request)
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
for override in [" ", "not-an-absolute-url", "http://cloudwatch.test"] {
|
||||
await #expect(throws: BedrockUsageError.cloudWatchParseFailed("invalid endpoint override")) {
|
||||
try await BedrockCloudWatchUsageFetcher.fetch(
|
||||
credentials: Self.testCredentials,
|
||||
region: "us-east-1",
|
||||
now: Date(timeIntervalSince1970: 1_750_000_000),
|
||||
endpointOverride: override,
|
||||
transport: transport)
|
||||
}
|
||||
}
|
||||
#expect(capture.requests.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cloudwatch allows HTTP only for loopback overrides`() async throws {
|
||||
let capture = BedrockRequestCapture()
|
||||
let transport = ProviderHTTPTransportHandler { request in
|
||||
capture.append(request)
|
||||
let requestURL = try #require(request.url)
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: requestURL,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil))
|
||||
return (Data(#"{"MetricDataResults":[]}"#.utf8), response)
|
||||
}
|
||||
let overrides = [
|
||||
"http://localhost:8080",
|
||||
"http://127.42.0.1:8080",
|
||||
"http://[::1]:8080",
|
||||
]
|
||||
|
||||
for endpointOverride in overrides {
|
||||
_ = try await BedrockCloudWatchUsageFetcher.fetch(
|
||||
credentials: Self.testCredentials,
|
||||
region: "us-east-1",
|
||||
now: Date(timeIntervalSince1970: 1_750_000_000),
|
||||
endpointOverride: endpointOverride,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
#expect(capture.requests.compactMap(\.url?.absoluteString) == overrides)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cloudwatch resolves AWS partition endpoints`() async throws {
|
||||
let capture = BedrockRequestCapture()
|
||||
let transport = ProviderHTTPTransportHandler { request in
|
||||
capture.append(request)
|
||||
let requestURL = try #require(request.url)
|
||||
let response = try #require(HTTPURLResponse(
|
||||
url: requestURL,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil))
|
||||
return (Data(#"{"MetricDataResults":[]}"#.utf8), response)
|
||||
}
|
||||
let cases = [
|
||||
("us-east-1", "monitoring.us-east-1.amazonaws.com"),
|
||||
("us-gov-west-1", "monitoring.us-gov-west-1.amazonaws.com"),
|
||||
("cn-north-1", "monitoring.cn-north-1.amazonaws.com.cn"),
|
||||
("eusc-de-east-1", "monitoring.eusc-de-east-1.amazonaws.eu"),
|
||||
("us-iso-east-1", "monitoring.us-iso-east-1.c2s.ic.gov"),
|
||||
("us-isob-east-1", "monitoring.us-isob-east-1.sc2s.sgov.gov"),
|
||||
("eu-isoe-west-1", "monitoring.eu-isoe-west-1.cloud.adc-e.uk"),
|
||||
("us-isof-south-1", "monitoring.us-isof-south-1.csp.hci.ic.gov"),
|
||||
]
|
||||
|
||||
for (region, _) in cases {
|
||||
_ = try await BedrockCloudWatchUsageFetcher.fetch(
|
||||
credentials: Self.testCredentials,
|
||||
region: region,
|
||||
now: Date(timeIntervalSince1970: 1_750_000_000),
|
||||
endpointOverride: nil,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
#expect(capture.requests.compactMap(\.url?.host) == cases.map(\.1))
|
||||
}
|
||||
|
||||
private static let testCredentials = BedrockAWSSigner.Credentials(
|
||||
accessKeyID: "AKIATEST",
|
||||
secretAccessKey: "testSecret",
|
||||
sessionToken: nil)
|
||||
|
||||
private final class BedrockStubResponseQueue {
|
||||
private let lock = NSLock()
|
||||
private var bodies: [String]
|
||||
|
||||
init(_ bodies: [String]) {
|
||||
self.bodies = bodies
|
||||
}
|
||||
|
||||
var remainingCount: Int {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.bodies.count
|
||||
}
|
||||
|
||||
func next(url: URL) -> (HTTPURLResponse, Data) {
|
||||
self.lock.lock()
|
||||
let body = self.bodies.isEmpty ? #"{"ResultsByTime":[]}"# : self.bodies.removeFirst()
|
||||
self.lock.unlock()
|
||||
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
return (response, Data(body.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeResponse(
|
||||
url: URL,
|
||||
body: String,
|
||||
statusCode: Int = 200) -> (HTTPURLResponse, Data)
|
||||
{
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: statusCode,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
return (response, Data(body.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
private final class BedrockRequestCapture: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage: [URLRequest] = []
|
||||
|
||||
var requests: [URLRequest] {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.storage
|
||||
}
|
||||
|
||||
func append(_ request: URLRequest) {
|
||||
self.lock.lock()
|
||||
self.storage.append(request)
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
final class BedrockStubURLProtocol: URLProtocol {
|
||||
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
override static func canInit(with request: URLRequest) -> Bool {
|
||||
request.url?.host == "bedrock.test"
|
||||
}
|
||||
|
||||
override static func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard let handler = Self.handler else {
|
||||
self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
do {
|
||||
let (response, data) = try handler(self.request)
|
||||
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
self.client?.urlProtocol(self, didLoad: data)
|
||||
self.client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
self.client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import SweetCookieKit
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct BrowserCookieOrderStatusStringTests {
|
||||
#if os(macOS)
|
||||
@Test
|
||||
func `codex cookie import order keeps firefox ahead of extra chromium browsers`() {
|
||||
let order = ProviderDefaults.metadata[.codex]?.browserCookieOrder ?? Browser.defaultImportOrder
|
||||
#expect(Array(order.prefix(3)) == [.safari, .chrome, .firefox])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `automatic cookie import includes newly supported chromium browsers`() {
|
||||
#expect(Browser.defaultImportOrder.contains(.comet))
|
||||
#expect(Browser.defaultImportOrder.contains(.yandex))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cursor no session includes browser login hint`() {
|
||||
let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder
|
||||
let message = CursorStatusProbeError.noSessionCookie.errorDescription ?? ""
|
||||
#expect(message.contains(order.loginHint))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cursor no session shows full disk access hint before browser list`() throws {
|
||||
let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder
|
||||
let message = try #require(CursorStatusProbeError.noSessionCookie.errorDescription)
|
||||
let fullDiskAccessRange = try #require(message.range(of: CursorStatusProbeError.safariFullDiskAccessHint))
|
||||
let browserListRange = try #require(message.range(of: order.loginHint))
|
||||
|
||||
#expect(fullDiskAccessRange.lowerBound < browserListRange.lowerBound)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `factory no session includes browser login hint`() {
|
||||
let order = ProviderDefaults.metadata[.factory]?.browserCookieOrder ?? Browser.defaultImportOrder
|
||||
let message = FactoryStatusProbeError.noSessionCookie.errorDescription ?? ""
|
||||
#expect(message.contains(order.loginHint))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `opencode go automatic cookies use full provider browser order`() {
|
||||
let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencodego)
|
||||
#expect(order == ProviderDefaults.metadata[.opencodego]?.browserCookieOrder)
|
||||
#expect(order.contains(.edge))
|
||||
#expect(order.contains(.firefox))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `opencode automatic cookies only use chrome and dia`() {
|
||||
let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode)
|
||||
#expect(order == ProviderDefaults.metadata[.opencode]?.browserCookieOrder)
|
||||
#expect(order == ProviderBrowserCookieDefaults.opencodeCookieImportOrder)
|
||||
#expect(order == [.chrome, .dia])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `opencode automatic cookies bound keychain prompt labels to chrome and dia`() {
|
||||
let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode)
|
||||
let labels = order.flatMap(\.safeStorageLabels).map(\.service)
|
||||
|
||||
#expect(labels == ["Chrome Safe Storage", "Dia Safe Storage"])
|
||||
#expect(!order.contains(.safari))
|
||||
#expect(!order.contains(.firefox))
|
||||
#expect(!order.contains(.edge))
|
||||
#expect(!order.contains(.brave))
|
||||
#expect(!order.contains(.arc))
|
||||
#expect(!order.contains(.chromium))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `mimo cookie import order supports safari firefox and edge`() {
|
||||
let order = ProviderDefaults.metadata[.mimo]?.browserCookieOrder ?? Browser.defaultImportOrder
|
||||
#expect(order == ProviderBrowserCookieDefaults.mimoCookieImportOrder)
|
||||
#expect(order == [.safari, .chrome, .chromeBeta, .chromeCanary, .firefox, .edge])
|
||||
#expect(order.first == .safari)
|
||||
#expect(order.contains(.firefox))
|
||||
#expect(order.contains(.edge))
|
||||
#expect(!order.contains(.arc))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `copilot cookie imports default to chrome only`() {
|
||||
#expect(ProviderDefaults.metadata[.copilot]?.browserCookieOrder == [.chrome])
|
||||
#expect(ProviderBrowserCookieDefaults.copilotCookieImportOrder == [.chrome])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
import Foundation
|
||||
import os.lock
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
import SweetCookieKit
|
||||
|
||||
@Suite(.serialized)
|
||||
struct BrowserDetectionTests {
|
||||
private func detection(
|
||||
homeDirectory: String,
|
||||
installedBrowsers: Set<Browser>) -> BrowserDetection
|
||||
{
|
||||
let installedAppPaths = Set(installedBrowsers.map { "/Applications/\($0.appBundleName).app" })
|
||||
return BrowserDetection(
|
||||
homeDirectory: homeDirectory,
|
||||
cacheTTL: 0,
|
||||
now: Date.init,
|
||||
fileExists: { path in
|
||||
if path.hasSuffix(".app") {
|
||||
return installedAppPaths.contains(path)
|
||||
}
|
||||
return FileManager.default.fileExists(atPath: path)
|
||||
},
|
||||
directoryContents: { path in
|
||||
try? FileManager.default.contentsOfDirectory(atPath: path)
|
||||
},
|
||||
applicationURLs: { _ in [] },
|
||||
profileAccessIssue: { _ in nil })
|
||||
}
|
||||
|
||||
private static func labelIDs(for browser: Browser) -> [String] {
|
||||
browser.safeStorageLabels.map { self.labelID(service: $0.service, account: $0.account) }
|
||||
}
|
||||
|
||||
private static func labelID(service: String, account: String?) -> String {
|
||||
"\(service)|\(account ?? "")"
|
||||
}
|
||||
|
||||
@Test(.disabled(
|
||||
if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1",
|
||||
"Default-home cookie access is explicitly enabled for this test run."))
|
||||
func `default home detection is suppressed before profile probes`() throws {
|
||||
let probeCount = OSAllocatedUnfairLock(initialState: 0)
|
||||
let defaultHome = try #require(BrowserCookieClient.defaultHomeDirectories().first)
|
||||
let detection = BrowserDetection(
|
||||
homeDirectory: defaultHome.path,
|
||||
cacheTTL: 0,
|
||||
fileExists: { _ in
|
||||
probeCount.withLock { $0 += 1 }
|
||||
return false
|
||||
},
|
||||
directoryContents: { _ in
|
||||
probeCount.withLock { $0 += 1 }
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = detection.isCookieSourceAvailable(.chrome)
|
||||
#expect(probeCount.withLock { $0 } == 0)
|
||||
}
|
||||
|
||||
@Test(.disabled(
|
||||
if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1",
|
||||
"Default-home cookie access is explicitly enabled for this test run."))
|
||||
func `default client reports structured suppression before store discovery`() {
|
||||
let client = BrowserCookieClient()
|
||||
|
||||
#expect(throws: BrowserCookieStoreAccessSuppressedError.self) {
|
||||
_ = try client.codexBarStores(for: .chrome)
|
||||
}
|
||||
#expect(throws: BrowserCookieStoreAccessSuppressedError.self) {
|
||||
_ = try client.codexBarRecords(
|
||||
matching: BrowserCookieQuery(domains: ["example.com"]),
|
||||
in: .safari)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cookie store decision allows production and explicit test opt in`() {
|
||||
let defaultHomes = BrowserCookieClient.defaultHomeDirectories()
|
||||
let testProcess = "swiftpm-testing-helper"
|
||||
|
||||
#expect(BrowserCookieAccessGate.cookieStoreAccessDecision(
|
||||
homeDirectories: defaultHomes,
|
||||
processName: testProcess,
|
||||
environment: [:]) == .suppressed)
|
||||
#expect(BrowserCookieAccessGate.cookieStoreAccessDecision(
|
||||
homeDirectories: defaultHomes,
|
||||
processName: testProcess,
|
||||
environment: [BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey: "1"]) == .allowed)
|
||||
#expect(BrowserCookieAccessGate.cookieStoreAccessDecision(
|
||||
homeDirectories: defaultHomes,
|
||||
processName: "CodexBar",
|
||||
environment: [:]) == .allowed)
|
||||
}
|
||||
|
||||
@Test(.disabled(
|
||||
if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1",
|
||||
"Default-home cookie access is explicitly enabled for this test run."))
|
||||
func `safari is installed but default cookie access is disabled during tests`() {
|
||||
#expect(BrowserDetection(cacheTTL: 0).isAppInstalled(.safari) == true)
|
||||
#expect(BrowserDetection(cacheTTL: 0).isCookieSourceAvailable(.safari) == false)
|
||||
}
|
||||
|
||||
@Test(.disabled(
|
||||
if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1",
|
||||
"Default-home cookie access is explicitly enabled for this test run."))
|
||||
func `default cookie candidates exclude safari during tests`() {
|
||||
let detection = BrowserDetection(cacheTTL: 0)
|
||||
let browsers: [Browser] = [.safari, .chrome, .firefox]
|
||||
#expect(browsers.cookieImportCandidates(using: detection).contains(.safari) == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `explicit isolated home keeps safari cookie source available`() {
|
||||
let detection = BrowserDetection(homeDirectory: "/tmp/codexbar-browser-detection", cacheTTL: 0)
|
||||
#expect(detection.isCookieSourceAvailable(.safari))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cookie client permits isolated chromium stores during tests`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let profile = temp
|
||||
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network")
|
||||
try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: profile.appendingPathComponent("Cookies").path, contents: Data())
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let client = BrowserCookieClient(configuration: .init(homeDirectories: [temp]))
|
||||
let stores = try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in .allowed } operation: {
|
||||
try client.codexBarStores(for: .chrome)
|
||||
}
|
||||
}
|
||||
#expect(stores.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `filter preserves order`() {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try? FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let firefoxProfile = temp
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Application Support")
|
||||
.appendingPathComponent("Firefox")
|
||||
.appendingPathComponent("Profiles")
|
||||
.appendingPathComponent("abc.default-release")
|
||||
try? FileManager.default.createDirectory(at: firefoxProfile, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(
|
||||
atPath: firefoxProfile.appendingPathComponent("cookies.sqlite").path,
|
||||
contents: Data())
|
||||
|
||||
let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.firefox])
|
||||
let browsers: [Browser] = [.firefox, .safari, .chrome]
|
||||
// Chrome is filtered out deterministically because it lacks usable on-disk profile/cookie store data.
|
||||
#expect(browsers.cookieImportCandidates(using: detection) == [.firefox, .safari])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chrome requires profile data`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.chrome])
|
||||
#expect(detection.isCookieSourceAvailable(.chrome) == false)
|
||||
|
||||
let profile = temp
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Application Support")
|
||||
.appendingPathComponent("Google")
|
||||
.appendingPathComponent("Chrome")
|
||||
.appendingPathComponent("Default")
|
||||
try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true)
|
||||
let cookiesDir = profile.appendingPathComponent("Network")
|
||||
try FileManager.default.createDirectory(at: cookiesDir, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: cookiesDir.appendingPathComponent("Cookies").path, contents: Data())
|
||||
|
||||
#expect(detection.isCookieSourceAvailable(.chrome) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `process filters chromium candidates despite false global keychain override`() throws {
|
||||
guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return }
|
||||
KeychainAccessGate.resetOverrideForTesting()
|
||||
defer { KeychainAccessGate.resetOverrideForTesting() }
|
||||
|
||||
KeychainAccessGate.isDisabled = false
|
||||
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let profile = temp
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Application Support")
|
||||
.appendingPathComponent("Google")
|
||||
.appendingPathComponent("Chrome")
|
||||
.appendingPathComponent("Default")
|
||||
try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true)
|
||||
let cookiesDir = profile.appendingPathComponent("Network")
|
||||
try FileManager.default.createDirectory(at: cookiesDir, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: cookiesDir.appendingPathComponent("Cookies").path, contents: Data())
|
||||
|
||||
let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.chrome])
|
||||
let browsers: [Browser] = [.chrome, .safari]
|
||||
#expect(browsers.cookieImportCandidates(using: detection) == [.safari])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `keychain interaction suppresses chromium family during cooldown`() {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
defer { BrowserCookieAccessGate.resetForTesting() }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 1000)
|
||||
var preflightCount = 0
|
||||
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
|
||||
preflightCount += 1
|
||||
return .interactionRequired
|
||||
} operation: {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == false)
|
||||
}
|
||||
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
|
||||
preflightCount += 1
|
||||
return .allowed
|
||||
} operation: {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false)
|
||||
#expect(
|
||||
BrowserCookieAccessGate.shouldAttempt(
|
||||
.chrome,
|
||||
now: start.addingTimeInterval((60 * 60 * 6) + 1)) == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(preflightCount == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `background cookie import allows authorized chromium keychain sources`() {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
defer { BrowserCookieAccessGate.resetForTesting() }
|
||||
|
||||
var preflightCount = 0
|
||||
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
|
||||
preflightCount += 1
|
||||
return .allowed
|
||||
} operation: {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(preflightCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `background cookie import suppresses chromium keychain sources requiring interaction`() {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
defer { BrowserCookieAccessGate.resetForTesting() }
|
||||
|
||||
var preflightCount = 0
|
||||
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
|
||||
preflightCount += 1
|
||||
return .interactionRequired
|
||||
} operation: {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(preflightCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `recorded browser denial suppresses automatic family and permits explicit source retry`() {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
defer { BrowserCookieAccessGate.resetForTesting() }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 1500)
|
||||
var preflightCount = 0
|
||||
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
BrowserCookieAccessGate.recordIfNeeded(
|
||||
BrowserCookieError.accessDenied(browser: .arc, details: "denied"),
|
||||
now: start)
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in
|
||||
preflightCount += 1
|
||||
return .allowed
|
||||
} operation: {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(1)) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(1)) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.safari, now: start.addingTimeInterval(1)) == true)
|
||||
}
|
||||
BrowserCookieAccessGate.withExplicitRetry {
|
||||
ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
#expect(BrowserCookieAccessGate
|
||||
.shouldAttempt(.chrome, now: start.addingTimeInterval(2)) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(2)) == true)
|
||||
#expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc))
|
||||
BrowserCookieAccessGate.recordAllowed(for: .arc)
|
||||
}
|
||||
}
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(3)) == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(preflightCount == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `denied explicit cookie read closes retry scope`() {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
defer { BrowserCookieAccessGate.resetForTesting() }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 1700)
|
||||
BrowserCookieAccessGate.recordDenied(for: .arc, now: start)
|
||||
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
BrowserCookieAccessGate.withExplicitRetry {
|
||||
ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1)))
|
||||
#expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc))
|
||||
|
||||
BrowserCookieAccessGate.recordDenied(for: .arc, now: start.addingTimeInterval(2))
|
||||
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(3)) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(3)) == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chrome keychain preflight queries only chrome labels`() {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
defer { BrowserCookieAccessGate.resetForTesting() }
|
||||
|
||||
let chromeLabels = Self.labelIDs(for: .chrome)
|
||||
let chromeLabelSet = Set(chromeLabels)
|
||||
var queriedLabels: [String] = []
|
||||
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in
|
||||
let label = Self.labelID(service: service, account: account)
|
||||
queriedLabels.append(label)
|
||||
return .notFound
|
||||
} operation: {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(queriedLabels == chromeLabels)
|
||||
#expect(queriedLabels.allSatisfy { chromeLabelSet.contains($0) })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `dia keychain preflight queries only dia labels`() {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
defer { BrowserCookieAccessGate.resetForTesting() }
|
||||
|
||||
let diaLabels = Self.labelIDs(for: .dia)
|
||||
let diaLabelSet = Set(diaLabels)
|
||||
var queriedLabels: [String] = []
|
||||
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in
|
||||
let label = Self.labelID(service: service, account: account)
|
||||
queriedLabels.append(label)
|
||||
return .notFound
|
||||
} operation: {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.dia) == true)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(queriedLabels == diaLabels)
|
||||
#expect(queriedLabels.allSatisfy { diaLabelSet.contains($0) })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `browser keychain interaction suppresses family and permits scoped explicit retry`() throws {
|
||||
BrowserCookieAccessGate.resetForTesting()
|
||||
defer { BrowserCookieAccessGate.resetForTesting() }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 2000)
|
||||
let chromeLabels = Self.labelIDs(for: .chrome)
|
||||
let diaLabels = Self.labelIDs(for: .dia)
|
||||
let firstChromeLabel = try #require(chromeLabels.first)
|
||||
let firstDiaLabel = try #require(diaLabels.first)
|
||||
let allowedLabels = Set(chromeLabels + diaLabels)
|
||||
var queriedLabels: [String] = []
|
||||
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { service, account in
|
||||
let label = Self.labelID(service: service, account: account)
|
||||
queriedLabels.append(label)
|
||||
if label == firstChromeLabel { return .allowed }
|
||||
if label == firstDiaLabel { return .interactionRequired }
|
||||
return .notFound
|
||||
} operation: {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false)
|
||||
}
|
||||
BrowserCookieAccessGate.withExplicitRetry {
|
||||
ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
#expect(BrowserCookieAccessGate
|
||||
.shouldAttempt(.chrome, now: start.addingTimeInterval(61)) == false)
|
||||
#expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(61)) == true)
|
||||
#expect(BrowserCookieAccessGate
|
||||
.shouldAttempt(.edge, now: start.addingTimeInterval(61)) == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstDiaLabel])
|
||||
#expect(queriedLabels.allSatisfy { allowedLabels.contains($0) })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `dia requires profile data`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.dia])
|
||||
#expect(detection.isCookieSourceAvailable(.dia) == false)
|
||||
|
||||
let profile = temp
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Application Support")
|
||||
.appendingPathComponent("Dia")
|
||||
.appendingPathComponent("User Data")
|
||||
.appendingPathComponent("Default")
|
||||
try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true)
|
||||
let cookiesDir = profile.appendingPathComponent("Network")
|
||||
try FileManager.default.createDirectory(at: cookiesDir, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: cookiesDir.appendingPathComponent("Cookies").path, contents: Data())
|
||||
|
||||
#expect(detection.isCookieSourceAvailable(.dia) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `removed browser with stale cookies is not a candidate`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let cookies = temp
|
||||
.appendingPathComponent("Library/Application Support/Dia/User Data/Default/Network/Cookies")
|
||||
try FileManager.default.createDirectory(
|
||||
at: cookies.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: cookies.path, contents: Data())
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [])
|
||||
|
||||
#expect(detection.hasUsableProfileData(.dia))
|
||||
#expect(!detection.isCookieSourceAvailable(.dia))
|
||||
#expect([Browser.dia].cookieImportCandidates(using: detection).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `browser uninstall invalidates cookie source immediately`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let cookies = temp
|
||||
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies")
|
||||
try FileManager.default.createDirectory(
|
||||
at: cookies.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: cookies.path, contents: Data())
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let installed = OSAllocatedUnfairLock(initialState: true)
|
||||
let detection = BrowserDetection(
|
||||
homeDirectory: temp.path,
|
||||
cacheTTL: 600,
|
||||
fileExists: { path in
|
||||
if path == "/Applications/Google Chrome.app" {
|
||||
return installed.withLock { $0 }
|
||||
}
|
||||
return FileManager.default.fileExists(atPath: path)
|
||||
},
|
||||
directoryContents: { path in
|
||||
try? FileManager.default.contentsOfDirectory(atPath: path)
|
||||
})
|
||||
|
||||
#expect(detection.isCookieSourceAvailable(.chrome))
|
||||
installed.withLock { $0 = false }
|
||||
#expect(!detection.isCookieSourceAvailable(.chrome))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `registered browser outside Applications is a candidate`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let cookies = temp
|
||||
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies")
|
||||
try FileManager.default.createDirectory(
|
||||
at: cookies.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: cookies.path, contents: Data())
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let appURL = URL(fileURLWithPath: "/Volumes/Tools/Google Chrome.app")
|
||||
let detection = BrowserDetection(
|
||||
homeDirectory: temp.path,
|
||||
cacheTTL: 0,
|
||||
now: Date.init,
|
||||
fileExists: { path in
|
||||
path == appURL.path || FileManager.default.fileExists(atPath: path)
|
||||
},
|
||||
directoryContents: { path in
|
||||
try? FileManager.default.contentsOfDirectory(atPath: path)
|
||||
},
|
||||
applicationURLs: { appName in
|
||||
appName == Browser.chrome.appBundleName ? [appURL] : []
|
||||
},
|
||||
profileAccessIssue: { _ in nil })
|
||||
|
||||
#expect(detection.isCookieSourceAvailable(.chrome))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `stale registered browser outside Applications is not a candidate`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let cookies = temp
|
||||
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/Network/Cookies")
|
||||
try FileManager.default.createDirectory(
|
||||
at: cookies.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: cookies.path, contents: Data())
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let staleAppURL = URL(fileURLWithPath: "/Volumes/Removed/Google Chrome.app")
|
||||
let detection = BrowserDetection(
|
||||
homeDirectory: temp.path,
|
||||
cacheTTL: 0,
|
||||
now: Date.init,
|
||||
fileExists: { path in
|
||||
if path.hasSuffix("/Google Chrome.app") {
|
||||
return false
|
||||
}
|
||||
return FileManager.default.fileExists(atPath: path)
|
||||
},
|
||||
directoryContents: { path in
|
||||
try? FileManager.default.contentsOfDirectory(atPath: path)
|
||||
},
|
||||
applicationURLs: { appName in
|
||||
appName == Browser.chrome.appBundleName ? [staleAppURL] : []
|
||||
},
|
||||
profileAccessIssue: { _ in nil })
|
||||
|
||||
#expect(!detection.isCookieSourceAvailable(.chrome))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `installed browser reports denied profile access`() {
|
||||
let home = "/tmp/codexbar-denied-browser-profile"
|
||||
let profileRoot = "\(home)/Library/Application Support/Google/Chrome"
|
||||
let detection = BrowserDetection(
|
||||
homeDirectory: home,
|
||||
cacheTTL: 0,
|
||||
now: Date.init,
|
||||
fileExists: { path in
|
||||
path == "/Applications/Google Chrome.app" || path == profileRoot
|
||||
},
|
||||
directoryContents: { _ in nil },
|
||||
applicationURLs: { _ in [] },
|
||||
profileAccessIssue: { _ in .accessDenied })
|
||||
|
||||
#expect(detection.cookieSourceProfileAccessIssue(.chrome) == .accessDenied)
|
||||
#expect(!detection.isCookieSourceAvailable(.chrome))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `firefox requires default profile dir`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let profiles = temp
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Application Support")
|
||||
.appendingPathComponent("Firefox")
|
||||
.appendingPathComponent("Profiles")
|
||||
try FileManager.default.createDirectory(at: profiles, withIntermediateDirectories: true)
|
||||
|
||||
let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.firefox])
|
||||
#expect(detection.isCookieSourceAvailable(.firefox) == false)
|
||||
|
||||
let profile = profiles.appendingPathComponent("abc.default-release")
|
||||
try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: profile.appendingPathComponent("cookies.sqlite").path, contents: Data())
|
||||
#expect(detection.isCookieSourceAvailable(.firefox) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `zen accepts uppercase default profile dir`() throws {
|
||||
let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
|
||||
let profiles = temp
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Application Support")
|
||||
.appendingPathComponent("zen")
|
||||
.appendingPathComponent("Profiles")
|
||||
try FileManager.default.createDirectory(at: profiles, withIntermediateDirectories: true)
|
||||
|
||||
let detection = self.detection(homeDirectory: temp.path, installedBrowsers: [.zen])
|
||||
#expect(detection.isCookieSourceAvailable(.zen) == false)
|
||||
|
||||
let profile = profiles.appendingPathComponent("abc.Default (release)")
|
||||
try FileManager.default.createDirectory(at: profile, withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: profile.appendingPathComponent("cookies.sqlite").path, contents: Data())
|
||||
#expect(detection.isCookieSourceAvailable(.zen) == true)
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
struct BrowserDetectionTests {
|
||||
@Test
|
||||
func `non mac OS returns no browsers`() {
|
||||
#expect(BrowserDetection(cacheTTL: 0).isCookieSourceAvailable(Browser()) == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `non mac OS filter returns empty`() {
|
||||
let detection = BrowserDetection(cacheTTL: 0)
|
||||
let browsers = [Browser(), Browser()]
|
||||
#expect(browsers.cookieImportCandidates(using: detection).isEmpty == true)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,129 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
|
||||
struct CLIArgumentParsingTests {
|
||||
@Test
|
||||
func `json shortcut does not enable json logs`() throws {
|
||||
let signature = CodexBarCLI._usageSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: ["--json"])
|
||||
|
||||
#expect(parsed.flags.contains("jsonShortcut"))
|
||||
#expect(!parsed.flags.contains("jsonOutput"))
|
||||
#expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `json output flag enables json logs`() throws {
|
||||
let signature = CodexBarCLI._usageSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: ["--json-output"])
|
||||
|
||||
#expect(parsed.flags.contains("jsonOutput"))
|
||||
#expect(!parsed.flags.contains("jsonShortcut"))
|
||||
#expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `log level and verbose are parsed`() throws {
|
||||
let signature = CodexBarCLI._usageSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: ["--log-level", "info", "--verbose"])
|
||||
|
||||
#expect(parsed.flags.contains("verbose"))
|
||||
#expect(parsed.options["logLevel"] == ["info"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolved log level defaults to error`() {
|
||||
#expect(CodexBarCLI.resolvedLogLevel(verbose: false, rawLevel: nil) == .error)
|
||||
#expect(CodexBarCLI.resolvedLogLevel(verbose: true, rawLevel: nil) == .debug)
|
||||
#expect(CodexBarCLI.resolvedLogLevel(verbose: false, rawLevel: "info") == .info)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `format option overrides json shortcut`() throws {
|
||||
let signature = CodexBarCLI._usageSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: ["--json", "--format", "text"])
|
||||
|
||||
#expect(parsed.flags.contains("jsonShortcut"))
|
||||
#expect(parsed.options["format"] == ["text"])
|
||||
#expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .text)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `json only enables json format`() throws {
|
||||
let signature = CodexBarCLI._usageSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: ["--json-only"])
|
||||
|
||||
#expect(parsed.flags.contains("jsonOnly"))
|
||||
#expect(!parsed.flags.contains("jsonOutput"))
|
||||
#expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `diagnose accepts json output flag but discards provider logs`() throws {
|
||||
let signature = CodexBarCLI._diagnoseSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: [
|
||||
"--provider", "minimax",
|
||||
"--format", "json",
|
||||
"--json-output",
|
||||
])
|
||||
|
||||
#expect(parsed.flags.contains("jsonOutput"))
|
||||
let config = CodexBarCLI.loggingConfiguration(path: ["diagnose"], values: parsed)
|
||||
switch config.destination {
|
||||
case .discard:
|
||||
break
|
||||
case .stderr, .oslog:
|
||||
Issue.record("diagnose should not emit provider logs beside the safe JSON export")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `diagnose accepts explicit redact and output path`() throws {
|
||||
let signature = CodexBarCLI._diagnoseSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: [
|
||||
"--provider", "minimax",
|
||||
"--format", "json",
|
||||
"--redact",
|
||||
"--output", "diagnostic.json",
|
||||
])
|
||||
|
||||
#expect(parsed.flags.contains("redact"))
|
||||
#expect(parsed.options["output"] == ["diagnostic.json"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Claude OAuth usage does not detect CLI version`() {
|
||||
#expect(!CodexBarCLI.shouldDetectVersion(
|
||||
provider: .claude,
|
||||
result: self.makeResult(kind: .oauth)))
|
||||
#expect(CodexBarCLI.shouldDetectVersion(
|
||||
provider: .claude,
|
||||
result: self.makeResult(kind: .cli)))
|
||||
#expect(CodexBarCLI.shouldDetectVersion(
|
||||
provider: .codex,
|
||||
result: self.makeResult(kind: .oauth)))
|
||||
}
|
||||
|
||||
private func makeResult(kind: ProviderFetchKind) -> ProviderFetchResult {
|
||||
ProviderFetchResult(
|
||||
usage: UsageSnapshot(
|
||||
primary: nil,
|
||||
secondary: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 0)),
|
||||
credits: nil,
|
||||
dashboard: nil,
|
||||
sourceLabel: "test",
|
||||
strategyID: "test",
|
||||
strategyKind: kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Commander
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
|
||||
struct CLICacheTests {
|
||||
@Test
|
||||
func `cache clear parses cookies provider flags`() throws {
|
||||
let parser = CommandParser(signature: CodexBarCLI._cacheSignatureForTesting())
|
||||
let parsed = try parser.parse(arguments: ["--cookies", "--provider", "claude", "--json"])
|
||||
|
||||
#expect(parsed.flags.contains("cookies"))
|
||||
#expect(parsed.flags.contains("jsonShortcut"))
|
||||
#expect(parsed.options["provider"] == ["claude"])
|
||||
#expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider scope is rejected for cost clearing`() {
|
||||
#expect(CodexBarCLI.cacheClearProviderScopeError(rawProvider: nil, clearCost: true) == nil)
|
||||
#expect(CodexBarCLI.cacheClearProviderScopeError(rawProvider: "claude", clearCost: false) == nil)
|
||||
#expect(CodexBarCLI.cacheClearProviderScopeError(rawProvider: "claude", clearCost: true)?
|
||||
.contains("--provider only scopes cookie caches") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cache help documents provider as cookie scoped`() {
|
||||
let help = CodexBarCLI.cacheHelp(version: "0.0.0")
|
||||
|
||||
#expect(help.contains("--provider with --cookies"))
|
||||
#expect(help.contains("codexbar cache clear --cookies --provider claude"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
|
||||
struct CLICardsRendererTests {
|
||||
@Test
|
||||
func `computes column count from terminal width`() {
|
||||
#expect(CLICardsRenderer.columnCount(terminalWidth: 80) == 2)
|
||||
#expect(CLICardsRenderer.columnCount(terminalWidth: 120) == 3)
|
||||
#expect(CLICardsRenderer.columnCount(terminalWidth: 160) == 4)
|
||||
#expect(CLICardsRenderer.columnCount(terminalWidth: 30) == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders single codex card without color`() {
|
||||
let identity = ProviderIdentitySnapshot(
|
||||
providerID: .codex,
|
||||
accountEmail: "user@example.com",
|
||||
accountOrganization: nil,
|
||||
loginMethod: "pro")
|
||||
let snapshot = UsageSnapshot(
|
||||
primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: "today at 3:00 PM"),
|
||||
secondary: .init(usedPercent: 25, windowMinutes: 10080, resetsAt: nil, resetDescription: "Fri at 9:00 AM"),
|
||||
tertiary: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 0),
|
||||
identity: identity)
|
||||
let card = CLICardsRenderer.makeCard(CLICardBuildInput(
|
||||
provider: .codex,
|
||||
snapshot: snapshot,
|
||||
credits: CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()),
|
||||
source: "oauth",
|
||||
status: nil,
|
||||
notes: [],
|
||||
useColor: false,
|
||||
resetStyle: .absolute,
|
||||
weeklyWorkDays: nil,
|
||||
now: Date()))
|
||||
|
||||
let output = CLICardsRenderer.render(cards: [card], failures: [], terminalWidth: 80, useColor: false)
|
||||
|
||||
#expect(output.contains("Codex"))
|
||||
#expect(output.contains("[oauth]"))
|
||||
#expect(output.contains("PLAN Pro 20x"))
|
||||
#expect(output.contains("Session"))
|
||||
#expect(output.contains("88% left"))
|
||||
#expect(output.contains("[ "))
|
||||
#expect(output.contains("━"))
|
||||
#expect(output.contains("Credits:"))
|
||||
#expect(output.contains("42 left"))
|
||||
#expect(output.contains("@ user@example.com"))
|
||||
#expect(output.contains("╰"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `card includes account line`() {
|
||||
let identity = ProviderIdentitySnapshot(
|
||||
providerID: .codex,
|
||||
accountEmail: "user@example.com",
|
||||
accountOrganization: nil,
|
||||
loginMethod: "pro")
|
||||
let snapshot = UsageSnapshot(
|
||||
primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
|
||||
secondary: nil,
|
||||
tertiary: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 0),
|
||||
identity: identity)
|
||||
let card = CLICardsRenderer.makeCard(CLICardBuildInput(
|
||||
provider: .codex,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
source: "cli",
|
||||
status: nil,
|
||||
notes: [],
|
||||
useColor: false,
|
||||
resetStyle: .absolute,
|
||||
weeklyWorkDays: nil,
|
||||
now: Date()))
|
||||
|
||||
let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false)
|
||||
let joined = lines.joined(separator: "\n")
|
||||
|
||||
#expect(joined.contains("@ user@example.com"))
|
||||
#expect(joined.contains("Session"))
|
||||
#expect(!joined.contains("Plan: Pro 20x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders two card grid at fixed width`() {
|
||||
let codex = CLICardModel(
|
||||
provider: .codex,
|
||||
title: "Codex",
|
||||
sourceLabel: "oauth",
|
||||
planBadge: "Pro",
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
let claude = CLICardModel(
|
||||
provider: .claude,
|
||||
title: "Claude",
|
||||
sourceLabel: "web",
|
||||
planBadge: "Max",
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
|
||||
let output = CLICardsRenderer.render(cards: [codex, claude], failures: [], terminalWidth: 120, useColor: false)
|
||||
|
||||
#expect(output.contains("Codex"))
|
||||
#expect(output.contains("Claude"))
|
||||
#expect(output.contains("88% left"))
|
||||
#expect(output.contains("50% left"))
|
||||
#expect(output.components(separatedBy: "╰").count >= 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders failure footer without cards`() {
|
||||
let failures = [
|
||||
CLICardFailure(provider: .cursor, accountLabel: nil, message: "not configured"),
|
||||
]
|
||||
let output = CLICardsRenderer.render(cards: [], failures: failures, terminalWidth: 80, useColor: false)
|
||||
|
||||
#expect(output.contains("Failed providers:"))
|
||||
#expect(output.contains("Cursor: not configured"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `appends failure footer after successful cards`() {
|
||||
let card = CLICardModel(
|
||||
provider: .codex,
|
||||
title: "Codex",
|
||||
sourceLabel: "oauth",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 88, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
let failures = [
|
||||
CLICardFailure(provider: .grok, accountLabel: nil, message: "timeout"),
|
||||
]
|
||||
|
||||
let output = CLICardsRenderer.render(cards: [card], failures: failures, terminalWidth: 80, useColor: false)
|
||||
|
||||
#expect(output.contains("88% left"))
|
||||
#expect(output.contains("Failed providers:"))
|
||||
#expect(output.contains("Grok: timeout"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `brief mode renders usage table`() {
|
||||
let card = CLICardModel(
|
||||
provider: .claude,
|
||||
title: "Claude",
|
||||
sourceLabel: "web",
|
||||
planBadge: "Max",
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 2, resetText: "⏳ Resets in 1h 49m")],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
let rows = CLICardsBriefRenderer.makeRows(cards: [card])
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: rows,
|
||||
failures: [],
|
||||
terminalWidth: 80,
|
||||
useColor: false,
|
||||
now: Date(timeIntervalSince1970: 0))
|
||||
|
||||
#expect(output.contains("codexbar • AI Usage & Limits"))
|
||||
#expect(output.contains("Provider"))
|
||||
#expect(output.contains("Claude"))
|
||||
#expect(output.contains("web"))
|
||||
#expect(output.contains("Max"))
|
||||
#expect(output.contains("98%"))
|
||||
#expect(output.contains("█"))
|
||||
#expect(output.contains("1h 49m"))
|
||||
#expect(output.contains("⚠ Warnings:"))
|
||||
let tableLine = output.split(separator: "\n").first { $0.hasPrefix("┌") } ?? ""
|
||||
#expect(tableLine.count >= 50)
|
||||
#expect(tableLine.count <= 72)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `synthetic quota lanes do not replace real brief usage`() {
|
||||
let snapshot = UsageSnapshot(
|
||||
primary: .init(
|
||||
usedPercent: 0,
|
||||
windowMinutes: 300,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil,
|
||||
isSyntheticPlaceholder: true),
|
||||
secondary: .init(
|
||||
usedPercent: 20,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil),
|
||||
tertiary: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
let card = CLICardsRenderer.makeCard(CLICardBuildInput(
|
||||
provider: .claude,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
source: "oauth",
|
||||
status: nil,
|
||||
notes: [],
|
||||
useColor: false,
|
||||
resetStyle: .countdown,
|
||||
weeklyWorkDays: nil,
|
||||
now: Date(timeIntervalSince1970: 0)))
|
||||
let rows = CLICardsBriefRenderer.makeRows(cards: [card])
|
||||
|
||||
#expect(card.metrics.map(\.label) == ["Weekly"])
|
||||
#expect(rows.first?.usedPercent == 20)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `brief reset summary wraps to terminal width`() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let card = CLICardModel(
|
||||
provider: .alibabatokenplan,
|
||||
title: "Alibaba Token Plan",
|
||||
sourceLabel: "web",
|
||||
planBadge: "International",
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(
|
||||
label: "Monthly budget",
|
||||
remainingPercent: 50,
|
||||
resetText: "⏳ Resets July 30 at 11:59 PM",
|
||||
resetAt: now.addingTimeInterval(3600))],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: CLICardsBriefRenderer.makeRows(cards: [card]),
|
||||
failures: [],
|
||||
terminalWidth: 40,
|
||||
useColor: false,
|
||||
now: now)
|
||||
let lines = output.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
|
||||
#expect(output.contains("Next reset: Alibaba Token Plan"))
|
||||
#expect(lines.allSatisfy { $0.count <= 40 })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `detail backed quota descriptions are not rendered as resets`() {
|
||||
let snapshot = UsageSnapshot(
|
||||
primary: .init(
|
||||
usedPercent: 25,
|
||||
windowMinutes: nil,
|
||||
resetsAt: nil,
|
||||
resetDescription: "25/100 credits"),
|
||||
secondary: nil,
|
||||
tertiary: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
let card = CLICardsRenderer.makeCard(CLICardBuildInput(
|
||||
provider: .kilo,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
source: "api",
|
||||
status: nil,
|
||||
notes: [],
|
||||
useColor: false,
|
||||
resetStyle: .countdown,
|
||||
weeklyWorkDays: nil,
|
||||
now: Date(timeIntervalSince1970: 0)))
|
||||
|
||||
#expect(card.metrics.first?.resetText == nil)
|
||||
#expect(card.metrics.first?.detailText == "25/100 credits")
|
||||
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: CLICardsBriefRenderer.makeRows(cards: [card]),
|
||||
failures: [],
|
||||
terminalWidth: 80,
|
||||
useColor: false,
|
||||
now: Date(timeIntervalSince1970: 0))
|
||||
#expect(!output.contains("Next reset"))
|
||||
#expect(!output.contains("Reset 25/100 credits"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `card metrics honor reset display style`() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let snapshot = UsageSnapshot(
|
||||
primary: .init(
|
||||
usedPercent: 25,
|
||||
windowMinutes: 300,
|
||||
resetsAt: now.addingTimeInterval(3600),
|
||||
resetDescription: nil),
|
||||
secondary: nil,
|
||||
tertiary: nil,
|
||||
updatedAt: now)
|
||||
let countdown = CLICardsRenderer.makeCard(CLICardBuildInput(
|
||||
provider: .codex,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
source: "oauth",
|
||||
status: nil,
|
||||
notes: [],
|
||||
useColor: false,
|
||||
resetStyle: .countdown,
|
||||
weeklyWorkDays: nil,
|
||||
now: now))
|
||||
let absolute = CLICardsRenderer.makeCard(CLICardBuildInput(
|
||||
provider: .codex,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
source: "oauth",
|
||||
status: nil,
|
||||
notes: [],
|
||||
useColor: false,
|
||||
resetStyle: .absolute,
|
||||
weeklyWorkDays: nil,
|
||||
now: now))
|
||||
|
||||
#expect(countdown.metrics.first?.resetText != absolute.metrics.first?.resetText)
|
||||
#expect(countdown.metrics.first?.resetText?.contains("in 1h") == true)
|
||||
#expect(absolute.metrics.first?.resetAt == now.addingTimeInterval(3600))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `long detail rows stay within card width`() {
|
||||
let card = CLICardModel(
|
||||
provider: .clawrouter,
|
||||
title: "ClawRouter",
|
||||
sourceLabel: "api",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: ["Workspace: " + String(repeating: "long-name-", count: 12)],
|
||||
metrics: [],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
|
||||
let lines = CLICardsRenderer.renderCard(card, width: 38, useColor: true, enhanced: true)
|
||||
#expect(lines.allSatisfy { TextParsing.stripANSICodes($0).count == 38 })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `brief warnings name the actual quota metric`() {
|
||||
let card = CLICardModel(
|
||||
provider: .openrouter,
|
||||
title: "OpenRouter",
|
||||
sourceLabel: "api",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Spend", remainingPercent: 10, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: CLICardsBriefRenderer.makeRows(cards: [card]),
|
||||
failures: [],
|
||||
terminalWidth: 80,
|
||||
useColor: false,
|
||||
now: Date(timeIntervalSince1970: 0))
|
||||
|
||||
#expect(output.contains("OpenRouter Spend: 90% used"))
|
||||
#expect(!output.contains("session limit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `brief rows preserve account identity`() {
|
||||
let cards = [
|
||||
CLICardModel(
|
||||
provider: .codex,
|
||||
title: "Codex",
|
||||
sourceLabel: "oauth",
|
||||
planBadge: "Pro",
|
||||
accountLine: "one@x.dev",
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 80, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil),
|
||||
CLICardModel(
|
||||
provider: .codex,
|
||||
title: "Codex",
|
||||
sourceLabel: "oauth",
|
||||
planBadge: "Pro",
|
||||
accountLine: "two@x.dev",
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 60, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil),
|
||||
]
|
||||
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: CLICardsBriefRenderer.makeRows(cards: cards),
|
||||
failures: [],
|
||||
terminalWidth: 80,
|
||||
useColor: false,
|
||||
now: Date(timeIntervalSince1970: 0))
|
||||
|
||||
#expect(output.contains("one@x.dev"))
|
||||
#expect(output.contains("two@x.dev"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `brief warnings wrap to terminal width`() {
|
||||
let cards = ["OpenRouter", "Antigravity", "CommandCode"].map { title in
|
||||
CLICardModel(
|
||||
provider: .openrouter,
|
||||
title: title,
|
||||
sourceLabel: "api",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Monthly budget", remainingPercent: 5, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
}
|
||||
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: CLICardsBriefRenderer.makeRows(cards: cards),
|
||||
failures: [],
|
||||
terminalWidth: 40,
|
||||
useColor: false,
|
||||
now: Date(timeIntervalSince1970: 0))
|
||||
let warningLines = output.split(separator: "\n").filter {
|
||||
$0.contains("Warnings:") || $0.contains("% used")
|
||||
}
|
||||
|
||||
#expect(warningLines.count > 1)
|
||||
#expect(warningLines.allSatisfy { $0.count <= 40 })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `brief summary ignores unparseable reset labels and fits narrow terminals`() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let rows = CLICardsBriefRenderer.makeRows(cards: [
|
||||
CLICardModel(
|
||||
provider: .kilo,
|
||||
title: "Kilo",
|
||||
sourceLabel: "api",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Credits", remainingPercent: 75, resetText: "Reset Unlimited")],
|
||||
extraLines: [],
|
||||
statusLine: nil),
|
||||
CLICardModel(
|
||||
provider: .codex,
|
||||
title: "Codex",
|
||||
sourceLabel: "oauth",
|
||||
planBadge: "Pro",
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(
|
||||
label: "Session",
|
||||
remainingPercent: 50,
|
||||
resetText: "⏳ Resets in 5h",
|
||||
resetAt: now.addingTimeInterval(5 * 3600))],
|
||||
extraLines: [],
|
||||
statusLine: nil),
|
||||
])
|
||||
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: rows,
|
||||
failures: [],
|
||||
terminalWidth: 40,
|
||||
useColor: false,
|
||||
now: now)
|
||||
let lines = output.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
|
||||
#expect(output.contains("Next reset: Codex in 5h"))
|
||||
#expect(!output.contains("Next reset: Kilo"))
|
||||
#expect(lines.allSatisfy { $0.count <= 40 })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `enhanced brief mode fills bars from used percentage`() {
|
||||
let rows = CLICardsBriefRenderer.makeRows(cards: [
|
||||
CLICardModel(
|
||||
provider: .codex,
|
||||
title: "Unused",
|
||||
sourceLabel: "oauth",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil),
|
||||
CLICardModel(
|
||||
provider: .openrouter,
|
||||
title: "Exhausted",
|
||||
sourceLabel: "api",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil),
|
||||
])
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: rows,
|
||||
failures: [],
|
||||
terminalWidth: 80,
|
||||
useColor: true,
|
||||
enhanced: true,
|
||||
now: Date(timeIntervalSince1970: 0))
|
||||
let plainLines = TextParsing.stripANSICodes(output).split(separator: "\n")
|
||||
let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "")
|
||||
let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "")
|
||||
|
||||
#expect(unusedLine.contains("0%"))
|
||||
#expect(unusedLine.filter { $0 == "█" }.isEmpty)
|
||||
#expect(exhaustedLine.contains("100%"))
|
||||
#expect(exhaustedLine.filter { $0 == "░" }.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `standard brief mode fills bars from used percentage`() {
|
||||
let rows = CLICardsBriefRenderer.makeRows(cards: [
|
||||
CLICardModel(
|
||||
provider: .codex,
|
||||
title: "Unused",
|
||||
sourceLabel: "oauth",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 100, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil),
|
||||
CLICardModel(
|
||||
provider: .openrouter,
|
||||
title: "Exhausted",
|
||||
sourceLabel: "api",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil),
|
||||
])
|
||||
let output = CLICardsBriefRenderer.render(
|
||||
rows: rows,
|
||||
failures: [],
|
||||
terminalWidth: 80,
|
||||
useColor: false,
|
||||
enhanced: false,
|
||||
now: Date(timeIntervalSince1970: 0))
|
||||
let plainLines = output.split(separator: "\n")
|
||||
let unusedLine = String(plainLines.first { $0.contains("Unused") } ?? "")
|
||||
let exhaustedLine = String(plainLines.first { $0.contains("Exhausted") } ?? "")
|
||||
|
||||
#expect(unusedLine.contains("0%"))
|
||||
#expect(unusedLine.filter { $0 == "█" }.isEmpty)
|
||||
#expect(exhaustedLine.contains("100%"))
|
||||
#expect(exhaustedLine.filter { $0 == "░" }.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `standard card grid shows empty remaining bar at exhaustion`() {
|
||||
let card = CLICardModel(
|
||||
provider: .openrouter,
|
||||
title: "Exhausted",
|
||||
sourceLabel: "api",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: false, enhanced: false)
|
||||
let barLine = String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? "")
|
||||
#expect(barLine.filter { $0 == "━" }.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `enhanced card grid shows empty remaining bar at exhaustion`() {
|
||||
let card = CLICardModel(
|
||||
provider: .openrouter,
|
||||
title: "Exhausted",
|
||||
sourceLabel: "api",
|
||||
planBadge: nil,
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 0, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
let lines = CLICardsRenderer.renderCard(card, width: 48, useColor: true, enhanced: true)
|
||||
let plainBarLine = TextParsing.stripANSICodes(
|
||||
String(lines.first { $0.contains("[ ") && $0.contains("]") } ?? ""))
|
||||
#expect(plainBarLine.filter { !$0.isWhitespace && $0 != "│" && $0 != "[" && $0 != "]" }.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `enhanced mode uses truecolor gradient bars`() {
|
||||
let card = CLICardModel(
|
||||
provider: .codex,
|
||||
title: "Codex",
|
||||
sourceLabel: "oauth",
|
||||
planBadge: "Pro",
|
||||
accountLine: nil,
|
||||
infoLines: [],
|
||||
metrics: [CLICardMetric(label: "Session", remainingPercent: 50, resetText: nil)],
|
||||
extraLines: [],
|
||||
statusLine: nil)
|
||||
let output = CLICardsRenderer.render(
|
||||
cards: [card],
|
||||
failures: [],
|
||||
terminalWidth: 80,
|
||||
useColor: true,
|
||||
enhanced: true)
|
||||
#expect(output.contains("38;2;"))
|
||||
#expect(output.contains("48;2;"))
|
||||
#expect(output.contains("[ "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
|
||||
struct CLIConfigCommandTests {
|
||||
@Test
|
||||
func `config set api key parses provider stdin and no enable flags`() throws {
|
||||
let parser = CommandParser(signature: CodexBarCLI._configSetAPIKeySignatureForTesting())
|
||||
let parsed = try parser.parse(arguments: [
|
||||
"--provider", "elevenlabs",
|
||||
"--stdin",
|
||||
"--no-enable",
|
||||
"--json",
|
||||
])
|
||||
|
||||
#expect(parsed.options["provider"] == ["elevenlabs"])
|
||||
#expect(parsed.flags.contains("stdin"))
|
||||
#expect(parsed.flags.contains("noEnable"))
|
||||
#expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config set api key parses zai team account options`() throws {
|
||||
let parser = CommandParser(signature: CodexBarCLI._configSetAPIKeySignatureForTesting())
|
||||
let parsed = try parser.parse(arguments: [
|
||||
"--provider", "zai",
|
||||
"--stdin",
|
||||
"--label", "Team",
|
||||
"--usage-scope", "team",
|
||||
"--organization-id", "org-team",
|
||||
"--workspace-id", "proj-team",
|
||||
])
|
||||
|
||||
#expect(parsed.options["provider"] == ["zai"])
|
||||
#expect(parsed.options["label"] == ["Team"])
|
||||
#expect(parsed.options["usageScope"] == ["team"])
|
||||
#expect(parsed.options["organizationId"] == ["org-team"])
|
||||
#expect(parsed.options["workspaceId"] == ["proj-team"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config set api key stores key and enables provider`() {
|
||||
let config = CodexBarConfig.makeDefault()
|
||||
let updated = CodexBarCLI.configSettingAPIKey(
|
||||
config,
|
||||
provider: .elevenlabs,
|
||||
apiKey: "xi-test-token",
|
||||
enableProvider: true)
|
||||
let provider = updated.providerConfig(for: .elevenlabs)
|
||||
|
||||
#expect(provider?.sanitizedAPIKey == "xi-test-token")
|
||||
#expect(provider?.enabled == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config set api key stores zai team token account`() throws {
|
||||
let config = CodexBarConfig.makeDefault()
|
||||
let options = try CodexBarCLI.resolveConfigAPIKeyAccountOptions(
|
||||
provider: .zai,
|
||||
label: "Team",
|
||||
usageScope: "team",
|
||||
organizationID: " org-team ",
|
||||
workspaceID: " proj-team ")
|
||||
let updated = CodexBarCLI.configSettingAPIKey(
|
||||
config,
|
||||
provider: .zai,
|
||||
apiKey: "z-token",
|
||||
enableProvider: true,
|
||||
accountOptions: options)
|
||||
let provider = try #require(updated.providerConfig(for: .zai))
|
||||
let account = try #require(provider.tokenAccounts?.accounts.first)
|
||||
|
||||
#expect(provider.enabled == true)
|
||||
#expect(provider.apiKey == nil)
|
||||
#expect(provider.tokenAccounts?.activeIndex == 0)
|
||||
#expect(account.label == "Team")
|
||||
#expect(account.token == "z-token")
|
||||
#expect(account.usageScope == "team")
|
||||
#expect(account.organizationID == "org-team")
|
||||
#expect(account.workspaceID == "proj-team")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config set api key rejects incomplete zai team account options`() {
|
||||
#expect(throws: CLIArgumentError.self) {
|
||||
_ = try CodexBarCLI.resolveConfigAPIKeyAccountOptions(
|
||||
provider: .zai,
|
||||
label: "Team",
|
||||
usageScope: "team",
|
||||
organizationID: "org-team",
|
||||
workspaceID: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config provider toggle parses provider and json flags`() throws {
|
||||
let parser = CommandParser(signature: CodexBarCLI._configProviderToggleSignatureForTesting())
|
||||
let parsed = try parser.parse(arguments: [
|
||||
"--provider", "grok",
|
||||
"--json",
|
||||
"--pretty",
|
||||
])
|
||||
|
||||
#expect(parsed.options["provider"] == ["grok"])
|
||||
#expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json)
|
||||
#expect(parsed.flags.contains("pretty"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config provider toggle enables and disables provider`() {
|
||||
let config = CodexBarConfig.makeDefault()
|
||||
let enabled = CodexBarCLI.configSettingProviderEnabled(config, provider: .grok, enabled: true)
|
||||
let disabled = CodexBarCLI.configSettingProviderEnabled(enabled, provider: .grok, enabled: false)
|
||||
|
||||
#expect(enabled.providerConfig(for: .grok)?.enabled == true)
|
||||
#expect(disabled.providerConfig(for: .grok)?.enabled == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config provider status includes effective default`() throws {
|
||||
let config = CodexBarConfig(providers: [
|
||||
ProviderConfig(id: .grok, enabled: true),
|
||||
ProviderConfig(id: .cursor, enabled: false),
|
||||
])
|
||||
let statuses = CodexBarCLI.configProviderStatuses(config)
|
||||
let grok = try #require(statuses.first { $0.provider == "grok" })
|
||||
let cursor = try #require(statuses.first { $0.provider == "cursor" })
|
||||
|
||||
#expect(grok.enabled)
|
||||
#expect(!cursor.enabled)
|
||||
#expect(statuses.count == UsageProvider.allCases.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config set api key only accepts consumed config keys`() {
|
||||
#expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .elevenlabs))
|
||||
#expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .groq))
|
||||
#expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .llmproxy))
|
||||
#expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .openai))
|
||||
#expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .amp))
|
||||
#expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .kimi))
|
||||
#expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .factory))
|
||||
#expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .bedrock))
|
||||
#expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .deepseek))
|
||||
#expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .cursor))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config set api key preserves disabled provider when requested`() {
|
||||
var config = CodexBarConfig.makeDefault()
|
||||
config.setProviderConfig(ProviderConfig(id: .elevenlabs, enabled: false))
|
||||
|
||||
let updated = CodexBarCLI.configSettingAPIKey(
|
||||
config,
|
||||
provider: .elevenlabs,
|
||||
apiKey: "xi-test-token",
|
||||
enableProvider: false)
|
||||
let provider = updated.providerConfig(for: .elevenlabs)
|
||||
|
||||
#expect(provider?.sanitizedAPIKey == "xi-test-token")
|
||||
#expect(provider?.enabled == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config set api key rejects ambiguous input`() {
|
||||
#expect(throws: CLIArgumentError.self) {
|
||||
try CodexBarCLI.resolveConfigAPIKeyInput(apiKey: "xi-test-token", readFromStdin: true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config help documents set api key`() {
|
||||
let help = CodexBarCLI.configHelp(version: "0.0.0")
|
||||
|
||||
#expect(help.contains("config set-api-key --provider <name>"))
|
||||
#expect(help.contains("config providers"))
|
||||
#expect(help.contains("config enable --provider <name>"))
|
||||
#expect(help.contains("config disable --provider <name>"))
|
||||
#expect(help.contains("--stdin"))
|
||||
#expect(help.contains("--usage-scope team"))
|
||||
#expect(help.contains("enables that provider by default"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
|
||||
struct CLICostTests {
|
||||
@Test
|
||||
func `cost json shortcut does not enable json logs`() throws {
|
||||
let signature = CodexBarCLI._costSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: ["--json"])
|
||||
|
||||
#expect(parsed.flags.contains("jsonShortcut"))
|
||||
#expect(!parsed.flags.contains("jsonOutput"))
|
||||
#expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders cost text snapshot`() {
|
||||
let snap = CostUsageTokenSnapshot(
|
||||
sessionTokens: 1200,
|
||||
sessionCostUSD: 1.25,
|
||||
last30DaysTokens: 9000,
|
||||
last30DaysCostUSD: 9.99,
|
||||
historyDays: 90,
|
||||
daily: [],
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
|
||||
let output = CodexBarCLI.renderCostText(provider: .claude, snapshot: snap, useColor: false)
|
||||
.replacingOccurrences(of: "\u{00A0}", with: " ")
|
||||
.replacingOccurrences(of: "$ ", with: "$")
|
||||
|
||||
#expect(output.contains("Claude Cost (API-rate estimate)"))
|
||||
#expect(output.contains("Today: $1.25 · 1.2K tokens"))
|
||||
#expect(output.contains("Last 90 days: $9.99 · 9K tokens"))
|
||||
#expect(output.contains("cache read/write tokens"))
|
||||
#expect(output.contains("Claude Code /status"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `renders codex project grouped cost text`() {
|
||||
let snap = CostUsageTokenSnapshot(
|
||||
sessionTokens: 1200,
|
||||
sessionCostUSD: 1.25,
|
||||
last30DaysTokens: 9000,
|
||||
last30DaysCostUSD: 9.99,
|
||||
historyDays: 30,
|
||||
daily: [],
|
||||
projects: [
|
||||
CostUsageProjectBreakdown(
|
||||
name: "client-a",
|
||||
path: "/work/client-a",
|
||||
totalTokens: 7000,
|
||||
totalCostUSD: 7.5,
|
||||
daily: [],
|
||||
modelBreakdowns: nil,
|
||||
sources: [
|
||||
CostUsageProjectSourceBreakdown(
|
||||
name: "client-a",
|
||||
path: "/work/client-a",
|
||||
totalTokens: 5000,
|
||||
totalCostUSD: 5.25,
|
||||
daily: [],
|
||||
modelBreakdowns: nil),
|
||||
CostUsageProjectSourceBreakdown(
|
||||
name: "client-a",
|
||||
path: "/Users/test/.codex/worktrees/abcd/client-a",
|
||||
totalTokens: 2000,
|
||||
totalCostUSD: 2.25,
|
||||
daily: [],
|
||||
modelBreakdowns: nil),
|
||||
]),
|
||||
CostUsageProjectBreakdown(
|
||||
name: CostUsageProjectBreakdown.unknownProjectName,
|
||||
path: nil,
|
||||
totalTokens: 2000,
|
||||
totalCostUSD: 2.49,
|
||||
daily: [],
|
||||
modelBreakdowns: nil),
|
||||
],
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
|
||||
let output = CodexBarCLI.renderCostText(
|
||||
provider: .codex,
|
||||
snapshot: snap,
|
||||
groupBy: .project,
|
||||
useColor: false)
|
||||
.replacingOccurrences(of: "\u{00A0}", with: " ")
|
||||
.replacingOccurrences(of: "$ ", with: "$")
|
||||
|
||||
#expect(output.contains("Codex Cost (API-rate estimate)"))
|
||||
#expect(output.contains("Projects (Last 30 days):"))
|
||||
#expect(output.contains("client-a: $7.50 · 7K tokens"))
|
||||
#expect(output.contains("/work/client-a"))
|
||||
#expect(output.contains(" - client-a: $5.25 · 5K tokens"))
|
||||
#expect(output.contains(" - client-a: $2.25 · 2K tokens"))
|
||||
#expect(output.contains("/Users/test/.codex/worktrees/abcd/client-a"))
|
||||
#expect(output.contains("Unknown project: $2.49 · 2K tokens"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `encodes cost payload JSON`() throws {
|
||||
let payload = CostPayload(
|
||||
provider: "claude",
|
||||
source: "local",
|
||||
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
sessionTokens: 100,
|
||||
sessionCostUSD: 0.5,
|
||||
historyDays: 90,
|
||||
last30DaysTokens: 200,
|
||||
last30DaysCostUSD: 1.5,
|
||||
daily: [
|
||||
CostDailyEntryPayload(
|
||||
date: "2025-12-20",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 2,
|
||||
cacheCreationTokens: 3,
|
||||
totalTokens: 15,
|
||||
costUSD: 0.01,
|
||||
modelsUsed: ["claude-sonnet-4-20250514"],
|
||||
modelBreakdowns: [
|
||||
CostModelBreakdownPayload(
|
||||
modelName: "claude-sonnet-4-20250514",
|
||||
costUSD: 0.01,
|
||||
totalTokens: 15),
|
||||
]),
|
||||
],
|
||||
totals: CostTotalsPayload(
|
||||
totalInputTokens: 10,
|
||||
totalOutputTokens: 5,
|
||||
cacheReadTokens: 2,
|
||||
cacheCreationTokens: 3,
|
||||
totalTokens: 15,
|
||||
totalCostUSD: 0.01),
|
||||
error: nil)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .secondsSince1970
|
||||
let data = try encoder.encode(payload)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
Issue.record("Failed to decode cost payload JSON")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(json.contains("\"provider\":\"claude\""))
|
||||
#expect(json.contains("\"source\":\"local\""))
|
||||
#expect(json.contains("\"historyDays\":90"))
|
||||
#expect(json.contains("\"daily\""))
|
||||
#expect(json.contains("\"totals\""))
|
||||
#expect(json.contains("\"cacheReadTokens\":2"))
|
||||
#expect(json.contains("\"cacheCreationTokens\":3"))
|
||||
#expect(json.contains("\"totalCost\""))
|
||||
#expect(json.contains("\"totalTokens\":15"))
|
||||
#expect(json.contains("1700000000"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex cost payload includes project rollups`() throws {
|
||||
let snapshot = CostUsageTokenSnapshot(
|
||||
sessionTokens: 10,
|
||||
sessionCostUSD: 0.01,
|
||||
last30DaysTokens: 40,
|
||||
last30DaysCostUSD: 0.04,
|
||||
daily: [
|
||||
CostUsageDailyReport.Entry(
|
||||
date: "2026-04-02",
|
||||
inputTokens: 30,
|
||||
outputTokens: 10,
|
||||
totalTokens: 40,
|
||||
costUSD: 0.04,
|
||||
modelsUsed: ["gpt-5.4"],
|
||||
modelBreakdowns: [
|
||||
CostUsageDailyReport.ModelBreakdown(
|
||||
modelName: "gpt-5.4",
|
||||
costUSD: 0.04,
|
||||
totalTokens: 40),
|
||||
]),
|
||||
],
|
||||
projects: [
|
||||
CostUsageProjectBreakdown(
|
||||
name: "client-a",
|
||||
path: "/work/client-a",
|
||||
totalTokens: 40,
|
||||
totalCostUSD: 0.04,
|
||||
daily: [
|
||||
CostUsageDailyReport.Entry(
|
||||
date: "2026-04-02",
|
||||
inputTokens: 30,
|
||||
outputTokens: 10,
|
||||
totalTokens: 40,
|
||||
costUSD: 0.04,
|
||||
modelsUsed: ["gpt-5.4"],
|
||||
modelBreakdowns: nil),
|
||||
],
|
||||
modelBreakdowns: [
|
||||
CostUsageDailyReport.ModelBreakdown(
|
||||
modelName: "gpt-5.4",
|
||||
costUSD: 0.04,
|
||||
totalTokens: 40),
|
||||
],
|
||||
sources: [
|
||||
CostUsageProjectSourceBreakdown(
|
||||
name: "client-a",
|
||||
path: "/work/client-a",
|
||||
totalTokens: 40,
|
||||
totalCostUSD: 0.04,
|
||||
daily: [
|
||||
CostUsageDailyReport.Entry(
|
||||
date: "2026-04-02",
|
||||
inputTokens: 30,
|
||||
outputTokens: 10,
|
||||
totalTokens: 40,
|
||||
costUSD: 0.04,
|
||||
modelsUsed: ["gpt-5.4"],
|
||||
modelBreakdowns: nil),
|
||||
],
|
||||
modelBreakdowns: nil),
|
||||
]),
|
||||
],
|
||||
updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
let payload = CodexBarCLI.makeCostPayload(provider: .codex, snapshot: snapshot, error: nil)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .secondsSince1970
|
||||
let data = try encoder.encode(payload)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
Issue.record("Failed to decode cost payload JSON")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(json.contains("\"projects\""))
|
||||
#expect(json.contains("\"sources\""))
|
||||
#expect(json.contains("\"name\":\"client-a\""))
|
||||
#expect(json.contains("/work/client-a") || json.contains("\\/work\\/client-a"))
|
||||
#expect(json.contains("\"totalCost\":0.04"))
|
||||
#expect(json.contains("\"daily\""))
|
||||
#expect(json.contains("\"gpt-5.4\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `encodes exact codex model I ds and zero cost breakdowns`() throws {
|
||||
let payload = CostPayload(
|
||||
provider: "codex",
|
||||
source: "local",
|
||||
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
sessionTokens: 155,
|
||||
sessionCostUSD: 0,
|
||||
historyDays: 30,
|
||||
last30DaysTokens: 155,
|
||||
last30DaysCostUSD: 0,
|
||||
daily: [
|
||||
CostDailyEntryPayload(
|
||||
date: "2025-12-21",
|
||||
inputTokens: 120,
|
||||
outputTokens: 15,
|
||||
cacheReadTokens: 20,
|
||||
cacheCreationTokens: nil,
|
||||
totalTokens: 155,
|
||||
costUSD: 0,
|
||||
modelsUsed: ["gpt-5.3-codex-spark", "gpt-5.2-codex"],
|
||||
modelBreakdowns: [
|
||||
CostModelBreakdownPayload(modelName: "gpt-5.3-codex-spark", costUSD: 0, totalTokens: 15),
|
||||
CostModelBreakdownPayload(modelName: "gpt-5.2-codex", costUSD: 1.23, totalTokens: 140),
|
||||
]),
|
||||
],
|
||||
totals: CostTotalsPayload(
|
||||
totalInputTokens: 120,
|
||||
totalOutputTokens: 15,
|
||||
cacheReadTokens: 20,
|
||||
cacheCreationTokens: nil,
|
||||
totalTokens: 155,
|
||||
totalCostUSD: 0),
|
||||
error: nil)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .secondsSince1970
|
||||
let data = try encoder.encode(payload)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
Issue.record("Failed to decode cost payload JSON")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(json.contains("\"gpt-5.3-codex-spark\""))
|
||||
#expect(json.contains("\"gpt-5.2-codex\""))
|
||||
#expect(!json.contains("\"gpt-5.2\""))
|
||||
#expect(json.contains("\"cost\":0"))
|
||||
#expect(json.contains("\"totalTokens\":140"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost estimate hint is stable string`() {
|
||||
let hint = UsageFormatter.costEstimateHint
|
||||
#expect(!hint.isEmpty)
|
||||
#expect(hint.contains("Estimated"))
|
||||
#expect(UsageFormatter.costEstimateHint(provider: .claude).contains("cache read/write tokens"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
|
||||
struct CLIDiagnoseCommandTests {
|
||||
@Test
|
||||
func `diagnose help describes generic JSON export`() {
|
||||
let help = CodexBarCLI.diagnoseHelp(version: "0.0.0")
|
||||
|
||||
#expect(help.contains("codexbar diagnose --provider <name|all> --format json"))
|
||||
#expect(help.contains("codexbar diagnose --provider all --format json"))
|
||||
#expect(help.contains("--redact"))
|
||||
#expect(help.contains("--output <path>"))
|
||||
#expect(help.contains("safe JSON export"))
|
||||
#expect(help.contains("raw API tokens"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `diagnose output writer creates parent directories`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("CodexBarDiagnoseTests-\(UUID().uuidString)")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let output = root.appendingPathComponent("nested/diagnostic.json")
|
||||
try CodexBarCLI.writeDiagnosticExport(#"{"provider":"minimax"}"#, to: output.path)
|
||||
|
||||
let contents = try String(contentsOf: output, encoding: .utf8)
|
||||
#expect(contents == #"{"provider":"minimax"}"#)
|
||||
}
|
||||
|
||||
private func makeSettingsWithMiniMaxCookie(_ manualCookieHeader: String) -> ProviderSettingsSnapshot {
|
||||
ProviderSettingsSnapshot(
|
||||
debugMenuEnabled: false,
|
||||
debugKeepCLISessionsAlive: false,
|
||||
codex: nil,
|
||||
claude: nil,
|
||||
cursor: nil,
|
||||
opencode: nil,
|
||||
opencodego: nil,
|
||||
alibaba: nil,
|
||||
factory: nil,
|
||||
minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: manualCookieHeader,
|
||||
apiRegion: .global),
|
||||
manus: nil,
|
||||
zai: nil,
|
||||
copilot: nil,
|
||||
kilo: nil,
|
||||
kimi: nil,
|
||||
augment: nil,
|
||||
amp: nil,
|
||||
ollama: nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `diagnose auth mode uses settings-backed MiniMax manual cookie when env token is absent`() {
|
||||
let settings = self.makeSettingsWithMiniMaxCookie("Cookie: session_id=demo-cookie")
|
||||
|
||||
let authMode = CodexBarCLI._resolveMiniMaxAuthModeForTesting(
|
||||
environment: [:],
|
||||
settings: settings)
|
||||
|
||||
#expect(authMode == .cookie)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `diagnose auth mode keeps apiToken precedence over settings cookie`() {
|
||||
let settings = self.makeSettingsWithMiniMaxCookie("Cookie: session_id=demo-cookie")
|
||||
|
||||
let authMode = CodexBarCLI._resolveMiniMaxAuthModeForTesting(
|
||||
environment: [MiniMaxAPISettingsReader.apiTokenKey: "sk-api-demo-token"],
|
||||
settings: settings)
|
||||
|
||||
#expect(authMode == .apiToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generic diagnose auth summary detects provider config`() {
|
||||
let summary = CodexBarCLI._diagnosticAuthSummaryForTesting(
|
||||
provider: .openai,
|
||||
account: nil,
|
||||
config: ProviderConfig(id: .openai, apiKey: "sk-test"),
|
||||
environment: [:],
|
||||
settings: nil)
|
||||
|
||||
#expect(summary.configured)
|
||||
#expect(summary.modes == ["api"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generic diagnose auth summary detects provider environment credentials`() {
|
||||
let summary = CodexBarCLI._diagnosticAuthSummaryForTesting(
|
||||
provider: .openai,
|
||||
account: nil,
|
||||
config: nil,
|
||||
environment: [OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-test"],
|
||||
settings: nil)
|
||||
|
||||
#expect(summary.configured)
|
||||
#expect(summary.modes == ["api"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generic diagnose auth summary detects Chutes environment credentials`() {
|
||||
let summary = CodexBarCLI._diagnosticAuthSummaryForTesting(
|
||||
provider: .chutes,
|
||||
account: nil,
|
||||
config: nil,
|
||||
environment: [ChutesSettingsReader.apiKeyEnvironmentKey: "chutes-test"],
|
||||
settings: nil)
|
||||
|
||||
#expect(summary.configured)
|
||||
#expect(summary.modes == ["api"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generic diagnose auth summary detects CrossModel environment credentials`() {
|
||||
let summary = CodexBarCLI._diagnosticAuthSummaryForTesting(
|
||||
provider: .crossmodel,
|
||||
account: nil,
|
||||
config: nil,
|
||||
environment: [CrossModelSettingsReader.envKey: "cm-test"],
|
||||
settings: nil)
|
||||
|
||||
#expect(summary.configured)
|
||||
#expect(summary.modes == ["api"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generic diagnose auth summary requires complete Bedrock credentials`() {
|
||||
let partial = CodexBarCLI._diagnosticAuthSummaryForTesting(
|
||||
provider: .bedrock,
|
||||
account: nil,
|
||||
config: ProviderConfig(id: .bedrock, apiKey: "access-only"),
|
||||
environment: [BedrockSettingsReader.accessKeyIDKey: "access-only"],
|
||||
settings: nil)
|
||||
#expect(!partial.configured)
|
||||
#expect(partial.modes.isEmpty)
|
||||
|
||||
let complete = CodexBarCLI._diagnosticAuthSummaryForTesting(
|
||||
provider: .bedrock,
|
||||
account: nil,
|
||||
config: nil,
|
||||
environment: [
|
||||
BedrockSettingsReader.accessKeyIDKey: "access",
|
||||
BedrockSettingsReader.secretAccessKeyKey: "secret",
|
||||
],
|
||||
settings: nil)
|
||||
#expect(complete.configured)
|
||||
#expect(complete.modes == ["api"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `generic diagnose auth summary does not assume ambient credentials`() {
|
||||
let summary = CodexBarCLI._diagnosticAuthSummaryForTesting(
|
||||
provider: .codex,
|
||||
account: nil,
|
||||
config: nil,
|
||||
environment: [:],
|
||||
settings: nil)
|
||||
|
||||
#expect(!summary.configured)
|
||||
#expect(summary.modes.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import CodexBarCore
|
||||
import Commander
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import CodexBarCLI
|
||||
|
||||
final class CLIEntryTests: XCTestCase {
|
||||
func test_effectiveArgvDefaultsToUsage() {
|
||||
XCTAssertEqual(CodexBarCLI.effectiveArgv([]), ["usage"])
|
||||
XCTAssertEqual(CodexBarCLI.effectiveArgv(["--json"]), ["usage", "--json"])
|
||||
XCTAssertEqual(CodexBarCLI.effectiveArgv(["usage", "--json"]), ["usage", "--json"])
|
||||
}
|
||||
|
||||
func test_decodesFormatFromOptionsAndFlags() {
|
||||
let jsonOption = ParsedValues(positional: [], options: ["format": ["json"]], flags: [])
|
||||
XCTAssertEqual(CodexBarCLI._decodeFormatForTesting(from: jsonOption), .json)
|
||||
|
||||
let jsonFlag = ParsedValues(positional: [], options: [:], flags: ["json"])
|
||||
XCTAssertEqual(CodexBarCLI._decodeFormatForTesting(from: jsonFlag), .json)
|
||||
|
||||
let textDefault = ParsedValues(positional: [], options: [:], flags: [])
|
||||
XCTAssertEqual(CodexBarCLI._decodeFormatForTesting(from: textDefault), .text)
|
||||
}
|
||||
|
||||
func test_providerSelectionPrefersOverride() {
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: "codex", enabled: [.claude, .gemini])
|
||||
XCTAssertEqual(selection.asList, [.codex])
|
||||
}
|
||||
|
||||
func test_normalizeVersionExtractsNumeric() {
|
||||
XCTAssertEqual(CodexBarCLI.normalizeVersion(raw: "codex 1.2.3 (build 4)"), "1.2.3")
|
||||
XCTAssertEqual(CodexBarCLI.normalizeVersion(raw: " v2.0 "), "2.0")
|
||||
}
|
||||
|
||||
func test_makeHeaderIncludesVersionWhenAvailable() {
|
||||
let header = CodexBarCLI.makeHeader(provider: .codex, version: "1.2.3", source: "cli")
|
||||
XCTAssertTrue(header.contains("Codex"))
|
||||
XCTAssertTrue(header.contains("1.2.3"))
|
||||
XCTAssertTrue(header.contains("cli"))
|
||||
}
|
||||
|
||||
func test_cliVersionFallsBackToContainingAppBundle() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codexbar-cli-version-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true)
|
||||
let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true)
|
||||
let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true)
|
||||
|
||||
let infoURL = contentsURL.appendingPathComponent("Info.plist")
|
||||
let plist: [String: Any] = ["CFBundleShortVersionString": "9.8.7"]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: infoURL)
|
||||
|
||||
let helperURL = helpersURL.appendingPathComponent("CodexBarCLI")
|
||||
try Data().write(to: helperURL)
|
||||
|
||||
XCTAssertEqual(CodexBarCLI.containingAppVersion(for: helperURL), "9.8.7")
|
||||
}
|
||||
|
||||
func test_cliVersionFollowsSymlinkedHelper() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codexbar-cli-version-symlink-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true)
|
||||
let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true)
|
||||
let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true)
|
||||
let binURL = root.appendingPathComponent("bin", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true)
|
||||
|
||||
let infoURL = contentsURL.appendingPathComponent("Info.plist")
|
||||
let plist: [String: Any] = ["CFBundleShortVersionString": "2.4.6"]
|
||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||
try data.write(to: infoURL)
|
||||
|
||||
let helperURL = helpersURL.appendingPathComponent("CodexBarCLI")
|
||||
try Data().write(to: helperURL)
|
||||
|
||||
let symlinkURL = binURL.appendingPathComponent("codexbar")
|
||||
try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: helperURL)
|
||||
|
||||
XCTAssertEqual(CodexBarCLI.currentVersion(bundleVersion: nil, executablePath: symlinkURL.path), "2.4.6")
|
||||
}
|
||||
|
||||
func test_cliVersionFallsBackToAdjacentVersionFile() throws {
|
||||
try self.expectAdjacentVersionFile(raw: "v3.2.1\n", expected: "3.2.1")
|
||||
try self.expectAdjacentVersionFile(raw: "3.2.2\n", expected: "3.2.2")
|
||||
try self.expectAdjacentVersionFile(raw: "version-3.2.3\n", expected: "version-3.2.3")
|
||||
}
|
||||
|
||||
func test_cliVersionPrefersAdjacentVersionOverStandaloneBundleName() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codexbar-cli-version-bundle-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let binURL = root.appendingPathComponent("bin", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true)
|
||||
|
||||
let helperURL = binURL.appendingPathComponent("CodexBarCLI")
|
||||
try Data().write(to: helperURL)
|
||||
try "4.5.6\n".write(
|
||||
to: binURL.appendingPathComponent("VERSION"),
|
||||
atomically: false,
|
||||
encoding: .utf8)
|
||||
|
||||
XCTAssertEqual(
|
||||
CodexBarCLI.currentVersion(bundleVersion: "CodexBar", executablePath: helperURL.path),
|
||||
"4.5.6")
|
||||
}
|
||||
|
||||
private func expectAdjacentVersionFile(raw: String, expected: String) throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codexbar-cli-version-file-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let binURL = root.appendingPathComponent("bin", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true)
|
||||
|
||||
let helperURL = binURL.appendingPathComponent("CodexBarCLI")
|
||||
try Data().write(to: helperURL)
|
||||
try raw.write(
|
||||
to: binURL.appendingPathComponent("VERSION"),
|
||||
atomically: false,
|
||||
encoding: .utf8)
|
||||
|
||||
XCTAssertEqual(CodexBarCLI.currentVersion(bundleVersion: nil, executablePath: helperURL.path), expected)
|
||||
}
|
||||
|
||||
func test_renderOpenAIWebDashboardTextIncludesSummary() {
|
||||
let event = CreditEvent(
|
||||
date: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
service: "codex",
|
||||
creditsUsed: 10)
|
||||
let snapshot = OpenAIDashboardSnapshot(
|
||||
signedInEmail: "user@example.com",
|
||||
codeReviewRemainingPercent: 45,
|
||||
codeReviewLimit: RateWindow(
|
||||
usedPercent: 55,
|
||||
windowMinutes: nil,
|
||||
resetsAt: Date().addingTimeInterval(3600),
|
||||
resetDescription: nil),
|
||||
creditEvents: [event],
|
||||
dailyBreakdown: [],
|
||||
usageBreakdown: [],
|
||||
creditsPurchaseURL: nil,
|
||||
updatedAt: Date())
|
||||
|
||||
let text = CodexBarCLI.renderOpenAIWebDashboardText(snapshot)
|
||||
|
||||
XCTAssertTrue(text.contains("Web session: user@example.com"))
|
||||
XCTAssertTrue(text.contains("Code review: 45% remaining (Resets in "))
|
||||
XCTAssertTrue(text.contains("Web history: 1 events"))
|
||||
}
|
||||
|
||||
func test_mapsErrorsToExitCodes() {
|
||||
XCTAssertEqual(CodexBarCLI.mapError(CodexStatusProbeError.codexNotInstalled), ExitCode(2))
|
||||
XCTAssertEqual(CodexBarCLI.mapError(CodexStatusProbeError.timedOut), ExitCode(4))
|
||||
XCTAssertEqual(CodexBarCLI.mapError(ClaudeWebFetchStrategyError.timedOut(seconds: 1)), ExitCode(4))
|
||||
XCTAssertEqual(CodexBarCLI.mapError(UsageError.noRateLimitsFound), ExitCode(3))
|
||||
}
|
||||
|
||||
func test_antigravityPlanDebugKeepsOneShotHelperAliveUntilDebugFetch() {
|
||||
XCTAssertTrue(CodexBarCLI.holdsAntigravityCLISessionForPlanDebug(
|
||||
provider: .antigravity,
|
||||
planDebugEnabled: true,
|
||||
jsonOnly: false,
|
||||
persistsCLISessions: false))
|
||||
XCTAssertFalse(CodexBarCLI.holdsAntigravityCLISessionForPlanDebug(
|
||||
provider: .codex,
|
||||
planDebugEnabled: true,
|
||||
jsonOnly: false,
|
||||
persistsCLISessions: false))
|
||||
XCTAssertFalse(CodexBarCLI.holdsAntigravityCLISessionForPlanDebug(
|
||||
provider: .antigravity,
|
||||
planDebugEnabled: true,
|
||||
jsonOnly: true,
|
||||
persistsCLISessions: false))
|
||||
XCTAssertFalse(CodexBarCLI.holdsAntigravityCLISessionForPlanDebug(
|
||||
provider: .antigravity,
|
||||
planDebugEnabled: true,
|
||||
jsonOnly: false,
|
||||
persistsCLISessions: true))
|
||||
}
|
||||
|
||||
func test_missingCodexBinaryErrorPayloadUsesInstallGuidance() {
|
||||
let payload = CodexBarCLI.makeErrorPayload(CodexStatusProbeError.codexNotInstalled, kind: .provider)
|
||||
|
||||
XCTAssertEqual(payload.code, ExitCode.binaryNotFound.rawValue)
|
||||
XCTAssertTrue(payload.message.contains("Codex CLI missing"))
|
||||
XCTAssertFalse(payload.message.contains("Codex not running"))
|
||||
}
|
||||
|
||||
func test_providerSelectionFallsBackToBothForPrimaryPair() {
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: [.codex, .claude])
|
||||
switch selection {
|
||||
case .both:
|
||||
break
|
||||
default:
|
||||
XCTFail("Expected both selection")
|
||||
}
|
||||
}
|
||||
|
||||
func test_providerSelectionFallsBackToCustomWhenNonPrimary() {
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: [.codex, .gemini])
|
||||
switch selection {
|
||||
case let .custom(providers):
|
||||
XCTAssertEqual(providers, [.codex, .gemini])
|
||||
default:
|
||||
XCTFail("Expected custom selection")
|
||||
}
|
||||
}
|
||||
|
||||
func test_providerSelectionHonorsEmptyEnabledSet() {
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: [])
|
||||
switch selection {
|
||||
case let .custom(providers):
|
||||
XCTAssertEqual(providers, [])
|
||||
default:
|
||||
XCTFail("Expected empty custom selection")
|
||||
}
|
||||
}
|
||||
|
||||
func test_decodesSourceAndTimeoutOptions() throws {
|
||||
let signature = CodexBarCLI._usageSignatureForTesting()
|
||||
let parser = CommandParser(signature: signature)
|
||||
let parsed = try parser.parse(arguments: ["--web-timeout", "45", "--source", "oauth"])
|
||||
XCTAssertEqual(try CodexBarCLI._decodeWebTimeoutForTesting(from: parsed), 45)
|
||||
XCTAssertEqual(CodexBarCLI._decodeSourceModeForTesting(from: parsed), .oauth)
|
||||
|
||||
let parsedWeb = try parser.parse(arguments: ["--web"])
|
||||
XCTAssertEqual(CodexBarCLI._decodeSourceModeForTesting(from: parsedWeb), .web)
|
||||
}
|
||||
|
||||
func test_rejectsUnsafeWebTimeoutOptions() throws {
|
||||
for value in ["-1", "nan", "inf", "1e300"] {
|
||||
let parsed = ParsedValues(positional: [], options: ["webTimeout": [value]], flags: [])
|
||||
XCTAssertThrowsError(try CodexBarCLI._decodeWebTimeoutForTesting(from: parsed))
|
||||
}
|
||||
}
|
||||
|
||||
func test_shouldUseColorRespectsFormatAndFlags() {
|
||||
XCTAssertFalse(CodexBarCLI.shouldUseColor(noColor: true, format: .text))
|
||||
XCTAssertFalse(CodexBarCLI.shouldUseColor(noColor: false, format: .json))
|
||||
}
|
||||
|
||||
func test_kiloUsageTextNotesShowFallbackOnlyForAutoResolvedToCLI() {
|
||||
XCTAssertEqual(CodexBarCLI.usageTextNotes(
|
||||
provider: .kilo,
|
||||
sourceMode: .auto,
|
||||
resolvedSourceLabel: "cli"), ["Using CLI fallback"])
|
||||
XCTAssertTrue(CodexBarCLI.usageTextNotes(
|
||||
provider: .kilo,
|
||||
sourceMode: .api,
|
||||
resolvedSourceLabel: "cli").isEmpty)
|
||||
XCTAssertTrue(CodexBarCLI.usageTextNotes(
|
||||
provider: .codex,
|
||||
sourceMode: .auto,
|
||||
resolvedSourceLabel: "cli").isEmpty)
|
||||
}
|
||||
|
||||
func test_kiloAutoFallbackSummaryIncludesOrderedAttemptDetails() {
|
||||
let attempts = [
|
||||
ProviderFetchAttempt(
|
||||
strategyID: "kilo.api",
|
||||
kind: .apiToken,
|
||||
wasAvailable: true,
|
||||
errorDescription: "Kilo authentication failed (401/403)."),
|
||||
ProviderFetchAttempt(
|
||||
strategyID: "kilo.cli",
|
||||
kind: .cli,
|
||||
wasAvailable: true,
|
||||
errorDescription: "Kilo CLI session not found."),
|
||||
]
|
||||
|
||||
let summary = CodexBarCLI.kiloAutoFallbackSummary(
|
||||
provider: .kilo,
|
||||
sourceMode: .auto,
|
||||
attempts: attempts)
|
||||
let expected = [
|
||||
"Kilo auto fallback attempts: api: Kilo authentication failed (401/403).",
|
||||
" -> cli: Kilo CLI session not found.",
|
||||
].joined()
|
||||
|
||||
XCTAssertEqual(summary, expected)
|
||||
}
|
||||
|
||||
func test_kiloAutoFallbackSummaryIsNilOutsideKiloAutoFailures() {
|
||||
let attempts = [
|
||||
ProviderFetchAttempt(
|
||||
strategyID: "kilo.api",
|
||||
kind: .apiToken,
|
||||
wasAvailable: true,
|
||||
errorDescription: "example"),
|
||||
]
|
||||
|
||||
XCTAssertNil(CodexBarCLI.kiloAutoFallbackSummary(
|
||||
provider: .kilo,
|
||||
sourceMode: .api,
|
||||
attempts: attempts))
|
||||
XCTAssertNil(CodexBarCLI.kiloAutoFallbackSummary(
|
||||
provider: .codex,
|
||||
sourceMode: .auto,
|
||||
attempts: attempts))
|
||||
}
|
||||
|
||||
func test_sourceModeRequiresWebSupportIsProviderAware() throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("mimo-cli-source-mode-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let validMiMoCache = directory.appendingPathComponent("valid.json")
|
||||
let invalidMiMoCache = directory.appendingPathComponent("invalid.json")
|
||||
let payload: [String: Any] = [
|
||||
"sessions_scanned": 1,
|
||||
"windows": [
|
||||
"today": [:],
|
||||
"week": [:],
|
||||
"all_time": [:],
|
||||
],
|
||||
]
|
||||
try JSONSerialization.data(withJSONObject: payload).write(to: validMiMoCache)
|
||||
try Data("{}".utf8).write(to: invalidMiMoCache)
|
||||
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .kilo))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .codex))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .claude))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .claude))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .kilo))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .grok))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .grok))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .amp))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.api, provider: .kilo))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .opencodego,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
opencodego: .init(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "auth=manual",
|
||||
workspaceID: nil))))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.web,
|
||||
provider: .opencodego,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
opencodego: .init(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "auth=manual",
|
||||
workspaceID: nil))))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .opencodego,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
opencodego: .init(
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil,
|
||||
workspaceID: nil))))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.web,
|
||||
provider: .opencodego,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
opencodego: .init(
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil,
|
||||
workspaceID: nil))))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .commandcode,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
commandcode: .init(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "session=manual"))))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.web,
|
||||
provider: .commandcode,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
commandcode: .init(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "session=manual"))))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .commandcode,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
commandcode: .init(
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil))))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .sakana,
|
||||
environment: ["SAKANA_COOKIE": "session=manual"]))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.web,
|
||||
provider: .sakana,
|
||||
environment: ["SAKANA_COOKIE": "session=manual"]))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .sakana,
|
||||
environment: [:]))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.web,
|
||||
provider: .qoder,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
qoder: .init(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "sid=manual"))))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.web,
|
||||
provider: .qoder,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
qoder: .init(
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil))))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .opencode,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
opencode: .init(
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "auth=manual",
|
||||
workspaceID: nil))))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .ollama,
|
||||
environment: ["OLLAMA_API_KEY": "ollama-test"]))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .codex,
|
||||
environment: ["OLLAMA_API_KEY": "ollama-test"]))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .ollama,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
ollama: .init(cookieSource: .off, manualCookieHeader: nil))))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .kimi,
|
||||
environment: ["KIMI_CODE_API_KEY": "kimi-test"]))
|
||||
try self.assertKimiCodeCredentialSourceMode(in: directory)
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .mimo,
|
||||
environment: ["MIMO_LOCAL_USAGE_PATH": validMiMoCache.path]))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.web,
|
||||
provider: .mimo,
|
||||
environment: ["MIMO_LOCAL_USAGE_PATH": validMiMoCache.path]))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .mimo,
|
||||
environment: ["MIMO_LOCAL_USAGE_PATH": invalidMiMoCache.path]))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .mimo,
|
||||
environment: ["MIMO_LOCAL_USAGE_PATH": directory.appendingPathComponent("missing.json").path]))
|
||||
}
|
||||
|
||||
private func assertKimiCodeCredentialSourceMode(in directory: URL) throws {
|
||||
let home = directory.appendingPathComponent("kimi-code", isDirectory: true)
|
||||
let credentials = home.appendingPathComponent("credentials", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: credentials, withIntermediateDirectories: true)
|
||||
let payload: [String: Any] = [
|
||||
"access_token": "expired",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": Date().addingTimeInterval(-60).timeIntervalSince1970,
|
||||
]
|
||||
try JSONSerialization.data(withJSONObject: payload)
|
||||
.write(to: credentials.appendingPathComponent("kimi-code.json"))
|
||||
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .kimi,
|
||||
environment: ["KIMI_CODE_HOME": home.path]))
|
||||
}
|
||||
|
||||
func test_sourceModeRequiresWebSupportAllowsFactoryAPIKeyOnLinuxGate() {
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .factory,
|
||||
environment: ["FACTORY_API_KEY": "fk-test"]))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.cli,
|
||||
provider: .factory,
|
||||
environment: ["FACTORY_API_KEY": "fk-test"]))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.auto,
|
||||
provider: .factory,
|
||||
environment: [:]))
|
||||
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.web,
|
||||
provider: .factory,
|
||||
environment: ["FACTORY_API_KEY": "fk-test"]))
|
||||
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
|
||||
.api,
|
||||
provider: .factory,
|
||||
environment: [:]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct CLIOpenAIDashboardCacheTests {
|
||||
@Test
|
||||
func `cached dashboard restores when authority allows cached reuse`() throws {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let authHome = try self.makeAuthHome(
|
||||
email: "owner@example.com",
|
||||
accountId: "acct-owner")
|
||||
defer { try? FileManager.default.removeItem(at: authHome) }
|
||||
|
||||
let context = self.makeContext(
|
||||
authHome: authHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-owner"),
|
||||
normalizedEmail: "owner@example.com"),
|
||||
])
|
||||
let dashboard = self.makeDashboard(email: "owner@example.com")
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "stale-route@example.com",
|
||||
snapshot: dashboard))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: nil),
|
||||
sourceLabel: "openai-web",
|
||||
context: context)
|
||||
|
||||
#expect(restored == dashboard)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard returns nil on display only and clears cache`() throws {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let authHome = try self.makeAuthHome(email: "shared@example.com")
|
||||
defer { try? FileManager.default.removeItem(at: authHome) }
|
||||
|
||||
let context = self.makeContext(
|
||||
authHome: authHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-alpha"),
|
||||
normalizedEmail: "shared@example.com"),
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-beta"),
|
||||
normalizedEmail: "shared@example.com"),
|
||||
])
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "shared@example.com",
|
||||
snapshot: self.makeDashboard(email: "shared@example.com")))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "shared@example.com"),
|
||||
sourceLabel: "codex-cli",
|
||||
context: context)
|
||||
|
||||
#expect(restored == nil)
|
||||
#expect(OpenAIDashboardCacheStore.load() == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard returns nil on fail closed and clears cache`() throws {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let authHome = try self.makeAuthHome(
|
||||
email: "owner@example.com",
|
||||
accountId: "acct-owner")
|
||||
defer { try? FileManager.default.removeItem(at: authHome) }
|
||||
|
||||
let context = self.makeContext(
|
||||
authHome: authHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-other"),
|
||||
normalizedEmail: "owner@example.com"),
|
||||
])
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "owner@example.com",
|
||||
snapshot: self.makeDashboard(email: "owner@example.com")))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "owner@example.com"),
|
||||
sourceLabel: "codex-cli",
|
||||
context: context)
|
||||
|
||||
#expect(restored == nil)
|
||||
#expect(OpenAIDashboardCacheStore.load() == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard wrong email returns nil and clears cache`() throws {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let authHome = try self.makeAuthHome(
|
||||
email: "owner@example.com",
|
||||
accountId: "acct-owner")
|
||||
defer { try? FileManager.default.removeItem(at: authHome) }
|
||||
|
||||
let context = self.makeContext(
|
||||
authHome: authHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-owner"),
|
||||
normalizedEmail: "owner@example.com"),
|
||||
])
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "other@example.com",
|
||||
snapshot: self.makeDashboard(email: "other@example.com")))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "owner@example.com"),
|
||||
sourceLabel: "codex-cli",
|
||||
context: context)
|
||||
|
||||
#expect(restored == nil)
|
||||
#expect(OpenAIDashboardCacheStore.load() == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard provider account without scoped auth email fails closed`() throws {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let authHome = try self.makeAuthHome(email: nil, accountId: "acct-owner")
|
||||
defer { try? FileManager.default.removeItem(at: authHome) }
|
||||
|
||||
let context = self.makeContext(
|
||||
authHome: authHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-alpha"),
|
||||
normalizedEmail: "shared@example.com"),
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-beta"),
|
||||
normalizedEmail: "shared@example.com"),
|
||||
])
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "shared@example.com",
|
||||
snapshot: self.makeDashboard(email: "shared@example.com")))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "shared@example.com"),
|
||||
sourceLabel: "codex-cli",
|
||||
context: context)
|
||||
|
||||
#expect(restored == nil)
|
||||
#expect(OpenAIDashboardCacheStore.load() == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard unresolved trusted continuity with competing owner returns nil`() {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let emptyHome = self.makeEmptyHome()
|
||||
defer { try? FileManager.default.removeItem(at: emptyHome) }
|
||||
let context = self.makeContext(
|
||||
authHome: emptyHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-alpha"),
|
||||
normalizedEmail: "shared@example.com"),
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-beta"),
|
||||
normalizedEmail: "shared@example.com"),
|
||||
])
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "shared@example.com",
|
||||
snapshot: self.makeDashboard(email: "shared@example.com")))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "shared@example.com"),
|
||||
sourceLabel: "codex-cli",
|
||||
context: context)
|
||||
|
||||
#expect(restored == nil)
|
||||
#expect(OpenAIDashboardCacheStore.load() == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard trusts codex cli usage continuity`() {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let emptyHome = self.makeEmptyHome()
|
||||
defer { try? FileManager.default.removeItem(at: emptyHome) }
|
||||
let context = self.makeContext(
|
||||
authHome: emptyHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-owner"),
|
||||
normalizedEmail: "owner@example.com"),
|
||||
])
|
||||
let dashboard = self.makeDashboard(email: "owner@example.com")
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "stale-route@example.com",
|
||||
snapshot: dashboard))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "owner@example.com"),
|
||||
sourceLabel: "codex-cli",
|
||||
context: context)
|
||||
|
||||
#expect(restored == dashboard)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard trusts oauth usage continuity`() {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let emptyHome = self.makeEmptyHome()
|
||||
defer { try? FileManager.default.removeItem(at: emptyHome) }
|
||||
let context = self.makeContext(
|
||||
authHome: emptyHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-owner"),
|
||||
normalizedEmail: "owner@example.com"),
|
||||
])
|
||||
let dashboard = self.makeDashboard(email: "owner@example.com")
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "stale-route@example.com",
|
||||
snapshot: dashboard))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "owner@example.com"),
|
||||
sourceLabel: "oauth",
|
||||
context: context)
|
||||
|
||||
#expect(restored == dashboard)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard does not trust open A I web usage continuity`() {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let emptyHome = self.makeEmptyHome()
|
||||
defer { try? FileManager.default.removeItem(at: emptyHome) }
|
||||
let context = self.makeContext(
|
||||
authHome: emptyHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-owner"),
|
||||
normalizedEmail: "owner@example.com"),
|
||||
])
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "owner@example.com",
|
||||
snapshot: self.makeDashboard(email: "owner@example.com")))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "owner@example.com"),
|
||||
sourceLabel: "openai-web",
|
||||
context: context)
|
||||
|
||||
#expect(restored == nil)
|
||||
#expect(OpenAIDashboardCacheStore.load() == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cached dashboard ignores cached account email equality when authority rejects`() throws {
|
||||
OpenAIDashboardCacheStore.clear()
|
||||
defer { OpenAIDashboardCacheStore.clear() }
|
||||
|
||||
let authHome = try self.makeAuthHome(
|
||||
email: "owner@example.com",
|
||||
accountId: "acct-owner")
|
||||
defer { try? FileManager.default.removeItem(at: authHome) }
|
||||
|
||||
let context = self.makeContext(
|
||||
authHome: authHome,
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-other"),
|
||||
normalizedEmail: "owner@example.com"),
|
||||
])
|
||||
OpenAIDashboardCacheStore.save(OpenAIDashboardCache(
|
||||
accountEmail: "owner@example.com",
|
||||
snapshot: self.makeDashboard(email: "owner@example.com")))
|
||||
|
||||
let restored = CodexBarCLI.loadOpenAIDashboardIfAvailable(
|
||||
usage: self.makeUsage(email: "owner@example.com"),
|
||||
sourceLabel: "codex-cli",
|
||||
context: context)
|
||||
|
||||
#expect(restored == nil)
|
||||
#expect(OpenAIDashboardCacheStore.load() == nil)
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
authHome: URL?,
|
||||
knownOwners: [CodexDashboardKnownOwnerCandidate]) -> ProviderFetchContext
|
||||
{
|
||||
let env = authHome.map { ["CODEX_HOME": $0.path] } ?? [:]
|
||||
let browserDetection = BrowserDetection(cacheTTL: 0)
|
||||
return ProviderFetchContext(
|
||||
runtime: .cli,
|
||||
sourceMode: .auto,
|
||||
includeCredits: true,
|
||||
webTimeout: 60,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: env,
|
||||
settings: ProviderSettingsSnapshot.make(
|
||||
codex: .init(
|
||||
usageDataSource: .auto,
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil,
|
||||
dashboardAuthorityKnownOwners: knownOwners)),
|
||||
fetcher: UsageFetcher(environment: env),
|
||||
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
||||
browserDetection: browserDetection)
|
||||
}
|
||||
|
||||
private func makeUsage(email: String?) -> UsageSnapshot {
|
||||
UsageSnapshot(
|
||||
primary: RateWindow(
|
||||
usedPercent: 10,
|
||||
windowMinutes: 300,
|
||||
resetsAt: Date(timeIntervalSince1970: 7200),
|
||||
resetDescription: nil),
|
||||
secondary: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 2000),
|
||||
identity: ProviderIdentitySnapshot(
|
||||
providerID: .codex,
|
||||
accountEmail: email,
|
||||
accountOrganization: nil,
|
||||
loginMethod: nil))
|
||||
}
|
||||
|
||||
private func makeDashboard(email: String) -> OpenAIDashboardSnapshot {
|
||||
let creditEvents = [
|
||||
CreditEvent(
|
||||
date: Date(timeIntervalSince1970: 1000),
|
||||
service: "codex",
|
||||
creditsUsed: 3),
|
||||
]
|
||||
return OpenAIDashboardSnapshot(
|
||||
signedInEmail: email,
|
||||
codeReviewRemainingPercent: 75,
|
||||
codeReviewLimit: RateWindow(
|
||||
usedPercent: 25,
|
||||
windowMinutes: 60,
|
||||
resetsAt: Date(timeIntervalSince1970: 3600),
|
||||
resetDescription: nil),
|
||||
creditEvents: creditEvents,
|
||||
dailyBreakdown: OpenAIDashboardSnapshot.makeDailyBreakdown(from: creditEvents, maxDays: 30),
|
||||
usageBreakdown: [],
|
||||
creditsPurchaseURL: nil,
|
||||
primaryLimit: RateWindow(
|
||||
usedPercent: 10,
|
||||
windowMinutes: 300,
|
||||
resetsAt: Date(timeIntervalSince1970: 7200),
|
||||
resetDescription: nil),
|
||||
secondaryLimit: nil,
|
||||
creditsRemaining: 42,
|
||||
accountPlan: "pro",
|
||||
updatedAt: Date(timeIntervalSince1970: 2000))
|
||||
}
|
||||
|
||||
private func makeAuthHome(email: String?, accountId: String? = nil) throws -> URL {
|
||||
let homeURL = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true)
|
||||
try self.writeCodexAuthFile(homeURL: homeURL, email: email, accountId: accountId)
|
||||
return homeURL
|
||||
}
|
||||
|
||||
private func makeEmptyHome() -> URL {
|
||||
let homeURL = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true)
|
||||
return homeURL
|
||||
}
|
||||
|
||||
private func writeCodexAuthFile(
|
||||
homeURL: URL,
|
||||
email: String?,
|
||||
accountId: String?) throws
|
||||
{
|
||||
try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true)
|
||||
var tokens: [String: Any] = [
|
||||
"accessToken": "access-token",
|
||||
"refreshToken": "refresh-token",
|
||||
"idToken": Self.fakeJWT(email: email, accountId: accountId),
|
||||
]
|
||||
if let accountId {
|
||||
tokens["accountId"] = accountId
|
||||
}
|
||||
let auth = ["tokens": tokens]
|
||||
let data = try JSONSerialization.data(withJSONObject: auth)
|
||||
try data.write(to: homeURL.appendingPathComponent("auth.json"))
|
||||
}
|
||||
|
||||
private static func fakeJWT(email: String?, accountId: String?) -> String {
|
||||
let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data()
|
||||
var authClaims: [String: Any] = [
|
||||
"chatgpt_plan_type": "pro",
|
||||
]
|
||||
if let accountId {
|
||||
authClaims["chatgpt_account_id"] = accountId
|
||||
}
|
||||
var claims: [String: Any] = [
|
||||
"chatgpt_plan_type": "pro",
|
||||
"https://api.openai.com/auth": authClaims,
|
||||
]
|
||||
if let email {
|
||||
claims["email"] = email
|
||||
}
|
||||
let payload = (try? JSONSerialization.data(withJSONObject: claims)) ?? Data()
|
||||
|
||||
func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
}
|
||||
|
||||
return "\(base64URL(header)).\(base64URL(payload))."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct CLIOutputTests {
|
||||
@Test
|
||||
func `output preferences json only forces JSON`() {
|
||||
let output = CLIOutputPreferences.from(argv: ["--json-only"])
|
||||
#expect(output.jsonOnly == true)
|
||||
#expect(output.format == .json)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli error payload is JSON array`() throws {
|
||||
let payload = CodexBarCLI.makeCLIErrorPayload(
|
||||
message: "Nope",
|
||||
code: .failure,
|
||||
kind: .args,
|
||||
pretty: false)
|
||||
#expect(payload != nil)
|
||||
let data = payload?.data(using: .utf8) ?? Data()
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
#expect(json?.isEmpty == false)
|
||||
let first = json?.first as? [String: Any]
|
||||
#expect(first?["provider"] as? String == "cli")
|
||||
let error = first?["error"] as? [String: Any]
|
||||
#expect(error?["message"] as? String == "Nope")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `exit omits generic error when command already emitted payload`() {
|
||||
#expect(!CodexBarCLI.shouldPrintExitError(code: .success, message: nil))
|
||||
#expect(!CodexBarCLI.shouldPrintExitError(code: .failure, message: nil))
|
||||
#expect(CodexBarCLI.shouldPrintExitError(code: .failure, message: "Nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `text renderer includes deepgram usage metrics`() {
|
||||
let deepgram = DeepgramUsageSnapshot(
|
||||
projectID: "project-123",
|
||||
start: "2026-05-10",
|
||||
end: "2026-05-17",
|
||||
hours: 12.5,
|
||||
totalHours: 14,
|
||||
agentHours: 1.25,
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
ttsCharacters: 1200,
|
||||
requests: 42,
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
let text = CLIRenderer.renderText(
|
||||
provider: .deepgram,
|
||||
snapshot: deepgram.toUsageSnapshot(),
|
||||
credits: nil,
|
||||
context: RenderContext(
|
||||
header: "Deepgram (api)",
|
||||
status: nil,
|
||||
useColor: false,
|
||||
resetStyle: .countdown))
|
||||
|
||||
#expect(text.contains("Requests: 42"))
|
||||
#expect(text.contains("Usage: 12.5 audio hours · 14 billable hours"))
|
||||
#expect(text.contains("Usage: 1.2 agent hours · 150 tokens · 1,200 TTS chars"))
|
||||
#expect(text.contains("Period: 2026-05-10 to 2026-05-17"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `text renderer includes amp credits without free tier usage`() {
|
||||
let snapshot = AmpUsageSnapshot(
|
||||
freeQuota: nil,
|
||||
freeUsed: nil,
|
||||
hourlyReplenishment: nil,
|
||||
windowHours: nil,
|
||||
individualCredits: 25.64,
|
||||
workspaceBalances: [
|
||||
AmpWorkspaceBalance(name: "Alpha Team", remaining: 1234.56),
|
||||
],
|
||||
accountEmail: "paid@example.com",
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
.toUsageSnapshot()
|
||||
|
||||
let text = CLIRenderer.renderText(
|
||||
provider: .amp,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
context: RenderContext(
|
||||
header: "Amp (cli)",
|
||||
status: nil,
|
||||
useColor: false,
|
||||
resetStyle: .countdown))
|
||||
|
||||
#expect(text.contains("Individual credits: $25.64"))
|
||||
#expect(text.contains("Workspace Alpha Team: $1,234.56"))
|
||||
#expect(text.contains("Account: paid@example.com"))
|
||||
#expect(!text.contains("Amp Free:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `text renderer shows mimo balance without quota or reset text`() {
|
||||
let snapshot = MiMoUsageSnapshot(
|
||||
balance: 25.51,
|
||||
currency: "USD",
|
||||
cashBalance: 20,
|
||||
giftBalance: 5.51,
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
.toUsageSnapshot()
|
||||
|
||||
let text = CLIRenderer.renderText(
|
||||
provider: .mimo,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
context: RenderContext(
|
||||
header: "Xiaomi MiMo (web)",
|
||||
status: nil,
|
||||
useColor: false,
|
||||
resetStyle: .countdown))
|
||||
|
||||
#expect(text.contains("Balance: $25.51 (Paid: $20.00 / Granted: $5.51)"))
|
||||
#expect(!text.contains("100%"))
|
||||
#expect(!text.contains("Resets"))
|
||||
#expect(!text.contains("Plan: Balance"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `text renderer shows mimo token credits and balance`() {
|
||||
let snapshot = MiMoUsageSnapshot(
|
||||
balance: 25.51,
|
||||
currency: "USD",
|
||||
planCode: "standard",
|
||||
tokenUsed: 10,
|
||||
tokenLimit: 100,
|
||||
tokenPercent: 0.1,
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
.toUsageSnapshot()
|
||||
|
||||
let text = CLIRenderer.renderText(
|
||||
provider: .mimo,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
context: RenderContext(
|
||||
header: "Xiaomi MiMo (web)",
|
||||
status: nil,
|
||||
useColor: false,
|
||||
resetStyle: .countdown))
|
||||
|
||||
#expect(text.contains("Credits: 90% left"))
|
||||
#expect(text.contains("Balance: $25.51"))
|
||||
#expect(text.contains("Plan: Standard"))
|
||||
#expect(!text.contains("Window: 100%"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `text renderer preserves compact mimo local summary casing`() {
|
||||
let summary = "Local · 1.5k total · 42 sessions · stale 34d"
|
||||
let snapshot = MiMoUsageSnapshot(
|
||||
balance: 0,
|
||||
currency: "",
|
||||
planCode: summary,
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
.toUsageSnapshot(includeBalance: false)
|
||||
|
||||
let text = CLIRenderer.renderText(
|
||||
provider: .mimo,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
context: RenderContext(
|
||||
header: "Xiaomi MiMo (local)",
|
||||
status: nil,
|
||||
useColor: false,
|
||||
resetStyle: .countdown))
|
||||
|
||||
#expect(CLIRenderer.planBadgeText(provider: .mimo, snapshot: snapshot) == summary)
|
||||
#expect(text.contains("Plan: \(summary)"))
|
||||
#expect(!text.contains("Stale 34D"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `text renderer includes crossmodel balance and usage`() {
|
||||
let snapshot = CrossModelUsageSnapshot(
|
||||
currency: "USD",
|
||||
balance: 8.059489,
|
||||
uncollected: 0,
|
||||
daily: Self.crossModelWindow(cost: 0.005746, totalTokens: 12467, requestCount: 9),
|
||||
weekly: Self.crossModelWindow(cost: 0.665033, totalTokens: 1_925_790, requestCount: 529),
|
||||
monthly: Self.crossModelWindow(cost: 5.368746, totalTokens: 35_412_471, requestCount: 3166),
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
.toUsageSnapshot()
|
||||
|
||||
let text = CLIRenderer.renderText(
|
||||
provider: .crossmodel,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
context: RenderContext(
|
||||
header: "CrossModel (api)",
|
||||
status: nil,
|
||||
useColor: false,
|
||||
resetStyle: .countdown))
|
||||
|
||||
#expect(text.contains("Balance: $8.06"))
|
||||
#expect(text.contains("Today: $0.01 · 12K tokens"))
|
||||
#expect(text.contains("Week: $0.67 · 529 requests"))
|
||||
#expect(text.contains("Month: $5.37 · 3.2K requests"))
|
||||
#expect(text.contains("Plan: Api Key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `text renderer preserves crossmodel non USD currency`() {
|
||||
let snapshot = CrossModelUsageSnapshot(
|
||||
currency: "EUR",
|
||||
balance: 8.059489,
|
||||
uncollected: 0,
|
||||
daily: Self.crossModelWindow(cost: 0.005746, totalTokens: 12467, requestCount: 9),
|
||||
weekly: Self.crossModelWindow(cost: 0.665033, totalTokens: 1_925_790, requestCount: 529),
|
||||
monthly: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 0))
|
||||
.toUsageSnapshot()
|
||||
|
||||
let text = CLIRenderer.renderText(
|
||||
provider: .crossmodel,
|
||||
snapshot: snapshot,
|
||||
credits: nil,
|
||||
context: RenderContext(
|
||||
header: "CrossModel (api)",
|
||||
status: nil,
|
||||
useColor: false,
|
||||
resetStyle: .countdown))
|
||||
|
||||
#expect(text.contains("Balance: €8.06"))
|
||||
#expect(text.contains("Today: €0.01 · 12K tokens"))
|
||||
#expect(text.contains("Week: €0.67 · 529 requests"))
|
||||
#expect(!text.contains("$"))
|
||||
}
|
||||
|
||||
private static func crossModelWindow(
|
||||
cost: Double,
|
||||
totalTokens: Int,
|
||||
requestCount: Int) -> CrossModelUsageWindow
|
||||
{
|
||||
CrossModelUsageWindow(
|
||||
cost: cost,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: totalTokens,
|
||||
requestCount: requestCount,
|
||||
successCount: requestCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
|
||||
struct CLIProviderSelectionTests {
|
||||
@Test
|
||||
func `help includes gemini and all`() {
|
||||
let usage = CodexBarCLI.usageHelp(version: "0.0.0")
|
||||
let root = CodexBarCLI.rootHelp(version: "0.0.0")
|
||||
let expectedProviders = [
|
||||
"--provider codex|",
|
||||
"|claude|",
|
||||
"|factory|",
|
||||
"|zai|",
|
||||
"|cursor|",
|
||||
"|gemini|",
|
||||
"|antigravity|",
|
||||
"|copilot|",
|
||||
"|synthetic|",
|
||||
"|kiro|",
|
||||
"|warp|",
|
||||
"|ollama|",
|
||||
"|both|",
|
||||
"|all]",
|
||||
]
|
||||
for provider in expectedProviders {
|
||||
#expect(usage.contains(provider))
|
||||
#expect(root.contains(provider))
|
||||
}
|
||||
#expect(usage.contains("--json"))
|
||||
#expect(root.contains("--json"))
|
||||
#expect(usage.contains("--json-only"))
|
||||
#expect(root.contains("--json-only"))
|
||||
#expect(usage.contains("--json-output"))
|
||||
#expect(root.contains("--json-output"))
|
||||
#expect(usage.contains("--log-level"))
|
||||
#expect(root.contains("--log-level"))
|
||||
#expect(usage.contains("--verbose"))
|
||||
#expect(root.contains("--verbose"))
|
||||
#expect(usage.contains("codexbar usage --provider gemini"))
|
||||
#expect(usage.contains("codexbar usage --format json --provider all --pretty"))
|
||||
#expect(root.contains("codexbar --provider gemini"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `help mentions source flag`() {
|
||||
let usage = CodexBarCLI.usageHelp(version: "0.0.0")
|
||||
let root = CodexBarCLI.rootHelp(version: "0.0.0")
|
||||
|
||||
func tokens(_ text: String) -> [String] {
|
||||
let split = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "[]|,"))
|
||||
return text.components(separatedBy: split).filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
#expect(usage.contains("--source"))
|
||||
#expect(root.contains("--source"))
|
||||
#expect(usage.contains("--web-timeout"))
|
||||
#expect(usage.contains("--web-debug-dump-html"))
|
||||
#expect(!tokens(usage).contains("--web"))
|
||||
#expect(!tokens(root).contains("--web"))
|
||||
#expect(!tokens(usage).contains("--claude-source"))
|
||||
#expect(!tokens(root).contains("--claude-source"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider selection respects override`() {
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: "gemini", enabled: [.codex, .claude])
|
||||
#expect(selection.asList == [.gemini])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider selection uses enabled providers when three or more are enabled`() {
|
||||
let selection = CodexBarCLI.providerSelection(
|
||||
rawOverride: nil,
|
||||
enabled: [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot])
|
||||
#expect(selection.asList == [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider selection does not expand three enabled providers to all providers`() {
|
||||
let enabled: [UsageProvider] = [.codex, .claude, .copilot]
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: enabled)
|
||||
#expect(selection.asList == enabled)
|
||||
#expect(!selection.asList.contains(.gemini))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider selection uses both for codex and claude`() {
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: [.codex, .claude])
|
||||
#expect(selection.asList == [.codex, .claude])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider selection uses custom for codex and gemini`() {
|
||||
let enabled: [UsageProvider] = [.codex, .gemini]
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: enabled)
|
||||
#expect(selection.asList == enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider selection accepts kiro alias`() {
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: "kiro-cli", enabled: [.codex])
|
||||
#expect(selection.asList == [.kiro])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider selection honors empty enabled set`() {
|
||||
let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: [])
|
||||
#expect(selection.asList == [])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,826 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct CLIServeTimeoutTests {
|
||||
@Test
|
||||
func `serve cost keeps pricing refresh outside the request deadline`() {
|
||||
#expect(CodexBarCLI.serveCostRefreshesPricingInBackground)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `serve deadlines clamp once from request entry`() throws {
|
||||
#expect(CodexBarCLI.clampedServeRequestTimeout(.greatestFiniteMagnitude) == 86400)
|
||||
#expect(CodexBarCLI.clampedServeRequestTimeout(1e308) == 86400)
|
||||
#expect(CodexBarCLI.clampedServeRequestTimeout(-5) == 0)
|
||||
|
||||
let startedAt = ContinuousClock().now
|
||||
let deadline = try #require(CodexBarCLI.serveRequestDeadline(
|
||||
startedAt: startedAt,
|
||||
requestTimeout: .greatestFiniteMagnitude))
|
||||
#expect(startedAt.duration(to: deadline) == .seconds(86400))
|
||||
#expect(CodexBarCLI.serveRequestDeadline(startedAt: startedAt, requestTimeout: 0) == nil)
|
||||
|
||||
let requestDeadline = startedAt.advanced(by: .seconds(40))
|
||||
#expect(CodexBarCLI.serveCostProviderDeadline(
|
||||
startedAt: startedAt,
|
||||
providerTimeout: 30,
|
||||
requestDeadline: requestDeadline) == startedAt.advanced(by: .seconds(30)))
|
||||
#expect(CodexBarCLI.serveCostProviderDeadline(
|
||||
startedAt: startedAt.advanced(by: .seconds(20)),
|
||||
providerTimeout: 30,
|
||||
requestDeadline: requestDeadline) == requestDeadline)
|
||||
#expect(CodexBarCLI.serveCostProviderDeadline(
|
||||
startedAt: startedAt,
|
||||
providerTimeout: nil,
|
||||
requestDeadline: nil) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `timed out source stays owned and later requests never overlap`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let coordinator: CLIServeOperationCoordinator<Int> = self.makeCoordinator(clock: clock)
|
||||
let deadline = clock.now().advanced(by: .seconds(30))
|
||||
|
||||
let first = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: deadline,
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(1)
|
||||
}
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
await clock.fireAll()
|
||||
|
||||
#expect(await first.value == -1)
|
||||
#expect(await coordinator.snapshot().operationCount == 1)
|
||||
#expect(await coordinator.snapshot().timerCount == 0)
|
||||
|
||||
let later = (0..<4).map { _ in
|
||||
Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: deadline.advanced(by: .seconds(30)),
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
await self.waitForOperationCount(2, coordinator: coordinator)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
await clock.fireAll()
|
||||
for task in later {
|
||||
#expect(await task.value == -1)
|
||||
}
|
||||
#expect(await gate.startCount() == 1)
|
||||
#expect(await gate.peakCount() == 1)
|
||||
|
||||
await gate.releaseAll()
|
||||
await gate.waitForActive(0)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `earlier follower tightens the shared absolute budget`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let coordinator: CLIServeOperationCoordinator<Int> = self.makeCoordinator(clock: clock)
|
||||
let firstDeadline = clock.now().advanced(by: .seconds(30))
|
||||
|
||||
let first = Task {
|
||||
await coordinator.value(
|
||||
for: "cost:",
|
||||
fingerprint: "config-a",
|
||||
deadline: firstDeadline,
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(1)
|
||||
}
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
|
||||
let shorterFollower = Task {
|
||||
await coordinator.value(
|
||||
for: "cost:",
|
||||
fingerprint: "config-a",
|
||||
deadline: firstDeadline.advanced(by: .seconds(-1)),
|
||||
timeoutValue: -2)
|
||||
{
|
||||
await gate.run(2)
|
||||
}
|
||||
}
|
||||
await self.waitForWaiterCount(2, coordinator: coordinator)
|
||||
await clock.waitForCancellations(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
clock.advance(by: .seconds(29))
|
||||
await clock.fireAll()
|
||||
|
||||
#expect(await first.value == -1)
|
||||
#expect(await shorterFollower.value == -2)
|
||||
#expect(await gate.startCount() == 1)
|
||||
|
||||
await gate.releaseAll()
|
||||
await gate.waitForActive(0)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
#expect(await coordinator.snapshot().operationCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `source completing at an overdue deadline cannot beat a delayed timer`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let acceptance = ServeAcceptanceProbe<Int>()
|
||||
let coordinator: CLIServeOperationCoordinator<Int> = self.makeCoordinator(clock: clock)
|
||||
|
||||
let result = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: clock.now().advanced(by: .seconds(30)),
|
||||
timeoutValue: -1,
|
||||
accept: { await acceptance.accept($0) },
|
||||
operation: { await gate.run(7) })
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
clock.advance(by: .seconds(30))
|
||||
await gate.releaseAll()
|
||||
|
||||
#expect(await result.value == -1)
|
||||
#expect(await acceptance.callCount() == 0)
|
||||
await clock.waitForCancellations(1)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `shared deadline returns each waiters own timeout value`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let coordinator: CLIServeOperationCoordinator<Int> = self.makeCoordinator(clock: clock)
|
||||
let deadline = clock.now().advanced(by: .seconds(30))
|
||||
|
||||
let leader = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: deadline,
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(1)
|
||||
}
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
let follower = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: deadline.advanced(by: .seconds(1)),
|
||||
timeoutValue: -2)
|
||||
{
|
||||
await gate.run(2)
|
||||
}
|
||||
}
|
||||
await self.waitForWaiterCount(2, coordinator: coordinator)
|
||||
await clock.fireAll()
|
||||
|
||||
#expect(await leader.value == -1)
|
||||
#expect(await follower.value == -2)
|
||||
await gate.releaseAll()
|
||||
await gate.waitForActive(0)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `finite follower fails closed behind deadline free source`() async {
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let coordinator = CLIServeOperationCoordinator<Int>()
|
||||
|
||||
let first = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: nil,
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(1)
|
||||
}
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
|
||||
let follower = await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: ContinuousClock().now.advanced(by: .seconds(30)),
|
||||
timeoutValue: -2)
|
||||
{
|
||||
await gate.run(2)
|
||||
}
|
||||
#expect(follower == -2)
|
||||
#expect(await gate.startCount() == 1)
|
||||
|
||||
await gate.releaseAll()
|
||||
#expect(await first.value == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `waiter cancellation unregisters and last waiter cancels source`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let coordinator: CLIServeOperationCoordinator<Int> = self.makeCoordinator(clock: clock)
|
||||
let deadline = clock.now().advanced(by: .seconds(30))
|
||||
|
||||
let leader = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: deadline,
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(1)
|
||||
}
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
let follower = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: deadline.advanced(by: .seconds(1)),
|
||||
timeoutValue: -2)
|
||||
{
|
||||
await gate.run(2)
|
||||
}
|
||||
}
|
||||
await self.waitForWaiterCount(2, coordinator: coordinator)
|
||||
follower.cancel()
|
||||
#expect(await follower.value == -2)
|
||||
await self.waitForWaiterCount(1, coordinator: coordinator)
|
||||
#expect(await coordinator.snapshot().operationCount == 1)
|
||||
|
||||
leader.cancel()
|
||||
#expect(await leader.value == -1)
|
||||
await clock.waitForCancellations(1)
|
||||
let retained = await coordinator.snapshot()
|
||||
#expect(retained.operationCount == 1)
|
||||
#expect(retained.waiterCount == 0)
|
||||
#expect(retained.timerCount == 0)
|
||||
|
||||
await gate.releaseAll()
|
||||
await gate.waitForActive(0)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `source completion cancels the operation timer`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let coordinator: CLIServeOperationCoordinator<Int> = self.makeCoordinator(clock: clock)
|
||||
|
||||
let result = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: clock.now().advanced(by: .seconds(30)),
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(7)
|
||||
}
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
await gate.releaseAll()
|
||||
|
||||
#expect(await result.value == 7)
|
||||
await clock.waitForCancellations(1)
|
||||
#expect(await clock.pendingSleepCount() == 0)
|
||||
#expect(await coordinator.snapshot() == .init(
|
||||
operationCount: 0,
|
||||
waiterCount: 0,
|
||||
timerCount: 0,
|
||||
isShutDown: false))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `accepted value stays owned through asynchronous commit`() async {
|
||||
let source = ServeFetchGate<Int>()
|
||||
let commit = ServeFetchGate<Int>()
|
||||
let coordinator = CLIServeOperationCoordinator<Int>()
|
||||
await source.releaseAll()
|
||||
|
||||
let first = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: nil,
|
||||
timeoutValue: -1,
|
||||
accept: { await commit.run($0) },
|
||||
operation: { await source.run(1) })
|
||||
}
|
||||
await commit.waitForStarts(1)
|
||||
let follower = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: nil,
|
||||
timeoutValue: -2,
|
||||
accept: { await commit.run($0) },
|
||||
operation: { await source.run(2) })
|
||||
}
|
||||
await self.waitForWaiterCount(2, coordinator: coordinator)
|
||||
|
||||
#expect(await source.startCount() == 1)
|
||||
#expect(await coordinator.snapshot().operationCount == 1)
|
||||
await commit.releaseAll()
|
||||
#expect(await first.value == 1)
|
||||
#expect(await follower.value == 1)
|
||||
#expect(await source.startCount() == 1)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `earlier finite follower fails closed during accepted commit`() async {
|
||||
let source = ServeFetchGate<Int>()
|
||||
let commit = ServeFetchGate<Int>()
|
||||
let coordinator = CLIServeOperationCoordinator<Int>()
|
||||
let leaderDeadline = ContinuousClock().now.advanced(by: .seconds(30))
|
||||
await source.releaseAll()
|
||||
|
||||
let leader = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: leaderDeadline,
|
||||
timeoutValue: -1,
|
||||
accept: { await commit.run($0) },
|
||||
operation: { await source.run(1) })
|
||||
}
|
||||
await commit.waitForStarts(1)
|
||||
let follower = await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: leaderDeadline.advanced(by: .seconds(-1)),
|
||||
timeoutValue: -2)
|
||||
{
|
||||
await source.run(2)
|
||||
}
|
||||
|
||||
#expect(follower == -2)
|
||||
#expect(await source.startCount() == 1)
|
||||
let accepting = await coordinator.snapshot()
|
||||
#expect(accepting.waiterCount == 1)
|
||||
#expect(accepting.timerCount == 0)
|
||||
await commit.releaseAll()
|
||||
#expect(await leader.value == 1)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config change queues a nonoverlapping successor without a deadline`() async {
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let coordinator = CLIServeOperationCoordinator<Int>()
|
||||
|
||||
let old = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: nil,
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(1)
|
||||
}
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
|
||||
let successor = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-b",
|
||||
deadline: nil,
|
||||
timeoutValue: -2)
|
||||
{
|
||||
await gate.run(2)
|
||||
}
|
||||
}
|
||||
await self.waitForOperationCount(2, coordinator: coordinator)
|
||||
#expect(await gate.startCount() == 1)
|
||||
#expect(await gate.peakCount() == 1)
|
||||
|
||||
await gate.releaseAll()
|
||||
#expect(await old.value == 1)
|
||||
#expect(await successor.value == 2)
|
||||
await gate.waitForActive(0)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
#expect(await gate.startCount() == 2)
|
||||
#expect(await gate.peakCount() == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `shutdown cancels owned work and rejects new operations`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let gate = ServeFetchGate<Int>()
|
||||
let coordinator: CLIServeOperationCoordinator<Int> = self.makeCoordinator(clock: clock)
|
||||
|
||||
let active = Task {
|
||||
await coordinator.value(
|
||||
for: "usage:",
|
||||
fingerprint: "config-a",
|
||||
deadline: clock.now().advanced(by: .seconds(30)),
|
||||
timeoutValue: -1)
|
||||
{
|
||||
await gate.run(1)
|
||||
}
|
||||
}
|
||||
await gate.waitForStarts(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
await coordinator.shutdown()
|
||||
|
||||
#expect(await active.value == -1)
|
||||
await clock.waitForCancellations(1)
|
||||
let rejected = await coordinator.value(
|
||||
for: "cost:",
|
||||
fingerprint: "config-a",
|
||||
deadline: nil,
|
||||
timeoutValue: -2)
|
||||
{
|
||||
2
|
||||
}
|
||||
#expect(rejected == -2)
|
||||
let retained = await coordinator.snapshot()
|
||||
#expect(retained.operationCount == 1)
|
||||
#expect(retained.isShutDown)
|
||||
|
||||
await gate.releaseAll()
|
||||
await gate.waitForActive(0)
|
||||
await self.waitForOperationCount(0, coordinator: coordinator)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `provider timeout preserves healthy rows and cannot stack provider work`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let blocked = ServeFetchGate<UsageCommandOutput>()
|
||||
let healthy = ServeFetchGate<UsageCommandOutput>()
|
||||
let operations: CLIServeOperationCoordinator<UsageCommandOutput> = self.makeCoordinator(clock: clock)
|
||||
let deadline = clock.now().advanced(by: .seconds(30))
|
||||
await healthy.releaseAll()
|
||||
|
||||
let first = Task {
|
||||
await CodexBarCLI.serveCollectUsageOutputs(
|
||||
providers: [.claude, .gemini],
|
||||
configFingerprint: "config-a",
|
||||
deadline: deadline,
|
||||
operations: operations)
|
||||
{ provider in
|
||||
if provider == .claude {
|
||||
return await blocked.run(UsageCommandOutput(sections: ["late:claude"]))
|
||||
}
|
||||
return await healthy.run(UsageCommandOutput(sections: ["ok:gemini"]))
|
||||
}
|
||||
}
|
||||
await blocked.waitForStarts(1)
|
||||
await healthy.waitForStarts(1)
|
||||
await self.waitForOperationCount(1, coordinator: operations)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
clock.advance(by: .seconds(30))
|
||||
await clock.fireAll()
|
||||
let firstOutput = await first.value
|
||||
#expect(firstOutput.sections == ["ok:gemini"])
|
||||
#expect(firstOutput.payload.count == 1)
|
||||
#expect(firstOutput.payload.first?.provider == UsageProvider.claude.rawValue)
|
||||
#expect(firstOutput.payload.first?.error?.kind == .provider)
|
||||
|
||||
let second = Task {
|
||||
await CodexBarCLI.serveCollectUsageOutputs(
|
||||
providers: [.claude],
|
||||
configFingerprint: "config-a",
|
||||
deadline: deadline.advanced(by: .seconds(30)),
|
||||
operations: operations)
|
||||
{ _ in
|
||||
await blocked.run(UsageCommandOutput(sections: ["overlap"]))
|
||||
}
|
||||
}
|
||||
await self.waitForOperationCount(2, coordinator: operations)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
clock.advance(by: .seconds(30))
|
||||
await clock.fireAll()
|
||||
let secondOutput = await second.value
|
||||
#expect(secondOutput.payload.first?.error?.kind == .provider)
|
||||
#expect(await blocked.startCount() == 1)
|
||||
#expect(await blocked.peakCount() == 1)
|
||||
|
||||
await blocked.releaseAll()
|
||||
await blocked.waitForActive(0)
|
||||
await self.waitForOperationCount(0, coordinator: operations)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cost route variants cannot stack the same provider scan`() async {
|
||||
let clock = ServeManualDeadlineClock()
|
||||
let late = CodexBarCLI.makeCostPayload(provider: .claude, snapshot: nil, error: nil)
|
||||
let blocked = ServeFetchGate<CostPayload>()
|
||||
let operations: CLIServeOperationCoordinator<CostPayload> = self.makeCoordinator(clock: clock)
|
||||
let requestDeadline = clock.now().advanced(by: .seconds(40))
|
||||
let firstContext = ServeCostCollectionContext(
|
||||
configFingerprint: "config-a",
|
||||
providerTimeout: 30,
|
||||
requestDeadline: requestDeadline,
|
||||
now: { clock.now() },
|
||||
providerOperations: operations)
|
||||
|
||||
let first = Task {
|
||||
await CodexBarCLI.serveCollectCostPayloads(
|
||||
providers: [.claude, .codex],
|
||||
context: firstContext)
|
||||
{ provider in
|
||||
if provider == .claude {
|
||||
return await blocked.run(late)
|
||||
}
|
||||
return CodexBarCLI.makeCostPayload(provider: provider, snapshot: nil, error: nil)
|
||||
}
|
||||
}
|
||||
await blocked.waitForStarts(1)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
clock.advance(by: .seconds(30))
|
||||
await clock.fireAll()
|
||||
let firstPayload = await first.value
|
||||
|
||||
#expect(firstPayload.map(\.provider) == ["claude", "codex"])
|
||||
#expect(firstPayload[0].error?.message == "claude cost refresh timed out")
|
||||
#expect(firstPayload[1].error == nil)
|
||||
|
||||
let overlappingContext = ServeCostCollectionContext(
|
||||
configFingerprint: "config-a",
|
||||
providerTimeout: 30,
|
||||
requestDeadline: requestDeadline.advanced(by: .seconds(20)),
|
||||
now: { clock.now() },
|
||||
providerOperations: operations)
|
||||
let overlappingVariant = Task {
|
||||
await CodexBarCLI.serveCollectCostPayloads(
|
||||
providers: [.claude],
|
||||
context: overlappingContext)
|
||||
{ _ in
|
||||
await blocked.run(late)
|
||||
}
|
||||
}
|
||||
await self.waitForOperationCount(2, coordinator: operations)
|
||||
await clock.waitForPendingSleeps(1)
|
||||
clock.advance(by: .seconds(30))
|
||||
await clock.fireAll()
|
||||
let secondPayload = await overlappingVariant.value
|
||||
|
||||
#expect(secondPayload.first?.error?.message == "claude cost refresh timed out")
|
||||
#expect(await blocked.startCount() == 1)
|
||||
#expect(await blocked.peakCount() == 1)
|
||||
|
||||
await blocked.releaseAll()
|
||||
await blocked.waitForActive(0)
|
||||
await self.waitForOperationCount(0, coordinator: operations)
|
||||
}
|
||||
|
||||
private func makeCoordinator<Value: Sendable>(
|
||||
clock: ServeManualDeadlineClock) -> CLIServeOperationCoordinator<Value>
|
||||
{
|
||||
CLIServeOperationCoordinator(
|
||||
now: { clock.now() },
|
||||
sleepUntil: { deadline in try await clock.sleep(until: deadline) })
|
||||
}
|
||||
|
||||
private func waitForOperationCount(
|
||||
_ expected: Int,
|
||||
coordinator: CLIServeOperationCoordinator<some Sendable>) async
|
||||
{
|
||||
for _ in 0..<1000 {
|
||||
if await coordinator.snapshot().operationCount == expected {
|
||||
return
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
Issue.record("operation count did not reach \(expected)")
|
||||
}
|
||||
|
||||
private func waitForWaiterCount(
|
||||
_ expected: Int,
|
||||
coordinator: CLIServeOperationCoordinator<some Sendable>) async
|
||||
{
|
||||
for _ in 0..<1000 {
|
||||
if await coordinator.snapshot().waiterCount == expected {
|
||||
return
|
||||
}
|
||||
await Task.yield()
|
||||
}
|
||||
Issue.record("waiter count did not reach \(expected)")
|
||||
}
|
||||
}
|
||||
|
||||
private actor ServeAcceptanceProbe<Value: Sendable> {
|
||||
private var calls = 0
|
||||
|
||||
func accept(_ value: Value) -> Value {
|
||||
self.calls += 1
|
||||
return value
|
||||
}
|
||||
|
||||
func callCount() -> Int {
|
||||
self.calls
|
||||
}
|
||||
}
|
||||
|
||||
private actor ServeFetchGate<Value: Sendable> {
|
||||
private var starts = 0
|
||||
private var active = 0
|
||||
private var peak = 0
|
||||
private var released = false
|
||||
private var releaseContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
private var startWaiters: [(Int, CheckedContinuation<Void, Never>)] = []
|
||||
private var activeWaiters: [(Int, CheckedContinuation<Void, Never>)] = []
|
||||
|
||||
func run(_ value: Value) async -> Value {
|
||||
self.starts += 1
|
||||
self.active += 1
|
||||
self.peak = max(self.peak, self.active)
|
||||
self.resumeStartWaiters()
|
||||
|
||||
if !self.released {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.releaseContinuations.append(continuation)
|
||||
}
|
||||
}
|
||||
self.active -= 1
|
||||
self.resumeActiveWaiters()
|
||||
return value
|
||||
}
|
||||
|
||||
func waitForStarts(_ expected: Int) async {
|
||||
guard self.starts < expected else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
self.startWaiters.append((expected, continuation))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForActive(_ expected: Int) async {
|
||||
guard self.active != expected else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
self.activeWaiters.append((expected, continuation))
|
||||
}
|
||||
}
|
||||
|
||||
func releaseAll() {
|
||||
self.released = true
|
||||
let continuations = self.releaseContinuations
|
||||
self.releaseContinuations.removeAll()
|
||||
for continuation in continuations {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func startCount() -> Int {
|
||||
self.starts
|
||||
}
|
||||
|
||||
func peakCount() -> Int {
|
||||
self.peak
|
||||
}
|
||||
|
||||
private func resumeStartWaiters() {
|
||||
let ready = self.startWaiters.filter { self.starts >= $0.0 }
|
||||
self.startWaiters.removeAll { self.starts >= $0.0 }
|
||||
for (_, continuation) in ready {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeActiveWaiters() {
|
||||
let ready = self.activeWaiters.filter { self.active == $0.0 }
|
||||
self.activeWaiters.removeAll { self.active == $0.0 }
|
||||
for (_, continuation) in ready {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ServeManualDeadlineClock: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var instant = ContinuousClock().now
|
||||
private let sleeper = ServeManualSleeper()
|
||||
|
||||
func now() -> ContinuousClock.Instant {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.instant
|
||||
}
|
||||
|
||||
func advance(by duration: Duration) {
|
||||
self.lock.lock()
|
||||
self.instant = self.instant.advanced(by: duration)
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func sleep(until deadline: ContinuousClock.Instant) async throws {
|
||||
try await self.sleeper.sleep(until: deadline)
|
||||
}
|
||||
|
||||
func waitForPendingSleeps(_ expected: Int) async {
|
||||
await self.sleeper.waitForPendingCount(expected)
|
||||
}
|
||||
|
||||
func waitForCancellations(_ expected: Int) async {
|
||||
await self.sleeper.waitForCancellationCount(expected)
|
||||
}
|
||||
|
||||
func pendingSleepCount() async -> Int {
|
||||
await self.sleeper.pendingCount()
|
||||
}
|
||||
|
||||
func fireAll() async {
|
||||
await self.sleeper.fireAll()
|
||||
}
|
||||
}
|
||||
|
||||
private actor ServeManualSleeper {
|
||||
private typealias SleepContinuation = CheckedContinuation<Void, any Error>
|
||||
|
||||
private struct Pending {
|
||||
let id: UUID
|
||||
let continuation: SleepContinuation
|
||||
}
|
||||
|
||||
private var pending: [Pending] = []
|
||||
private var cancellationCount = 0
|
||||
private var pendingWaiters: [(Int, CheckedContinuation<Void, Never>)] = []
|
||||
private var cancellationWaiters: [(Int, CheckedContinuation<Void, Never>)] = []
|
||||
|
||||
func sleep(until _: ContinuousClock.Instant) async throws {
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await withCheckedThrowingContinuation { (continuation: SleepContinuation) in
|
||||
guard !Task.isCancelled else {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
return
|
||||
}
|
||||
self.pending.append(Pending(id: id, continuation: continuation))
|
||||
self.resumePendingWaiters()
|
||||
}
|
||||
}, onCancel: {
|
||||
Task { await self.cancel(id: id) }
|
||||
})
|
||||
}
|
||||
|
||||
func waitForPendingCount(_ expected: Int) async {
|
||||
guard self.pending.count < expected else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
self.pendingWaiters.append((expected, continuation))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForCancellationCount(_ expected: Int) async {
|
||||
guard self.cancellationCount < expected else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
self.cancellationWaiters.append((expected, continuation))
|
||||
}
|
||||
}
|
||||
|
||||
func pendingCount() -> Int {
|
||||
self.pending.count
|
||||
}
|
||||
|
||||
func fireAll() {
|
||||
let pending = self.pending
|
||||
self.pending.removeAll()
|
||||
for item in pending {
|
||||
item.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancel(id: UUID) {
|
||||
guard let index = self.pending.firstIndex(where: { $0.id == id }) else { return }
|
||||
let item = self.pending.remove(at: index)
|
||||
self.cancellationCount += 1
|
||||
item.continuation.resume(throwing: CancellationError())
|
||||
self.resumeCancellationWaiters()
|
||||
}
|
||||
|
||||
private func resumePendingWaiters() {
|
||||
let ready = self.pendingWaiters.filter { self.pending.count >= $0.0 }
|
||||
self.pendingWaiters.removeAll { self.pending.count >= $0.0 }
|
||||
for (_, continuation) in ready {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeCancellationWaiters() {
|
||||
let ready = self.cancellationWaiters.filter { self.cancellationCount >= $0.0 }
|
||||
self.cancellationWaiters.removeAll { self.cancellationCount >= $0.0 }
|
||||
for (_, continuation) in ready {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
import Testing
|
||||
@testable import CodexBarCLI
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct CLIWebFallbackTests {
|
||||
private func makeContext(
|
||||
runtime: ProviderRuntime = .cli,
|
||||
sourceMode: ProviderSourceMode = .auto,
|
||||
settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext
|
||||
{
|
||||
let browserDetection = BrowserDetection(cacheTTL: 0)
|
||||
return ProviderFetchContext(
|
||||
runtime: runtime,
|
||||
sourceMode: sourceMode,
|
||||
includeCredits: true,
|
||||
webTimeout: 60,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: [:],
|
||||
settings: settings,
|
||||
fetcher: UsageFetcher(),
|
||||
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
||||
browserDetection: browserDetection)
|
||||
}
|
||||
|
||||
private func makeClaudeSettingsSnapshot(cookieHeader: String?) -> ProviderSettingsSnapshot {
|
||||
ProviderSettingsSnapshot.make(
|
||||
claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: cookieHeader))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex falls back when cookies missing`() {
|
||||
let context = self.makeContext()
|
||||
let strategy = CodexWebDashboardStrategy()
|
||||
#expect(strategy.shouldFallback(
|
||||
on: OpenAIDashboardBrowserCookieImporter.ImportError.noCookiesFound,
|
||||
context: context))
|
||||
#expect(strategy.shouldFallback(
|
||||
on: OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount(found: []),
|
||||
context: context))
|
||||
#expect(strategy.shouldFallback(
|
||||
on: OpenAIDashboardBrowserCookieImporter.ImportError.browserAccessDenied(details: "no access"),
|
||||
context: context))
|
||||
#expect(strategy.shouldFallback(
|
||||
on: OpenAIDashboardBrowserCookieImporter.ImportError.dashboardStillRequiresLogin,
|
||||
context: context))
|
||||
#expect(strategy.shouldFallback(
|
||||
on: OpenAIDashboardFetcher.FetchError.loginRequired,
|
||||
context: context))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex falls back for dashboard data errors in auto`() {
|
||||
let context = self.makeContext()
|
||||
let strategy = CodexWebDashboardStrategy()
|
||||
#expect(strategy.shouldFallback(
|
||||
on: OpenAIDashboardFetcher.FetchError.noDashboardData(body: "missing"),
|
||||
context: context))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex retries fresh browser import for missing usage and no data`() {
|
||||
#expect(CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport(
|
||||
after: OpenAIWebCodexError.missingUsage))
|
||||
#expect(CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport(
|
||||
after: OpenAIDashboardFetcher.FetchError.noDashboardData(body: "missing")))
|
||||
#expect(!CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport(
|
||||
after: OpenAIDashboardFetcher.FetchError.loginRequired))
|
||||
#expect(!CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport(
|
||||
after: OpenAIWebCodexError.timedOut(seconds: 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex shared deadline timeout has useful error`() {
|
||||
let error = OpenAIWebCodexError.timedOut(seconds: 30)
|
||||
#expect(error.localizedDescription == "OpenAI web dashboard fetch timed out after 30 seconds.")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex display only falls back in auto`() {
|
||||
let strategy = CodexWebDashboardStrategy()
|
||||
let decision = self.makeCodexDisplayOnlyDecision()
|
||||
|
||||
#expect(strategy.shouldFallback(
|
||||
on: CodexDashboardPolicyError.displayOnly(decision),
|
||||
context: self.makeContext(sourceMode: .auto)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex display only does not fall back in explicit web`() {
|
||||
let strategy = CodexWebDashboardStrategy()
|
||||
let decision = self.makeCodexDisplayOnlyDecision()
|
||||
|
||||
#expect(!strategy.shouldFallback(
|
||||
on: CodexDashboardPolicyError.displayOnly(decision),
|
||||
context: self.makeContext(sourceMode: .web)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex web strategy is unavailable when managed account store is unreadable`() async {
|
||||
let context = self.makeContext(settings: ProviderSettingsSnapshot.make(
|
||||
codex: .init(
|
||||
usageDataSource: .auto,
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil,
|
||||
managedAccountStoreUnreadable: true)))
|
||||
let strategy = CodexWebDashboardStrategy()
|
||||
let available = await strategy.isAvailable(context)
|
||||
|
||||
#expect(!available)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex web strategy is unavailable when selected managed target is unavailable`() async {
|
||||
let context = self.makeContext(settings: ProviderSettingsSnapshot.make(
|
||||
codex: .init(
|
||||
usageDataSource: .auto,
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil,
|
||||
managedAccountTargetUnavailable: true)))
|
||||
let strategy = CodexWebDashboardStrategy()
|
||||
let available = await strategy.isAvailable(context)
|
||||
|
||||
#expect(!available)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `codex web strategy fails closed when profile target is unavailable`() async {
|
||||
let settings = ProviderSettingsSnapshot.make(
|
||||
codex: .init(
|
||||
usageDataSource: .auto,
|
||||
cookieSource: .auto,
|
||||
manualCookieHeader: nil,
|
||||
profileAccountTargetUnavailable: true))
|
||||
let strategy = CodexWebDashboardStrategy()
|
||||
|
||||
let autoContext = self.makeContext(sourceMode: .auto, settings: settings)
|
||||
let autoAvailable = await strategy.isAvailable(autoContext)
|
||||
#expect(!autoAvailable)
|
||||
|
||||
let explicitWebContext = self.makeContext(sourceMode: .web, settings: settings)
|
||||
let explicitWebAvailable = await strategy.isAvailable(explicitWebContext)
|
||||
#expect(explicitWebAvailable)
|
||||
do {
|
||||
_ = try await strategy.fetch(explicitWebContext)
|
||||
Issue.record("Expected unavailable profile target to require login")
|
||||
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
|
||||
// Expected before browser import can accept an arbitrary account.
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `claude falls back when no session key`() {
|
||||
let context = self.makeContext()
|
||||
let strategy = ClaudeWebFetchStrategy(browserDetection: BrowserDetection(cacheTTL: 0))
|
||||
#expect(strategy.shouldFallback(on: ClaudeWebAPIFetcher.FetchError.noSessionKeyFound, context: context))
|
||||
#expect(strategy.shouldFallback(on: ClaudeWebAPIFetcher.FetchError.unauthorized, context: context))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `claude CLI fallback is enabled only for app auto`() {
|
||||
let webAvailableStrategy = ClaudeCLIFetchStrategy(
|
||||
useWebExtras: false,
|
||||
manualCookieHeader: nil,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
hasWebFallback: true)
|
||||
let webUnavailableStrategy = ClaudeCLIFetchStrategy(
|
||||
useWebExtras: false,
|
||||
manualCookieHeader: nil,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
hasWebFallback: false)
|
||||
let error = ClaudeUsageError.parseFailed("cli failed")
|
||||
let webAvailableSettings = self.makeClaudeSettingsSnapshot(cookieHeader: "sessionKey=sk-ant-test")
|
||||
let webUnavailableSettings = self.makeClaudeSettingsSnapshot(cookieHeader: "foo=bar")
|
||||
|
||||
#expect(webAvailableStrategy.shouldFallback(
|
||||
on: error,
|
||||
context: self.makeContext(runtime: .app, sourceMode: .auto, settings: webAvailableSettings)))
|
||||
#expect(!webUnavailableStrategy.shouldFallback(
|
||||
on: error,
|
||||
context: self.makeContext(runtime: .app, sourceMode: .auto, settings: webUnavailableSettings)))
|
||||
#expect(!webAvailableStrategy.shouldFallback(
|
||||
on: error,
|
||||
context: self.makeContext(runtime: .app, sourceMode: .cli)))
|
||||
#expect(!webAvailableStrategy.shouldFallback(
|
||||
on: error,
|
||||
context: self.makeContext(runtime: .app, sourceMode: .web)))
|
||||
#expect(!webAvailableStrategy.shouldFallback(
|
||||
on: error,
|
||||
context: self.makeContext(runtime: .app, sourceMode: .oauth)))
|
||||
#expect(!webAvailableStrategy.shouldFallback(
|
||||
on: error,
|
||||
context: self.makeContext(runtime: .cli, sourceMode: .auto)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `claude web fallback is disabled for app auto`() {
|
||||
let strategy = ClaudeWebFetchStrategy(browserDetection: BrowserDetection(cacheTTL: 0))
|
||||
let error = ClaudeWebAPIFetcher.FetchError.unauthorized
|
||||
#expect(strategy.shouldFallback(on: error, context: self.makeContext(runtime: .cli, sourceMode: .auto)))
|
||||
#expect(!strategy.shouldFallback(on: error, context: self.makeContext(runtime: .app, sourceMode: .auto)))
|
||||
}
|
||||
|
||||
private func makeCodexDisplayOnlyDecision() -> CodexDashboardAuthorityDecision {
|
||||
CodexDashboardAuthority.evaluate(
|
||||
CodexDashboardAuthorityInput(
|
||||
sourceKind: .liveWeb,
|
||||
proof: CodexDashboardOwnershipProofContext(
|
||||
currentIdentity: .emailOnly(normalizedEmail: "shared@example.com"),
|
||||
expectedScopedEmail: nil,
|
||||
trustedCurrentUsageEmail: nil,
|
||||
dashboardSignedInEmail: "shared@example.com",
|
||||
knownOwners: [
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-alpha"),
|
||||
normalizedEmail: "shared@example.com"),
|
||||
CodexDashboardKnownOwnerCandidate(
|
||||
identity: .providerAccount(id: "acct-beta"),
|
||||
normalizedEmail: "shared@example.com"),
|
||||
]),
|
||||
routing: CodexDashboardRoutingHints(
|
||||
targetEmail: "shared@example.com",
|
||||
lastKnownDashboardRoutingEmail: nil)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
struct ChartBarHoverSelectionTests {
|
||||
@Test
|
||||
func `single selectable bar accepts the full plot`() {
|
||||
#expect(ChartBarHoverSelection.accepts(
|
||||
distanceFromBarCenter: 120,
|
||||
barHalfWidth: 5,
|
||||
selectableCount: 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `multiple selectable bars accept only the bar body`() {
|
||||
#expect(ChartBarHoverSelection.accepts(
|
||||
distanceFromBarCenter: 5,
|
||||
barHalfWidth: 5,
|
||||
selectableCount: 2))
|
||||
#expect(!ChartBarHoverSelection.accepts(
|
||||
distanceFromBarCenter: 5.1,
|
||||
barHalfWidth: 5,
|
||||
selectableCount: 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `calendar day spacing follows daylight saving transitions`() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles"))
|
||||
let springDate = try #require(calendar.date(from: DateComponents(
|
||||
year: 2026,
|
||||
month: 3,
|
||||
day: 7,
|
||||
hour: 12)))
|
||||
let fallDate = try #require(calendar.date(from: DateComponents(
|
||||
year: 2026,
|
||||
month: 10,
|
||||
day: 31,
|
||||
hour: 12)))
|
||||
|
||||
let springNextDay = ChartBarHoverSelection.nextCalendarDay(after: springDate, calendar: calendar)
|
||||
let fallNextDay = ChartBarHoverSelection.nextCalendarDay(after: fallDate, calendar: calendar)
|
||||
|
||||
#expect(calendar.component(.day, from: springNextDay) == 8)
|
||||
#expect(springNextDay.timeIntervalSince(springDate) == 23 * 60 * 60)
|
||||
#expect(calendar.component(.day, from: fallNextDay) == 1)
|
||||
#expect(fallNextDay.timeIntervalSince(fallDate) == 25 * 60 * 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ChutesProviderTests {
|
||||
@Test
|
||||
func `settings reader trims quoted API key`() {
|
||||
let token = ChutesSettingsReader.apiKey(environment: [
|
||||
ChutesSettingsReader.apiKeyEnvironmentKey: " 'chutes-test' ",
|
||||
])
|
||||
|
||||
#expect(token == "chutes-test")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config API key projects into Chutes environment`() {
|
||||
let config = ProviderConfig(id: .chutes, apiKey: "chutes-config-token")
|
||||
let env = ProviderConfigEnvironment.applyAPIKeyOverride(
|
||||
base: [:],
|
||||
provider: .chutes,
|
||||
config: config)
|
||||
|
||||
#expect(env[ChutesSettingsReader.apiKeyEnvironmentKey] == "chutes-config-token")
|
||||
#expect(ChutesSettingsReader.apiKey(environment: env) == "chutes-config-token")
|
||||
#expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .chutes))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fetch usage maps active subscription monthly and rolling windows`() async throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let rollingReset = try Self.date("2026-06-13T18:00:00Z")
|
||||
let monthlyReset = try Self.date("2026-07-01T00:00:00Z")
|
||||
let transport = ProviderHTTPTransportStub { request in
|
||||
let url = try #require(request.url)
|
||||
#expect(url.path == "/users/me/subscription_usage")
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer chutes-key")
|
||||
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
|
||||
#expect(request.timeoutInterval == 15)
|
||||
|
||||
let body = #"""
|
||||
{
|
||||
"subscription": {
|
||||
"active": true,
|
||||
"plan_name": "Pro",
|
||||
"current_period_end": "2026-07-01T00:00:00Z"
|
||||
},
|
||||
"monthly": {
|
||||
"used": 250,
|
||||
"limit": 1000,
|
||||
"resets_at": "2026-07-01T00:00:00Z",
|
||||
"unit": "credits"
|
||||
},
|
||||
"rolling_window": {
|
||||
"requests": 40,
|
||||
"limit": 100,
|
||||
"window_minutes": 240,
|
||||
"reset_at": "2026-06-13T18:00:00Z",
|
||||
"unit": "requests"
|
||||
}
|
||||
}
|
||||
"""#
|
||||
return Self.makeResponse(url: url, body: body)
|
||||
}
|
||||
|
||||
let snapshot = try await ChutesUsageFetcher.fetchUsage(
|
||||
apiKey: " chutes-key ",
|
||||
environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"],
|
||||
transport: transport,
|
||||
now: now)
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary?.usedPercent == 40)
|
||||
#expect(usage.primary?.windowMinutes == 240)
|
||||
#expect(usage.primary?.resetsAt == rollingReset)
|
||||
#expect(usage.primary?.resetDescription == "40/100 requests")
|
||||
#expect(usage.secondary?.usedPercent == 25)
|
||||
#expect(usage.secondary?.resetsAt == monthlyReset)
|
||||
#expect(usage.secondary?.resetDescription == "250/1000 credits")
|
||||
#expect(usage.subscriptionRenewsAt == monthlyReset)
|
||||
#expect(usage.loginMethod(for: .chutes) == "Pro")
|
||||
|
||||
let requests = await transport.requests()
|
||||
#expect(requests.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `no active subscription falls back to quotas endpoint`() async throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let transport = ProviderHTTPTransportStub { request in
|
||||
let url = try #require(request.url)
|
||||
switch url.path {
|
||||
case "/users/me/subscription_usage":
|
||||
return Self.makeResponse(url: url, body: #"""
|
||||
{
|
||||
"subscription": {
|
||||
"active": false,
|
||||
"status": "free"
|
||||
}
|
||||
}
|
||||
"""#)
|
||||
case "/users/me/quotas":
|
||||
return Self.makeResponse(url: url, body: #"""
|
||||
[
|
||||
{
|
||||
"chute_id": "0",
|
||||
"is_default": true,
|
||||
"quota": 100
|
||||
}
|
||||
]
|
||||
"""#)
|
||||
case "/users/me/quota_usage/0":
|
||||
return Self.makeResponse(url: url, body: #"""
|
||||
{
|
||||
"quota": 100,
|
||||
"used": 10
|
||||
}
|
||||
"""#)
|
||||
default:
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = try await ChutesUsageFetcher.fetchUsage(
|
||||
apiKey: "chutes-key",
|
||||
environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"],
|
||||
transport: transport,
|
||||
now: now)
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary?.usedPercent == 10)
|
||||
#expect(usage.primary?.resetDescription == "10/100 credits")
|
||||
#expect(usage.secondary == nil)
|
||||
#expect(usage.loginMethod(for: .chutes) == "No active subscription")
|
||||
|
||||
let requests = await transport.requests()
|
||||
let paths = requests.compactMap { $0.url?.path }
|
||||
#expect(paths == [
|
||||
"/users/me/subscription_usage",
|
||||
"/users/me/quotas",
|
||||
"/users/me/quota_usage/0",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `wrapped quota list fetches per quota usage`() async throws {
|
||||
let transport = ProviderHTTPTransportStub { request in
|
||||
let url = try #require(request.url)
|
||||
switch url.path {
|
||||
case "/users/me/subscription_usage":
|
||||
return Self.makeResponse(url: url, body: #"{"subscription":{"active":false}}"#)
|
||||
case "/users/me/quotas":
|
||||
return Self.makeResponse(url: url, body: #"""
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"chute_id": "wrapped",
|
||||
"quota": 200
|
||||
}
|
||||
]
|
||||
}
|
||||
"""#)
|
||||
case "/users/me/quota_usage/wrapped":
|
||||
return Self.makeResponse(url: url, body: #"{"quota":200,"used":50}"#)
|
||||
default:
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = try await ChutesUsageFetcher.fetchUsage(
|
||||
apiKey: "chutes-key",
|
||||
environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"],
|
||||
transport: transport)
|
||||
|
||||
#expect(snapshot.toUsageSnapshot().primary?.usedPercent == 25)
|
||||
let requests = await transport.requests()
|
||||
#expect(requests.compactMap { $0.url?.path } == [
|
||||
"/users/me/subscription_usage",
|
||||
"/users/me/quotas",
|
||||
"/users/me/quota_usage/wrapped",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `partial subscription usage fills missing rolling window from quotas`() async throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let transport = ProviderHTTPTransportStub { request in
|
||||
let url = try #require(request.url)
|
||||
switch url.path {
|
||||
case "/users/me/subscription_usage":
|
||||
return Self.makeResponse(url: url, body: #"""
|
||||
{
|
||||
"subscription": {
|
||||
"active": true,
|
||||
"plan_name": "Pro",
|
||||
"current_period_end": "2026-07-01T00:00:00Z"
|
||||
},
|
||||
"monthly": {
|
||||
"used": 250,
|
||||
"limit": 1000,
|
||||
"unit": "credits"
|
||||
}
|
||||
}
|
||||
"""#)
|
||||
case "/users/me/quotas":
|
||||
return Self.makeResponse(url: url, body: #"""
|
||||
{
|
||||
"rolling_window": {
|
||||
"requests": 40,
|
||||
"limit": 100,
|
||||
"window_minutes": 240,
|
||||
"unit": "requests"
|
||||
}
|
||||
}
|
||||
"""#)
|
||||
default:
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = try await ChutesUsageFetcher.fetchUsage(
|
||||
apiKey: "chutes-key",
|
||||
environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"],
|
||||
transport: transport,
|
||||
now: now)
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary?.usedPercent == 40)
|
||||
#expect(usage.primary?.windowMinutes == 240)
|
||||
#expect(usage.primary?.resetDescription == "40/100 requests")
|
||||
#expect(usage.secondary?.usedPercent == 25)
|
||||
#expect(usage.secondary?.resetDescription == "250/1000 credits")
|
||||
#expect(usage.loginMethod(for: .chutes) == "Pro")
|
||||
|
||||
let requests = await transport.requests()
|
||||
let paths = requests.compactMap { $0.url?.path }
|
||||
#expect(paths == ["/users/me/subscription_usage", "/users/me/quotas"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `missing usage fields returns no data snapshot without decode failure`() throws {
|
||||
let data = Data(#"{"subscription":{"active":true},"unexpected":{"nested":true}}"#.utf8)
|
||||
let snapshot = try ChutesUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123))
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(!snapshot.hasUsageData)
|
||||
#expect(usage.primary == nil)
|
||||
#expect(usage.secondary == nil)
|
||||
#expect(usage.loginMethod(for: .chutes) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `identical usage values keep distinct quota windows`() throws {
|
||||
let data = Data(#"""
|
||||
{
|
||||
"quotas": [
|
||||
{
|
||||
"used": 0,
|
||||
"limit": 100,
|
||||
"window_minutes": 240
|
||||
},
|
||||
{
|
||||
"used": 0,
|
||||
"limit": 100,
|
||||
"window_minutes": 43200
|
||||
}
|
||||
]
|
||||
}
|
||||
"""#.utf8)
|
||||
|
||||
let snapshot = try ChutesUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123))
|
||||
let usage = snapshot.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary?.usedPercent == 0)
|
||||
#expect(usage.primary?.windowMinutes == 240)
|
||||
#expect(usage.secondary?.usedPercent == 0)
|
||||
#expect(usage.secondary?.windowMinutes == 43200)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `exact percent value of one stays one percent`() throws {
|
||||
let usedData = Data(#"""
|
||||
{
|
||||
"rolling_window": {
|
||||
"usage_percent": 1
|
||||
}
|
||||
}
|
||||
"""#.utf8)
|
||||
let remainingData = Data(#"""
|
||||
{
|
||||
"rolling_window": {
|
||||
"percent_remaining": 1
|
||||
}
|
||||
}
|
||||
"""#.utf8)
|
||||
|
||||
let usedSnapshot = try ChutesUsageParser.parse(
|
||||
data: usedData,
|
||||
now: Date(timeIntervalSince1970: 123))
|
||||
let remainingSnapshot = try ChutesUsageParser.parse(
|
||||
data: remainingData,
|
||||
now: Date(timeIntervalSince1970: 123))
|
||||
|
||||
#expect(usedSnapshot.toUsageSnapshot().primary?.usedPercent == 1)
|
||||
#expect(remainingSnapshot.toUsageSnapshot().primary?.usedPercent == 99)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auth failure surfaces invalid credentials`() async {
|
||||
let transport = ProviderHTTPTransportStub { request in
|
||||
let url = try #require(request.url)
|
||||
return Self.makeResponse(url: url, body: #"{"detail":"unauthorized"}"#, statusCode: 401)
|
||||
}
|
||||
|
||||
await #expect {
|
||||
_ = try await ChutesUsageFetcher.fetchUsage(
|
||||
apiKey: "bad-key",
|
||||
environment: [ChutesSettingsReader.apiURLEnvironmentKey: "https://chutes.test"],
|
||||
transport: transport)
|
||||
} throws: { error in
|
||||
guard case ChutesUsageError.invalidCredentials = error else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `descriptor and app implementation registry include Chutes`() throws {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .chutes)
|
||||
#expect(descriptor.metadata.displayName == "Chutes")
|
||||
#expect(ProviderDescriptorRegistry.all.contains { $0.id == .chutes })
|
||||
|
||||
let implementation = try #require(ProviderImplementationRegistry.implementation(for: .chutes))
|
||||
#expect(implementation is ChutesProviderImplementation)
|
||||
}
|
||||
|
||||
private static func makeResponse(
|
||||
url: URL,
|
||||
body: String,
|
||||
statusCode: Int = 200) -> (Data, URLResponse)
|
||||
{
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: statusCode,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
return (Data(body.utf8), response)
|
||||
}
|
||||
|
||||
private static func date(_ text: String) throws -> Date {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return try #require(formatter.date(from: text))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import CodexBarCore
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
struct ClaudeAdminAPIInlineDashboardModelTests {
|
||||
@Test
|
||||
func `claude admin api usage gets inline dashboard`() throws {
|
||||
let now = try Self.localNoon(year: 2023, month: 11, day: 17)
|
||||
let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14)
|
||||
let metadata = try #require(ProviderDefaults.metadata[.claude])
|
||||
let usage = ClaudeAdminAPIUsageSnapshot(
|
||||
daily: [
|
||||
ClaudeAdminAPIUsageSnapshot.DailyBucket(
|
||||
day: "2023-11-14",
|
||||
startTime: bucketDay,
|
||||
endTime: bucketDay.addingTimeInterval(86400),
|
||||
costUSD: 1.25,
|
||||
inputTokens: 1000,
|
||||
cacheCreationInputTokens: 400,
|
||||
cacheReadInputTokens: 300,
|
||||
outputTokens: 250,
|
||||
totalTokens: 1950,
|
||||
costItems: [
|
||||
ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25),
|
||||
],
|
||||
models: [
|
||||
ClaudeAdminAPIUsageSnapshot.ModelBreakdown(
|
||||
name: "claude-sonnet-4-20250514",
|
||||
inputTokens: 1000,
|
||||
cacheCreationInputTokens: 400,
|
||||
cacheReadInputTokens: 300,
|
||||
outputTokens: 250,
|
||||
totalTokens: 1950),
|
||||
]),
|
||||
],
|
||||
updatedAt: now)
|
||||
|
||||
let model = UsageMenuCardView.Model.make(.init(
|
||||
provider: .claude,
|
||||
metadata: metadata,
|
||||
snapshot: usage.toUsageSnapshot(),
|
||||
credits: nil,
|
||||
creditsError: nil,
|
||||
dashboard: nil,
|
||||
dashboardError: nil,
|
||||
tokenSnapshot: nil,
|
||||
tokenError: nil,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
isRefreshing: false,
|
||||
lastError: nil,
|
||||
usageBarsShowUsed: false,
|
||||
resetTimeDisplayStyle: .countdown,
|
||||
tokenCostUsageEnabled: false,
|
||||
showOptionalCreditsAndExtraUsage: true,
|
||||
hidePersonalInfo: false,
|
||||
now: now))
|
||||
|
||||
#expect(model.metrics.isEmpty)
|
||||
#expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00")
|
||||
#expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25")
|
||||
#expect(model.inlineUsageDashboard?.detailLines
|
||||
.contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true)
|
||||
#expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true)
|
||||
#expect(model.planText == "Admin API")
|
||||
}
|
||||
|
||||
private static func localNoon(year: Int, month: Int, day: Int) throws -> Date {
|
||||
try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ClaudeAdminAPIUsageTests {
|
||||
private func makeContext(
|
||||
apiKey: String = "sk-ant-admin-test",
|
||||
sourceMode: ProviderSourceMode = .api) -> ProviderFetchContext
|
||||
{
|
||||
let browserDetection = BrowserDetection(cacheTTL: 0)
|
||||
let env = [ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey: apiKey]
|
||||
return ProviderFetchContext(
|
||||
runtime: .app,
|
||||
sourceMode: sourceMode,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: env,
|
||||
settings: nil,
|
||||
fetcher: UsageFetcher(environment: env),
|
||||
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
||||
browserDetection: browserDetection)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `prefers primary Anthropic admin key environment variable`() {
|
||||
let token = ClaudeAdminAPISettingsReader.apiKey(environment: [
|
||||
ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "sk-ant-admin-alt",
|
||||
ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-ant-admin-primary",
|
||||
])
|
||||
|
||||
#expect(token == "sk-ant-admin-primary")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `routes Claude token account admin keys into admin api environment`() {
|
||||
let env = TokenAccountSupportCatalog.envOverride(for: .claude, token: "Bearer sk-ant-admin-token")
|
||||
|
||||
#expect(env?[ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey] == "sk-ant-admin-token")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto source uses configured admin api key`() async {
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude)
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context)
|
||||
|
||||
#expect(strategies.map(\.id) == ["claude.admin-api"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parses Anthropic admin cost and messages usage into daily summaries`() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_700_179_200)
|
||||
let costs = """
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"starting_at": "2023-11-14T00:00:00Z",
|
||||
"ending_at": "2023-11-15T00:00:00Z",
|
||||
"results": [
|
||||
{
|
||||
"currency": "USD",
|
||||
"amount": "12345.00",
|
||||
"description": "Claude Sonnet 4 Usage - Input Tokens",
|
||||
"cost_type": "tokens"
|
||||
},
|
||||
{
|
||||
"currency": "USD",
|
||||
"amount": "2500.00",
|
||||
"description": "Web Search Usage",
|
||||
"cost_type": "web_search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"starting_at": "2023-11-15T00:00:00Z",
|
||||
"ending_at": "2023-11-16T00:00:00Z",
|
||||
"results": [
|
||||
{
|
||||
"currency": "USD",
|
||||
"amount": "5000",
|
||||
"description": "Claude Haiku Usage - Output Tokens",
|
||||
"cost_type": "tokens"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"next_page": null
|
||||
}
|
||||
"""
|
||||
let messages = """
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"starting_at": "2023-11-14T00:00:00Z",
|
||||
"ending_at": "2023-11-15T00:00:00Z",
|
||||
"results": [
|
||||
{
|
||||
"uncached_input_tokens": 1500,
|
||||
"cache_creation": {
|
||||
"ephemeral_1h_input_tokens": 1000,
|
||||
"ephemeral_5m_input_tokens": 500
|
||||
},
|
||||
"cache_read_input_tokens": 200,
|
||||
"output_tokens": 500,
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
},
|
||||
{
|
||||
"uncached_input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"model": "claude-opus-4-20250514"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"starting_at": "2023-11-15T00:00:00Z",
|
||||
"ending_at": "2023-11-16T00:00:00Z",
|
||||
"results": [
|
||||
{
|
||||
"uncached_input_tokens": 200,
|
||||
"cache_read_input_tokens": 300,
|
||||
"output_tokens": 100,
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"next_page": null
|
||||
}
|
||||
"""
|
||||
|
||||
let snapshot = try ClaudeAdminAPIUsageFetcher._parseSnapshotForTesting(
|
||||
costs: Data(costs.utf8),
|
||||
messages: Data(messages.utf8),
|
||||
now: now)
|
||||
|
||||
#expect(snapshot.daily.count == 2)
|
||||
#expect(snapshot.daily[0].costUSD == 148.45)
|
||||
#expect(snapshot.daily[0].inputTokens == 1600)
|
||||
#expect(snapshot.daily[0].cacheCreationInputTokens == 1500)
|
||||
#expect(snapshot.daily[0].cacheReadInputTokens == 200)
|
||||
#expect(snapshot.daily[0].outputTokens == 550)
|
||||
#expect(snapshot.daily[0].totalTokens == 3850)
|
||||
#expect(snapshot.last30Days.costUSD == 198.45)
|
||||
#expect(snapshot.last30Days.totalTokens == 4450)
|
||||
#expect(snapshot.topModels.first?.name == "claude-sonnet-4-20250514")
|
||||
#expect(snapshot.topModels.first?.totalTokens == 4300)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `maps Anthropic admin usage to Claude usage snapshot`() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_179_200)
|
||||
let apiUsage = ClaudeAdminAPIUsageSnapshot(
|
||||
daily: [
|
||||
ClaudeAdminAPIUsageSnapshot.DailyBucket(
|
||||
day: "2023-11-14",
|
||||
startTime: now,
|
||||
endTime: now.addingTimeInterval(86400),
|
||||
costUSD: 8.5,
|
||||
inputTokens: 1000,
|
||||
cacheCreationInputTokens: 400,
|
||||
cacheReadInputTokens: 300,
|
||||
outputTokens: 250,
|
||||
totalTokens: 1950,
|
||||
costItems: [],
|
||||
models: []),
|
||||
],
|
||||
updatedAt: now)
|
||||
|
||||
let usage = apiUsage.toUsageSnapshot()
|
||||
|
||||
#expect(usage.primary == nil)
|
||||
#expect(usage.providerCost?.used == 8.5)
|
||||
#expect(usage.providerCost?.limit == 0)
|
||||
#expect(usage.providerCost?.period == "Last 30 days")
|
||||
#expect(usage.claudeAdminAPIUsage?.last30Days.totalTokens == 1950)
|
||||
#expect(usage.identity?.providerID == .claude)
|
||||
#expect(usage.identity?.loginMethod == "Admin API")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `current day summary is zero when Claude admin history is stale`() throws {
|
||||
let now = try Self.localNoon(year: 2023, month: 11, day: 17)
|
||||
let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14)
|
||||
let apiUsage = ClaudeAdminAPIUsageSnapshot(
|
||||
daily: [
|
||||
ClaudeAdminAPIUsageSnapshot.DailyBucket(
|
||||
day: "2023-11-14",
|
||||
startTime: bucketDay,
|
||||
endTime: bucketDay.addingTimeInterval(86400),
|
||||
costUSD: 8.5,
|
||||
inputTokens: 1000,
|
||||
cacheCreationInputTokens: 400,
|
||||
cacheReadInputTokens: 300,
|
||||
outputTokens: 250,
|
||||
totalTokens: 1950,
|
||||
costItems: [],
|
||||
models: []),
|
||||
],
|
||||
updatedAt: now)
|
||||
|
||||
#expect(apiUsage.currentDay.costUSD == 0)
|
||||
#expect(apiUsage.currentDay.totalTokens == 0)
|
||||
#expect(apiUsage.latestDay.costUSD == 8.5)
|
||||
#expect(apiUsage.latestDay.totalTokens == 1950)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `fetch strategy reports admin api source label`() async throws {
|
||||
let strategy = ClaudeAdminAPIFetchStrategy(usageFetcher: { apiKey in
|
||||
#expect(apiKey == "sk-ant-admin-test")
|
||||
return ClaudeAdminAPIUsageSnapshot(daily: [], updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
})
|
||||
|
||||
let result = try await strategy.fetch(self.makeContext())
|
||||
|
||||
#expect(result.sourceLabel == "admin-api")
|
||||
#expect(result.usage.identity?.loginMethod == "Admin API")
|
||||
}
|
||||
|
||||
private static func localNoon(year: Int, month: Int, day: Int) throws -> Date {
|
||||
try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeBaselineCharacterizationTests {
|
||||
private func makeStubClaudeCLI(loggedIn: Bool = true, invocationLog: URL? = nil) throws -> String {
|
||||
let loggedInJSON = loggedIn ? "true" : "false"
|
||||
return try self.makeStubClaudeCLI(
|
||||
authStatusScript: "printf '%s\\n' '{\"loggedIn\":\(loggedInJSON)}'",
|
||||
invocationLog: invocationLog)
|
||||
}
|
||||
|
||||
private func makeStubClaudeCLI(authStatusScript: String, invocationLog: URL? = nil) throws -> String {
|
||||
let sample = """
|
||||
Current session
|
||||
12% used (Resets 11am)
|
||||
Current week (all models)
|
||||
40% used (Resets Nov 21)
|
||||
Current week (Sonnet only)
|
||||
5% used (Resets Nov 21)
|
||||
Account: user@example.com
|
||||
Org: Example Org
|
||||
"""
|
||||
let recordInvocation = invocationLog.map { "printf '%s\\n' \"$*\" >> '\($0.path)'" } ?? ""
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
\(recordInvocation)
|
||||
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
|
||||
\(authStatusScript)
|
||||
exit 0
|
||||
fi
|
||||
cat <<'EOF'
|
||||
\(sample)
|
||||
EOF
|
||||
"""
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("claude-stub-\(UUID().uuidString)")
|
||||
try Data(script.utf8).write(to: url)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
return url.path
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
runtime: ProviderRuntime,
|
||||
sourceMode: ProviderSourceMode,
|
||||
env: [String: String] = [:],
|
||||
settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext
|
||||
{
|
||||
let browserDetection = BrowserDetection(cacheTTL: 0)
|
||||
return ProviderFetchContext(
|
||||
runtime: runtime,
|
||||
sourceMode: sourceMode,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: env,
|
||||
settings: settings,
|
||||
fetcher: UsageFetcher(environment: env),
|
||||
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
||||
browserDetection: browserDetection)
|
||||
}
|
||||
|
||||
private func strategyIDs(
|
||||
runtime: ProviderRuntime,
|
||||
sourceMode: ProviderSourceMode,
|
||||
env: [String: String] = [:],
|
||||
settings: ProviderSettingsSnapshot? = nil) async -> [String]
|
||||
{
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude)
|
||||
let context = self.makeContext(runtime: runtime, sourceMode: sourceMode, env: env, settings: settings)
|
||||
let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context)
|
||||
return strategies.map(\.id)
|
||||
}
|
||||
|
||||
private func fetchOutcome(
|
||||
runtime: ProviderRuntime,
|
||||
sourceMode: ProviderSourceMode,
|
||||
env: [String: String] = [:],
|
||||
settings: ProviderSettingsSnapshot? = nil) async -> ProviderFetchOutcome
|
||||
{
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude)
|
||||
let context = self.makeContext(runtime: runtime, sourceMode: sourceMode, env: env, settings: settings)
|
||||
return await descriptor.fetchPlan.fetchOutcome(context: context, provider: .claude)
|
||||
}
|
||||
|
||||
private func withNoOAuthCredentials<T>(operation: () async throws -> T) async rethrows -> T {
|
||||
let missingCredentialsURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json")
|
||||
return try await KeychainCacheStore.withServiceOverrideForTesting("rat-110-\(UUID().uuidString)") {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
return try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
|
||||
try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app auto pipeline order is OAuth then CLI then web`() async {
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: true,
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "sessionKey=sk-ant-session-token"))
|
||||
let env = [
|
||||
ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token",
|
||||
ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile",
|
||||
"CLAUDE_CLI_PATH": "/usr/bin/true",
|
||||
]
|
||||
let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto, env: env, settings: settings)
|
||||
#expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `CLI auto pipeline order is web then CLI`() async {
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "sessionKey=sk-ant-session-token"))
|
||||
let env = [
|
||||
"CLAUDE_CLI_PATH": "/usr/bin/true",
|
||||
]
|
||||
let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: .auto, env: env, settings: settings)
|
||||
#expect(strategyIDs == ["claude.web", "claude.cli"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `explicit CLI pipeline attempts strategy even when planner marks CLI unavailable`() async {
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .cli,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: .off,
|
||||
manualCookieHeader: nil))
|
||||
let env = [
|
||||
"CLAUDE_CLI_PATH": "/definitely/missing/claude",
|
||||
]
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude)
|
||||
let context = self.makeContext(runtime: .app, sourceMode: .cli, env: env, settings: settings)
|
||||
let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context)
|
||||
|
||||
#expect(strategies.map(\.id) == ["claude.cli"])
|
||||
#expect(await strategies[0].isAvailable(context))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto pipeline records unavailable planned steps when planner has no executable source`() async {
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: true,
|
||||
cookieSource: .off,
|
||||
manualCookieHeader: nil))
|
||||
let env = ["CLAUDE_CLI_PATH": "/definitely/missing/claude"]
|
||||
|
||||
await self.withNoOAuthCredentials {
|
||||
await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") {
|
||||
let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: .auto, env: env, settings: settings)
|
||||
#expect(strategyIDs == ["claude.oauth", "claude.cli", "claude.web"])
|
||||
|
||||
let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
|
||||
#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"])
|
||||
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, false])
|
||||
|
||||
switch outcome.result {
|
||||
case let .failure(error as ProviderFetchError):
|
||||
switch error {
|
||||
case let .noAvailableStrategy(provider):
|
||||
#expect(provider == .claude)
|
||||
}
|
||||
case let .failure(error):
|
||||
Issue.record("Unexpected failure: \(error)")
|
||||
case let .success(result):
|
||||
Issue.record("Unexpected success: \(result.sourceLabel)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app background auto does not start logged out Claude CLI`() async throws {
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: .off,
|
||||
manualCookieHeader: nil))
|
||||
let invocationLog = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("claude-invocations-\(UUID().uuidString).log")
|
||||
let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog)
|
||||
let env = ["CLAUDE_CLI_PATH": stubCLIPath]
|
||||
|
||||
await self.withNoOAuthCredentials {
|
||||
let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
|
||||
|
||||
#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"])
|
||||
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, false])
|
||||
}
|
||||
|
||||
let invocations = try String(contentsOf: invocationLog, encoding: .utf8)
|
||||
#expect(invocations == "auth status --json\n")
|
||||
}
|
||||
|
||||
@Test(arguments: ["nonzero", "timeout", "malformed"])
|
||||
func `app background auto falls back to web when auth status is unusable`(
|
||||
failureMode: String) async throws
|
||||
{
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: .manual,
|
||||
manualCookieHeader: "sessionKey=sk-ant-session-token"))
|
||||
let invocationLog = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("claude-invocations-\(UUID().uuidString).log")
|
||||
let authStatusScript = switch failureMode {
|
||||
case "nonzero":
|
||||
"exit 9"
|
||||
case "timeout":
|
||||
"sleep 6"
|
||||
default:
|
||||
"printf '%s\\n' 'not-json'"
|
||||
}
|
||||
let stubCLIPath = try self.makeStubClaudeCLI(
|
||||
authStatusScript: authStatusScript,
|
||||
invocationLog: invocationLog)
|
||||
let env = ["CLAUDE_CLI_PATH": stubCLIPath]
|
||||
let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in
|
||||
ClaudeUsageSnapshot(
|
||||
primary: RateWindow(
|
||||
usedPercent: 20,
|
||||
windowMinutes: 300,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil),
|
||||
secondary: nil,
|
||||
opus: nil,
|
||||
updatedAt: Date(timeIntervalSince1970: 1_800_000_100),
|
||||
accountEmail: nil,
|
||||
accountOrganization: nil,
|
||||
loginMethod: nil,
|
||||
rawText: nil)
|
||||
}
|
||||
|
||||
let outcome = await self.withNoOAuthCredentials {
|
||||
await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) {
|
||||
await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
|
||||
}
|
||||
}
|
||||
let result = try outcome.result.get()
|
||||
|
||||
#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"])
|
||||
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, true])
|
||||
#expect(result.strategyID == "claude.web")
|
||||
let invocations = try String(contentsOf: invocationLog, encoding: .utf8)
|
||||
#expect(invocations == "auth status --json\n")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app user initiated auto preserves CLI fallback without auth preflight`() async throws {
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: .off,
|
||||
manualCookieHeader: nil))
|
||||
let invocationLog = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("claude-invocations-\(UUID().uuidString).log")
|
||||
let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog)
|
||||
let env = ["CLAUDE_CLI_PATH": stubCLIPath]
|
||||
let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude)
|
||||
let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings)
|
||||
let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context)
|
||||
let cli = try #require(strategies.first { $0.id == "claude.cli" })
|
||||
|
||||
let cliAvailable = await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
await cli.isAvailable(context)
|
||||
}
|
||||
|
||||
#expect(cliAvailable)
|
||||
#expect(!FileManager.default.fileExists(atPath: invocationLog.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `app auto pipeline retains OAuth bootstrap strategy at startup`() async {
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: .off,
|
||||
manualCookieHeader: nil))
|
||||
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
}
|
||||
|
||||
await self.withNoOAuthCredentials {
|
||||
let strategyIDs = await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
await ProviderRefreshContext.$current.withValue(.startup) {
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await self.strategyIDs(runtime: .app, sourceMode: .auto, settings: settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(strategyIDs.first == "claude.oauth")
|
||||
#expect(strategyIDs.contains("claude.oauth"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto pipeline CLI uses planned environment for execution`() async throws {
|
||||
let settings = ProviderSettingsSnapshot.make(claude: .init(
|
||||
usageDataSource: .auto,
|
||||
webExtrasEnabled: false,
|
||||
cookieSource: .off,
|
||||
manualCookieHeader: nil))
|
||||
let stubCLIPath = try self.makeStubClaudeCLI()
|
||||
let env = ["CLAUDE_CLI_PATH": stubCLIPath]
|
||||
|
||||
await self.withNoOAuthCredentials {
|
||||
let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws
|
||||
-> ClaudeStatusSnapshot = { binary, _, _ in
|
||||
#expect(binary == stubCLIPath)
|
||||
return ClaudeStatusSnapshot(
|
||||
sessionPercentLeft: 88,
|
||||
weeklyPercentLeft: 60,
|
||||
opusPercentLeft: 95,
|
||||
accountEmail: "user@example.com",
|
||||
accountOrganization: "Example Org",
|
||||
loginMethod: nil,
|
||||
primaryResetDescription: "Resets 11am",
|
||||
secondaryResetDescription: "Resets Nov 21",
|
||||
opusResetDescription: "Resets Nov 21",
|
||||
rawText: "stub")
|
||||
}
|
||||
let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) {
|
||||
await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
|
||||
}
|
||||
|
||||
#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"])
|
||||
#expect(outcome.attempts.map(\.wasAvailable) == [false, true])
|
||||
|
||||
switch outcome.result {
|
||||
case let .success(result):
|
||||
#expect(result.strategyID == "claude.cli")
|
||||
#expect(result.sourceLabel == "claude")
|
||||
#expect(result.usage.primary?.usedPercent == 12)
|
||||
#expect(result.usage.secondary?.usedPercent == 40)
|
||||
#expect(result.usage.tertiary?.usedPercent == 5)
|
||||
#expect(result.usage.identity?.accountEmail == "user@example.com")
|
||||
case let .failure(error):
|
||||
Issue.record("Unexpected failure: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(ProviderSourceMode.oauth, "claude.oauth"),
|
||||
(ProviderSourceMode.cli, "claude.cli"),
|
||||
(ProviderSourceMode.web, "claude.web"),
|
||||
])
|
||||
func `explicit modes resolve single Claude strategy`(
|
||||
sourceMode: ProviderSourceMode,
|
||||
expectedStrategyID: String) async
|
||||
{
|
||||
let strategyIDs = await self.strategyIDs(runtime: .app, sourceMode: sourceMode)
|
||||
#expect(strategyIDs == [expectedStrategyID])
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(ProviderSourceMode.oauth, "claude.oauth"),
|
||||
(ProviderSourceMode.cli, "claude.cli"),
|
||||
(ProviderSourceMode.web, "claude.web"),
|
||||
])
|
||||
func `CLI explicit modes resolve single Claude strategy`(
|
||||
sourceMode: ProviderSourceMode,
|
||||
expectedStrategyID: String) async
|
||||
{
|
||||
let strategyIDs = await self.strategyIDs(runtime: .cli, sourceMode: sourceMode)
|
||||
#expect(strategyIDs == [expectedStrategyID])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Claude OAuth token heuristics accept raw and bearer inputs`() {
|
||||
#expect(TokenAccountSupportCatalog.isClaudeOAuthToken("sk-ant-oat-test-token"))
|
||||
#expect(TokenAccountSupportCatalog.isClaudeOAuthToken("Bearer sk-ant-oat-test-token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Claude OAuth token heuristics reject cookie shaped inputs`() {
|
||||
#expect(!TokenAccountSupportCatalog.isClaudeOAuthToken("sessionKey=sk-ant-session"))
|
||||
#expect(!TokenAccountSupportCatalog.isClaudeOAuthToken("Cookie: sessionKey=sk-ant-session; foo=bar"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ClaudeCLIAuthStatusProbeTests {
|
||||
@Test
|
||||
func `parses logged in status`() {
|
||||
#expect(ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"loggedIn":true,"authMethod":"claude.ai"}"#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `rejects logged out and malformed status`() {
|
||||
#expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"loggedIn":false,"authMethod":"none"}"#))
|
||||
#expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn("not-json"))
|
||||
#expect(!ClaudeCLIAuthStatusProbe.parseLoggedIn(#"{"authMethod":"none"}"#))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ClaudeCLIScopedWeeklyUsageTests {
|
||||
@Test
|
||||
func `CLI usage surfaces Fable scoped weekly limit`() async throws {
|
||||
let cliUsage = """
|
||||
Settings Status Config Usage Stats
|
||||
|
||||
Current session
|
||||
9% used
|
||||
Resets 2:09pm (Europe/Prague)
|
||||
|
||||
Current week (all models)
|
||||
67% used
|
||||
Resets Jul 10 t 2:59am (Europe/Prague)
|
||||
|
||||
Current week (Fable)
|
||||
68% used
|
||||
Reset Jul 10 at 2:59am (Europe/Prague)
|
||||
|
||||
Current week (Example Model)
|
||||
12% used
|
||||
"""
|
||||
let status = try ClaudeStatusProbe.parse(text: cliUsage)
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .cli)
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, _, _ in status }
|
||||
|
||||
let snapshot = try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
|
||||
let fable = try #require(snapshot.extraRateWindows.first { $0.id == "claude-weekly-scoped-fable" })
|
||||
#expect(fable.title == "Fable only")
|
||||
#expect(fable.window.usedPercent == 68)
|
||||
#expect(fable.window.resetDescription == "Reset Jul 10 at 2:59am (Europe/Prague)")
|
||||
let example = try #require(
|
||||
snapshot.extraRateWindows.first { $0.id == "claude-weekly-scoped-example-model" })
|
||||
#expect(example.title == "Example Model only")
|
||||
#expect(example.window.usedPercent == 12)
|
||||
#expect(example.window.resetDescription == "Resets Jul 10 at 2:59am (Europe/Prague)")
|
||||
#expect(snapshot.opus == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `scoped weekly panel does not become all models weekly usage`() throws {
|
||||
let cliUsage = """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (Fable)
|
||||
68% used
|
||||
"""
|
||||
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: cliUsage)
|
||||
|
||||
#expect(snapshot.weeklyPercentLeft == nil)
|
||||
#expect(snapshot.secondaryResetDescription == nil)
|
||||
#expect(snapshot.extraRateWindows.map(\.title) == ["Fable only"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `compact scoped weekly label is parsed`() throws {
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Currentweek(Fable)
|
||||
68% used
|
||||
""")
|
||||
|
||||
#expect(snapshot.extraRateWindows.map(\.title) == ["Fable only"])
|
||||
#expect(snapshot.extraRateWindows.first?.window.usedPercent == 68)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `overlapping scoped model names do not cross panel boundaries`() throws {
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (Example Model)
|
||||
rendering
|
||||
|
||||
Current week (Example Model Plus)
|
||||
42% used
|
||||
""")
|
||||
|
||||
#expect(snapshot.extraRateWindows.map(\.title) == ["Example Model Plus only"])
|
||||
#expect(snapshot.extraRateWindows.first?.window.usedPercent == 42)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `informational Sonnet prose does not duplicate a scoped limit`() throws {
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (all models)
|
||||
20% used
|
||||
|
||||
Current week (Fable)
|
||||
42% used
|
||||
|
||||
Sonnet now has its own limit.
|
||||
""")
|
||||
|
||||
#expect(snapshot.opusPercentLeft == nil)
|
||||
#expect(snapshot.extraRateWindows.map(\.title) == ["Fable only"])
|
||||
#expect(snapshot.extraRateWindows.first?.window.usedPercent == 42)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Sonnet prefixed scoped model does not become legacy quota`() throws {
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (all models)
|
||||
20% used
|
||||
|
||||
Current week (Sonnet Test Variant)
|
||||
42% used
|
||||
""")
|
||||
|
||||
#expect(snapshot.opusPercentLeft == nil)
|
||||
#expect(snapshot.extraRateWindows.map(\.title) == ["Sonnet Test Variant only"])
|
||||
#expect(snapshot.extraRateWindows.first?.window.usedPercent == 42)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `later complete scoped panel replaces partial redraw`() throws {
|
||||
let spacer = Array(repeating: "rendering", count: 14).joined(separator: "\n")
|
||||
let cliUsage = """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (all models)
|
||||
67% used
|
||||
|
||||
Current week (Fable)
|
||||
\(spacer)
|
||||
|
||||
Current week (Fable)
|
||||
70% used
|
||||
Reset Jul 10 at 2:59am (Europe/Prague)
|
||||
"""
|
||||
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: cliUsage)
|
||||
let fable = try #require(snapshot.extraRateWindows.first)
|
||||
|
||||
#expect(snapshot.extraRateWindows.count == 1)
|
||||
#expect(fable.window.usedPercent == 70)
|
||||
#expect(fable.window.resetDescription == "Reset Jul 10 at 2:59am (Europe/Prague)")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `incomplete scoped panel stops at session redraw`() throws {
|
||||
let cliUsage = """
|
||||
Current week (Fable)
|
||||
rendering
|
||||
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (all models)
|
||||
20% used
|
||||
"""
|
||||
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: cliUsage)
|
||||
|
||||
#expect(snapshot.sessionPercentLeft == 91)
|
||||
#expect(snapshot.weeklyPercentLeft == 80)
|
||||
#expect(snapshot.extraRateWindows.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `incomplete all models panel does not consume scoped percentage`() throws {
|
||||
let cliUsage = """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (all models)
|
||||
rendering
|
||||
|
||||
Current week (Fable)
|
||||
42% used
|
||||
"""
|
||||
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: cliUsage)
|
||||
|
||||
#expect(snapshot.weeklyPercentLeft == nil)
|
||||
#expect(snapshot.extraRateWindows.map(\.title) == ["Fable only"])
|
||||
#expect(snapshot.extraRateWindows.first?.window.usedPercent == 42)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `incomplete Opus panel does not consume prefixed scoped percentage`() throws {
|
||||
let cliUsage = """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (all models)
|
||||
20% used
|
||||
|
||||
Current week (Opus)
|
||||
rendering
|
||||
|
||||
Current week (Opus Test Variant)
|
||||
42% used
|
||||
"""
|
||||
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: cliUsage)
|
||||
|
||||
#expect(snapshot.opusPercentLeft == nil)
|
||||
#expect(snapshot.extraRateWindows.map(\.title) == ["Opus Test Variant only"])
|
||||
#expect(snapshot.extraRateWindows.first?.window.usedPercent == 42)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `later complete scoped panel replaces earlier complete value`() throws {
|
||||
let cliUsage = """
|
||||
Current session
|
||||
9% used
|
||||
|
||||
Current week (all models)
|
||||
67% used
|
||||
|
||||
Current week (Fable)
|
||||
20% used
|
||||
|
||||
Current week (Fable)
|
||||
70% used
|
||||
"""
|
||||
|
||||
let snapshot = try ClaudeStatusProbe.parse(text: cliUsage)
|
||||
let fable = try #require(snapshot.extraRateWindows.first)
|
||||
|
||||
#expect(snapshot.extraRateWindows.count == 1)
|
||||
#expect(fable.window.usedPercent == 70)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `web extra windows merge with CLI scoped weekly limits`() throws {
|
||||
let fable = NamedRateWindow(
|
||||
id: "claude-weekly-scoped-fable",
|
||||
title: "Fable only",
|
||||
window: RateWindow(
|
||||
usedPercent: 68,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: nil,
|
||||
resetDescription: "Resets Jul 10 at 2:59am (Europe/Prague)"))
|
||||
let webFable = try #require(ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: [
|
||||
ClaudeScopedWeeklyLimitMapper.Limit(
|
||||
kind: "weekly_scoped",
|
||||
group: "weekly",
|
||||
percent: 70,
|
||||
resetsAt: nil,
|
||||
modelID: "test-only-fable-id",
|
||||
modelName: "Fable"),
|
||||
]).first)
|
||||
let routines = NamedRateWindow(
|
||||
id: "claude-routines",
|
||||
title: "Daily Routines",
|
||||
window: RateWindow(
|
||||
usedPercent: 11,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil))
|
||||
|
||||
let merged = ClaudeUsageFetcher._mergeExtraRateWindowsForTesting(
|
||||
primary: [fable],
|
||||
web: [webFable, routines])
|
||||
|
||||
#expect(merged.map(\.id) == ["claude-weekly-scoped-fable", "claude-routines"])
|
||||
#expect(webFable.id == "claude-weekly-scoped-test-only-fable-id")
|
||||
#expect(merged.first?.window.usedPercent == 68)
|
||||
#expect(merged.last?.title == "Daily Routines")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `same title web limits keep distinct stable IDs`() {
|
||||
let webLimits = ["first-id", "second-id"].map { id in
|
||||
NamedRateWindow(
|
||||
id: "claude-weekly-scoped-\(id)",
|
||||
title: "Example Model only",
|
||||
window: RateWindow(
|
||||
usedPercent: 25,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil))
|
||||
}
|
||||
|
||||
let merged = ClaudeUsageFetcher._mergeExtraRateWindowsForTesting(
|
||||
primary: [],
|
||||
web: webLimits)
|
||||
|
||||
#expect(merged.map(\.id) == [
|
||||
"claude-weekly-scoped-first-id",
|
||||
"claude-weekly-scoped-second-id",
|
||||
])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ambiguous same title web limits survive CLI merge`() {
|
||||
let cli = NamedRateWindow(
|
||||
id: "claude-weekly-scoped-example-model",
|
||||
title: "Example Model only",
|
||||
window: RateWindow(
|
||||
usedPercent: 20,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil))
|
||||
let webLimits = ["first-id", "second-id"].map { id in
|
||||
NamedRateWindow(
|
||||
id: "claude-weekly-scoped-\(id)",
|
||||
title: "Example Model only",
|
||||
window: RateWindow(
|
||||
usedPercent: 25,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil))
|
||||
}
|
||||
|
||||
let merged = ClaudeUsageFetcher._mergeExtraRateWindowsForTesting(
|
||||
primary: [cli],
|
||||
web: webLimits)
|
||||
|
||||
#expect(merged.map(\.id) == [
|
||||
"claude-weekly-scoped-example-model",
|
||||
"claude-weekly-scoped-first-id",
|
||||
"claude-weekly-scoped-second-id",
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeCLITimeoutRetryTests {
|
||||
private actor AttemptRecorder {
|
||||
private var count = 0
|
||||
private var timeouts: [TimeInterval] = []
|
||||
|
||||
func record(timeout: TimeInterval) -> Int {
|
||||
self.count += 1
|
||||
self.timeouts.append(timeout)
|
||||
return self.count
|
||||
}
|
||||
|
||||
func snapshot() -> (count: Int, timeouts: [TimeInterval]) {
|
||||
(self.count, self.timeouts)
|
||||
}
|
||||
}
|
||||
|
||||
private final class WebRequestRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var paths: [String] = []
|
||||
|
||||
func record(_ path: String) {
|
||||
self.lock.withLock {
|
||||
self.paths.append(path)
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot() -> [String] {
|
||||
self.lock.withLock {
|
||||
self.paths
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli usage retries with longer timeout after transient probe failure`() async throws {
|
||||
let attempts = AttemptRecorder()
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .cli)
|
||||
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
|
||||
let attempt = await attempts.record(timeout: timeout)
|
||||
if attempt == 1 {
|
||||
throw ClaudeStatusProbeError.timedOut
|
||||
}
|
||||
return ClaudeStatusSnapshot(
|
||||
sessionPercentLeft: 91,
|
||||
weeklyPercentLeft: 88,
|
||||
opusPercentLeft: nil,
|
||||
accountEmail: "cli@example.com",
|
||||
accountOrganization: "CLI Org",
|
||||
loginMethod: "cli",
|
||||
primaryResetDescription: nil,
|
||||
secondaryResetDescription: nil,
|
||||
opusResetDescription: nil,
|
||||
rawText: "probe raw")
|
||||
}
|
||||
|
||||
let snapshot = try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
|
||||
let recorded = await attempts.snapshot()
|
||||
#expect(recorded.count == 2)
|
||||
#expect(recorded.timeouts == [24, 60])
|
||||
#expect(snapshot.primary.usedPercent == 9)
|
||||
#expect(snapshot.secondary?.usedPercent == 12)
|
||||
#expect(snapshot.accountEmail == "cli@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto cli usage does not retry unrecoverable parse failure`() async throws {
|
||||
let attempts = AttemptRecorder()
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .auto,
|
||||
manualCookieHeader: "foo=bar")
|
||||
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
|
||||
_ = await attempts.record(timeout: timeout)
|
||||
throw ClaudeStatusProbeError.parseFailed("Missing Current session.")
|
||||
}
|
||||
|
||||
await #expect(throws: ClaudeStatusProbeError.self) {
|
||||
try await self.withNoOAuthCredentials {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recorded = await attempts.snapshot()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded.timeouts == [12])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto cli usage retries loading panel before stale web fallback`() async throws {
|
||||
let attempts = AttemptRecorder()
|
||||
let webRequests = WebRequestRecorder()
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .auto,
|
||||
manualCookieHeader: "sessionKey=sk-ant-session-token")
|
||||
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
|
||||
let attempt = await attempts.record(timeout: timeout)
|
||||
if attempt == 1 {
|
||||
throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.")
|
||||
}
|
||||
return ClaudeStatusSnapshot(
|
||||
sessionPercentLeft: 95,
|
||||
weeklyPercentLeft: 93,
|
||||
opusPercentLeft: nil,
|
||||
accountEmail: "loading-cli@example.com",
|
||||
accountOrganization: "Loading CLI Org",
|
||||
loginMethod: "cli",
|
||||
primaryResetDescription: nil,
|
||||
secondaryResetDescription: nil,
|
||||
opusResetDescription: nil,
|
||||
rawText: "probe raw")
|
||||
}
|
||||
|
||||
let snapshot = try await self.withNoOAuthCredentials {
|
||||
try await self.withClaudeWebStub(handler: { request in
|
||||
webRequests.record(request.url?.path ?? "<missing>")
|
||||
throw URLError(.userAuthenticationRequired)
|
||||
}, operation: {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let recorded = await attempts.snapshot()
|
||||
#expect(recorded.count == 2)
|
||||
#expect(recorded.timeouts == [12, 60])
|
||||
#expect(webRequests.snapshot().isEmpty)
|
||||
#expect(snapshot.primary.usedPercent == 5)
|
||||
#expect(snapshot.secondary?.usedPercent == 7)
|
||||
#expect(snapshot.accountEmail == "loading-cli@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto cli usage retries timeout when cli is final source`() async throws {
|
||||
let attempts = AttemptRecorder()
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .auto,
|
||||
manualCookieHeader: "foo=bar")
|
||||
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
|
||||
let attempt = await attempts.record(timeout: timeout)
|
||||
if attempt == 1 {
|
||||
throw ClaudeStatusProbeError.timedOut
|
||||
}
|
||||
return ClaudeStatusSnapshot(
|
||||
sessionPercentLeft: 72,
|
||||
weeklyPercentLeft: 64,
|
||||
opusPercentLeft: nil,
|
||||
accountEmail: "auto-cli@example.com",
|
||||
accountOrganization: "Auto CLI Org",
|
||||
loginMethod: "cli",
|
||||
primaryResetDescription: nil,
|
||||
secondaryResetDescription: nil,
|
||||
opusResetDescription: nil,
|
||||
rawText: "probe raw")
|
||||
}
|
||||
|
||||
let snapshot = try await self.withNoOAuthCredentials {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recorded = await attempts.snapshot()
|
||||
#expect(recorded.count == 2)
|
||||
#expect(recorded.timeouts == [12, 60])
|
||||
#expect(snapshot.primary.usedPercent == 28)
|
||||
#expect(snapshot.secondary?.usedPercent == 36)
|
||||
#expect(snapshot.accountEmail == "auto-cli@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli usage does not retry cancelled probe`() async throws {
|
||||
let attempts = AttemptRecorder()
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .cli)
|
||||
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
|
||||
_ = await attempts.record(timeout: timeout)
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
await #expect(throws: CancellationError.self) {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recorded = await attempts.snapshot()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded.timeouts == [24])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli usage records background cooldown after rate limit`() async {
|
||||
ClaudeCLIRateLimitGate.resetForTesting()
|
||||
defer { ClaudeCLIRateLimitGate.resetForTesting() }
|
||||
|
||||
let attempts = AttemptRecorder()
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .cli)
|
||||
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
|
||||
_ = await attempts.record(timeout: timeout)
|
||||
throw ClaudeStatusProbeError.parseFailed(ClaudeCLIRateLimitGate.message)
|
||||
}
|
||||
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await #expect(throws: ClaudeStatusProbeError.self) {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recordedAfterRateLimit = await attempts.snapshot()
|
||||
#expect(recordedAfterRateLimit.count == 1)
|
||||
#expect(recordedAfterRateLimit.timeouts == [24])
|
||||
#expect(ClaudeCLIRateLimitGate.currentBlockedUntil() != nil)
|
||||
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await #expect(throws: ClaudeUsageError.self) {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recordedAfterBlockedRetry = await attempts.snapshot()
|
||||
#expect(recordedAfterBlockedRetry.count == 1)
|
||||
#expect(recordedAfterBlockedRetry.timeouts == [24])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `user initiated cli usage bypasses rate limit cooldown`() async throws {
|
||||
ClaudeCLIRateLimitGate.resetForTesting()
|
||||
defer { ClaudeCLIRateLimitGate.resetForTesting() }
|
||||
ClaudeCLIRateLimitGate.recordRateLimit()
|
||||
|
||||
let attempts = AttemptRecorder()
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .cli)
|
||||
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in
|
||||
_ = await attempts.record(timeout: timeout)
|
||||
return ClaudeStatusSnapshot(
|
||||
sessionPercentLeft: 89,
|
||||
weeklyPercentLeft: 83,
|
||||
opusPercentLeft: nil,
|
||||
accountEmail: "manual-cli@example.com",
|
||||
accountOrganization: "Manual CLI Org",
|
||||
loginMethod: "cli",
|
||||
primaryResetDescription: nil,
|
||||
secondaryResetDescription: nil,
|
||||
opusResetDescription: nil,
|
||||
rawText: "probe raw")
|
||||
}
|
||||
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await #expect(throws: ClaudeUsageError.self) {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(await (attempts.snapshot()).timeouts.isEmpty)
|
||||
|
||||
let snapshot = try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let recorded = await attempts.snapshot()
|
||||
#expect(recorded.count == 1)
|
||||
#expect(recorded.timeouts == [24])
|
||||
#expect(snapshot.primary.usedPercent == 11)
|
||||
#expect(snapshot.secondary?.usedPercent == 17)
|
||||
#expect(snapshot.accountEmail == "manual-cli@example.com")
|
||||
#expect(ClaudeCLIRateLimitGate.currentBlockedUntil() == nil)
|
||||
}
|
||||
|
||||
private func withNoOAuthCredentials<T>(operation: () async throws -> T) async rethrows -> T {
|
||||
let missingCredentialsURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json")
|
||||
return try await KeychainCacheStore.withServiceOverrideForTesting("rat-107-\(UUID().uuidString)") {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
return try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
|
||||
try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func withClaudeWebStub<T>(
|
||||
handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data),
|
||||
operation: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
let registered = URLProtocol.registerClass(ClaudeAutoFetcherStubURLProtocol.self)
|
||||
ClaudeAutoFetcherStubURLProtocol.handler = handler
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(ClaudeAutoFetcherStubURLProtocol.self)
|
||||
}
|
||||
ClaudeAutoFetcherStubURLProtocol.handler = nil
|
||||
}
|
||||
return try await operation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import CodexBarCore
|
||||
import Testing
|
||||
|
||||
struct ClaudeCredentialRoutingTests {
|
||||
@Test
|
||||
func `resolves raw OAuth token`() {
|
||||
let routing = ClaudeCredentialRouting.resolve(
|
||||
tokenAccountToken: "sk-ant-oat-test-token",
|
||||
manualCookieHeader: nil)
|
||||
|
||||
#expect(routing == .oauth(accessToken: "sk-ant-oat-test-token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolves bearer OAuth token`() {
|
||||
let routing = ClaudeCredentialRouting.resolve(
|
||||
tokenAccountToken: "Bearer sk-ant-oat-test-token",
|
||||
manualCookieHeader: nil)
|
||||
|
||||
#expect(routing == .oauth(accessToken: "sk-ant-oat-test-token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolves session token to cookie header`() {
|
||||
let routing = ClaudeCredentialRouting.resolve(
|
||||
tokenAccountToken: "sk-ant-session-token",
|
||||
manualCookieHeader: nil)
|
||||
|
||||
#expect(routing == .webCookie(header: "sessionKey=sk-ant-session-token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `resolves config cookie header through shared normalizer`() {
|
||||
let routing = ClaudeCredentialRouting.resolve(
|
||||
tokenAccountToken: nil,
|
||||
manualCookieHeader: "Cookie: sessionKey=sk-ant-session-token; foo=bar")
|
||||
|
||||
#expect(routing == .webCookie(header: "sessionKey=sk-ant-session-token; foo=bar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `token account input wins over config cookie fallback`() {
|
||||
let routing = ClaudeCredentialRouting.resolve(
|
||||
tokenAccountToken: "Bearer sk-ant-oat-test-token",
|
||||
manualCookieHeader: "Cookie: sessionKey=sk-ant-session-token")
|
||||
|
||||
#expect(routing == .oauth(accessToken: "sk-ant-oat-test-token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `empty inputs resolve to none`() {
|
||||
let routing = ClaudeCredentialRouting.resolve(
|
||||
tokenAccountToken: " ",
|
||||
manualCookieHeader: "\n")
|
||||
|
||||
#expect(routing == .none)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeDebugDiagnosticsTests {
|
||||
private func makeCredentialsData(
|
||||
accessToken: String,
|
||||
expiresAt: Date,
|
||||
refreshToken: String? = nil) -> Data
|
||||
{
|
||||
let refreshTokenLine = if let refreshToken {
|
||||
"""
|
||||
"refreshToken": "\(refreshToken)",
|
||||
"""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
\(refreshTokenLine)
|
||||
"expiresAt": \(Int(expiresAt.timeIntervalSince1970 * 1000)),
|
||||
"scopes": ["user:profile"]
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `debug log uses planner derived order and reasons`() async throws {
|
||||
let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)"
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let credentialsURL = tempDir.appendingPathComponent("credentials.json")
|
||||
let credsJSON = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "oauth-token",
|
||||
"expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)),
|
||||
"scopes": ["user:profile"]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try Data(credsJSON.utf8).write(to: credentialsURL)
|
||||
|
||||
let store = try await MainActor.run { () -> UsageStore in
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configStore = testConfigStore(suiteName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: configStore,
|
||||
zaiTokenStore: NoopZaiTokenStore())
|
||||
settings.claudeUsageDataSource = .auto
|
||||
settings.claudeCookieSource = .manual
|
||||
settings.claudeCookieHeader = "sessionKey=sk-ant-session-token"
|
||||
|
||||
return UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
}
|
||||
|
||||
let text = await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) {
|
||||
await store.debugLog(for: .claude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(text.contains("planner_order=oauth→cli→web"))
|
||||
#expect(text.contains("planner_selected=oauth"))
|
||||
#expect(text.contains("planner_no_source=false"))
|
||||
#expect(text.contains("planner_step.oauth=available reason=app-auto-preferred-oauth"))
|
||||
#expect(text.contains("planner_step.cli="))
|
||||
#expect(text.contains("reason=app-auto-fallback-cli"))
|
||||
#expect(text.contains("planner_step.web=available reason=app-auto-fallback-web"))
|
||||
#expect(!text.contains("auto_heuristic="))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `debug log reports no planner selected source when auto has no available sources`() async throws {
|
||||
let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)"
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json")
|
||||
|
||||
let store = try await MainActor.run { () -> UsageStore in
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configStore = testConfigStore(suiteName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: configStore,
|
||||
zaiTokenStore: NoopZaiTokenStore())
|
||||
settings.claudeUsageDataSource = .auto
|
||||
settings.claudeCookieSource = .off
|
||||
settings.claudeWebExtrasEnabled = true
|
||||
|
||||
return UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
}
|
||||
|
||||
let text = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") {
|
||||
await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
|
||||
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: nil)
|
||||
{
|
||||
await store.debugLog(for: .claude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(text.contains("planner_selected=none"))
|
||||
#expect(text.contains("planner_no_source=true"))
|
||||
#expect(text.contains("No planner-selected Claude source."))
|
||||
#expect(!text.contains("web_extras=enabled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `debug Claude dump returns recorded parse dumps`() async {
|
||||
await ClaudeStatusProbe._replaceDumpsForTesting([
|
||||
"dump one",
|
||||
"dump two",
|
||||
])
|
||||
|
||||
let store = await MainActor.run {
|
||||
UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: SettingsStore(
|
||||
userDefaults: UserDefaults(),
|
||||
configStore: testConfigStore(suiteName: "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)"),
|
||||
zaiTokenStore: NoopZaiTokenStore()))
|
||||
}
|
||||
let text = await store.debugClaudeDump()
|
||||
|
||||
#expect(text.contains("dump one"))
|
||||
#expect(text.contains("dump two"))
|
||||
#expect(!text.contains("planner_order="))
|
||||
await ClaudeStatusProbe._replaceDumpsForTesting([])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `debug log uses runtime OAuth availability for token account routing`() async throws {
|
||||
let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)"
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json")
|
||||
|
||||
let store = try await MainActor.run { () -> UsageStore in
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configStore = testConfigStore(suiteName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: configStore,
|
||||
zaiTokenStore: NoopZaiTokenStore())
|
||||
settings.claudeUsageDataSource = .auto
|
||||
settings.claudeCookieSource = .off
|
||||
settings.addTokenAccount(
|
||||
provider: .claude,
|
||||
label: "OAuth Account",
|
||||
token: "sk-ant-oat-test-token")
|
||||
|
||||
return UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
}
|
||||
|
||||
let text = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") {
|
||||
await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
|
||||
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: nil)
|
||||
{
|
||||
await store.debugLog(for: .claude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(text.contains("planner_selected=oauth"))
|
||||
#expect(text.contains("hasOAuthCredentials=true"))
|
||||
#expect(text.contains("oauthCredentialOwner=environment"))
|
||||
#expect(text.contains("oauthCredentialSource=environment"))
|
||||
#expect(!text.contains("planner_selected=none"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `debug log preserves CLI probe overrides across detached work`() async throws {
|
||||
let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)"
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json")
|
||||
let store = try await MainActor.run { () -> UsageStore in
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configStore = testConfigStore(suiteName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: configStore,
|
||||
zaiTokenStore: NoopZaiTokenStore())
|
||||
settings.claudeUsageDataSource = .cli
|
||||
settings.claudeCookieSource = .off
|
||||
|
||||
return UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
}
|
||||
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { resolved, _, _ in
|
||||
#expect(resolved == "/usr/bin/true")
|
||||
return ClaudeStatusSnapshot(
|
||||
sessionPercentLeft: 76,
|
||||
weeklyPercentLeft: 55,
|
||||
opusPercentLeft: nil,
|
||||
accountEmail: "cli@example.com",
|
||||
accountOrganization: "CLI Org",
|
||||
loginMethod: "cli",
|
||||
primaryResetDescription: "Mar 7 at 1pm",
|
||||
secondaryResetDescription: nil,
|
||||
opusResetDescription: nil,
|
||||
rawText: "probe raw")
|
||||
}
|
||||
|
||||
let text = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
|
||||
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: nil)
|
||||
{
|
||||
await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
await store.debugLog(for: .claude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(text.contains("planner_selected=cli"))
|
||||
#expect(text.contains("session_left=76.0 weekly_left=55.0"))
|
||||
#expect(text.contains("email cli@example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `debug log uses user initiated interaction for OAuth prompt gate`() async throws {
|
||||
let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)"
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json")
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "user-initiated-oauth",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore()
|
||||
deniedStore.deniedUntil = Date(timeIntervalSinceNow: 300)
|
||||
|
||||
let store = try await MainActor.run { () -> UsageStore in
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configStore = testConfigStore(suiteName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: configStore,
|
||||
zaiTokenStore: NoopZaiTokenStore())
|
||||
settings.claudeUsageDataSource = .auto
|
||||
settings.claudeCookieSource = .off
|
||||
|
||||
return UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
}
|
||||
|
||||
let text = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") {
|
||||
await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
|
||||
await ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) {
|
||||
await ClaudeOAuthKeychainPromptPreference
|
||||
.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
await store.debugLog(for: .claude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(text.contains("planner_selected=oauth"))
|
||||
#expect(text.contains("hasOAuthCredentials=true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `debug log invalidates cached planner output when Claude settings change`() async throws {
|
||||
let suite = "ClaudeDebugDiagnosticsTests-\(UUID().uuidString)"
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json")
|
||||
let storeAndSettings = try await MainActor.run { () -> (UsageStore, SettingsStore) in
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let configStore = testConfigStore(suiteName: suite)
|
||||
let settings = SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: configStore,
|
||||
zaiTokenStore: NoopZaiTokenStore())
|
||||
settings.claudeUsageDataSource = .cli
|
||||
settings.claudeCookieSource = .off
|
||||
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
return (store, settings)
|
||||
}
|
||||
let store = storeAndSettings.0
|
||||
let settings = storeAndSettings.1
|
||||
let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, _, _ in
|
||||
ClaudeStatusSnapshot(
|
||||
sessionPercentLeft: 80,
|
||||
weeklyPercentLeft: 60,
|
||||
opusPercentLeft: nil,
|
||||
accountEmail: "cache@example.com",
|
||||
accountOrganization: nil,
|
||||
loginMethod: "cli",
|
||||
primaryResetDescription: "Mar 7 at 1pm",
|
||||
secondaryResetDescription: nil,
|
||||
opusResetDescription: nil,
|
||||
rawText: "probe raw")
|
||||
}
|
||||
|
||||
let first = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") {
|
||||
await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
|
||||
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) {
|
||||
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: nil)
|
||||
{
|
||||
await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) {
|
||||
await store.debugLog(for: .claude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
settings.claudeUsageDataSource = .auto
|
||||
}
|
||||
await Task.yield()
|
||||
await Task.yield()
|
||||
|
||||
let second = await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") {
|
||||
await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
return await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) {
|
||||
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: nil)
|
||||
{
|
||||
await store.debugLog(for: .claude)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(first.contains("planner_selected=cli"))
|
||||
#expect(second.contains("planner_selected=none"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ClaudeDirectUsageFallbackTests {
|
||||
private final class InvocationLog: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let lock = NSLock()
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
func contents() -> String {
|
||||
self.lock.withLock {
|
||||
(try? String(contentsOf: self.url, encoding: .utf8)) ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `passive claude probes always disable the cli auto updater`() {
|
||||
let environment = ClaudeCLISession.launchEnvironment(baseEnv: [
|
||||
"DISABLE_AUTOUPDATER": "0",
|
||||
ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token",
|
||||
"ANTHROPIC_API_KEY": "api-token",
|
||||
])
|
||||
|
||||
#expect(environment["DISABLE_AUTOUPDATER"] == "1")
|
||||
#expect(environment[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil)
|
||||
#expect(environment["ANTHROPIC_API_KEY"] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli source falls back to direct usage when pty usage fails to load`() async throws {
|
||||
let cliLogURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("claude-direct-fallback-log-\(UUID().uuidString).txt")
|
||||
let log = InvocationLog(url: cliLogURL)
|
||||
let fakeCLI = try Self.makeDirectFallbackClaudeCLI(logURL: cliLogURL)
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [
|
||||
"CLAUDE_CLI_PATH": fakeCLI.path,
|
||||
ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token",
|
||||
ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile",
|
||||
"ANTHROPIC_ADMIN_KEY": "admin-token",
|
||||
],
|
||||
dataSource: .cli)
|
||||
|
||||
try await ClaudeCLISession.withIsolatedSessionForTesting {
|
||||
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) {
|
||||
do {
|
||||
_ = try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
#expect(Bool(false), "Subscription-only usage should fail parsing")
|
||||
} catch let ClaudeUsageError.parseFailed(message) {
|
||||
#expect(message.lowercased().contains("subscription"))
|
||||
} catch let ClaudeStatusProbeError.parseFailed(message) {
|
||||
#expect(message.lowercased().contains("subscription"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let invocations = log.contents()
|
||||
#expect(invocations.contains("pty-usage"))
|
||||
#expect(invocations.contains("direct-usage"))
|
||||
#expect(invocations.contains("pty-auto-updater-disabled"))
|
||||
#expect(invocations.contains("direct-auto-updater-disabled"))
|
||||
#expect(!invocations.contains("pty-secret-env"))
|
||||
#expect(!invocations.contains("direct-secret-env"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `direct usage timeout keeps original pty failure`() async throws {
|
||||
let cliLogURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("claude-direct-timeout-log-\(UUID().uuidString).txt")
|
||||
let log = InvocationLog(url: cliLogURL)
|
||||
let fakeCLI = try Self.makeDirectTimeoutClaudeCLI(logURL: cliLogURL)
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: ["CLAUDE_CLI_PATH": fakeCLI.path],
|
||||
dataSource: .cli)
|
||||
|
||||
await ClaudeCLISession.withIsolatedSessionForTesting {
|
||||
await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) {
|
||||
do {
|
||||
_ = try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
#expect(Bool(false), "PTY failure should still surface")
|
||||
} catch let ClaudeStatusProbeError.parseFailed(message) {
|
||||
#expect(message.lowercased().contains("could not load usage data"))
|
||||
} catch {
|
||||
#expect(Bool(false), "Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let invocations = log.contents()
|
||||
#expect(invocations.contains("pty-usage"))
|
||||
#expect(invocations.contains("direct-usage"))
|
||||
}
|
||||
|
||||
private static func makeDirectFallbackClaudeCLI(logURL: URL) throws -> URL {
|
||||
try self.makeClaudeCLI(name: "claude-direct-fallback", logURL: logURL, scriptBody: """
|
||||
if [ "$1" = "/usage" ]; then
|
||||
printf 'direct-usage\\n' >> "$LOG_FILE"
|
||||
if [ "$DISABLE_AUTOUPDATER" = "1" ]; then
|
||||
printf 'direct-auto-updater-disabled\\n' >> "$LOG_FILE"
|
||||
fi
|
||||
if [ -n "$CODEXBAR_CLAUDE_OAUTH_TOKEN" ] ||
|
||||
[ -n "$CODEXBAR_CLAUDE_OAUTH_SCOPES" ] ||
|
||||
[ -n "$ANTHROPIC_ADMIN_KEY" ]; then
|
||||
printf 'direct-secret-env\\n' >> "$LOG_FILE"
|
||||
fi
|
||||
printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage'
|
||||
exit 0
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*"/usage"*)
|
||||
printf 'pty-usage\\n' >> "$LOG_FILE"
|
||||
if [ "$DISABLE_AUTOUPDATER" = "1" ]; then
|
||||
printf 'pty-auto-updater-disabled\\n' >> "$LOG_FILE"
|
||||
fi
|
||||
if [ -n "$CODEXBAR_CLAUDE_OAUTH_TOKEN" ] ||
|
||||
[ -n "$CODEXBAR_CLAUDE_OAUTH_SCOPES" ] ||
|
||||
[ -n "$ANTHROPIC_ADMIN_KEY" ]; then
|
||||
printf 'pty-secret-env\\n' >> "$LOG_FILE"
|
||||
fi
|
||||
printf '%s\\n' 'Failed to load usage data'
|
||||
;;
|
||||
*"/status"*)
|
||||
printf 'pty-status\\n' >> "$LOG_FILE"
|
||||
printf '%s\\n' 'Account: subscription@example.com'
|
||||
;;
|
||||
esac
|
||||
done
|
||||
""")
|
||||
}
|
||||
|
||||
private static func makeDirectTimeoutClaudeCLI(logURL: URL) throws -> URL {
|
||||
try self.makeClaudeCLI(name: "claude-direct-timeout", logURL: logURL, scriptBody: """
|
||||
if [ "$1" = "/usage" ]; then
|
||||
printf 'direct-usage\\n' >> "$LOG_FILE"
|
||||
sleep 30
|
||||
exit 0
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*"/usage"*)
|
||||
printf 'pty-usage\\n' >> "$LOG_FILE"
|
||||
printf '%s\\n' 'Failed to load usage data'
|
||||
;;
|
||||
esac
|
||||
done
|
||||
""")
|
||||
}
|
||||
|
||||
private static func makeClaudeCLI(name: String, logURL: URL, scriptBody: String) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let scriptURL = directory.appendingPathComponent("claude")
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
LOG_FILE='\(logURL.path)'
|
||||
\(scriptBody)
|
||||
"""
|
||||
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: NSNumber(value: Int16(0o755))],
|
||||
ofItemAtPath: scriptURL.path)
|
||||
return scriptURL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ClaudeEducationAvailabilityTests {
|
||||
@Test
|
||||
func `auto CLI subscription notice is terminal before web fallback`() {
|
||||
let browserDetection = BrowserDetection(cacheTTL: 0)
|
||||
let strategy = ClaudeCLIFetchStrategy(
|
||||
useWebExtras: false,
|
||||
manualCookieHeader: "sessionKey=test-session",
|
||||
browserDetection: browserDetection,
|
||||
hasWebFallback: true)
|
||||
let context = ProviderFetchContext(
|
||||
runtime: .app,
|
||||
sourceMode: .auto,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: [:],
|
||||
settings: nil,
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
||||
browserDetection: browserDetection)
|
||||
|
||||
let unavailable = ClaudeStatusProbeError.parseFailed(
|
||||
ClaudeStatusProbe.subscriptionQuotaUnavailableDescription)
|
||||
#expect(!strategy.shouldFallback(on: unavailable, context: context))
|
||||
#expect(strategy.shouldFallback(on: ClaudeStatusProbeError.timedOut, context: context))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `subscription-only response is informational across Claude surfaces`() async throws {
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("missing-credentials.json")
|
||||
|
||||
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let (store, tokenSnapshot) = try await MainActor.run {
|
||||
let settings = testSettingsStore(suiteName: "ClaudeEducationAvailabilityTests")
|
||||
settings.refreshFrequency = .manual
|
||||
settings.statusChecksEnabled = false
|
||||
settings.claudeUsageDataSource = .cli
|
||||
settings.claudeOAuthKeychainPromptMode = .never
|
||||
settings.providerDetectionCompleted = true
|
||||
|
||||
let metadata = ProviderRegistry.shared.metadata
|
||||
for provider in UsageProvider.allCases {
|
||||
try settings.setProviderEnabled(
|
||||
provider: provider,
|
||||
metadata: #require(metadata[provider]),
|
||||
enabled: provider == .claude)
|
||||
}
|
||||
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(environment: [:]),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
startupBehavior: .testing,
|
||||
environmentBase: [:])
|
||||
store._setSnapshotForTesting(
|
||||
UsageSnapshot(
|
||||
primary: RateWindow(
|
||||
usedPercent: 12,
|
||||
windowMinutes: 300,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil),
|
||||
secondary: RateWindow(
|
||||
usedPercent: 34,
|
||||
windowMinutes: 10080,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil),
|
||||
updatedAt: Date(timeIntervalSince1970: 1_800_000_000),
|
||||
identity: ProviderIdentitySnapshot(
|
||||
providerID: .claude,
|
||||
accountEmail: "claude@example.com",
|
||||
accountOrganization: nil,
|
||||
loginMethod: "Education")),
|
||||
provider: .claude)
|
||||
let tokenSnapshot = CostUsageTokenSnapshot(
|
||||
sessionTokens: 4200,
|
||||
sessionCostUSD: 1.25,
|
||||
last30DaysTokens: 42000,
|
||||
last30DaysCostUSD: 12.50,
|
||||
daily: [],
|
||||
updatedAt: Date(timeIntervalSince1970: 1_800_000_001))
|
||||
store._setTokenSnapshotForTesting(tokenSnapshot, provider: .claude)
|
||||
try Self.installStrategy(ClaudeSubscriptionOnlyFetchStrategy(), in: store)
|
||||
return (store, tokenSnapshot)
|
||||
}
|
||||
|
||||
await store.refreshProvider(.claude)
|
||||
await MainActor.run {
|
||||
let pane = ProvidersPane(settings: store.settings, store: store)
|
||||
let menuModel = pane._test_menuCardModel(for: .claude)
|
||||
let descriptor = MenuDescriptor.build(
|
||||
provider: .claude,
|
||||
store: store,
|
||||
settings: store.settings,
|
||||
account: AccountInfo(email: nil, plan: nil),
|
||||
updateReady: false,
|
||||
includeContextualActions: false)
|
||||
let descriptorLines = descriptor.sections
|
||||
.flatMap(\.entries)
|
||||
.compactMap { entry -> String? in
|
||||
guard case let .text(text, _) = entry else { return nil }
|
||||
return text
|
||||
}
|
||||
|
||||
#expect(store.snapshot(for: .claude) == nil)
|
||||
#expect(store.error(for: .claude) == nil)
|
||||
#expect(store.knownLimitsAvailability(for: .claude) == .unavailable)
|
||||
#expect(!store.isStale(provider: .claude))
|
||||
#expect(store.hasSatisfiedUsageFetch(for: .claude))
|
||||
#expect(!store.needsUsageRefreshRetry(for: .claude))
|
||||
#expect(store.tokenSnapshot(for: .claude) == tokenSnapshot)
|
||||
#expect(pane._test_providerErrorDisplay(for: .claude) == nil)
|
||||
#expect(pane._test_providerSidebarSubtitle(.claude).hasSuffix("\nLimits not available"))
|
||||
#expect(menuModel.placeholder == "Limits not available")
|
||||
#expect(descriptorLines.contains("Limits not available"))
|
||||
#expect(!descriptorLines.contains("No usage yet"))
|
||||
}
|
||||
|
||||
try await MainActor.run {
|
||||
try Self.installStrategy(ClaudeAvailabilityTimeoutFetchStrategy(), in: store)
|
||||
}
|
||||
await store.refreshProvider(.claude)
|
||||
|
||||
await MainActor.run {
|
||||
#expect(store.error(for: .claude) == nil)
|
||||
#expect(store.knownLimitsAvailability(for: .claude) == .unavailable)
|
||||
#expect(!store.isStale(provider: .claude))
|
||||
#expect(store.hasSatisfiedUsageFetch(for: .claude))
|
||||
#expect(!store.needsUsageRefreshRetry(for: .claude))
|
||||
#expect(store.tokenSnapshot(for: .claude) == tokenSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func installStrategy(
|
||||
_ strategy: some ProviderFetchStrategy,
|
||||
in store: UsageStore) throws
|
||||
{
|
||||
let currentSpec = try #require(store.providerSpecs[.claude])
|
||||
let currentDescriptor = currentSpec.descriptor
|
||||
store.providerSpecs[.claude] = ProviderSpec(
|
||||
style: currentSpec.style,
|
||||
isEnabled: currentSpec.isEnabled,
|
||||
descriptor: ProviderDescriptor(
|
||||
id: .claude,
|
||||
metadata: currentDescriptor.metadata,
|
||||
branding: currentDescriptor.branding,
|
||||
tokenCost: currentDescriptor.tokenCost,
|
||||
fetchPlan: ProviderFetchPlan(
|
||||
sourceModes: [.cli],
|
||||
pipeline: ProviderFetchPipeline { _ in [strategy] }),
|
||||
cli: currentDescriptor.cli),
|
||||
makeFetchContext: currentSpec.makeFetchContext)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClaudeAvailabilityTimeoutFetchStrategy: ProviderFetchStrategy {
|
||||
let id = "test.claude-availability-timeout"
|
||||
let kind: ProviderFetchKind = .cli
|
||||
|
||||
func isAvailable(_: ProviderFetchContext) async -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
|
||||
throw ClaudeStatusProbeError.timedOut
|
||||
}
|
||||
|
||||
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClaudeSubscriptionOnlyFetchStrategy: ProviderFetchStrategy {
|
||||
let id = "test.claude-subscription-only"
|
||||
let kind: ProviderFetchKind = .cli
|
||||
|
||||
func isAvailable(_: ProviderFetchContext) async -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
|
||||
throw ClaudeStatusProbeError.parseFailed(ClaudeStatusProbe.subscriptionQuotaUnavailableDescription)
|
||||
}
|
||||
|
||||
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
@testable import CodexBarCore
|
||||
|
||||
@MainActor
|
||||
struct ClaudeExtraWindowQuotaWarningTests {
|
||||
private func makeSettings(suiteName: String) -> SettingsStore {
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return SettingsStore(
|
||||
userDefaults: defaults,
|
||||
configStore: testConfigStore(suiteName: suiteName),
|
||||
zaiTokenStore: NoopZaiTokenStore(),
|
||||
syntheticTokenStore: NoopSyntheticTokenStore())
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SessionQuotaNotifierSpy: SessionQuotaNotifying {
|
||||
private(set) var quotaWarningPosts: [(
|
||||
event: QuotaWarningEvent,
|
||||
provider: UsageProvider,
|
||||
soundEnabled: Bool,
|
||||
onScreenAlertEnabled: Bool)] = []
|
||||
|
||||
func post(transition _: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) {}
|
||||
|
||||
func postQuotaWarning(
|
||||
event: QuotaWarningEvent,
|
||||
provider: UsageProvider,
|
||||
soundEnabled: Bool,
|
||||
onScreenAlertEnabled: Bool)
|
||||
{
|
||||
self.quotaWarningPosts.append((
|
||||
event: event,
|
||||
provider: provider,
|
||||
soundEnabled: soundEnabled,
|
||||
onScreenAlertEnabled: onScreenAlertEnabled))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `claude scoped weekly and routines extra windows fire independent weekly warnings`() {
|
||||
let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-independent")
|
||||
settings.refreshFrequency = .manual
|
||||
settings.statusChecksEnabled = false
|
||||
settings.quotaWarningNotificationsEnabled = true
|
||||
settings.quotaWarningThresholds = [50, 20]
|
||||
settings.setQuotaWarningWindowEnabled(.session, enabled: true)
|
||||
settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
|
||||
|
||||
let notifier = SessionQuotaNotifierSpy()
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
sessionQuotaNotifier: notifier)
|
||||
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude,
|
||||
snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40))
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude,
|
||||
snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55))
|
||||
|
||||
#expect(notifier.quotaWarningPosts.count == 2)
|
||||
let fable = notifier.quotaWarningPosts.first { $0.event.windowID == "claude-weekly-scoped-fable" }
|
||||
let routines = notifier.quotaWarningPosts.first { $0.event.windowID == "claude-routines" }
|
||||
#expect(fable?.event.window == .weekly)
|
||||
#expect(fable?.event.threshold == 50)
|
||||
#expect(fable?.event.windowDisplayLabel == "Fable only")
|
||||
#expect(routines?.event.threshold == 50)
|
||||
#expect(routines?.event.windowDisplayLabel == "Daily Routines")
|
||||
|
||||
// Each window keeps independent fired-threshold state instead of clobbering the shared weekly key.
|
||||
let fableKey = UsageStore.QuotaWarningStateKey(
|
||||
provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable")
|
||||
let routinesKey = UsageStore.QuotaWarningStateKey(
|
||||
provider: .claude, window: .weekly, windowID: "claude-routines")
|
||||
#expect(store.quotaWarningState[fableKey]?.firedThresholds.contains(50) == true)
|
||||
#expect(store.quotaWarningState[routinesKey]?.firedThresholds.contains(50) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `antigravity summary extra windows do not trigger the claude extra-window lane`() {
|
||||
let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-antigravity-guard")
|
||||
settings.refreshFrequency = .manual
|
||||
settings.statusChecksEnabled = false
|
||||
settings.quotaWarningNotificationsEnabled = true
|
||||
settings.quotaWarningThresholds = [50, 20]
|
||||
settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
|
||||
|
||||
let notifier = SessionQuotaNotifierSpy()
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
sessionQuotaNotifier: notifier)
|
||||
|
||||
func snapshot(used: Double) -> UsageSnapshot {
|
||||
UsageSnapshot(
|
||||
primary: nil,
|
||||
secondary: nil,
|
||||
extraRateWindows: [
|
||||
NamedRateWindow(
|
||||
id: "antigravity-quota-summary-model-weekly",
|
||||
title: "Weekly",
|
||||
window: RateWindow(
|
||||
usedPercent: used,
|
||||
windowMinutes: 7 * 24 * 60,
|
||||
resetsAt: nil,
|
||||
resetDescription: nil)),
|
||||
],
|
||||
updatedAt: Date())
|
||||
}
|
||||
store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot(used: 40))
|
||||
store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot(used: 55))
|
||||
|
||||
#expect(notifier.quotaWarningPosts.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `claude scoped weekly window refires after recovering above threshold`() {
|
||||
let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-refire")
|
||||
settings.refreshFrequency = .manual
|
||||
settings.statusChecksEnabled = false
|
||||
settings.quotaWarningNotificationsEnabled = true
|
||||
settings.quotaWarningThresholds = [50]
|
||||
settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
|
||||
|
||||
let notifier = SessionQuotaNotifierSpy()
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
sessionQuotaNotifier: notifier)
|
||||
|
||||
// 60% remaining -> 45% (fires 50) -> 60% (clears 50) -> 45% (refires 50).
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil))
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil))
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil))
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil))
|
||||
|
||||
#expect(notifier.quotaWarningPosts.count == 2)
|
||||
#expect(notifier.quotaWarningPosts.allSatisfy { $0.event.windowID == "claude-weekly-scoped-fable" })
|
||||
}
|
||||
|
||||
@Test
|
||||
func `claude extra-window fired state is pruned when a window disappears but others remain`() {
|
||||
let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-prune")
|
||||
settings.refreshFrequency = .manual
|
||||
settings.statusChecksEnabled = false
|
||||
settings.quotaWarningNotificationsEnabled = true
|
||||
settings.quotaWarningThresholds = [50]
|
||||
settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
|
||||
|
||||
let notifier = SessionQuotaNotifierSpy()
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
sessionQuotaNotifier: notifier)
|
||||
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40))
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55))
|
||||
let fableKey = UsageStore.QuotaWarningStateKey(
|
||||
provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable")
|
||||
let routinesKey = UsageStore.QuotaWarningStateKey(
|
||||
provider: .claude, window: .weekly, windowID: "claude-routines")
|
||||
#expect(store.quotaWarningState[fableKey] != nil)
|
||||
#expect(store.quotaWarningState[routinesKey] != nil)
|
||||
|
||||
// Fable ends while Routines is still present: this refresh carries authoritative extras, so
|
||||
// Fable's stale state is dropped and Routines is kept.
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: nil, routinesUsed: 55))
|
||||
#expect(store.quotaWarningState[fableKey] == nil)
|
||||
#expect(store.quotaWarningState[routinesKey] != nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `disabling weekly warnings clears all claude extra-window state`() {
|
||||
let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-disable")
|
||||
settings.refreshFrequency = .manual
|
||||
settings.statusChecksEnabled = false
|
||||
settings.quotaWarningNotificationsEnabled = true
|
||||
settings.quotaWarningThresholds = [50]
|
||||
settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
|
||||
|
||||
let notifier = SessionQuotaNotifierSpy()
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
sessionQuotaNotifier: notifier)
|
||||
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: 40))
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: 55))
|
||||
let fableKey = UsageStore.QuotaWarningStateKey(
|
||||
provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable")
|
||||
let routinesKey = UsageStore.QuotaWarningStateKey(
|
||||
provider: .claude, window: .weekly, windowID: "claude-routines")
|
||||
#expect(store.quotaWarningState[fableKey] != nil)
|
||||
#expect(store.quotaWarningState[routinesKey] != nil)
|
||||
|
||||
settings.setQuotaWarningWindowEnabled(.weekly, enabled: false)
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude,
|
||||
snapshot: UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: nil, updatedAt: Date()))
|
||||
#expect(store.quotaWarningState[fableKey] == nil)
|
||||
#expect(store.quotaWarningState[routinesKey] == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `claude extra-window state survives a transient extras miss without re-posting`() {
|
||||
let settings = self.makeSettings(suiteName: "ClaudeExtraWindowQuotaWarningTests-transient-miss")
|
||||
settings.refreshFrequency = .manual
|
||||
settings.statusChecksEnabled = false
|
||||
settings.quotaWarningNotificationsEnabled = true
|
||||
settings.quotaWarningThresholds = [50]
|
||||
settings.setQuotaWarningWindowEnabled(.weekly, enabled: true)
|
||||
|
||||
let notifier = SessionQuotaNotifierSpy()
|
||||
let store = UsageStore(
|
||||
fetcher: UsageFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings,
|
||||
sessionQuotaNotifier: notifier)
|
||||
|
||||
// Fable crosses 50% and warns once.
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 40, routinesUsed: nil))
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil))
|
||||
#expect(notifier.quotaWarningPosts.count == 1)
|
||||
let fableKey = UsageStore.QuotaWarningStateKey(
|
||||
provider: .claude, window: .weekly, windowID: "claude-weekly-scoped-fable")
|
||||
|
||||
// A failed web-extras fetch delivers nil extras while the main snapshot is intact. The fired
|
||||
// state must persist so the warning is not re-posted when extras recover.
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude,
|
||||
snapshot: UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: nil, updatedAt: Date()))
|
||||
#expect(store.quotaWarningState[fableKey] != nil)
|
||||
|
||||
store.handleQuotaWarningTransitions(
|
||||
provider: .claude, snapshot: self.claudeExtraWindowSnapshot(fableUsed: 55, routinesUsed: nil))
|
||||
#expect(notifier.quotaWarningPosts.count == 1)
|
||||
}
|
||||
|
||||
private func claudeExtraWindowSnapshot(fableUsed: Double?, routinesUsed: Double?) -> UsageSnapshot {
|
||||
var windows: [NamedRateWindow] = []
|
||||
if let fableUsed {
|
||||
windows.append(NamedRateWindow(
|
||||
id: "claude-weekly-scoped-fable",
|
||||
title: "Fable only",
|
||||
window: RateWindow(
|
||||
usedPercent: fableUsed, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil)))
|
||||
}
|
||||
if let routinesUsed {
|
||||
windows.append(NamedRateWindow(
|
||||
id: "claude-routines",
|
||||
title: "Daily Routines",
|
||||
window: RateWindow(
|
||||
usedPercent: routinesUsed, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil)))
|
||||
}
|
||||
return UsageSnapshot(primary: nil, secondary: nil, extraRateWindows: windows, updatedAt: Date())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import CodexBarCore
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct ClaudeLoginFlowTests {
|
||||
@Test
|
||||
func `successful Claude login controller flow preserves selected source and enables provider`() async throws {
|
||||
let registry = ProviderRegistry.shared
|
||||
let claudeMetadata = try #require(registry.metadata[.claude])
|
||||
|
||||
for source in ClaudeUsageDataSource.allCases {
|
||||
let settings = testSettingsStore(
|
||||
suiteName: "ClaudeLoginFlowTests-controller-\(source.rawValue)")
|
||||
settings.statusChecksEnabled = false
|
||||
settings.refreshFrequency = .manual
|
||||
settings.providerDetectionCompleted = true
|
||||
settings.claudeUsageDataSource = source
|
||||
settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false)
|
||||
|
||||
let fetcher = UsageFetcher()
|
||||
let store = UsageStore(
|
||||
fetcher: fetcher,
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
settings: settings)
|
||||
|
||||
await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in
|
||||
let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in
|
||||
onPhaseChange(.requesting)
|
||||
await Task.yield()
|
||||
onPhaseChange(.waitingBrowser)
|
||||
await Task.yield()
|
||||
return ClaudeLoginRunner.Result(
|
||||
outcome: .success,
|
||||
output: "Successfully logged in",
|
||||
authLink: nil)
|
||||
}
|
||||
|
||||
#expect(didLogin)
|
||||
#expect(controller.loginPhase == .idle)
|
||||
}
|
||||
|
||||
#expect(settings.claudeUsageDataSource == source)
|
||||
#expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBar
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeLoginRunnerTests {
|
||||
@Test
|
||||
func `dedicated auth command opens browser prompt and completes successfully`() async throws {
|
||||
let fixture = try self.makeFixture(script: """
|
||||
#!/bin/sh
|
||||
printf 'args:%s\\n' "$*"
|
||||
printf 'Authenticate your account at (press ENTER to open in browser): '
|
||||
IFS= read -r _
|
||||
printf 'https://claude.ai/oauth/authorize?test=1\\n'
|
||||
printf 'Successfully logged in\\n'
|
||||
""")
|
||||
defer { fixture.remove() }
|
||||
|
||||
let result = await ClaudeLoginRunner.run(
|
||||
timeout: 2,
|
||||
binary: fixture.executable.path,
|
||||
environment: fixture.environment,
|
||||
onPhaseChange: { _ in })
|
||||
|
||||
guard case .success = result.outcome else {
|
||||
Issue.record("Expected success, got \(String(describing: result.outcome))")
|
||||
return
|
||||
}
|
||||
#expect(result.output.contains("args:auth login --claudeai"))
|
||||
#expect(result.authLink == "https://claude.ai/oauth/authorize?test=1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `authorization URL alone is not treated as success`() async throws {
|
||||
let fixture = try self.makeFixture(script: """
|
||||
#!/bin/sh
|
||||
printf 'https://claude.ai/oauth/authorize?test=1\\n'
|
||||
/bin/sleep 5
|
||||
""")
|
||||
defer { fixture.remove() }
|
||||
|
||||
let result = await ClaudeLoginRunner.run(
|
||||
timeout: 1,
|
||||
binary: fixture.executable.path,
|
||||
environment: fixture.environment,
|
||||
onPhaseChange: { _ in })
|
||||
|
||||
guard case .timedOut = result.outcome else {
|
||||
Issue.record("Expected timeout, got \(String(describing: result.outcome))")
|
||||
return
|
||||
}
|
||||
#expect(result.authLink == "https://claude.ai/oauth/authorize?test=1")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `dedicated auth command preserves failure status`() async throws {
|
||||
let fixture = try self.makeFixture(script: """
|
||||
#!/bin/sh
|
||||
printf 'login failed\\n'
|
||||
exit 7
|
||||
""")
|
||||
defer { fixture.remove() }
|
||||
|
||||
let result = await ClaudeLoginRunner.run(
|
||||
timeout: 2,
|
||||
binary: fixture.executable.path,
|
||||
environment: fixture.environment,
|
||||
onPhaseChange: { _ in })
|
||||
|
||||
guard case .failed(status: 7) = result.outcome else {
|
||||
Issue.record("Expected status 7, got \(String(describing: result.outcome))")
|
||||
return
|
||||
}
|
||||
#expect(result.output.contains("login failed"))
|
||||
}
|
||||
|
||||
private func makeFixture(script: String) throws -> Fixture {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codexbar-claude-login-\(UUID().uuidString)", isDirectory: true)
|
||||
let binDirectory = root.appendingPathComponent("bin", isDirectory: true)
|
||||
let homeDirectory = root.appendingPathComponent("home", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: binDirectory, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: homeDirectory, withIntermediateDirectories: true)
|
||||
|
||||
let executable = binDirectory.appendingPathComponent("claude")
|
||||
try script.write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
return Fixture(
|
||||
root: root,
|
||||
executable: executable,
|
||||
environment: [
|
||||
"HOME": homeDirectory.path,
|
||||
"PATH": binDirectory.path,
|
||||
])
|
||||
}
|
||||
|
||||
private struct Fixture {
|
||||
let root: URL
|
||||
let executable: URL
|
||||
let environment: [String: String]
|
||||
|
||||
func remove() {
|
||||
try? FileManager.default.removeItem(at: self.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests {
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let refreshField: String = {
|
||||
guard let refreshToken else { return "" }
|
||||
return ",\n \"refreshToken\": \"\(refreshToken)\""
|
||||
}()
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]\(refreshField)
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
private func withClaudeOAuthTokenRefreshStub<T>(
|
||||
handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data),
|
||||
operation: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
let registered = URLProtocol.registerClass(ClaudeOAuthTokenRefreshStubURLProtocol.self)
|
||||
ClaudeOAuthTokenRefreshStubURLProtocol.reset()
|
||||
ClaudeOAuthTokenRefreshStubURLProtocol.handler = handler
|
||||
defer {
|
||||
if registered {
|
||||
URLProtocol.unregisterClass(ClaudeOAuthTokenRefreshStubURLProtocol.self)
|
||||
}
|
||||
ClaudeOAuthTokenRefreshStubURLProtocol.reset()
|
||||
}
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
private func requestBodyString(_ request: URLRequest) -> String {
|
||||
if let body = request.httpBody {
|
||||
return String(data: body, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
guard let stream = request.httpBodyStream else { return "" }
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
|
||||
var data = Data()
|
||||
let bufferSize = 1024
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||
defer { buffer.deallocate() }
|
||||
|
||||
while stream.hasBytesAvailable {
|
||||
let count = stream.read(buffer, maxLength: bufferSize)
|
||||
guard count > 0 else { break }
|
||||
data.append(buffer, count: count)
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
@Test
|
||||
func `successful codexbar refresh is re-owned when Claude CLI storage appears`() async throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-codexbar-only",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: "cached-refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(timeIntervalSinceNow: 60),
|
||||
owner: .codexbar))
|
||||
|
||||
var tokenRefreshRequestCount = 0
|
||||
let refreshed = try await self.withClaudeOAuthTokenRefreshStub(handler: { request in
|
||||
tokenRefreshRequestCount += 1
|
||||
#expect(request.url?.host == "platform.claude.com")
|
||||
#expect(request.url?.path == "/v1/oauth/token")
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
|
||||
#expect(
|
||||
request.value(forHTTPHeaderField: "Content-Type") ==
|
||||
"application/x-www-form-urlencoded")
|
||||
|
||||
let body = self.requestBodyString(request)
|
||||
#expect(body.contains("grant_type=refresh_token"))
|
||||
#expect(body.contains("refresh_token=cached-refresh-token"))
|
||||
#expect(body.contains("client_id=\(ClaudeOAuthCredentialsStore.defaultOAuthClientID)"))
|
||||
|
||||
let response = try HTTPURLResponse(
|
||||
url: #require(request.url),
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
let json = """
|
||||
{
|
||||
"access_token": "fresh-codexbar-token",
|
||||
"refresh_token": "fresh-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
"""
|
||||
return (response, Data(json.utf8))
|
||||
}, operation: {
|
||||
try await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(true) {
|
||||
try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
})
|
||||
|
||||
#expect(refreshed.accessToken == "fresh-codexbar-token")
|
||||
#expect(refreshed.refreshToken == "fresh-refresh-token")
|
||||
#expect(tokenRefreshRequestCount == 1)
|
||||
|
||||
switch KeychainCacheStore.load(
|
||||
key: cacheKey,
|
||||
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
|
||||
{
|
||||
case let .found(entry):
|
||||
#expect(entry.owner == .codexbar)
|
||||
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
|
||||
#expect(parsed.accessToken == "fresh-codexbar-token")
|
||||
#expect(parsed.refreshToken == "fresh-refresh-token")
|
||||
default:
|
||||
Issue.record("Expected refreshed CodexBar-owned cache entry")
|
||||
}
|
||||
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "claude-keychain",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "keychain-refresh-token")
|
||||
|
||||
let recordAfterCLIStorageAppears = try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) {
|
||||
try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true,
|
||||
allowClaudeKeychainRepairWithoutPrompt: false)
|
||||
}
|
||||
|
||||
#expect(recordAfterCLIStorageAppears.credentials.accessToken == "fresh-codexbar-token")
|
||||
#expect(recordAfterCLIStorageAppears.owner == .claudeCLI)
|
||||
#expect(recordAfterCLIStorageAppears.source == .memoryCache)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `rotated refresh token preserves history owner through cache restart`() async throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "access-before-rotation",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: "refresh-before-rotation")
|
||||
let originalCredentials = try ClaudeOAuthCredentials.parse(data: expiredData)
|
||||
let originalHistoryOwner = try #require(originalCredentials.historyOwnerIdentifier)
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(),
|
||||
owner: .codexbar))
|
||||
|
||||
let refreshedRecord = try await ClaudeOAuthCredentialsStore
|
||||
.withIsolatedMemoryCacheForTesting {
|
||||
try await self.withClaudeOAuthTokenRefreshStub(handler: { request in
|
||||
let response = try HTTPURLResponse(
|
||||
url: #require(request.url),
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
let json = """
|
||||
{
|
||||
"access_token": "access-after-rotation",
|
||||
"refresh_token": "refresh-after-rotation",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
"""
|
||||
return (response, Data(json.utf8))
|
||||
}, operation: {
|
||||
try await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(true) {
|
||||
try await ClaudeOAuthCredentialsStore.loadRecordWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let rotatedCredentialOwner = try #require(
|
||||
refreshedRecord.credentials.historyOwnerIdentifier)
|
||||
#expect(rotatedCredentialOwner != originalHistoryOwner)
|
||||
#expect(refreshedRecord.historyOwnerIdentifier == originalHistoryOwner)
|
||||
|
||||
switch KeychainCacheStore.load(
|
||||
key: cacheKey,
|
||||
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
|
||||
{
|
||||
case let .found(entry):
|
||||
#expect(entry.owner == .codexbar)
|
||||
#expect(entry.historyOwnerIdentifier == originalHistoryOwner)
|
||||
default:
|
||||
Issue.record("Expected refreshed cache entry with preserved history lineage")
|
||||
}
|
||||
|
||||
let restartedRecord = try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true,
|
||||
allowClaudeKeychainRepairWithoutPrompt: false)
|
||||
}
|
||||
#expect(restartedRecord.credentials.accessToken == "access-after-rotation")
|
||||
#expect(restartedRecord.credentials.refreshToken == "refresh-after-rotation")
|
||||
#expect(restartedRecord.source == .cacheKeychain)
|
||||
#expect(restartedRecord.historyOwnerIdentifier == originalHistoryOwner)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load record treats codexbar cache as claude CLI owned when credentials file exists`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let fileData = self.makeCredentialsData(
|
||||
accessToken: "claude-cli-file",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "cli-refresh-token")
|
||||
try fileData.write(to: fileURL)
|
||||
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "codexbar-cache",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "cached-refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: cachedData,
|
||||
storedAt: Date(timeIntervalSinceNow: 60),
|
||||
owner: .codexbar))
|
||||
|
||||
let record = try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true,
|
||||
allowClaudeKeychainRepairWithoutPrompt: false)
|
||||
|
||||
#expect(record.credentials.accessToken == "codexbar-cache")
|
||||
#expect(record.owner == .claudeCLI)
|
||||
#expect(record.source == .cacheKeychain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load with auto refresh delegates expired codexbar cache when credentials file exists`() async throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
try Data("not valid credentials".utf8).write(to: fileURL)
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-codexbar-with-file",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: "cached-refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(timeIntervalSinceNow: 60),
|
||||
owner: .codexbar))
|
||||
|
||||
await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) {
|
||||
do {
|
||||
_ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
Issue.record("Expected delegated refresh error when Claude CLI file is present")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .refreshDelegatedToClaudeCLI = error else {
|
||||
Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load with auto refresh keeps codexbar cache ownership without Claude CLI storage`() async throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let fileURL = tempDir.appendingPathComponent("missing-credentials.json")
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-codexbar-only",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: "cached-refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(timeIntervalSinceNow: 60),
|
||||
owner: .codexbar))
|
||||
|
||||
await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) {
|
||||
do {
|
||||
_ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
Issue.record("Expected direct CodexBar refresh failure")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case let .refreshFailed(message) = error else {
|
||||
Issue.record("Expected .refreshFailed, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("suppressed") || message.contains("backed off"))
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load record treats codexbar cache as claude CLI owned when Claude keychain item exists`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "codexbar-cache",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "cached-refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: cachedData,
|
||||
storedAt: Date(),
|
||||
owner: .codexbar))
|
||||
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "claude-keychain",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "keychain-refresh-token")
|
||||
|
||||
let record = try ClaudeOAuthKeychainPromptPreference
|
||||
.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true,
|
||||
allowClaudeKeychainRepairWithoutPrompt: false)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(record.credentials.accessToken == "codexbar-cache")
|
||||
#expect(record.owner == .claudeCLI)
|
||||
#expect(record.source == .cacheKeychain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load record ignores codexbar cache in never prompt mode`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore()
|
||||
try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "codexbar-cache",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "cached-refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: cachedData,
|
||||
storedAt: Date(),
|
||||
owner: .codexbar))
|
||||
|
||||
do {
|
||||
_ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: self.makeCredentialsData(
|
||||
accessToken: "claude-keychain",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "keychain-refresh-token"),
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true,
|
||||
allowClaudeKeychainRepairWithoutPrompt: false)
|
||||
}
|
||||
}
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `expired claude CLI owner blocks background mcp O auth but lets user action delegate`() async throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let mcpOAuthOnly = Data("""
|
||||
{
|
||||
"mcpOAuth": {
|
||||
"plugin:slack:slack": { "accessToken": "" }
|
||||
}
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(mcpOAuthOnly))
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-claude-cli-owner",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: "refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI))
|
||||
|
||||
do {
|
||||
_ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
Issue.record("Expected mcpOAuth-only keychain error")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .mcpOAuthOnlyKeychain = error else {
|
||||
Issue.record("Expected .mcpOAuthOnlyKeychain, got \(error)")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
Issue.record("Expected delegated refresh on explicit user action")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .refreshDelegatedToClaudeCLI = error else {
|
||||
Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ClaudeOAuthTokenRefreshStubURLProtocol: URLProtocol {
|
||||
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
static func reset() {
|
||||
self.handler = nil
|
||||
}
|
||||
|
||||
override static func canInit(with request: URLRequest) -> Bool {
|
||||
request.url?.host == "platform.claude.com" && request.url?.path == "/v1/oauth/token"
|
||||
}
|
||||
|
||||
override static func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard let handler = Self.handler else {
|
||||
self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (response, data) = try handler(self.request)
|
||||
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
self.client?.urlProtocol(self, didLoad: data)
|
||||
self.client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
self.client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests {
|
||||
@Test
|
||||
func `safety blocks security CLI access to the login keychain`() {
|
||||
let blockedEnvironment = [KeychainTestSafety.suppressAccessEnvironmentKey: "1"]
|
||||
#expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments(
|
||||
account: nil,
|
||||
environment: blockedEnvironment) == nil)
|
||||
|
||||
let explicitOptIn = [
|
||||
KeychainTestSafety.suppressAccessEnvironmentKey: "1",
|
||||
KeychainTestSafety.allowAccessEnvironmentKey: "1",
|
||||
]
|
||||
let expectedArguments = [
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
"Claude Code-credentials",
|
||||
"-w",
|
||||
]
|
||||
#expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments(
|
||||
account: nil,
|
||||
environment: explicitOptIn) == expectedArguments)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `isolated security CLI keychain requires global keychain disable`() {
|
||||
let keychainPath = "/tmp/codexbar-fixtures/verify.keychain-db"
|
||||
let isolatedEnvironment = [
|
||||
KeychainAccessGate.disableAccessEnvironmentKey: "1",
|
||||
ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: keychainPath,
|
||||
]
|
||||
let expectedArguments = [
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
"Claude Code-credentials",
|
||||
"-w",
|
||||
keychainPath,
|
||||
]
|
||||
|
||||
#expect(KeychainAccessGate.isDisabledByEnvironment(isolatedEnvironment))
|
||||
#expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments(
|
||||
account: nil,
|
||||
environment: isolatedEnvironment) == expectedArguments)
|
||||
#expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments(
|
||||
account: nil,
|
||||
environment: [
|
||||
ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: keychainPath,
|
||||
]) == nil)
|
||||
#expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments(
|
||||
account: nil,
|
||||
environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) == nil)
|
||||
#expect(ClaudeOAuthCredentialsStore.securityCLIReadArguments(
|
||||
account: nil,
|
||||
environment: [
|
||||
KeychainAccessGate.disableAccessEnvironmentKey: "1",
|
||||
ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "relative.keychain-db",
|
||||
]) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `isolated security CLI keychain remains readable while other keychain access is disabled`() {
|
||||
let mcpOnlyPayload = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8)
|
||||
let environment = [
|
||||
KeychainAccessGate.disableAccessEnvironmentKey: "1",
|
||||
ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "/tmp/verify.keychain-db",
|
||||
]
|
||||
|
||||
let isMcpOnly = ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) {
|
||||
ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent(
|
||||
interaction: .background,
|
||||
readStrategy: .securityCLIExperimental,
|
||||
keychainAccessDisabled: true,
|
||||
environment: environment)
|
||||
}
|
||||
#expect(isMcpOnly)
|
||||
|
||||
let blockedWithoutIsolatedKeychain = ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) {
|
||||
ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent(
|
||||
interaction: .background,
|
||||
readStrategy: .securityCLIExperimental,
|
||||
keychainAccessDisabled: true,
|
||||
environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"])
|
||||
}
|
||||
#expect(blockedWithoutIsolatedKeychain == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `never prompt mode still detects MCP-only payload via experimental security CLI reader`() {
|
||||
let mcpOnlyPayload = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8)
|
||||
let environment = [
|
||||
KeychainAccessGate.disableAccessEnvironmentKey: "1",
|
||||
ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "/tmp/verify.keychain-db",
|
||||
]
|
||||
|
||||
let isMcpOnly = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) {
|
||||
ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent(
|
||||
interaction: .background,
|
||||
readStrategy: .securityCLIExperimental,
|
||||
keychainAccessDisabled: true,
|
||||
environment: environment)
|
||||
}
|
||||
}
|
||||
#expect(isMcpOnly)
|
||||
|
||||
let blockedViaSecurityFramework = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) {
|
||||
ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent(
|
||||
interaction: .background,
|
||||
readStrategy: .securityFramework,
|
||||
keychainAccessDisabled: false,
|
||||
environment: [:])
|
||||
}
|
||||
}
|
||||
#expect(!blockedViaSecurityFramework)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStoreMCPOnlyGuardTests {
|
||||
@Test
|
||||
func `standard reader blocks expired CLI owner in background but preserves user refresh`() async throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let mcpOAuthOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8)
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
let credentialsURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
|
||||
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) {
|
||||
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: mcpOAuthOnly,
|
||||
fingerprint: nil)
|
||||
{
|
||||
let isMcpOnly = ProviderInteractionContext.$current.withValue(.background) {
|
||||
ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent(
|
||||
interaction: .background,
|
||||
readStrategy: .securityFramework,
|
||||
keychainAccessDisabled: false,
|
||||
environment: [:])
|
||||
}
|
||||
#expect(isMcpOnly)
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: self.expiredCredentialsData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI))
|
||||
|
||||
do {
|
||||
_ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
Issue.record("Expected MCP-only keychain failure")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .mcpOAuthOnlyKeychain = error else {
|
||||
Issue.record("Expected .mcpOAuthOnlyKeychain, got \(error)")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
Issue.record("Expected explicit user Refresh to delegate")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .refreshDelegatedToClaudeCLI = error else {
|
||||
Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var expiredCredentialsData: Data {
|
||||
let json = #"""
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "expired",
|
||||
"refreshToken": "refresh",
|
||||
"expiresAt": 1000,
|
||||
"scopes": ["user:profile"]
|
||||
}
|
||||
}
|
||||
"""#
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,693 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests {
|
||||
private struct TestState {
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let pendingStore: ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore
|
||||
let recorder: ClaudeOAuthCredentialsStore.OAuthCacheOperationRecorder
|
||||
}
|
||||
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let refreshField: String = {
|
||||
guard let refreshToken else { return "" }
|
||||
return ",\n \"refreshToken\": \"\(refreshToken)\""
|
||||
}()
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]\(refreshField)
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
private func withTestState<T>(_ operation: (TestState) throws -> T) throws -> T {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore()
|
||||
let recorder = ClaudeOAuthCredentialsStore.OAuthCacheOperationRecorder()
|
||||
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
|
||||
let state = TestState(pendingStore: pendingStore, recorder: recorder)
|
||||
|
||||
return try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
return try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) {
|
||||
try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) {
|
||||
try ClaudeOAuthCredentialsStore.withOAuthCacheOperationRecorderForTesting(recorder) {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainFingerprintStoreOverrideForTesting(fingerprintStore) {
|
||||
try operation(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func withCredentialsFile<T>(
|
||||
data: Data?,
|
||||
operation: (URL) throws -> T) throws -> T
|
||||
{
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDirectory) }
|
||||
|
||||
let fileURL = tempDirectory.appendingPathComponent("credentials.json")
|
||||
if let data {
|
||||
try data.write(to: fileURL)
|
||||
}
|
||||
return try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
try operation(fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func seedCache(
|
||||
_ state: TestState,
|
||||
accessToken: String,
|
||||
storedAt: Date = Date())
|
||||
{
|
||||
let data = self.makeCredentialsData(
|
||||
accessToken: accessToken,
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let stored = ClaudeOAuthCredentialsStore.withOAuthCacheOperationRecorderForTesting(nil) {
|
||||
KeychainCacheStore.storeResult(
|
||||
key: state.cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: data, storedAt: storedAt))
|
||||
}
|
||||
#expect(stored)
|
||||
}
|
||||
|
||||
private func cachedToken(_ state: TestState) throws -> String? {
|
||||
try ClaudeOAuthCredentialsStore.withOAuthCacheOperationRecorderForTesting(nil) {
|
||||
switch KeychainCacheStore.load(
|
||||
key: state.cacheKey,
|
||||
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
|
||||
{
|
||||
case let .found(entry):
|
||||
return try ClaudeOAuthCredentials.parse(data: entry.data).accessToken
|
||||
case .missing:
|
||||
return nil
|
||||
case .invalid, .temporarilyUnavailable:
|
||||
Issue.record("Expected a valid or missing test cache entry")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runDefaults(_ arguments: [String]) throws -> (status: Int32, output: String) {
|
||||
let process = Process()
|
||||
let output = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
|
||||
process.arguments = arguments
|
||||
process.standardOutput = output
|
||||
process.standardError = output
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||
return (process.terminationStatus, String(data: data, encoding: .utf8) ?? "")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `never mode loads the credentials file with zero oauth cache IO`() throws {
|
||||
try self.withTestState { state in
|
||||
let fileData = self.makeCredentialsData(
|
||||
accessToken: "file-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try self.withCredentialsFile(data: fileData) { _ in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
|
||||
let credentials = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(credentials.accessToken == "file-token")
|
||||
#expect(state.recorder.operations.isEmpty)
|
||||
#expect(state.pendingStore.isPending)
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == "cached-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `never mode file invalidation records a tombstone without oauth cache IO`() throws {
|
||||
try self.withTestState { state in
|
||||
let initialData = self.makeCredentialsData(
|
||||
accessToken: "initial-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try self.withCredentialsFile(data: initialData) { fileURL in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
|
||||
let initialChange = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
|
||||
}
|
||||
#expect(initialChange)
|
||||
|
||||
let updatedData = self.makeCredentialsData(
|
||||
accessToken: "updated-token-with-a-different-size",
|
||||
expiresAt: Date(timeIntervalSinceNow: 7200))
|
||||
try updatedData.write(to: fileURL)
|
||||
|
||||
let changed = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
|
||||
}
|
||||
let changedAgain = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
|
||||
}
|
||||
|
||||
#expect(changed)
|
||||
#expect(!changedAgain)
|
||||
#expect(state.recorder.operations.isEmpty)
|
||||
#expect(state.pendingStore.isPending)
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == "cached-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `never mode has cached credentials ignores stale oauth cache with zero IO`() throws {
|
||||
try self.withTestState { state in
|
||||
try self.withCredentialsFile(data: nil) { _ in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
|
||||
let hasCached = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:])
|
||||
}
|
||||
}
|
||||
|
||||
#expect(!hasCached)
|
||||
#expect(state.recorder.operations.isEmpty)
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == "cached-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `has cached credentials ignores stale oauth cache when pending clear fails`() throws {
|
||||
try self.withTestState { state in
|
||||
try self.withCredentialsFile(data: nil) { _ in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
}
|
||||
|
||||
let hasCached = KeychainCacheStore.withClearFailureStatusOverrideForTesting(
|
||||
errSecInteractionNotAllowed)
|
||||
{
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(!hasCached)
|
||||
#expect(state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations == [.clear])
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == "cached-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `leaving never mode clears stale oauth cache before repopulating from file`() throws {
|
||||
try self.withTestState { state in
|
||||
let fileData = self.makeCredentialsData(
|
||||
accessToken: "file-token-new",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try self.withCredentialsFile(data: fileData) { _ in
|
||||
self.seedCache(
|
||||
state,
|
||||
accessToken: "cached-token",
|
||||
storedAt: Date(timeIntervalSince1970: 0))
|
||||
|
||||
_ = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
|
||||
}
|
||||
#expect(state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations.isEmpty)
|
||||
|
||||
let credentials = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(credentials.accessToken == "file-token-new")
|
||||
#expect(!state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations == [.clear, .load, .store])
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == "file-token-new")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `logout under never mode clears stale oauth cache after access is reenabled`() throws {
|
||||
try self.withTestState { state in
|
||||
try self.withCredentialsFile(data: nil) { _ in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
}
|
||||
#expect(state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations.isEmpty)
|
||||
let staleToken = try self.cachedToken(state)
|
||||
#expect(staleToken == "cached-token")
|
||||
|
||||
do {
|
||||
_ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
#expect(!state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations == [.clear, .load])
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `pending oauth cache clear retries after a temporarily unavailable delete`() throws {
|
||||
try self.withTestState { state in
|
||||
let fileData = self.makeCredentialsData(
|
||||
accessToken: "file-token-new",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try self.withCredentialsFile(data: fileData) { _ in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
}
|
||||
|
||||
let first = try KeychainCacheStore.withClearFailureStatusOverrideForTesting(
|
||||
errSecInteractionNotAllowed)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(first.accessToken == "file-token-new")
|
||||
#expect(state.pendingStore.isPending)
|
||||
let staleToken = try self.cachedToken(state)
|
||||
#expect(staleToken == "cached-token")
|
||||
|
||||
let second = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
#expect(second.accessToken == "file-token-new")
|
||||
#expect(!state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations == [.clear, .clear, .load, .store])
|
||||
let refreshedToken = try self.cachedToken(state)
|
||||
#expect(refreshedToken == "file-token-new")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replacement store failure after successful clear keeps tombstone and cache missing`() throws {
|
||||
try self.withTestState { state in
|
||||
try self.withCredentialsFile(data: nil) { _ in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
}
|
||||
|
||||
let syncData = self.makeCredentialsData(
|
||||
accessToken: "sync-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "sync-refresh-token")
|
||||
let synced = KeychainCacheStore.withStoreFailureStatusOverrideForTesting(
|
||||
errSecInteractionNotAllowed)
|
||||
{
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: syncData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(synced)
|
||||
#expect(state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations == [.clear, .store])
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replacement store failure after failed clear keeps tombstone and stale cache`() throws {
|
||||
try self.withTestState { state in
|
||||
try self.withCredentialsFile(data: nil) { _ in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
}
|
||||
|
||||
let syncData = self.makeCredentialsData(
|
||||
accessToken: "sync-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "sync-refresh-token")
|
||||
let synced = KeychainCacheStore.withClearFailureStatusOverrideForTesting(
|
||||
errSecInteractionNotAllowed)
|
||||
{
|
||||
KeychainCacheStore.withStoreFailureStatusOverrideForTesting(errSecInteractionNotAllowed) {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: syncData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(synced)
|
||||
#expect(state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations == [.clear, .store])
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == "cached-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `bundled CLI resolves the owning app prompt policy domain`() throws {
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let appURL = tempDirectory.appendingPathComponent("CodexBar.app", isDirectory: true)
|
||||
let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true)
|
||||
let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true)
|
||||
let macOSURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true)
|
||||
let binURL = tempDirectory.appendingPathComponent("bin", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDirectory) }
|
||||
|
||||
let info: [String: Any] = [
|
||||
"CFBundleExecutable": "CodexBar",
|
||||
"CFBundleIdentifier": ClaudeOAuthKeychainPromptPreference.debugApplicationDefaultsDomain,
|
||||
"CFBundlePackageType": "APPL",
|
||||
]
|
||||
let infoData = try PropertyListSerialization.data(
|
||||
fromPropertyList: info,
|
||||
format: .xml,
|
||||
options: 0)
|
||||
try infoData.write(to: contentsURL.appendingPathComponent("Info.plist"))
|
||||
try Data().write(to: macOSURL.appendingPathComponent("CodexBar"))
|
||||
|
||||
let helperURL = helpersURL.appendingPathComponent("CodexBarCLI")
|
||||
try Data().write(to: helperURL)
|
||||
let symlinkURL = binURL.appendingPathComponent("codexbar")
|
||||
try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: helperURL)
|
||||
|
||||
let bundledCLIDomain = ClaudeOAuthKeychainPromptPreference.resolveApplicationDefaultsDomain(
|
||||
bundleIdentifier: nil,
|
||||
bundleURL: nil,
|
||||
executableURL: nil,
|
||||
invocationURL: symlinkURL)
|
||||
#expect(bundledCLIDomain == ClaudeOAuthKeychainPromptPreference.debugApplicationDefaultsDomain)
|
||||
|
||||
let debugWidgetDomain = ClaudeOAuthKeychainPromptPreference.resolveApplicationDefaultsDomain(
|
||||
bundleIdentifier: "com.steipete.codexbar.debug.widget",
|
||||
bundleURL: nil,
|
||||
executableURL: nil,
|
||||
invocationURL: nil)
|
||||
#expect(debugWidgetDomain == ClaudeOAuthKeychainPromptPreference.debugApplicationDefaultsDomain)
|
||||
|
||||
let standaloneDomain = ClaudeOAuthKeychainPromptPreference.resolveApplicationDefaultsDomain(
|
||||
bundleIdentifier: nil,
|
||||
bundleURL: nil,
|
||||
executableURL: URL(fileURLWithPath: "/usr/local/bin/codexbar"),
|
||||
invocationURL: nil)
|
||||
#expect(standaloneDomain == ClaudeOAuthKeychainPromptPreference.releaseApplicationDefaultsDomain)
|
||||
|
||||
let testProcessDomain = ClaudeOAuthKeychainPromptPreference.resolveApplicationDefaultsDomain(
|
||||
bundleIdentifier: nil,
|
||||
bundleURL: Bundle.main.bundleURL,
|
||||
executableURL: Bundle.main.executableURL,
|
||||
invocationURL: CommandLine.arguments.first.map(URL.init(fileURLWithPath:)),
|
||||
bundleIdentifierForApp: { _ in nil })
|
||||
#expect(testProcessDomain == ClaudeOAuthKeychainPromptPreference.releaseApplicationDefaultsDomain)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `shared tombstone propagates across process boundaries`() throws {
|
||||
let domain = "ClaudeOAuthPendingCacheTests.\(UUID().uuidString)"
|
||||
let key = "pending"
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let lockURL = tempDirectory.appendingPathComponent("cache.lock")
|
||||
let userDefaults = try #require(UserDefaults(suiteName: domain))
|
||||
defer {
|
||||
userDefaults.removePersistentDomain(forName: domain)
|
||||
userDefaults.synchronize()
|
||||
try? FileManager.default.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let store = ClaudeOAuthPendingCacheClearUserDefaultsStore(
|
||||
domain: domain,
|
||||
key: key,
|
||||
lockURL: lockURL)
|
||||
store.markPending()
|
||||
|
||||
let childRead = try self.runDefaults(["read", domain, key])
|
||||
#expect(childRead.status == 0)
|
||||
#expect(!childRead.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
let childDelete = try self.runDefaults(["delete", domain, key])
|
||||
#expect(childDelete.status == 0)
|
||||
#expect(!store.isPending)
|
||||
|
||||
let childWrite = try self.runDefaults(["write", domain, key, UUID().uuidString])
|
||||
#expect(childWrite.status == 0)
|
||||
#expect(store.isPending)
|
||||
|
||||
store.withCacheTransaction { pending in
|
||||
pending = false
|
||||
}
|
||||
let childReadAfterResolution = try self.runDefaults(["read", domain, key])
|
||||
#expect(childReadAfterResolution.status != 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `newer tombstone survives an older cache transaction`() throws {
|
||||
let domain = "ClaudeOAuthPendingCacheRaceTests.\(UUID().uuidString)"
|
||||
let key = "pending"
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let lockURL = tempDirectory.appendingPathComponent("cache.lock")
|
||||
let userDefaults = try #require(UserDefaults(suiteName: domain))
|
||||
defer {
|
||||
userDefaults.removePersistentDomain(forName: domain)
|
||||
userDefaults.synchronize()
|
||||
try? FileManager.default.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let store = ClaudeOAuthPendingCacheClearUserDefaultsStore(
|
||||
domain: domain,
|
||||
key: key,
|
||||
lockURL: lockURL)
|
||||
store.markPending()
|
||||
|
||||
let newerGeneration = UUID().uuidString
|
||||
var childWriteStatus: Int32?
|
||||
store.withCacheTransaction { pending in
|
||||
childWriteStatus = try? self.runDefaults(["write", domain, key, newerGeneration]).status
|
||||
pending = false
|
||||
}
|
||||
userDefaults.synchronize()
|
||||
|
||||
#expect(childWriteStatus == 0)
|
||||
#expect(userDefaults.string(forKey: key) == newerGeneration)
|
||||
#expect(store.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy boolean tombstone remains pending until cache resolution`() throws {
|
||||
let domain = "ClaudeOAuthPendingCacheLegacyTests.\(UUID().uuidString)"
|
||||
let key = "pending"
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let userDefaults = try #require(UserDefaults(suiteName: domain))
|
||||
defer {
|
||||
userDefaults.removePersistentDomain(forName: domain)
|
||||
userDefaults.synchronize()
|
||||
try? FileManager.default.removeItem(at: tempDirectory)
|
||||
}
|
||||
userDefaults.set(true, forKey: key)
|
||||
userDefaults.synchronize()
|
||||
|
||||
let store = ClaudeOAuthPendingCacheClearUserDefaultsStore(
|
||||
domain: domain,
|
||||
key: key,
|
||||
lockURL: tempDirectory.appendingPathComponent("cache.lock"))
|
||||
#expect(store.isPending)
|
||||
store.withCacheTransaction { pending in
|
||||
pending = false
|
||||
}
|
||||
#expect(!store.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cache transaction fails closed when its lock is unavailable`() throws {
|
||||
let domain = "ClaudeOAuthPendingCacheLockFailureTests.\(UUID().uuidString)"
|
||||
let key = "pending"
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
|
||||
let nonDirectoryURL = tempDirectory.appendingPathComponent("not-a-directory")
|
||||
try Data().write(to: nonDirectoryURL)
|
||||
let userDefaults = try #require(UserDefaults(suiteName: domain))
|
||||
defer {
|
||||
userDefaults.removePersistentDomain(forName: domain)
|
||||
userDefaults.synchronize()
|
||||
try? FileManager.default.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let store = ClaudeOAuthPendingCacheClearUserDefaultsStore(
|
||||
domain: domain,
|
||||
key: key,
|
||||
lockURL: nonDirectoryURL.appendingPathComponent("cache.lock"))
|
||||
var operationCalled = false
|
||||
store.withCacheTransaction { _ in
|
||||
operationCalled = true
|
||||
}
|
||||
userDefaults.synchronize()
|
||||
|
||||
#expect(!operationCalled)
|
||||
#expect(userDefaults.string(forKey: key) != nil)
|
||||
#expect(store.isPending)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `never mode bypasses oauth cache while preserving experimental security CLI reader`() throws {
|
||||
try self.withTestState { state in
|
||||
try self.withCredentialsFile(data: nil) { _ in
|
||||
self.seedCache(state, accessToken: "cached-token")
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-cli-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "security-cli-refresh-token")
|
||||
|
||||
let credentials = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(credentials.accessToken == "security-cli-token")
|
||||
#expect(state.recorder.operations.isEmpty)
|
||||
#expect(state.pendingStore.isPending)
|
||||
let cachedToken = try self.cachedToken(state)
|
||||
#expect(cachedToken == "cached-token")
|
||||
|
||||
do {
|
||||
_ = try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(nil)) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: Data(),
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
#expect(!state.pendingStore.isPending)
|
||||
#expect(state.recorder.operations == [.clear, .load])
|
||||
let clearedToken = try self.cachedToken(state)
|
||||
#expect(clearedToken == nil)
|
||||
|
||||
let mcpOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8)
|
||||
let isMcpOnly = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnly)) {
|
||||
ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent(
|
||||
interaction: .background,
|
||||
readStrategy: .securityCLIExperimental,
|
||||
keychainAccessDisabled: true,
|
||||
environment: [
|
||||
KeychainAccessGate.disableAccessEnvironmentKey: "1",
|
||||
ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey:
|
||||
"/tmp/codexbar-test.keychain-db",
|
||||
])
|
||||
}
|
||||
}
|
||||
#expect(isMcpOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStorePromptPolicyTests {
|
||||
@Test
|
||||
func `keychain prompt notify preserves its void function signature`() {
|
||||
let notify: (KeychainPromptContext) -> Void = KeychainPromptHandler.notify
|
||||
_ = notify
|
||||
}
|
||||
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let refreshField: String = {
|
||||
guard let refreshToken else { return "" }
|
||||
return ",\n \"refreshToken\": \"\(refreshToken)\""
|
||||
}()
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]\(refreshField)
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `does not read claude keychain in background when prompt mode only on user action`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
|
||||
let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1")
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
do {
|
||||
_ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: fingerprint)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `can read claude keychain on user action when prompt mode only on user action`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
|
||||
let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1")
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let creds = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: fingerprint)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(creds.accessToken == "keychain-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `user initiated claude keychain reads respect pre alert acknowledgement cooldown`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
var preAlertHits = 0
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.allowed
|
||||
}
|
||||
let promptHandler: (KeychainPromptContext) -> Void = { _ in
|
||||
preAlertHits += 1
|
||||
}
|
||||
let credentials = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
let first = try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true)
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let second = try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true)
|
||||
return (first, second)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(credentials.0.accessToken == "keychain-token")
|
||||
#expect(credentials.1.accessToken == "keychain-token")
|
||||
#expect(preAlertHits == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `shows pre alert when claude keychain likely requires interaction`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
var preAlertHits = 0
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.interactionRequired
|
||||
}
|
||||
let promptHandler: (KeychainPromptContext) -> Void = { _ in
|
||||
preAlertHits += 1
|
||||
}
|
||||
let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "keychain-token")
|
||||
#expect(preAlertHits == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `shows pre alert when claude keychain preflight fails`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
var preAlertHits = 0
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.failure(-1)
|
||||
}
|
||||
let promptHandler: (KeychainPromptContext) -> Void = { _ in
|
||||
preAlertHits += 1
|
||||
}
|
||||
let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "keychain-token")
|
||||
#expect(preAlertHits == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader skips pre alert when security CLI read succeeds`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
var preAlertHits = 0
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.interactionRequired
|
||||
}
|
||||
let promptHandler: (KeychainPromptContext) -> Void = { _ in
|
||||
preAlertHits += 1
|
||||
}
|
||||
let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "security-token")
|
||||
#expect(preAlertHits == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader shows pre alert when security CLI fails and fallback needs interaction`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
var preAlertHits = 0
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.interactionRequired
|
||||
}
|
||||
let promptHandler: (KeychainPromptContext) -> Void = { _ in
|
||||
preAlertHits += 1
|
||||
}
|
||||
let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(.timedOut) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "fallback-token")
|
||||
#expect(preAlertHits == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader does not fallback in background when stored mode only on user action`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
var preAlertHits = 0
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.interactionRequired
|
||||
}
|
||||
let promptHandler: (KeychainPromptContext) -> Void = { _ in
|
||||
preAlertHits += 1
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(.timedOut) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
#expect(preAlertHits == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader does not fallback when stored mode never`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
var preAlertHits = 0
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.interactionRequired
|
||||
}
|
||||
let promptHandler: (KeychainPromptContext) -> Void = { _ in
|
||||
preAlertHits += 1
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(.timedOut) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
#expect(preAlertHits == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader non interactive fallback blocked in background when stored mode only on user action`()
|
||||
throws
|
||||
{
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-token-only-on-user-action",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.allowed
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(.timedOut) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader allows fallback in background when stored mode always`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
var preAlertHits = 0
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.interactionRequired
|
||||
}
|
||||
let promptHandler: (KeychainPromptContext) -> Void = { _ in
|
||||
preAlertHits += 1
|
||||
}
|
||||
|
||||
let creds = try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try KeychainPromptHandler.withHandlerForTesting(promptHandler, operation: {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(.timedOut) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true,
|
||||
respectKeychainPromptCooldown: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "fallback-token")
|
||||
#expect(preAlertHits == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStoreSecurityCLIFallbackPolicyTests {
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader blocks background fallback per stored policy`() {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-should-be-blocked",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let hasCredentials = KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction,
|
||||
operation: {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.nonZeroExit)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#expect(hasCredentials == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader sync from claude keychain without prompt background fallback blocked by stored policy`() {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
defer { ClaudeOAuthCredentialsStore.invalidateCache() }
|
||||
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "sync-fallback-should-be-blocked",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
final class Counter: @unchecked Sendable {
|
||||
var value = 0
|
||||
}
|
||||
let preflightCalls = Counter()
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
preflightCalls.value += 1
|
||||
return .allowed
|
||||
}
|
||||
|
||||
let synced = KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction,
|
||||
operation: {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.timedOut)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore
|
||||
.syncFromClaudeKeychainWithoutPrompt(now: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
#expect(synced == false)
|
||||
#expect(preflightCalls.value == 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStoreSecurityCLITests {
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let refreshField: String = {
|
||||
guard let refreshToken else { return "" }
|
||||
return ",\n \"refreshToken\": \"\(refreshToken)\""
|
||||
}()
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]\(refreshField)
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader prefers security CLI for non interactive load`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "security-refresh")
|
||||
|
||||
let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction,
|
||||
operation: {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "security-token")
|
||||
#expect(creds.refreshToken == "security-refresh")
|
||||
#expect(creds.scopes.contains("user:profile"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader non interactive background load still executes security CLI read`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-token-background",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "security-refresh-background")
|
||||
final class ReadCounter: @unchecked Sendable {
|
||||
var count = 0
|
||||
}
|
||||
let securityReadCalls = ReadCounter()
|
||||
|
||||
let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction,
|
||||
operation: {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.dynamic { _ in
|
||||
securityReadCalls.count += 1
|
||||
return securityData
|
||||
}) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "security-token-background")
|
||||
#expect(securityReadCalls.count == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader falls back when security CLI throws`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "fallback-refresh")
|
||||
|
||||
let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction,
|
||||
operation: {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.timedOut)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "fallback-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader falls back when security CLI output malformed`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction,
|
||||
operation: {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(Data("not-json".utf8)))
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "fallback-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader load from claude keychain uses security CLI`() throws {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-direct",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "security-refresh")
|
||||
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
|
||||
let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 200,
|
||||
createdAt: 199,
|
||||
persistentRefHash: "sentinel")
|
||||
|
||||
let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.always,
|
||||
operation: {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
|
||||
fingerprintStore)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: sentinelFingerprint)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let creds = try ClaudeOAuthCredentials.parse(data: loaded)
|
||||
#expect(creds.accessToken == "security-direct")
|
||||
#expect(creds.refreshToken == "security-refresh")
|
||||
#expect(fingerprintStore.fingerprint == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader has claude keychain credentials without prompt uses security CLI`() {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-available",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let hasCredentials = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.always,
|
||||
operation: {
|
||||
ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(hasCredentials == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader has claude keychain credentials without prompt falls back when security CLI fails`() {
|
||||
let fallbackData = self.makeCredentialsData(
|
||||
accessToken: "fallback-available",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let hasCredentials = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.always,
|
||||
operation: {
|
||||
ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.nonZeroExit)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
#expect(hasCredentials == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader ignores prompt policy and cooldown for background silent check`() {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-background",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let hasCredentials = KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) {
|
||||
ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.never,
|
||||
operation: {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.hasClaudeKeychainCredentialsWithoutPrompt()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#expect(hasCredentials == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader load from claude keychain fallback blocked when stored mode never`() throws {
|
||||
var threwNotFound = false
|
||||
do {
|
||||
_ = try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.never,
|
||||
operation: {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.nonZeroExit)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
if case .notFound = error {
|
||||
threwNotFound = true
|
||||
}
|
||||
}
|
||||
|
||||
#expect(threwNotFound == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader security CLI read pins preferred account when available`() throws {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-account-pinned",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
final class AccountBox: @unchecked Sendable {
|
||||
var value: String?
|
||||
}
|
||||
let pinnedAccount = AccountBox()
|
||||
|
||||
let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadAccountOverrideForTesting("new-account") {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.always,
|
||||
operation: {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.dynamic { request in
|
||||
pinnedAccount.value = request.account
|
||||
return securityData
|
||||
}) {
|
||||
try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let creds = try ClaudeOAuthCredentials.parse(data: loaded)
|
||||
#expect(pinnedAccount.value == "new-account")
|
||||
#expect(creds.accessToken == "security-account-pinned")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader security CLI read does not pin account in background`() throws {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-account-not-pinned",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
final class AccountBox: @unchecked Sendable {
|
||||
var value: String?
|
||||
}
|
||||
let pinnedAccount = AccountBox()
|
||||
|
||||
let loaded = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadAccountOverrideForTesting("new-account") {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.always,
|
||||
operation: {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.dynamic { request in
|
||||
pinnedAccount.value = request.account
|
||||
return securityData
|
||||
}) {
|
||||
try ClaudeOAuthCredentialsStore.loadFromClaudeKeychain()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let creds = try ClaudeOAuthCredentials.parse(data: loaded)
|
||||
#expect(pinnedAccount.value == nil)
|
||||
#expect(creds.accessToken == "security-account-not-pinned")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader freshness sync skips security CLI when preflight requires interaction`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-sync",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
final class ReadCounter: @unchecked Sendable {
|
||||
var count = 0
|
||||
}
|
||||
let securityReadCalls = ReadCounter()
|
||||
|
||||
func loadWithPreflight(
|
||||
_ outcome: KeychainAccessPreflight.Outcome) throws -> ClaudeOAuthCredentials
|
||||
{
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
outcome
|
||||
}
|
||||
return try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference
|
||||
.withTaskOverrideForTesting(.always) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(
|
||||
.dynamic { _ in
|
||||
securityReadCalls.count += 1
|
||||
return securityData
|
||||
}) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let first = try loadWithPreflight(.allowed)
|
||||
#expect(first.accessToken == "security-sync")
|
||||
#expect(securityReadCalls.count == 1)
|
||||
|
||||
let second = try loadWithPreflight(.interactionRequired)
|
||||
#expect(second.accessToken == "security-sync")
|
||||
#expect(securityReadCalls.count == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader freshness sync background respects stored only on user action`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-sync-only-on-user-action",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
final class ReadCounter: @unchecked Sendable {
|
||||
var count = 0
|
||||
}
|
||||
let securityReadCalls = ReadCounter()
|
||||
let preflightOverride: (String, String?) -> KeychainAccessPreflight.Outcome = { _, _ in
|
||||
.allowed
|
||||
}
|
||||
|
||||
func load(_ interaction: ProviderInteraction) throws -> ClaudeOAuthCredentials {
|
||||
try KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting(
|
||||
preflightOverride,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ProviderInteractionContext.$current.withValue(interaction) {
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(
|
||||
.dynamic { _ in
|
||||
securityReadCalls.count += 1
|
||||
return securityData
|
||||
}) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let first = try load(.userInitiated)
|
||||
#expect(first.accessToken == "security-sync-only-on-user-action")
|
||||
#expect(securityReadCalls.count == 1)
|
||||
|
||||
let second = try load(.background)
|
||||
#expect(second.accessToken == "security-sync-only-on-user-action")
|
||||
#expect(securityReadCalls.count == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader sync skips fingerprint probe after security CLI read`() {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
defer { ClaudeOAuthCredentialsStore.invalidateCache() }
|
||||
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-sync-no-fingerprint-probe",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
|
||||
let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 123,
|
||||
createdAt: 122,
|
||||
persistentRefHash: "sentinel")
|
||||
|
||||
let synced = ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
|
||||
fingerprintStore)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: sentinelFingerprint)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(
|
||||
now: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
#expect(synced == true)
|
||||
#expect(fingerprintStore.fingerprint == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader no prompt repair skips fingerprint probe after security CLI success`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-repair-no-fingerprint-probe",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
|
||||
let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 456,
|
||||
createdAt: 455,
|
||||
persistentRefHash: "sentinel")
|
||||
|
||||
let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainFingerprintStoreOverrideForTesting(
|
||||
fingerprintStore)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: sentinelFingerprint)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
#expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe")
|
||||
#expect(record.source == .claudeKeychain)
|
||||
#expect(fingerprintStore.fingerprint == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader load with prompt skips fingerprint probe after security CLI success`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-load-with-prompt",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
|
||||
let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 321,
|
||||
createdAt: 320,
|
||||
persistentRefHash: "sentinel")
|
||||
|
||||
let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainFingerprintStoreOverrideForTesting(
|
||||
fingerprintStore)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: sentinelFingerprint)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withSecurityCLIReadOverrideForTesting(
|
||||
.data(securityData))
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true,
|
||||
respectKeychainPromptCooldown: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
#expect(creds.accessToken == "security-load-with-prompt")
|
||||
#expect(fingerprintStore.fingerprint == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental reader load with prompt does not read when global keychain disabled`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(true) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-should-not-read",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
var threwNotFound = false
|
||||
final class ReadCounter: @unchecked Sendable {
|
||||
var count = 0
|
||||
}
|
||||
let securityReadCalls = ReadCounter()
|
||||
|
||||
do {
|
||||
_ = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental,
|
||||
operation: {
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.dynamic { _ in
|
||||
securityReadCalls.count += 1
|
||||
return securityData
|
||||
}) {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: true,
|
||||
respectKeychainPromptCooldown: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
if case .notFound = error {
|
||||
threwNotFound = true
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
#expect(threwNotFound == true)
|
||||
#expect(securityReadCalls.count < 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests {
|
||||
private struct WrongCacheEntry: Codable {
|
||||
let value: String
|
||||
}
|
||||
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let refreshField: String = {
|
||||
guard let refreshToken else { return "" }
|
||||
return ",\n \"refreshToken\": \"\(refreshToken)\""
|
||||
}()
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]\(refreshField)
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@Test
|
||||
func `credentials file invalidation preserves keychain cache when temporarily unavailable`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let firstFile = self.makeCredentialsData(
|
||||
accessToken: "first-file",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try firstFile.write(to: fileURL)
|
||||
#expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
|
||||
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: cachedData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI))
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let updatedFile = self.makeCredentialsData(
|
||||
accessToken: "updated-file-token-longer",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try updatedFile.write(to: fileURL)
|
||||
|
||||
KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) {
|
||||
#expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
|
||||
}
|
||||
|
||||
switch KeychainCacheStore.load(
|
||||
key: cacheKey,
|
||||
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
|
||||
{
|
||||
case let .found(entry):
|
||||
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
|
||||
#expect(parsed.accessToken == "cached-token")
|
||||
case .missing, .temporarilyUnavailable, .invalid:
|
||||
#expect(Bool(false), "Expected temporary unavailability not to clear Claude cache")
|
||||
}
|
||||
|
||||
#expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
|
||||
|
||||
switch KeychainCacheStore.load(
|
||||
key: cacheKey,
|
||||
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
|
||||
{
|
||||
case .missing:
|
||||
#expect(true)
|
||||
case .found, .temporarilyUnavailable, .invalid:
|
||||
#expect(Bool(false), "Expected pending invalidation to clear stale Claude cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `temporary keychain cache unavailability does not overwrite cache from credentials file fallback`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(true) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let fileData = self.makeCredentialsData(
|
||||
accessToken: "file-fallback-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try fileData.write(to: fileURL)
|
||||
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: cachedData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI))
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let loaded = try KeychainCacheStore.withLoadFailureStatusOverrideForTesting(
|
||||
errSecInteractionNotAllowed)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
#expect(loaded.accessToken == "file-fallback-token")
|
||||
|
||||
switch KeychainCacheStore.load(
|
||||
key: cacheKey,
|
||||
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
|
||||
{
|
||||
case let .found(entry):
|
||||
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
|
||||
#expect(parsed.accessToken == "cached-token")
|
||||
case .missing, .temporarilyUnavailable, .invalid:
|
||||
#expect(Bool(false), "Expected file fallback not to overwrite unavailable cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `has cached credentials treats temporary keychain cache unavailability as present`() {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let hasCached = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(
|
||||
errSecInteractionNotAllowed)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:])
|
||||
}
|
||||
|
||||
#expect(hasCached == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@Test
|
||||
func `invalid keychain cache is cleared by load`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(true) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
KeychainCacheStore.store(key: cacheKey, entry: WrongCacheEntry(value: "wrong-shape"))
|
||||
|
||||
do {
|
||||
_ = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch KeychainCacheStore.load(
|
||||
key: cacheKey,
|
||||
as: ClaudeOAuthCredentialsStore.CacheEntry.self)
|
||||
{
|
||||
case .missing:
|
||||
#expect(true)
|
||||
case .found, .temporarilyUnavailable, .invalid:
|
||||
#expect(Bool(false), "Expected invalid Claude cache to be cleared")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthCredentialsStoreTests {
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let refreshField: String = {
|
||||
guard let refreshToken else { return "" }
|
||||
return ",\n \"refreshToken\": \"\(refreshToken)\""
|
||||
}()
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]\(refreshField)
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `persistent reference hash stays stable across keychain metadata refresh`() {
|
||||
let first = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "opaque-ref")
|
||||
let refreshed = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "opaque-ref")
|
||||
|
||||
let firstHash = ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: first)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.claudeKeychainPersistentRefHashWithoutPrompt()
|
||||
}
|
||||
let refreshedHash = ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: refreshed)
|
||||
{
|
||||
ClaudeOAuthCredentialsStore.claudeKeychainPersistentRefHashWithoutPrompt()
|
||||
}
|
||||
|
||||
#expect(firstHash == "opaque-ref")
|
||||
#expect(refreshedHash == firstHash)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `loads from keychain cache before expired file`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600))
|
||||
try expiredData.write(to: fileURL)
|
||||
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: cachedData,
|
||||
storedAt: Date())
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
_ = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityFramework)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
// Re-store to cache after file check has marked file as "seen"
|
||||
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
|
||||
let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityFramework)
|
||||
{
|
||||
try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
|
||||
#expect(creds.accessToken == "cached")
|
||||
#expect(creds.isExpired == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load record non interactive repair can be disabled`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
// Ensure file-based lookup doesn't interfere (and avoid touching ~/.claude).
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "claude-keychain",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
// Simulate Claude Keychain containing creds, without querying the real Keychain.
|
||||
try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore
|
||||
.withClaudeKeychainOverridesForTesting(data: keychainData, fingerprint: nil) {
|
||||
// When repair is disabled, non-interactive loads should not consult Claude's
|
||||
// keychain data.
|
||||
do {
|
||||
_ = try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true,
|
||||
allowClaudeKeychainRepairWithoutPrompt: false)
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .notFound = error else {
|
||||
Issue.record("Expected .notFound, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// With repair enabled, we should be able to seed from the "Claude keychain"
|
||||
// override.
|
||||
let record = try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true,
|
||||
allowClaudeKeychainRepairWithoutPrompt: true)
|
||||
#expect(record.credentials.accessToken == "claude-keychain")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `invalidates cache when credentials file changes`() throws {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
// Avoid interacting with the real Keychain in unit tests.
|
||||
try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let first = self.makeCredentialsData(
|
||||
accessToken: "first",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try first.write(to: fileURL)
|
||||
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date())
|
||||
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
|
||||
|
||||
_ = try ClaudeOAuthCredentialsStore.load(environment: [:])
|
||||
|
||||
let updated = self.makeCredentialsData(
|
||||
accessToken: "second",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
try updated.write(to: fileURL)
|
||||
|
||||
#expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
|
||||
KeychainCacheStore.clear(key: cacheKey)
|
||||
|
||||
let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
|
||||
#expect(creds.accessToken == "second")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `returns expired file when no other sources`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(true) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-only",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600))
|
||||
try expiredData.write(to: fileURL)
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let creds = try ClaudeOAuthCredentialsStore.load(environment: [:])
|
||||
|
||||
#expect(creds.accessToken == "expired-only")
|
||||
#expect(creds.isExpired == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load with auto refresh expired claude CLI owner throws delegated refresh`() async throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-claude-cli-owner",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: "refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI))
|
||||
|
||||
do {
|
||||
_ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
Issue.record("Expected delegated refresh error for Claude CLI-owned credentials")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .refreshDelegatedToClaudeCLI = error else {
|
||||
Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load with auto refresh expired codexbar owner uses direct refresh path`() async throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-codexbar-owner",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: "refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(),
|
||||
owner: .codexbar))
|
||||
|
||||
await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) {
|
||||
do {
|
||||
_ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
Issue.record("Expected refresh failure for CodexBar-owned direct refresh path")
|
||||
} catch let error as ClaudeOAuthCredentialsError {
|
||||
guard case .refreshFailed = error else {
|
||||
Issue.record("Expected .refreshFailed, got \(error)")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `load record legacy cache entry without owner defaults to claude CLI owner`() throws {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
let validData = self.makeCredentialsData(
|
||||
accessToken: "legacy-owner",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600),
|
||||
refreshToken: "refresh-token")
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: validData,
|
||||
storedAt: Date()))
|
||||
|
||||
let record = try ClaudeOAuthCredentialsStore.loadRecord(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
#expect(record.owner == .claudeCLI)
|
||||
#expect(record.source == .cacheKeychain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `has cached credentials returns false for expired unrefreshable codexbar cache entry`() throws {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-no-refresh",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: nil)
|
||||
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(),
|
||||
owner: .codexbar)
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
|
||||
|
||||
#expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `has cached credentials returns true for expired refreshable cache entry`() throws {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-refreshable",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: "refresh")
|
||||
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: expiredData, storedAt: Date())
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
|
||||
|
||||
#expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `has cached credentials returns true for expired claude CLI backed credentials file`() throws {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-file-no-refresh",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600),
|
||||
refreshToken: nil)
|
||||
try expiredData.write(to: fileURL)
|
||||
|
||||
#expect(ClaudeOAuthCredentialsStore.hasCachedCredentials() == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `syncs cache when claude keychain fingerprint changes and token differs`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
|
||||
}
|
||||
|
||||
// Avoid cross-suite interference from UserDefaults fingerprint persistence.
|
||||
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
|
||||
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
|
||||
|
||||
let fingerprint1 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1")
|
||||
|
||||
let first = try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
|
||||
fingerprintStore)
|
||||
{
|
||||
try ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: cachedData,
|
||||
fingerprint: fingerprint1)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(first.accessToken == "cached-token")
|
||||
#expect(fingerprintStore.fingerprint == fingerprint1)
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
|
||||
|
||||
let fingerprint2 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "ref2")
|
||||
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let second = try ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
|
||||
fingerprintStore)
|
||||
{
|
||||
try ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: fingerprint2)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(second.accessToken == "keychain-token")
|
||||
#expect(fingerprintStore.fingerprint == fingerprint2)
|
||||
|
||||
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
|
||||
case let .found(entry):
|
||||
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
|
||||
#expect(parsed.accessToken == "keychain-token")
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `does not sync in background when cache valid and prompt mode only on user action`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
|
||||
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainDataOverrideForTesting(nil)
|
||||
ClaudeOAuthCredentialsStore.setClaudeKeychainFingerprintOverrideForTesting(nil)
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
|
||||
let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
|
||||
let fingerprint1 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1")
|
||||
fingerprintStore.fingerprint = fingerprint1
|
||||
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: cachedData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI))
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
|
||||
|
||||
let fingerprint2 = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "ref2")
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let creds = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try ProviderInteractionContext.$current.withValue(.background) {
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting(
|
||||
fingerprintStore)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: fingerprint2)
|
||||
{
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(creds.accessToken == "cached-token")
|
||||
#expect(fingerprintStore.fingerprint == fingerprint1)
|
||||
|
||||
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
|
||||
case let .found(entry):
|
||||
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
|
||||
#expect(parsed.accessToken == "cached-token")
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `does not sync when claude keychain fingerprint unchanged`() throws {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
}
|
||||
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
|
||||
|
||||
let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1")
|
||||
let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: cachedData,
|
||||
fingerprint: fingerprint,
|
||||
operation: {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
})
|
||||
#expect(first.accessToken == "cached-token")
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: fingerprint,
|
||||
operation: {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
})
|
||||
#expect(second.accessToken == "cached-token")
|
||||
|
||||
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
|
||||
case let .found(entry):
|
||||
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
|
||||
#expect(parsed.accessToken == "cached-token")
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `does not sync when keychain credentials expired but cache valid`() throws {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
}
|
||||
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
|
||||
|
||||
let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: cachedData,
|
||||
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
operation: {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
})
|
||||
#expect(first.accessToken == "cached-token")
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
|
||||
|
||||
let expiredKeychainData = self.makeCredentialsData(
|
||||
accessToken: "expired-keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600))
|
||||
let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: expiredKeychainData,
|
||||
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "ref2"),
|
||||
operation: {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
})
|
||||
#expect(second.accessToken == "cached-token")
|
||||
|
||||
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
|
||||
case let .found(entry):
|
||||
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
|
||||
#expect(parsed.accessToken == "cached-token")
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `respects prompt cooldown gate when disabled prompting`() throws {
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
try KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
}
|
||||
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cachedData = self.makeCredentialsData(
|
||||
accessToken: "cached-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
KeychainCacheStore.store(
|
||||
key: cacheKey,
|
||||
entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
|
||||
|
||||
let first = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: cachedData,
|
||||
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
operation: {
|
||||
try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
|
||||
})
|
||||
#expect(first.accessToken == "cached-token")
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeThrottleForTesting()
|
||||
ClaudeOAuthKeychainAccessGate.recordDenied(now: Date())
|
||||
|
||||
let keychainData = self.makeCredentialsData(
|
||||
accessToken: "keychain-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let second = try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "ref2"),
|
||||
operation: {
|
||||
try ClaudeOAuthCredentialsStore.load(
|
||||
environment: [:],
|
||||
allowKeychainPrompt: false,
|
||||
respectKeychainPromptCooldown: true)
|
||||
})
|
||||
#expect(second.accessToken == "cached-token")
|
||||
|
||||
switch KeychainCacheStore.load(key: cacheKey, as: ClaudeOAuthCredentialsStore.CacheEntry.self) {
|
||||
case let .found(entry):
|
||||
let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
|
||||
#expect(parsed.accessToken == "cached-token")
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `sync from claude keychain without prompt respects backoff in background`() {
|
||||
ProviderInteractionContext.$current.withValue(.background) {
|
||||
KeychainAccessGate.withTaskOverrideForTesting(true) {
|
||||
ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) {
|
||||
let store = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
|
||||
data: self.makeCredentialsData(
|
||||
accessToken: "override-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600)),
|
||||
fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "deadbeefdead"))
|
||||
|
||||
let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore()
|
||||
deniedStore.deniedUntil = Date(timeIntervalSinceNow: 3600)
|
||||
|
||||
ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) {
|
||||
ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(store) {
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.syncFromClaudeKeychainWithoutPrompt(now: Date()) == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `testing override snapshot forwards mutable Claude keychain override store across detached task`() async {
|
||||
let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 11,
|
||||
createdAt: 7,
|
||||
persistentRefHash: "snapshot-store")
|
||||
let store = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
|
||||
data: nil,
|
||||
fingerprint: fingerprint)
|
||||
|
||||
let forwarded = await ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(store) {
|
||||
let snapshot = ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask
|
||||
|
||||
return await Task.detached {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
ClaudeOAuthCredentialsStore.withTestingOverridesSnapshotForTask(snapshot) {
|
||||
ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPromptForAuthGate()
|
||||
}
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
#expect(forwarded == fingerprint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthDelegatedRefreshCoordinatorTests {
|
||||
private enum StubError: Error, LocalizedError {
|
||||
case failed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failed:
|
||||
"failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
private func withCoordinatorOverrides<T>(
|
||||
isolateState: Bool = true,
|
||||
cliAvailable: Bool? = nil,
|
||||
touchAuthPath: (@Sendable (TimeInterval, [String: String]) async throws -> Void)? = nil,
|
||||
keychainFingerprint: (@Sendable () -> ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?)? = nil,
|
||||
operation: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
if isolateState {
|
||||
return try await ClaudeOAuthDelegatedRefreshCoordinator.withIsolatedStateForTesting {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting(
|
||||
keychainFingerprint)
|
||||
{
|
||||
try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(
|
||||
cliAvailable)
|
||||
{
|
||||
try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting(
|
||||
touchAuthPath)
|
||||
{
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
return try await ClaudeOAuthDelegatedRefreshCoordinator.withKeychainFingerprintOverrideForTesting(
|
||||
keychainFingerprint)
|
||||
{
|
||||
try await ClaudeOAuthDelegatedRefreshCoordinator.withCLIAvailableOverrideForTesting(cliAvailable) {
|
||||
try await ClaudeOAuthDelegatedRefreshCoordinator.withTouchAuthPathOverrideForTesting(
|
||||
touchAuthPath)
|
||||
{
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cooldown prevents repeated attempts`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
|
||||
final class FingerprintBox: @unchecked Sendable {
|
||||
var fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?
|
||||
init(_ fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) {
|
||||
self.fingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
let box = FingerprintBox(ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"))
|
||||
let start = Date(timeIntervalSince1970: 10000)
|
||||
let (first, second) = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityFramework)
|
||||
{
|
||||
await self.withCoordinatorOverrides(
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in
|
||||
box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "ref2")
|
||||
},
|
||||
keychainFingerprint: { box.fingerprint },
|
||||
operation: {
|
||||
let first = await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: start, timeout: 0.1)
|
||||
let second = await ClaudeOAuthDelegatedRefreshCoordinator
|
||||
.attempt(now: start.addingTimeInterval(30), timeout: 0.1)
|
||||
return (first, second)
|
||||
})
|
||||
}
|
||||
|
||||
#expect(first == .attemptedSucceeded)
|
||||
#expect(second == .skippedByCooldown)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cli unavailable returns cli unavailable`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
|
||||
let outcome = await self.withCoordinatorOverrides(cliAvailable: false, operation: {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 20000),
|
||||
timeout: 0.1)
|
||||
})
|
||||
|
||||
#expect(outcome == .cliUnavailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `successful auth touch reports attempted succeeded`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
|
||||
final class FingerprintBox: @unchecked Sendable {
|
||||
var fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?
|
||||
init(_ fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) {
|
||||
self.fingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
let box = FingerprintBox(ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 10,
|
||||
createdAt: 10,
|
||||
persistentRefHash: "refA"))
|
||||
let outcome = await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityFramework)
|
||||
{
|
||||
await self.withCoordinatorOverrides(
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in
|
||||
box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 11,
|
||||
createdAt: 11,
|
||||
persistentRefHash: "refB")
|
||||
},
|
||||
keychainFingerprint: { box.fingerprint },
|
||||
operation: {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 30000),
|
||||
timeout: 0.1)
|
||||
})
|
||||
}
|
||||
|
||||
#expect(outcome == .attemptedSucceeded)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `failed auth touch reports attempted failed`() async throws {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
|
||||
let outcome = try await self.withCoordinatorOverrides(
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in
|
||||
throw StubError.failed
|
||||
},
|
||||
keychainFingerprint: {
|
||||
ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 20,
|
||||
createdAt: 20,
|
||||
persistentRefHash: "refX")
|
||||
},
|
||||
operation: {
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 40000),
|
||||
timeout: 0.1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
guard case let .attemptedFailed(message) = outcome else {
|
||||
Issue.record("Expected .attemptedFailed outcome")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("failed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `environment CLI override avoids CLI unavailable`() async throws {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
|
||||
let stubCLI = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let script = "#!/bin/sh\nexit 0\n"
|
||||
try script.write(to: stubCLI, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stubCLI.path)
|
||||
|
||||
let outcome = try await self.withCoordinatorOverrides(
|
||||
touchAuthPath: { _, environment in
|
||||
#expect(environment["CLAUDE_CLI_PATH"] == stubCLI.path)
|
||||
throw StubError.failed
|
||||
},
|
||||
keychainFingerprint: {
|
||||
ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 20,
|
||||
createdAt: 20,
|
||||
persistentRefHash: "ref-env")
|
||||
},
|
||||
operation: {
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 45000),
|
||||
timeout: 0.1,
|
||||
environment: ["CLAUDE_CLI_PATH": stubCLI.path])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
guard case let .attemptedFailed(message) = outcome else {
|
||||
Issue.record("Expected env-provided CLI override to reach touch attempt")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("failed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `concurrent attempts join in flight`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
|
||||
actor Gate {
|
||||
private var startedContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
private var releaseContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
private var hasStarted = false
|
||||
private var isReleased = false
|
||||
|
||||
func markStarted() {
|
||||
self.hasStarted = true
|
||||
let continuations = self.startedContinuations
|
||||
self.startedContinuations.removeAll()
|
||||
continuations.forEach { $0.resume() }
|
||||
}
|
||||
|
||||
func waitStarted() async {
|
||||
if self.hasStarted { return }
|
||||
await withCheckedContinuation { cont in
|
||||
self.startedContinuations.append(cont)
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
self.isReleased = true
|
||||
let continuations = self.releaseContinuations
|
||||
self.releaseContinuations.removeAll()
|
||||
continuations.forEach { $0.resume() }
|
||||
}
|
||||
|
||||
func waitRelease() async {
|
||||
if self.isReleased { return }
|
||||
await withCheckedContinuation { cont in
|
||||
self.releaseContinuations.append(cont)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class FingerprintBox: @unchecked Sendable {
|
||||
var fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?
|
||||
init(_ fingerprint: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?) {
|
||||
self.fingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
final class CounterBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private(set) var count: Int = 0
|
||||
func increment() {
|
||||
self.lock.lock()
|
||||
self.count += 1
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
let counter = CounterBox()
|
||||
let gate = Gate()
|
||||
let box = FingerprintBox(ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"))
|
||||
let now = Date(timeIntervalSince1970: 50000)
|
||||
let outcomes = await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
|
||||
await self.withCoordinatorOverrides(
|
||||
isolateState: false,
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in
|
||||
counter.increment()
|
||||
await gate.markStarted()
|
||||
await gate.waitRelease()
|
||||
box.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "ref2")
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
},
|
||||
keychainFingerprint: { box.fingerprint },
|
||||
operation: {
|
||||
let first = Task {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(now: now, timeout: 2)
|
||||
}
|
||||
await gate.waitStarted()
|
||||
let second = Task {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: now.addingTimeInterval(30),
|
||||
timeout: 2)
|
||||
}
|
||||
|
||||
await gate.release()
|
||||
return await [first.value, second.value]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#expect(outcomes.allSatisfy { $0 == .attemptedSucceeded })
|
||||
#expect(counter.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `user action retries after joining failed background attempt`() async throws {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
|
||||
actor Gate {
|
||||
private var releaseContinuation: CheckedContinuation<Void, Never>?
|
||||
private var startedContinuation: CheckedContinuation<Void, Never>?
|
||||
private var joinedContinuation: CheckedContinuation<Void, Never>?
|
||||
private var hasStarted = false
|
||||
private var isReleased = false
|
||||
private var hasJoined = false
|
||||
|
||||
func markStarted() {
|
||||
self.hasStarted = true
|
||||
self.startedContinuation?.resume()
|
||||
self.startedContinuation = nil
|
||||
}
|
||||
|
||||
func waitStarted() async {
|
||||
if self.hasStarted { return }
|
||||
await withCheckedContinuation { self.startedContinuation = $0 }
|
||||
}
|
||||
|
||||
func release() {
|
||||
self.isReleased = true
|
||||
self.releaseContinuation?.resume()
|
||||
self.releaseContinuation = nil
|
||||
}
|
||||
|
||||
func waitRelease() async {
|
||||
if self.isReleased { return }
|
||||
await withCheckedContinuation { self.releaseContinuation = $0 }
|
||||
}
|
||||
|
||||
func markJoined() {
|
||||
self.hasJoined = true
|
||||
self.joinedContinuation?.resume()
|
||||
self.joinedContinuation = nil
|
||||
}
|
||||
|
||||
func waitJoined() async {
|
||||
if self.hasJoined { return }
|
||||
await withCheckedContinuation { self.joinedContinuation = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
final class StateBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var touchCount = 0
|
||||
private var fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "before")
|
||||
|
||||
func beginTouch() -> Int {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.touchCount += 1
|
||||
return self.touchCount
|
||||
}
|
||||
|
||||
func markChanged() {
|
||||
self.lock.lock()
|
||||
self.fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "after")
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> (Int, ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint) {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return (self.touchCount, self.fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
let gate = Gate()
|
||||
let state = StateBox()
|
||||
let outcomes = try await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
|
||||
try await self.withCoordinatorOverrides(
|
||||
isolateState: false,
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in
|
||||
if state.beginTouch() == 1 {
|
||||
await gate.markStarted()
|
||||
await gate.waitRelease()
|
||||
throw StubError.failed
|
||||
}
|
||||
state.markChanged()
|
||||
},
|
||||
keychainFingerprint: { state.snapshot().1 },
|
||||
operation: {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator
|
||||
.withUserInitiatedBackgroundJoinObserverForTesting {
|
||||
Task { await gate.markJoined() }
|
||||
} operation: {
|
||||
let background = Task {
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 51000),
|
||||
timeout: 2)
|
||||
}
|
||||
}
|
||||
await gate.waitStarted()
|
||||
let userInitiated = Task {
|
||||
await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 51001),
|
||||
timeout: 2)
|
||||
}
|
||||
}
|
||||
await gate.waitJoined()
|
||||
await gate.release()
|
||||
return await (background.value, userInitiated.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
guard case .attemptedFailed = outcomes.0 else {
|
||||
Issue.record("Expected the background attempt to fail")
|
||||
return
|
||||
}
|
||||
#expect(outcomes.1 == .attemptedSucceeded)
|
||||
#expect(state.snapshot().0 == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental strategy does not use security framework fingerprint observation`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
final class CounterBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private(set) var count: Int = 0
|
||||
func increment() {
|
||||
self.lock.lock()
|
||||
self.count += 1
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
let fingerprintCounter = CounterBox()
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-token-a",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let outcome = await self.withCoordinatorOverrides(
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in },
|
||||
keychainFingerprint: {
|
||||
fingerprintCounter.increment()
|
||||
return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "framework-fingerprint")
|
||||
},
|
||||
operation: {
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(securityData)) {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 60000),
|
||||
timeout: 0.1)
|
||||
}
|
||||
})
|
||||
|
||||
guard case .attemptedFailed = outcome else {
|
||||
Issue.record("Expected .attemptedFailed outcome")
|
||||
return
|
||||
}
|
||||
#expect(fingerprintCounter.count < 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental strategy observes security CLI change after touch`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
final class DataBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _data: Data?
|
||||
init(data: Data?) {
|
||||
self._data = data
|
||||
}
|
||||
|
||||
func load() -> Data? {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self._data
|
||||
}
|
||||
|
||||
func store(_ data: Data?) {
|
||||
self.lock.lock()
|
||||
self._data = data
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
final class CounterBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private(set) var count: Int = 0
|
||||
func increment() {
|
||||
self.lock.lock()
|
||||
self.count += 1
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
let fingerprintCounter = CounterBox()
|
||||
let beforeData = self.makeCredentialsData(
|
||||
accessToken: "security-token-before",
|
||||
expiresAt: Date(timeIntervalSinceNow: -60))
|
||||
let afterData = self.makeCredentialsData(
|
||||
accessToken: "security-token-after",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let dataBox = DataBox(data: beforeData)
|
||||
let outcome = await self.withCoordinatorOverrides(
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in
|
||||
dataBox.store(afterData)
|
||||
},
|
||||
keychainFingerprint: {
|
||||
fingerprintCounter.increment()
|
||||
return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 11,
|
||||
createdAt: 11,
|
||||
persistentRefHash: "framework-fingerprint")
|
||||
},
|
||||
operation: {
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.dynamic { _ in dataBox.load() })
|
||||
{
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 61000),
|
||||
timeout: 0.1)
|
||||
}
|
||||
})
|
||||
|
||||
#expect(outcome == .attemptedSucceeded)
|
||||
#expect(fingerprintCounter.count < 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental strategy missing baseline does not auto succeed when later read succeeds`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
final class DataBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _data: Data?
|
||||
init(data: Data?) {
|
||||
self._data = data
|
||||
}
|
||||
|
||||
func load() -> Data? {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self._data
|
||||
}
|
||||
|
||||
func store(_ data: Data?) {
|
||||
self.lock.lock()
|
||||
self._data = data
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
final class CounterBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private(set) var count: Int = 0
|
||||
func increment() {
|
||||
self.lock.lock()
|
||||
self.count += 1
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
let fingerprintCounter = CounterBox()
|
||||
let afterData = self.makeCredentialsData(
|
||||
accessToken: "security-token-after-baseline-miss",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let dataBox = DataBox(data: nil)
|
||||
let outcome = await self.withCoordinatorOverrides(
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in
|
||||
dataBox.store(afterData)
|
||||
},
|
||||
keychainFingerprint: {
|
||||
fingerprintCounter.increment()
|
||||
return ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 21,
|
||||
createdAt: 21,
|
||||
persistentRefHash: "framework-fingerprint")
|
||||
},
|
||||
operation: {
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.dynamic { _ in dataBox.load() })
|
||||
{
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 61500),
|
||||
timeout: 0.1)
|
||||
}
|
||||
})
|
||||
|
||||
guard case .attemptedFailed = outcome else {
|
||||
Issue.record("Expected .attemptedFailed outcome when baseline is unavailable")
|
||||
return
|
||||
}
|
||||
#expect(fingerprintCounter.count < 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental strategy observation skips security CLI when global keychain disabled`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
final class CounterBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private(set) var count: Int = 0
|
||||
func increment() {
|
||||
self.lock.lock()
|
||||
self.count += 1
|
||||
self.lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
let securityReadCounter = CounterBox()
|
||||
let securityData = self.makeCredentialsData(
|
||||
accessToken: "security-should-not-be-read",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let outcome = await self.withCoordinatorOverrides(
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in },
|
||||
operation: {
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in
|
||||
securityReadCounter.increment()
|
||||
return securityData
|
||||
}) {
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(true) {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 62000),
|
||||
timeout: 0.1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
guard case .attemptedFailed = outcome else {
|
||||
Issue.record("Expected .attemptedFailed outcome")
|
||||
return
|
||||
}
|
||||
#expect(securityReadCounter.count < 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `experimental strategy blocks background mcp O auth but lets user action retry`() async {
|
||||
ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting()
|
||||
defer { ClaudeOAuthDelegatedRefreshCoordinator.resetForTesting() }
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
final class StateBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var touchCount = 0
|
||||
|
||||
func touch() {
|
||||
self.lock.lock()
|
||||
self.touchCount += 1
|
||||
self.lock.unlock()
|
||||
}
|
||||
|
||||
func count() -> Int {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return self.touchCount
|
||||
}
|
||||
}
|
||||
|
||||
let state = StateBox()
|
||||
let mcpOAuthOnly = Data("""
|
||||
{
|
||||
"mcpOAuth": {
|
||||
"plugin:slack:slack": { "accessToken": "" }
|
||||
}
|
||||
}
|
||||
""".utf8)
|
||||
let refreshedCredentials = self.makeCredentialsData(
|
||||
accessToken: "refreshed-after-user-action",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let outcomes = await self.withCoordinatorOverrides(
|
||||
cliAvailable: true,
|
||||
touchAuthPath: { _, _ in state.touch() },
|
||||
operation: {
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.dynamic { _ in
|
||||
state.count() > 0 ? refreshedCredentials : mcpOAuthOnly
|
||||
}) {
|
||||
let background = await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 63000),
|
||||
timeout: 0.1)
|
||||
}
|
||||
let backgroundTouchCount = state.count()
|
||||
let userInitiated = await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
||||
now: Date(timeIntervalSince1970: 63001),
|
||||
timeout: 0.1)
|
||||
}
|
||||
return (background, backgroundTouchCount, userInitiated)
|
||||
}
|
||||
})
|
||||
|
||||
guard case let .attemptedFailed(message) = outcomes.0 else {
|
||||
Issue.record("Expected background .attemptedFailed outcome")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("MCP OAuth"))
|
||||
#expect(outcomes.1 == 0)
|
||||
#expect(outcomes.2 == .attemptedSucceeded)
|
||||
#expect(state.count() == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthDelegatedRefreshRecoveryTests {
|
||||
private actor AsyncCounter {
|
||||
private var value = 0
|
||||
|
||||
func increment() -> Int {
|
||||
self.value += 1
|
||||
return self.value
|
||||
}
|
||||
|
||||
func current() -> Int {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
private actor TokenCapture {
|
||||
private var token: String?
|
||||
|
||||
func set(_ token: String) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
func get() -> String? {
|
||||
self.token
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeOAuthUsageResponse() throws -> OAuthUsageResponse {
|
||||
let json = """
|
||||
{
|
||||
"five_hour": { "utilization": 7, "resets_at": "2025-12-23T16:00:00.000Z" },
|
||||
"seven_day": { "utilization": 21, "resets_at": "2025-12-29T23:00:00.000Z" }
|
||||
}
|
||||
"""
|
||||
return try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8))
|
||||
}
|
||||
|
||||
private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
|
||||
let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
|
||||
let refreshField: String = {
|
||||
guard let refreshToken else { return "" }
|
||||
return ",\n \"refreshToken\": \"\(refreshToken)\""
|
||||
}()
|
||||
let json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
"expiresAt": \(millis),
|
||||
"scopes": ["user:profile"]\(refreshField)
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `silent keychain repair recovers without delegation`() async throws {
|
||||
let delegatedCounter = AsyncCounter()
|
||||
let usageResponse = try Self.makeOAuthUsageResponse()
|
||||
let tokenCapture = TokenCapture()
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() }
|
||||
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
let snapshot = try await ClaudeOAuthCredentialsStore
|
||||
.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
// Seed an expired cache entry owned by Claude CLI, so the initial load delegates
|
||||
// refresh.
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600))
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI)
|
||||
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
// Sanity: setup should be visible to the code under test.
|
||||
// Otherwise it may attempt interactive reads.
|
||||
#expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) == true)
|
||||
|
||||
// Simulate Claude CLI writing fresh credentials into the Claude Code keychain entry.
|
||||
let freshData = self.makeCredentialsData(
|
||||
accessToken: "fresh-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "test")
|
||||
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .oauth,
|
||||
oauthKeychainPromptCooldownEnabled: true)
|
||||
|
||||
let fetchOverride: @Sendable (
|
||||
String,
|
||||
Bool) async throws -> OAuthUsageResponse = { token, _ in
|
||||
await tokenCapture.set(token)
|
||||
return usageResponse
|
||||
}
|
||||
let delegatedOverride: (@Sendable (
|
||||
Date,
|
||||
TimeInterval,
|
||||
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? =
|
||||
{ _, _, _ in
|
||||
_ = await delegatedCounter.increment()
|
||||
return .attemptedSucceeded
|
||||
}
|
||||
|
||||
let snapshot = try await ClaudeOAuthKeychainPromptPreference
|
||||
.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: freshData,
|
||||
fingerprint: fingerprint)
|
||||
{
|
||||
try await ClaudeUsageFetcher.$fetchOAuthUsageOverride
|
||||
.withValue(fetchOverride) {
|
||||
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride
|
||||
.withValue(delegatedOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If Claude keychain already contains fresh credentials, we should recover without
|
||||
// needing a
|
||||
// CLI
|
||||
// touch.
|
||||
#expect(await delegatedCounter.current() == 0)
|
||||
#expect(await tokenCapture.get() == "fresh-token")
|
||||
#expect(snapshot.primary.usedPercent == 7)
|
||||
#expect(snapshot.secondary?.usedPercent == 21)
|
||||
return snapshot
|
||||
}
|
||||
_ = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `delegated refresh attempted succeeded recovers after keychain sync`() async throws {
|
||||
let delegatedCounter = AsyncCounter()
|
||||
let usageResponse = try Self.makeOAuthUsageResponse()
|
||||
let tokenCapture = TokenCapture()
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() }
|
||||
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
let snapshot = try await ClaudeOAuthCredentialsStore
|
||||
.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
// Seed an expired cache entry owned by Claude CLI, so the initial load delegates
|
||||
// refresh.
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600))
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI)
|
||||
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
// Ensure we don't silently repair from the Claude keychain before delegation.
|
||||
// Use an explicit empty-data override so we never consult the real system Keychain
|
||||
// during
|
||||
// tests.
|
||||
let stubFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "test")
|
||||
let keychainOverrideStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
|
||||
data: Data(),
|
||||
fingerprint: stubFingerprint)
|
||||
|
||||
let freshData = self.makeCredentialsData(
|
||||
accessToken: "fresh-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .oauth,
|
||||
oauthKeychainPromptCooldownEnabled: true)
|
||||
|
||||
let fetchOverride: @Sendable (
|
||||
String,
|
||||
Bool) async throws -> OAuthUsageResponse = { token, _ in
|
||||
await tokenCapture.set(token)
|
||||
return usageResponse
|
||||
}
|
||||
|
||||
let delegatedOverride: (@Sendable (
|
||||
Date,
|
||||
TimeInterval,
|
||||
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? =
|
||||
{ _, _, _ in
|
||||
// Simulate Claude CLI writing fresh credentials after the delegated refresh
|
||||
// touch.
|
||||
keychainOverrideStore.data = freshData
|
||||
keychainOverrideStore.fingerprint = stubFingerprint
|
||||
_ = await delegatedCounter.increment()
|
||||
return .attemptedSucceeded
|
||||
}
|
||||
|
||||
let snapshot = try await ClaudeOAuthKeychainPromptPreference
|
||||
.withTaskOverrideForTesting(.always) {
|
||||
try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
try await ClaudeOAuthCredentialsStore
|
||||
.withMutableClaudeKeychainOverrideStoreForTesting(
|
||||
keychainOverrideStore)
|
||||
{
|
||||
try await ClaudeUsageFetcher.$fetchOAuthUsageOverride
|
||||
.withValue(fetchOverride) {
|
||||
try await ClaudeUsageFetcher
|
||||
.$delegatedRefreshAttemptOverride
|
||||
.withValue(delegatedOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(await delegatedCounter.current() == 1)
|
||||
let capturedToken = await tokenCapture.get()
|
||||
if capturedToken != "fresh-token" {
|
||||
Issue.record("Expected fresh-token, got \(capturedToken ?? "nil")")
|
||||
}
|
||||
#expect(capturedToken == "fresh-token")
|
||||
#expect(snapshot.primary.usedPercent == 7)
|
||||
#expect(snapshot.secondary?.usedPercent == 21)
|
||||
return snapshot
|
||||
}
|
||||
_ = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `delegated refresh attempted succeeded background only on user action does not recover from keychain`()
|
||||
async throws
|
||||
{
|
||||
let delegatedCounter = AsyncCounter()
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
try await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
|
||||
ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting()
|
||||
defer { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() }
|
||||
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
let expiredData = self.makeCredentialsData(
|
||||
accessToken: "expired-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: -3600))
|
||||
let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
|
||||
let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(
|
||||
data: expiredData,
|
||||
storedAt: Date(),
|
||||
owner: .claudeCLI)
|
||||
KeychainCacheStore.store(key: cacheKey, entry: cacheEntry)
|
||||
defer { KeychainCacheStore.clear(key: cacheKey) }
|
||||
|
||||
// Expired Claude-CLI-owned credentials are still considered cache-present (delegatable).
|
||||
#expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:]) == true)
|
||||
|
||||
let stubFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "test")
|
||||
let keychainOverrideStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(
|
||||
data: Data(),
|
||||
fingerprint: stubFingerprint)
|
||||
let freshData = self.makeCredentialsData(
|
||||
accessToken: "fresh-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: 3600))
|
||||
|
||||
let fetcher = ClaudeUsageFetcher(
|
||||
browserDetection: BrowserDetection(cacheTTL: 0),
|
||||
environment: [:],
|
||||
dataSource: .oauth,
|
||||
oauthKeychainPromptCooldownEnabled: false,
|
||||
allowBackgroundDelegatedRefresh: true)
|
||||
|
||||
let delegatedOverride: (@Sendable (
|
||||
Date,
|
||||
TimeInterval,
|
||||
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? =
|
||||
{ _, _, _ in
|
||||
keychainOverrideStore.data = freshData
|
||||
keychainOverrideStore.fingerprint = stubFingerprint
|
||||
_ = await delegatedCounter.increment()
|
||||
return .attemptedSucceeded
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(
|
||||
.onlyOnUserAction)
|
||||
{
|
||||
try await ProviderInteractionContext.$current.withValue(.background) {
|
||||
try await ClaudeOAuthCredentialsStore
|
||||
.withMutableClaudeKeychainOverrideStoreForTesting(keychainOverrideStore) {
|
||||
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride
|
||||
.withValue(delegatedOverride) {
|
||||
try await fetcher.loadLatestUsage(model: "sonnet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Issue.record(
|
||||
"Expected OAuth fetch failure: background keychain recovery should stay blocked")
|
||||
} catch let error as ClaudeUsageError {
|
||||
guard case let .oauthFailed(message) = error else {
|
||||
Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("still unavailable after delegated Claude CLI refresh"))
|
||||
} catch {
|
||||
Issue.record("Expected ClaudeUsageError, got \(error)")
|
||||
}
|
||||
|
||||
#expect(await delegatedCounter.current() == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthFetchStrategyAvailabilityTests {
|
||||
private struct StubClaudeFetcher: ClaudeUsageFetching {
|
||||
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
|
||||
throw ClaudeUsageError.parseFailed("stub")
|
||||
}
|
||||
|
||||
func debugRawProbe(model _: String) async -> String {
|
||||
"stub"
|
||||
}
|
||||
|
||||
func detectVersion() -> String? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
sourceMode: ProviderSourceMode,
|
||||
env: [String: String] = [:]) -> ProviderFetchContext
|
||||
{
|
||||
ProviderFetchContext(
|
||||
runtime: .app,
|
||||
sourceMode: sourceMode,
|
||||
includeCredits: false,
|
||||
webTimeout: 1,
|
||||
webDebugDumpHTML: false,
|
||||
verbose: false,
|
||||
env: env,
|
||||
settings: nil,
|
||||
fetcher: UsageFetcher(environment: env),
|
||||
claudeFetcher: StubClaudeFetcher(),
|
||||
browserDetection: BrowserDetection(cacheTTL: 0))
|
||||
}
|
||||
|
||||
private func expiredRecord(owner: ClaudeOAuthCredentialOwner = .claudeCLI) -> ClaudeOAuthCredentialRecord {
|
||||
ClaudeOAuthCredentialRecord(
|
||||
credentials: ClaudeOAuthCredentials(
|
||||
accessToken: "expired-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: -60),
|
||||
scopes: ["user:profile"],
|
||||
rateLimitTier: nil),
|
||||
owner: owner,
|
||||
source: .cacheKeychain)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode expired creds cli available returns available`() async {
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
|
||||
.withValue(self.expiredRecord()) {
|
||||
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
#expect(available == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode expired CLI creds with MCP-only keychain returns unavailable in background`() async {
|
||||
let available = await self.expiredCLIAvailability(
|
||||
sourceMode: .auto,
|
||||
interaction: .background,
|
||||
keychainData: self.mcpOAuthOnlyKeychainPayload)
|
||||
|
||||
#expect(!available)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode expired CLI creds with MCP-only keychain remains available for user action`() async {
|
||||
let available = await self.expiredCLIAvailability(
|
||||
sourceMode: .auto,
|
||||
interaction: .userInitiated,
|
||||
keychainData: self.mcpOAuthOnlyKeychainPayload)
|
||||
|
||||
#expect(available)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `explicit O auth keeps expired CLI credentials available with MCP-only keychain`() async {
|
||||
let available = await self.expiredCLIAvailability(
|
||||
sourceMode: .oauth,
|
||||
interaction: .background,
|
||||
keychainData: self.mcpOAuthOnlyKeychainPayload)
|
||||
|
||||
#expect(available)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode keeps expired CLI credentials available with ordinary O auth keychain`() async {
|
||||
let available = await self.expiredCLIAvailability(
|
||||
sourceMode: .auto,
|
||||
interaction: .background,
|
||||
keychainData: self.ordinaryOAuthKeychainPayload)
|
||||
|
||||
#expect(available)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode ignores MCP-only keychain when keychain access is disabled`() async {
|
||||
let available = await self.expiredCLIAvailability(
|
||||
sourceMode: .auto,
|
||||
interaction: .background,
|
||||
keychainData: self.mcpOAuthOnlyKeychainPayload,
|
||||
keychainAccessDisabled: true)
|
||||
|
||||
#expect(available)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode expired creds cli unavailable returns unavailable`() async {
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
|
||||
.withValue(self.expiredRecord()) {
|
||||
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(false) {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
#expect(available == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `oauth mode expired creds cli available returns available`() async {
|
||||
let context = self.makeContext(sourceMode: .oauth)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
|
||||
.withValue(self.expiredRecord()) {
|
||||
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
#expect(available == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode expired codexbar creds cli unavailable still available`() async {
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
|
||||
.withValue(self.expiredRecord(owner: .codexbar)) {
|
||||
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(false) {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
#expect(available == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `oauth mode does not fallback after O auth failure`() {
|
||||
let context = self.makeContext(sourceMode: .oauth)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
#expect(strategy.shouldFallback(
|
||||
on: ClaudeUsageError.oauthFailed("oauth failed"),
|
||||
context: context) == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode falls back after O auth failure`() {
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
#expect(strategy.shouldFallback(
|
||||
on: ClaudeUsageError.oauthFailed("oauth failed"),
|
||||
context: context) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode user initiated clears keychain cooldown gate`() async {
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
|
||||
credentials: ClaudeOAuthCredentials(
|
||||
accessToken: "expired-token",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: -60),
|
||||
scopes: ["user:inference"],
|
||||
rateLimitTier: nil),
|
||||
owner: .claudeCLI,
|
||||
source: .cacheKeychain)
|
||||
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
|
||||
|
||||
let now = Date(timeIntervalSince1970: 1000)
|
||||
ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
|
||||
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false)
|
||||
|
||||
_ = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
|
||||
.withValue(recordWithoutRequiredScope) {
|
||||
await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
await self.withAvailabilityKeychainDoubles {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode only on user action background startup without cache is available for bootstrap`() async throws {
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
|
||||
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
await self.withAvailabilityKeychainDoubles {
|
||||
await ProviderRefreshContext.$current.withValue(.startup) {
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(available == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode expired Claude CLI creds env provided CLI override returns available`() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let cliURL = tempDir.appendingPathComponent("claude")
|
||||
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: cliURL)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path)
|
||||
|
||||
let context = self.makeContext(
|
||||
sourceMode: .auto,
|
||||
env: ["CLAUDE_CLI_PATH": cliURL.path])
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let available = await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
|
||||
.withValue(self.expiredRecord()) {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
|
||||
#expect(available == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode default reader keeps background startup bootstrap available`() async throws {
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
|
||||
|
||||
try await KeychainCacheStore.withServiceOverrideForTesting(service) {
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
|
||||
try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer {
|
||||
ClaudeOAuthCredentialsStore.invalidateCache()
|
||||
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
}
|
||||
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let fileURL = tempDir.appendingPathComponent("credentials.json")
|
||||
|
||||
let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
|
||||
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.nonZeroExit) {
|
||||
await self.withAvailabilityKeychainDoubles {
|
||||
await ProviderRefreshContext.$current.withValue(.startup) {
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(available == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode experimental reader ignores prompt policy cooldown gate`() async {
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let securityData = Data("""
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "security-token",
|
||||
"expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)),
|
||||
"scopes": ["user:profile"]
|
||||
}
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
|
||||
credentials: ClaudeOAuthCredentials(
|
||||
accessToken: "token-no-scope",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: -60),
|
||||
scopes: ["user:inference"],
|
||||
rateLimitTier: nil),
|
||||
owner: .claudeCLI,
|
||||
source: .cacheKeychain)
|
||||
|
||||
let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue(
|
||||
recordWithoutRequiredScope)
|
||||
{
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(
|
||||
securityData))
|
||||
{
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(available == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `auto mode experimental reader security failure blocks availability when stored policy blocks fallback`()
|
||||
async
|
||||
{
|
||||
let context = self.makeContext(sourceMode: .auto)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
let fallbackData = Data("""
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "fallback-token",
|
||||
"expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)),
|
||||
"scopes": ["user:profile"]
|
||||
}
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
let recordWithoutRequiredScope = ClaudeOAuthCredentialRecord(
|
||||
credentials: ClaudeOAuthCredentials(
|
||||
accessToken: "token-no-scope",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: Date(timeIntervalSinceNow: -60),
|
||||
scopes: ["user:inference"],
|
||||
rateLimitTier: nil),
|
||||
owner: .claudeCLI,
|
||||
source: .cacheKeychain)
|
||||
|
||||
let available = await KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
||||
.securityCLIExperimental)
|
||||
{
|
||||
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
||||
await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue(
|
||||
recordWithoutRequiredScope)
|
||||
{
|
||||
await ProviderInteractionContext.$current.withValue(.background) {
|
||||
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: fallbackData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
|
||||
.nonZeroExit)
|
||||
{
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(available == false)
|
||||
}
|
||||
|
||||
private func withAvailabilityKeychainDoubles<T>(
|
||||
operation: () async throws -> T) async rethrows -> T
|
||||
{
|
||||
KeychainCacheStore.setTestStoreForTesting(true)
|
||||
defer { KeychainCacheStore.setTestStoreForTesting(false) }
|
||||
return try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(
|
||||
true,
|
||||
operation: operation)
|
||||
}
|
||||
|
||||
private var mcpOAuthOnlyKeychainPayload: Data {
|
||||
Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"fixture"}}}"#.utf8)
|
||||
}
|
||||
|
||||
private var ordinaryOAuthKeychainPayload: Data {
|
||||
Data(#"{"claudeAiOauth":{"accessToken":"fixture"}}"#.utf8)
|
||||
}
|
||||
|
||||
private func expiredCLIAvailability(
|
||||
sourceMode: ProviderSourceMode,
|
||||
interaction: ProviderInteraction,
|
||||
keychainData: Data,
|
||||
keychainAccessDisabled: Bool = false) async -> Bool
|
||||
{
|
||||
let context = self.makeContext(sourceMode: sourceMode)
|
||||
let strategy = ClaudeOAuthFetchStrategy()
|
||||
return await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride
|
||||
.withValue(self.expiredRecord()) {
|
||||
await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) {
|
||||
await KeychainAccessGate.withTaskOverrideForTesting(keychainAccessDisabled) {
|
||||
await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(.securityFramework) {
|
||||
await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: nil)
|
||||
{
|
||||
await ProviderInteractionContext.$current.withValue(interaction) {
|
||||
await strategy.isAvailable(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,169 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthHistoryCredentialRoutingTests {
|
||||
@Test
|
||||
func `history keychain reference only matches the credential that won routing`() throws {
|
||||
let keychainData = self.makeCredentialsData(accessToken: "keychain-token")
|
||||
let keychainCredentials = try ClaudeOAuthCredentials.parse(data: keychainData)
|
||||
let differentCredentials = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "different-token"))
|
||||
let fingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "opaque-ref")
|
||||
|
||||
let matchingCLIRecord = ClaudeOAuthCredentialRecord(
|
||||
credentials: keychainCredentials,
|
||||
owner: .claudeCLI,
|
||||
source: .memoryCache)
|
||||
let differentCLIRecord = ClaudeOAuthCredentialRecord(
|
||||
credentials: differentCredentials,
|
||||
owner: .claudeCLI,
|
||||
source: .credentialsFile)
|
||||
let matchingEnvironmentRecord = ClaudeOAuthCredentialRecord(
|
||||
credentials: keychainCredentials,
|
||||
owner: .environment,
|
||||
source: .environment)
|
||||
let matchingCodexBarRecord = ClaudeOAuthCredentialRecord(
|
||||
credentials: keychainCredentials,
|
||||
owner: .codexbar,
|
||||
source: .cacheKeychain)
|
||||
|
||||
ProviderInteractionContext.$current.withValue(.userInitiated) {
|
||||
ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: keychainData,
|
||||
fingerprint: fingerprint)
|
||||
{
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingCLIRecord) == "opaque-ref")
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: differentCLIRecord) == nil)
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingEnvironmentRecord) == nil)
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.matchingClaudeKeychainPersistentRefHashWithoutPrompt(for: matchingCodexBarRecord) == nil)
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) ==
|
||||
.matched(persistentRefHash: "opaque-ref"))
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.claudeKeychainCredentialMatchWithoutPrompt(for: differentCLIRecord) == .mismatch)
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.claudeKeychainCredentialMatchWithoutPrompt(for: matchingEnvironmentRecord) == .notApplicable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
|
||||
data: nil,
|
||||
fingerprint: fingerprint)
|
||||
{
|
||||
let unavailable = ClaudeOAuthCredentialsStore
|
||||
.claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord)
|
||||
#expect(unavailable == .unavailable)
|
||||
#expect(unavailable.isUnavailable)
|
||||
#expect(!unavailable.isMismatch)
|
||||
}
|
||||
|
||||
let absentStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore()
|
||||
ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting(absentStore) {
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.claudeKeychainCredentialMatchWithoutPrompt(for: matchingCLIRecord) == .absent)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `newest duplicate reference cannot label a different winning credential`() throws {
|
||||
let winningCredentials = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "winning-token"))
|
||||
let newestCandidateCredentials = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "newest-candidate-token"))
|
||||
let winningRecord = ClaudeOAuthCredentialRecord(
|
||||
credentials: winningCredentials,
|
||||
owner: .claudeCLI,
|
||||
source: .memoryCache)
|
||||
let newestCandidateRecord = ClaudeOAuthCredentialRecord(
|
||||
credentials: newestCandidateCredentials,
|
||||
owner: .claudeCLI,
|
||||
source: .claudeKeychain)
|
||||
|
||||
#expect(ClaudeOAuthCredentialsStore._matchingClaudeKeychainPersistentRefHashForTesting(
|
||||
record: winningRecord,
|
||||
candidateCredentials: newestCandidateCredentials,
|
||||
persistentRefHash: "newest-candidate-ref") == nil)
|
||||
#expect(ClaudeOAuthCredentialsStore._matchingClaudeKeychainPersistentRefHashForTesting(
|
||||
record: newestCandidateRecord,
|
||||
candidateCredentials: newestCandidateCredentials,
|
||||
persistentRefHash: "newest-candidate-ref") == "newest-candidate-ref")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `history owner follows refresh credential across access token rotation`() throws {
|
||||
let beforeRefresh = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "access-before", refreshToken: "stable-refresh"))
|
||||
let afterRefresh = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "access-after", refreshToken: "stable-refresh"))
|
||||
|
||||
let beforeIdentifier = try #require(beforeRefresh.historyOwnerIdentifier)
|
||||
let afterIdentifier = try #require(afterRefresh.historyOwnerIdentifier)
|
||||
#expect(beforeIdentifier == afterIdentifier)
|
||||
#expect(beforeIdentifier.count == 64)
|
||||
#expect(!beforeIdentifier.contains("stable-refresh"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `access-only credential replacement rotates history owner`() throws {
|
||||
let original = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "original-access"))
|
||||
let replacement = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "replacement-access"))
|
||||
|
||||
let originalIdentifier = try #require(original.historyOwnerIdentifier)
|
||||
let replacementIdentifier = try #require(replacement.historyOwnerIdentifier)
|
||||
#expect(originalIdentifier != replacementIdentifier)
|
||||
#expect(!originalIdentifier.contains("original-access"))
|
||||
#expect(!replacementIdentifier.contains("replacement-access"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `only an explicit refresh lineage can preserve a rotated credential owner`() throws {
|
||||
let original = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "access-before", refreshToken: "refresh-before"))
|
||||
let rotated = try ClaudeOAuthCredentials.parse(
|
||||
data: self.makeCredentialsData(accessToken: "access-after", refreshToken: "refresh-after"))
|
||||
let originalIdentifier = try #require(original.historyOwnerIdentifier)
|
||||
let rotatedIdentifier = try #require(rotated.historyOwnerIdentifier)
|
||||
#expect(originalIdentifier != rotatedIdentifier)
|
||||
|
||||
let refreshProvenRecord = ClaudeOAuthCredentialRecord(
|
||||
credentials: rotated,
|
||||
owner: .codexbar,
|
||||
source: .memoryCache,
|
||||
historyOwnerIdentifier: originalIdentifier)
|
||||
let unrelatedReplacementRecord = ClaudeOAuthCredentialRecord(
|
||||
credentials: rotated,
|
||||
owner: .codexbar,
|
||||
source: .cacheKeychain)
|
||||
|
||||
#expect(refreshProvenRecord.historyOwnerIdentifier == originalIdentifier)
|
||||
#expect(unrelatedReplacementRecord.historyOwnerIdentifier == rotatedIdentifier)
|
||||
}
|
||||
|
||||
private func makeCredentialsData(accessToken: String, refreshToken: String? = nil) -> Data {
|
||||
let expiresAt = Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)
|
||||
let refreshTokenJSON = refreshToken.map { "\n \"refreshToken\": \"\($0)\"," } ?? ""
|
||||
return Data("""
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "\(accessToken)",
|
||||
\(refreshTokenJSON)
|
||||
"expiresAt": \(expiresAt),
|
||||
"scopes": ["user:profile"]
|
||||
}
|
||||
}
|
||||
""".utf8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthKeychainAccessGateTests {
|
||||
@Test
|
||||
func `blocks until cooldown expires`() {
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
let store = ClaudeOAuthKeychainAccessGate.DeniedUntilStore()
|
||||
ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(store) {
|
||||
let now = Date(timeIntervalSince1970: 1000)
|
||||
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now))
|
||||
|
||||
ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
|
||||
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false)
|
||||
#expect(
|
||||
ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1))
|
||||
== false)
|
||||
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 + 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `persists denied until`() {
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
|
||||
|
||||
let now = Date(timeIntervalSince1970: 2000)
|
||||
ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
|
||||
|
||||
ClaudeOAuthKeychainAccessGate.resetInMemoryForTesting()
|
||||
|
||||
#expect(
|
||||
ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now.addingTimeInterval(60 * 60 * 6 - 1)) == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `respects debug disable keychain access`() {
|
||||
KeychainAccessGate.withTaskOverrideForTesting(true) {
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
|
||||
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: Date()) == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `process keeps keychain access disabled despite false global override`() {
|
||||
guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return }
|
||||
KeychainAccessGate.resetOverrideForTesting()
|
||||
defer { KeychainAccessGate.resetOverrideForTesting() }
|
||||
|
||||
KeychainAccessGate.isDisabled = false
|
||||
|
||||
#expect(KeychainAccessGate.isDisabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `process force disable survives settings override`() {
|
||||
KeychainAccessGate.resetOverrideForTesting()
|
||||
defer { KeychainAccessGate.resetOverrideForTesting() }
|
||||
|
||||
KeychainAccessGate.forceDisabledForProcess(reason: "unbundled-executable")
|
||||
KeychainAccessGate.isDisabled = false
|
||||
|
||||
#expect(KeychainAccessGate.isDisabled)
|
||||
#expect(KeychainAccessGate.processDisableReason == "unbundled-executable")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `clear denied allows immediate retry`() {
|
||||
KeychainAccessGate.withTaskOverrideForTesting(false) {
|
||||
ClaudeOAuthKeychainAccessGate.resetForTesting()
|
||||
defer { ClaudeOAuthKeychainAccessGate.resetForTesting() }
|
||||
|
||||
let store = ClaudeOAuthKeychainAccessGate.DeniedUntilStore()
|
||||
ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(store) {
|
||||
let now = Date(timeIntervalSince1970: 3000)
|
||||
ClaudeOAuthKeychainAccessGate.recordDenied(now: now)
|
||||
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now) == false)
|
||||
|
||||
#expect(ClaudeOAuthKeychainAccessGate.clearDenied(now: now))
|
||||
#expect(ClaudeOAuthKeychainAccessGate.shouldAllowPrompt(now: now))
|
||||
#expect(ClaudeOAuthKeychainAccessGate.clearDenied(now: now) == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthKeychainPreAlertGateTests {
|
||||
@Test
|
||||
func `acknowledgement suppresses repeated presentation until cooldown expires`() {
|
||||
let store = ClaudeOAuthKeychainPreAlertGate.StateStore()
|
||||
ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(store) {
|
||||
let now = Date(timeIntervalSince1970: 1000)
|
||||
#expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { true })
|
||||
|
||||
#expect(
|
||||
ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(
|
||||
now: now.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval - 1),
|
||||
completedAt: now,
|
||||
present: { true }) == false)
|
||||
#expect(
|
||||
ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(
|
||||
now: now.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval + 1),
|
||||
completedAt: now,
|
||||
present: { true }))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `cooldown starts when presentation completes`() {
|
||||
let store = ClaudeOAuthKeychainPreAlertGate.StateStore()
|
||||
ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(store) {
|
||||
let startedAt = Date(timeIntervalSince1970: 1000)
|
||||
let completedAt = Date(timeIntervalSince1970: 2000)
|
||||
#expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(
|
||||
now: startedAt,
|
||||
completedAt: completedAt,
|
||||
present: { true }))
|
||||
|
||||
#expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(
|
||||
now: startedAt.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval + 1),
|
||||
completedAt: completedAt,
|
||||
present: { true }) == false)
|
||||
#expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(
|
||||
now: completedAt.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval + 1),
|
||||
completedAt: completedAt,
|
||||
present: { true }))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `missing prompt handler does not consume acknowledgement cooldown`() {
|
||||
let store = ClaudeOAuthKeychainPreAlertGate.StateStore()
|
||||
ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(store) {
|
||||
let now = Date(timeIntervalSince1970: 2000)
|
||||
#expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { false } == false)
|
||||
#expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { true })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `duplicate while presentation is in flight is suppressed`() {
|
||||
let store = ClaudeOAuthKeychainPreAlertGate.StateStore()
|
||||
ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(store) {
|
||||
let now = Date(timeIntervalSince1970: 3000)
|
||||
var nestedPresentationRan = false
|
||||
var nestedResult: Bool?
|
||||
let outerResult = ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) {
|
||||
nestedResult = ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now) {
|
||||
nestedPresentationRan = true
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
#expect(outerResult)
|
||||
#expect(nestedResult == false)
|
||||
#expect(nestedPresentationRan == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `acknowledgement persists across in memory reset`() {
|
||||
ClaudeOAuthKeychainPreAlertGate.resetForTesting()
|
||||
defer { ClaudeOAuthKeychainPreAlertGate.resetForTesting() }
|
||||
|
||||
let now = Date(timeIntervalSince1970: 4000)
|
||||
#expect(ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(now: now, completedAt: now) { true })
|
||||
ClaudeOAuthKeychainPreAlertGate.resetInMemoryForTesting()
|
||||
|
||||
#expect(
|
||||
ClaudeOAuthKeychainPreAlertGate.presentIfNeeded(
|
||||
now: now.addingTimeInterval(ClaudeOAuthKeychainPreAlertGate.cooldownInterval - 1),
|
||||
completedAt: now,
|
||||
present: { true }) == false)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
struct ClaudeOAuthRefreshDispositionTests {
|
||||
@Test
|
||||
func `invalid grant is terminal`() {
|
||||
let data = Data(#"{"error":"invalid_grant"}"#.utf8)
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.refreshFailureDispositionForTesting(statusCode: 400, data: data) == "terminalInvalidGrant")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `other error is transient`() {
|
||||
let data = Data(#"{"error":"invalid_request"}"#.utf8)
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.refreshFailureDispositionForTesting(statusCode: 400, data: data) == "transientBackoff")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `undecodable body is transient`() {
|
||||
let data = Data("not-json".utf8)
|
||||
#expect(ClaudeOAuthCredentialsStore
|
||||
.refreshFailureDispositionForTesting(statusCode: 401, data: data) == "transientBackoff")
|
||||
#expect(ClaudeOAuthCredentialsStore.extractOAuthErrorCodeForTesting(from: data) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `non auth status is not handled`() {
|
||||
let data = Data(#"{"error":"invalid_grant"}"#.utf8)
|
||||
#expect(ClaudeOAuthCredentialsStore.refreshFailureDispositionForTesting(statusCode: 500, data: data) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodexBarCore
|
||||
|
||||
#if os(macOS)
|
||||
@Suite(.serialized)
|
||||
struct ClaudeOAuthRefreshFailureGateTests {
|
||||
private let legacyBlockedUntilKey = "claudeOAuthRefreshBackoffBlockedUntilV1"
|
||||
private let legacyFailureCountKey = "claudeOAuthRefreshBackoffFailureCountV1"
|
||||
private let legacyFingerprintKey = "claudeOAuthRefreshBackoffFingerprintV2"
|
||||
private let terminalBlockedKey = "claudeOAuthRefreshTerminalBlockedV1"
|
||||
private let transientBlockedUntilKey = "claudeOAuthRefreshTransientBlockedUntilV1"
|
||||
private let transientFailureCountKey = "claudeOAuthRefreshTransientFailureCountV1"
|
||||
|
||||
@Test
|
||||
func `blocks indefinitely when fingerprint unchanged`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
var fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 1000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start)
|
||||
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false)
|
||||
|
||||
// Ensure we do not get unblocked unless fingerprint changes.
|
||||
fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 4)) == false)
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 24)) == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `migrates legacy blocked until in past does not block and clears key`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
let now = Date(timeIntervalSince1970: 10000)
|
||||
UserDefaults.standard.set(now.addingTimeInterval(-60).timeIntervalSince1970, forKey: self.legacyBlockedUntilKey)
|
||||
UserDefaults.standard.set(0, forKey: self.legacyFailureCountKey)
|
||||
UserDefaults.standard.removeObject(forKey: self.terminalBlockedKey)
|
||||
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: now) == true)
|
||||
#expect(UserDefaults.standard.object(forKey: self.legacyBlockedUntilKey) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `migrates legacy backoff to transient backoff does not set terminal block`() throws {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
let now = Date(timeIntervalSince1970: 20000)
|
||||
|
||||
let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let legacyBlockedUntil = now.addingTimeInterval(60 * 10)
|
||||
UserDefaults.standard.set(2, forKey: self.legacyFailureCountKey)
|
||||
UserDefaults.standard.removeObject(forKey: self.terminalBlockedKey)
|
||||
UserDefaults.standard.set(legacyBlockedUntil.timeIntervalSince1970, forKey: self.legacyBlockedUntilKey)
|
||||
let data = try JSONEncoder().encode(fingerprint)
|
||||
UserDefaults.standard.set(data, forKey: self.legacyFingerprintKey)
|
||||
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: now) == false)
|
||||
#expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == false)
|
||||
#expect(UserDefaults.standard.object(forKey: self.legacyBlockedUntilKey) == nil)
|
||||
#expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) != nil)
|
||||
#expect(UserDefaults.standard.integer(forKey: self.transientFailureCountKey) == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `unblocks when fingerprint becomes available after being unknown at failure`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
var fingerprint: ClaudeOAuthRefreshFailureGate.AuthFingerprint?
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 25000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start)
|
||||
|
||||
// Still blocked while fingerprint is unavailable.
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false)
|
||||
|
||||
// Once fingerprint becomes available, the sentinel differs and we unblock.
|
||||
fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `unblocks immediately when fingerprint changes`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
var fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 2000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start)
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false)
|
||||
|
||||
fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "ref2"),
|
||||
credentialsFile: "file2")
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 2)) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `throttles fingerprint recheck while terminal blocked`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
var calls = 0
|
||||
let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting {
|
||||
calls += 1
|
||||
return fingerprint
|
||||
}
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 30000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start)
|
||||
#expect(calls == 1)
|
||||
|
||||
// First blocked check is throttled (we already captured fingerprint at failure).
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(1)) == false)
|
||||
#expect(calls == 1)
|
||||
|
||||
// After the throttle window, it should re-read.
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false)
|
||||
#expect(calls == 2)
|
||||
|
||||
// Subsequent checks within the throttle window should not re-read again.
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(21)) == false)
|
||||
#expect(calls == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `terminal block is monotonic when transient failure is recorded`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 35000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start)
|
||||
ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start.addingTimeInterval(1))
|
||||
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false)
|
||||
#expect(UserDefaults.standard.bool(forKey: self.terminalBlockedKey) == true)
|
||||
#expect(UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `record success clears terminal block`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 5000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure(now: start)
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == false)
|
||||
|
||||
ClaudeOAuthRefreshFailureGate.recordSuccess()
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60)) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `transient backoff blocks until expiry then unblocks`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 60000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start)
|
||||
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(1)) == false)
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 5 - 1)) == false)
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 5 + 1)) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `transient backoff is exponential and capped`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
let fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 70000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start)
|
||||
// Second failure before the first window expires should double the backoff.
|
||||
let secondFailureAt = start.addingTimeInterval(1)
|
||||
ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: secondFailureAt)
|
||||
#expect(ClaudeOAuthRefreshFailureGate
|
||||
.shouldAttempt(now: secondFailureAt.addingTimeInterval(60 * 10 - 1)) == false)
|
||||
#expect(ClaudeOAuthRefreshFailureGate
|
||||
.shouldAttempt(now: secondFailureAt.addingTimeInterval(60 * 10 + 1)) == true)
|
||||
|
||||
ClaudeOAuthRefreshFailureGate.resetInMemoryStateForTesting()
|
||||
for _ in 0..<20 {
|
||||
ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start)
|
||||
}
|
||||
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 6 - 1)) == false)
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(60 * 60 * 6 + 1)) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `transient backoff unblocks early when fingerprint changes`() {
|
||||
ClaudeOAuthRefreshFailureGate.resetForTesting()
|
||||
defer { ClaudeOAuthRefreshFailureGate.resetForTesting() }
|
||||
|
||||
var fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
persistentRefHash: "ref1"),
|
||||
credentialsFile: "file1")
|
||||
ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting { fingerprint }
|
||||
defer { ClaudeOAuthRefreshFailureGate.setFingerprintProviderOverrideForTesting(nil) }
|
||||
|
||||
let start = Date(timeIntervalSince1970: 80000)
|
||||
ClaudeOAuthRefreshFailureGate.recordTransientFailure(now: start)
|
||||
|
||||
// Still blocked while timer is active and fingerprint unchanged.
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(20)) == false)
|
||||
|
||||
fingerprint = ClaudeOAuthRefreshFailureGate.AuthFingerprint(
|
||||
keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
|
||||
modifiedAt: 2,
|
||||
createdAt: 2,
|
||||
persistentRefHash: "ref2"),
|
||||
credentialsFile: "file2")
|
||||
|
||||
// Even though the 5-minute cooldown window hasn't elapsed, a fingerprint change should unblock.
|
||||
#expect(ClaudeOAuthRefreshFailureGate.shouldAttempt(now: start.addingTimeInterval(40)) == true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user