1511 lines
62 KiB
Swift
1511 lines
62 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBar
|
|
@testable import CodexBarCore
|
|
|
|
struct ClaudeUsageTests {
|
|
private actor AsyncCounter {
|
|
private var value = 0
|
|
|
|
func increment() -> Int {
|
|
self.value += 1
|
|
return self.value
|
|
}
|
|
|
|
func current() -> Int {
|
|
self.value
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `parses usage JSON with sonnet limit`() {
|
|
let json = """
|
|
{
|
|
"ok": true,
|
|
"session_5h": { "pct_used": 1, "resets": "11am (Europe/Vienna)" },
|
|
"week_all_models": { "pct_used": 8, "resets": "Nov 21 at 5am (Europe/Vienna)" },
|
|
"week_sonnet": { "pct_used": 0, "resets": "Nov 21 at 5am (Europe/Vienna)" }
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let snap = ClaudeUsageFetcher.parse(json: data)
|
|
#expect(snap != nil)
|
|
#expect(snap?.primary.usedPercent == 1)
|
|
#expect(snap?.primary.windowMinutes == 300)
|
|
#expect(snap?.secondary?.usedPercent == 8)
|
|
#expect(snap?.secondary?.windowMinutes == 10080)
|
|
#expect(snap?.primary.resetDescription == "11am (Europe/Vienna)")
|
|
}
|
|
|
|
@Test
|
|
func `oauth delegated retry retries once then succeeds`() async throws {
|
|
let loadCounter = AsyncCounter()
|
|
let delegatedCounter = AsyncCounter()
|
|
let usageResponse = try Self.makeOAuthUsageResponse()
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true)
|
|
|
|
let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse }
|
|
let delegatedOverride: (@Sendable (
|
|
Date,
|
|
TimeInterval,
|
|
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in
|
|
_ = await delegatedCounter.increment()
|
|
return .attemptedSucceeded
|
|
}
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
let call = await loadCounter.increment()
|
|
if call == 1 {
|
|
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
|
|
}
|
|
return ClaudeOAuthCredentials(
|
|
accessToken: "fresh-token",
|
|
refreshToken: "refresh-token",
|
|
expiresAt: Date(timeIntervalSinceNow: 3600),
|
|
scopes: ["user:profile"],
|
|
rateLimitTier: nil)
|
|
}
|
|
|
|
let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
|
try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: {
|
|
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
|
|
delegatedOverride,
|
|
operation: {
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride
|
|
.withValue(loadCredsOverride, operation: {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
})
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
#expect(await loadCounter.current() == 2)
|
|
#expect(await delegatedCounter.current() == 1)
|
|
#expect(snapshot.primary.usedPercent == 7)
|
|
#expect(snapshot.secondary?.usedPercent == 21)
|
|
}
|
|
|
|
@Test
|
|
func `oauth delegated retry second attempt still expired fails cleanly`() async throws {
|
|
let loadCounter = AsyncCounter()
|
|
let delegatedCounter = AsyncCounter()
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true)
|
|
|
|
do {
|
|
let delegatedOverride: (@Sendable (
|
|
Date,
|
|
TimeInterval,
|
|
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in
|
|
_ = await delegatedCounter.increment()
|
|
return .attemptedSucceeded
|
|
}
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
_ = await loadCounter.increment()
|
|
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
|
|
}
|
|
|
|
_ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
|
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
|
|
delegatedOverride,
|
|
operation: {
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
|
|
loadCredsOverride,
|
|
operation: {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
})
|
|
})
|
|
}
|
|
}
|
|
Issue.record("Expected delegated retry to fail when credentials remain expired")
|
|
} catch let error as ClaudeUsageError {
|
|
guard case let .oauthFailed(message) = error else {
|
|
Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("delegated Claude CLI refresh"))
|
|
} catch {
|
|
Issue.record("Expected ClaudeUsageError, got \(error)")
|
|
}
|
|
|
|
#expect(await loadCounter.current() == 2)
|
|
#expect(await delegatedCounter.current() == 1)
|
|
}
|
|
|
|
@Test
|
|
func `oauth delegated retry auto mode cli unavailable fails fast`() async throws {
|
|
let loadCounter = AsyncCounter()
|
|
let delegatedCounter = AsyncCounter()
|
|
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true)
|
|
|
|
let delegatedOverride: (@Sendable (Date, TimeInterval, [String: String]) async
|
|
-> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in
|
|
_ = await delegatedCounter.increment()
|
|
return .cliUnavailable
|
|
}
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
_ = await loadCounter.increment()
|
|
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
|
|
}
|
|
|
|
do {
|
|
_ = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
|
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
|
|
delegatedOverride,
|
|
operation: {
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
|
|
loadCredsOverride,
|
|
operation: {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
})
|
|
})
|
|
}
|
|
}
|
|
Issue.record("Expected delegated retry to fail fast when CLI is unavailable")
|
|
} catch let error as ClaudeUsageError {
|
|
guard case let .oauthFailed(message) = error else {
|
|
Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("Claude CLI is not available"))
|
|
} catch {
|
|
Issue.record("Expected ClaudeUsageError, got \(error)")
|
|
}
|
|
|
|
// Auto-mode: should not attempt a second credential load.
|
|
#expect(await loadCounter.current() == 1)
|
|
#expect(await delegatedCounter.current() == 1)
|
|
}
|
|
|
|
@Test
|
|
func `oauth delegated retry auto mode attempted failed then non interactive reload succeeds`() async throws {
|
|
let loadCounter = AsyncCounter()
|
|
let delegatedCounter = AsyncCounter()
|
|
let usageResponse = try Self.makeOAuthUsageResponse()
|
|
|
|
final class FlagBox: @unchecked Sendable {
|
|
var allowKeychainPromptFlags: [Bool] = []
|
|
}
|
|
let flags = FlagBox()
|
|
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true)
|
|
|
|
let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse }
|
|
let delegatedOverride: (@Sendable (Date, TimeInterval, [String: String]) async
|
|
-> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in
|
|
_ = await delegatedCounter.increment()
|
|
return .attemptedFailed("no-change")
|
|
}
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, allowKeychainPrompt, _ in
|
|
flags.allowKeychainPromptFlags.append(allowKeychainPrompt)
|
|
let call = await loadCounter.increment()
|
|
if call == 1 {
|
|
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
|
|
}
|
|
return ClaudeOAuthCredentials(
|
|
accessToken: "fresh-token",
|
|
refreshToken: "refresh-token",
|
|
expiresAt: Date(timeIntervalSinceNow: 3600),
|
|
scopes: ["user:profile"],
|
|
rateLimitTier: nil)
|
|
}
|
|
|
|
let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderInteractionContext.$current.withValue(.userInitiated) {
|
|
try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride, operation: {
|
|
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
|
|
delegatedOverride,
|
|
operation: {
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride
|
|
.withValue(loadCredsOverride, operation: {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
})
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
#expect(await loadCounter.current() == 2)
|
|
#expect(await delegatedCounter.current() == 1)
|
|
#expect(snapshot.primary.usedPercent == 7)
|
|
|
|
// User-initiated repair: if the delegated refresh couldn't sync silently, we may allow an interactive prompt
|
|
// on the retry to help recovery.
|
|
#expect(flags.allowKeychainPromptFlags.count == 2)
|
|
#expect(flags.allowKeychainPromptFlags[1] == true)
|
|
}
|
|
|
|
@Test
|
|
func `oauth delegated retry only on user action background suppresses delegation`() async throws {
|
|
let loadCounter = AsyncCounter()
|
|
let delegatedCounter = AsyncCounter()
|
|
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true)
|
|
|
|
let delegatedOverride: (@Sendable (
|
|
Date,
|
|
TimeInterval,
|
|
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in
|
|
_ = await delegatedCounter.increment()
|
|
return .attemptedSucceeded
|
|
}
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
_ = await loadCounter.increment()
|
|
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
|
|
}
|
|
|
|
do {
|
|
_ = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
|
.securityFramework,
|
|
operation: {
|
|
try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderInteractionContext.$current.withValue(.background) {
|
|
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
|
|
delegatedOverride)
|
|
{
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
|
|
loadCredsOverride)
|
|
{
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
Issue.record("Expected delegated refresh to be suppressed in background")
|
|
} catch let error as ClaudeUsageError {
|
|
guard case let .oauthFailed(message) = error else {
|
|
Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("background repair is suppressed"))
|
|
} catch {
|
|
Issue.record("Expected ClaudeUsageError, got \(error)")
|
|
}
|
|
|
|
#expect(await loadCounter.current() == 1)
|
|
#expect(await delegatedCounter.current() == 0)
|
|
}
|
|
|
|
@Test
|
|
func `oauth delegated retry never background suppresses delegation even for CLI`() async throws {
|
|
let loadCounter = AsyncCounter()
|
|
let delegatedCounter = AsyncCounter()
|
|
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true,
|
|
allowBackgroundDelegatedRefresh: true)
|
|
|
|
let delegatedOverride: (@Sendable (
|
|
Date,
|
|
TimeInterval,
|
|
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in
|
|
_ = await delegatedCounter.increment()
|
|
return .attemptedSucceeded
|
|
}
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
_ = await loadCounter.increment()
|
|
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
|
|
}
|
|
|
|
do {
|
|
_ = try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
|
.securityFramework,
|
|
operation: {
|
|
try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) {
|
|
try await ProviderInteractionContext.$current.withValue(.background) {
|
|
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
|
|
delegatedOverride)
|
|
{
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
|
|
loadCredsOverride)
|
|
{
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
Issue.record("Expected delegated refresh to be suppressed for prompt policy 'never'")
|
|
} catch let error as ClaudeUsageError {
|
|
guard case let .oauthFailed(message) = error else {
|
|
Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("Delegated refresh is disabled by 'never' keychain policy"))
|
|
} catch {
|
|
Issue.record("Expected ClaudeUsageError, got \(error)")
|
|
}
|
|
|
|
#expect(await loadCounter.current() == 1)
|
|
#expect(await delegatedCounter.current() == 0)
|
|
}
|
|
|
|
@Test
|
|
func `oauth bootstrap only on user action background startup allows interactive read when no cache`() async throws {
|
|
final class FlagBox: @unchecked Sendable {
|
|
var allowKeychainPromptFlags: [Bool] = []
|
|
var allowBackgroundPromptBootstrapFlags: [Bool] = []
|
|
}
|
|
|
|
let flags = FlagBox()
|
|
let usageResponse = try Self.makeOAuthUsageResponse()
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true,
|
|
allowStartupBootstrapPrompt: true)
|
|
|
|
let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse }
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, allowKeychainPrompt, _ in
|
|
flags.allowKeychainPromptFlags.append(allowKeychainPrompt)
|
|
flags.allowBackgroundPromptBootstrapFlags.append(ClaudeOAuthCredentialsStore.allowBackgroundPromptBootstrap)
|
|
return ClaudeOAuthCredentials(
|
|
accessToken: "fresh-token",
|
|
refreshToken: "refresh-token",
|
|
expiresAt: Date(timeIntervalSinceNow: 3600),
|
|
scopes: ["user:profile"],
|
|
rateLimitTier: nil)
|
|
}
|
|
|
|
let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderRefreshContext.$current.withValue(.startup) {
|
|
try await ProviderInteractionContext.$current.withValue(.background) {
|
|
try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(false) {
|
|
try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#expect(flags.allowKeychainPromptFlags == [true])
|
|
#expect(flags.allowBackgroundPromptBootstrapFlags == [true])
|
|
#expect(snapshot.primary.usedPercent == 7)
|
|
}
|
|
|
|
@Test
|
|
func `oauth delegated retry only on user action background allows delegation for CLI`() async throws {
|
|
let loadCounter = AsyncCounter()
|
|
let delegatedCounter = AsyncCounter()
|
|
let usageResponse = try Self.makeOAuthUsageResponse()
|
|
|
|
final class FlagBox: @unchecked Sendable {
|
|
var allowKeychainPromptFlags: [Bool] = []
|
|
}
|
|
let flags = FlagBox()
|
|
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: false,
|
|
allowBackgroundDelegatedRefresh: true)
|
|
|
|
let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse }
|
|
let delegatedOverride: (@Sendable (
|
|
Date,
|
|
TimeInterval,
|
|
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in
|
|
_ = await delegatedCounter.increment()
|
|
return .attemptedSucceeded
|
|
}
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, allowKeychainPrompt, _ in
|
|
flags.allowKeychainPromptFlags.append(allowKeychainPrompt)
|
|
let call = await loadCounter.increment()
|
|
if call == 1 {
|
|
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
|
|
}
|
|
return ClaudeOAuthCredentials(
|
|
accessToken: "fresh-token",
|
|
refreshToken: "refresh-token",
|
|
expiresAt: Date(timeIntervalSinceNow: 3600),
|
|
scopes: ["user:profile"],
|
|
rateLimitTier: nil)
|
|
}
|
|
|
|
let snapshot = try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderInteractionContext.$current.withValue(.background) {
|
|
try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
|
|
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(delegatedOverride) {
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#expect(await loadCounter.current() == 2)
|
|
#expect(await delegatedCounter.current() == 1)
|
|
#expect(snapshot.primary.usedPercent == 7)
|
|
#expect(flags.allowKeychainPromptFlags.allSatisfy { !$0 })
|
|
}
|
|
|
|
@Test
|
|
func `parses usage JSON when weekly missing`() {
|
|
let json = """
|
|
{
|
|
"ok": true,
|
|
"session_5h": { "pct_used": 4, "resets": "11am (Europe/Vienna)" }
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let snap = ClaudeUsageFetcher.parse(json: data)
|
|
#expect(snap != nil)
|
|
#expect(snap?.primary.usedPercent == 4)
|
|
#expect(snap?.secondary == nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses legacy opus and account`() {
|
|
let json = """
|
|
{
|
|
"ok": true,
|
|
"session_5h": { "pct_used": 2, "resets": "10:59pm (Europe/Vienna)" },
|
|
"week_all_models": { "pct_used": 13, "resets": "Nov 21 at 4:59am (Europe/Vienna)" },
|
|
"week_opus": { "pct_used": 0, "resets": "" },
|
|
"account_email": " steipete@gmail.com ",
|
|
"account_org": ""
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let snap = ClaudeUsageFetcher.parse(json: data)
|
|
#expect(snap?.opus?.usedPercent == 0)
|
|
#expect(snap?.opus?.windowMinutes == 10080)
|
|
#expect(snap?.opus?.resetDescription?.isEmpty == true)
|
|
#expect(snap?.accountEmail == "steipete@gmail.com")
|
|
#expect(snap?.accountOrganization == nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses usage JSON when only sonnet limit is present`() {
|
|
let json = """
|
|
{
|
|
"ok": true,
|
|
"session_5h": { "pct_used": 3, "resets": "11am (Europe/Vienna)" },
|
|
"week_all_models": { "pct_used": 9, "resets": "Nov 21 at 5am (Europe/Vienna)" },
|
|
"week_sonnet_only": { "pct_used": 12, "resets": "Nov 22 at 5am (Europe/Vienna)" }
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let snap = ClaudeUsageFetcher.parse(json: data)
|
|
#expect(snap?.secondary?.usedPercent == 9)
|
|
#expect(snap?.opus?.usedPercent == 12)
|
|
#expect(snap?.opus?.resetDescription == "Nov 22 at 5am (Europe/Vienna)")
|
|
}
|
|
|
|
@Test
|
|
func `trims account fields`() throws {
|
|
let cases: [[String: String?]] = [
|
|
["email": " steipete@gmail.com ", "org": " Org "],
|
|
["email": "", "org": " Claude Max Account "],
|
|
["email": nil, "org": " "],
|
|
]
|
|
|
|
for entry in cases {
|
|
var payload = [
|
|
"ok": true,
|
|
"session_5h": ["pct_used": 0, "resets": ""],
|
|
"week_all_models": ["pct_used": 0, "resets": ""],
|
|
] as [String: Any]
|
|
if let email = entry["email"] {
|
|
payload["account_email"] = email
|
|
}
|
|
if let org = entry["org"] {
|
|
payload["account_org"] = org
|
|
}
|
|
let data = try JSONSerialization.data(withJSONObject: payload)
|
|
let snap = ClaudeUsageFetcher.parse(json: data)
|
|
let emailRaw: String? = entry["email"] ?? String?.none
|
|
let expectedEmail = emailRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalizedEmail = (expectedEmail?.isEmpty ?? true) ? nil : expectedEmail
|
|
#expect(snap?.accountEmail == normalizedEmail)
|
|
let orgRaw: String? = entry["org"] ?? String?.none
|
|
let expectedOrg = orgRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalizedOrg = (expectedOrg?.isEmpty ?? true) ? nil : expectedOrg
|
|
#expect(snap?.accountOrganization == normalizedOrg)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `live claude fetch PTY`() async throws {
|
|
guard ProcessInfo.processInfo.environment["LIVE_CLAUDE_FETCH"] == "1" else {
|
|
return
|
|
}
|
|
let fetcher = ClaudeUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0), dataSource: .cli)
|
|
do {
|
|
let snap = try await fetcher.loadLatestUsage()
|
|
let opusUsed = snap.opus?.usedPercent ?? -1
|
|
let weeklyUsed = snap.secondary?.usedPercent ?? -1
|
|
let email = snap.accountEmail ?? "nil"
|
|
let org = snap.accountOrganization ?? "nil"
|
|
print(
|
|
"""
|
|
Live Claude usage (PTY):
|
|
session used \(snap.primary.usedPercent)%
|
|
week used \(weeklyUsed)%
|
|
opus \(opusUsed)%
|
|
email \(email) org \(org)
|
|
""")
|
|
#expect(snap.primary.usedPercent >= 0)
|
|
} catch {
|
|
// Dump raw CLI text captured via `script` to help debug.
|
|
let raw = try Self.captureClaudeUsageRaw(timeout: 15)
|
|
print("RAW CLAUDE OUTPUT BEGIN\n\(raw)\nRAW CLAUDE OUTPUT END")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private static func captureClaudeUsageRaw(timeout: TimeInterval) throws -> String {
|
|
let process = Process()
|
|
process.launchPath = "/usr/bin/script"
|
|
process.arguments = [
|
|
"-q",
|
|
"/dev/null",
|
|
"claude",
|
|
"/usage",
|
|
"--allowed-tools",
|
|
"",
|
|
]
|
|
let pipe = Pipe()
|
|
process.standardOutput = pipe
|
|
process.standardError = Pipe()
|
|
process.standardInput = nil
|
|
|
|
try process.run()
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
|
|
if process.isRunning {
|
|
process.terminate()
|
|
}
|
|
}
|
|
process.waitUntilExit()
|
|
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
return String(data: data, encoding: .utf8) ?? ""
|
|
}
|
|
|
|
// MARK: - Web API tests
|
|
|
|
@Test
|
|
func `live claude fetch web API`() async throws {
|
|
// Set LIVE_CLAUDE_WEB_FETCH=1 to run this test with real browser cookies
|
|
guard ProcessInfo.processInfo.environment["LIVE_CLAUDE_WEB_FETCH"] == "1" else {
|
|
return
|
|
}
|
|
let fetcher = ClaudeUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0), dataSource: .web)
|
|
let snap = try await fetcher.loadLatestUsage()
|
|
let weeklyUsed = snap.secondary?.usedPercent ?? -1
|
|
let opusUsed = snap.opus?.usedPercent ?? -1
|
|
print(
|
|
"""
|
|
Live Claude usage (Web API):
|
|
session used \(snap.primary.usedPercent)%
|
|
week used \(weeklyUsed)%
|
|
opus \(opusUsed)%
|
|
login method: \(snap.loginMethod ?? "nil")
|
|
""")
|
|
#expect(snap.primary.usedPercent >= 0)
|
|
}
|
|
|
|
@Test
|
|
func `claude web API has session key check`() {
|
|
// Quick check that hasSessionKey returns a boolean (doesn't crash)
|
|
let hasKey = ClaudeWebAPIFetcher.hasSessionKey(browserDetection: BrowserDetection(cacheTTL: 0))
|
|
// We can't assert the value since it depends on the test environment
|
|
#expect(hasKey == true || hasKey == false)
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API usage response`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" },
|
|
"seven_day": { "utilization": 4, "resets_at": "2025-12-29T23:00:00.000Z" },
|
|
"seven_day_opus": { "utilization": 1 }
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data)
|
|
#expect(parsed.sessionPercentUsed == 9)
|
|
#expect(parsed.weeklyPercentUsed == 4)
|
|
#expect(parsed.opusPercentUsed == 1)
|
|
#expect(parsed.sessionResetsAt != nil)
|
|
#expect(parsed.weeklyResetsAt != nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API usage response when weekly missing`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data)
|
|
#expect(parsed.sessionPercentUsed == 9)
|
|
#expect(parsed.weeklyPercentUsed == nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API overage spend limit`() {
|
|
let json = """
|
|
{
|
|
"monthly_credit_limit": 2000,
|
|
"currency": "EUR",
|
|
"used_credits": 0,
|
|
"is_enabled": true
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let cost = ClaudeWebAPIFetcher._parseOverageSpendLimitForTesting(data)
|
|
#expect(cost != nil)
|
|
#expect(cost?.currencyCode == "EUR")
|
|
#expect(cost?.limit == 20)
|
|
#expect(cost?.used == 0)
|
|
#expect(cost?.period == "Monthly cap")
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API overage spend limit cents`() {
|
|
let json = """
|
|
{
|
|
"monthly_credit_limit": 12345,
|
|
"currency": "USD",
|
|
"used_credits": 6789,
|
|
"is_enabled": true
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let cost = ClaudeWebAPIFetcher._parseOverageSpendLimitForTesting(data)
|
|
#expect(cost?.currencyCode == "USD")
|
|
#expect(cost?.limit == 123.45)
|
|
#expect(cost?.used == 67.89)
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API organizations response`() throws {
|
|
let json = """
|
|
[
|
|
{ "uuid": "org-123", "name": "Example Org", "capabilities": [] }
|
|
]
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let org = try ClaudeWebAPIFetcher._parseOrganizationsResponseForTesting(data)
|
|
#expect(org.id == "org-123")
|
|
#expect(org.name == "Example Org")
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API organizations prefers chat capability over api only`() throws {
|
|
let json = """
|
|
[
|
|
{ "uuid": "org-api", "name": "API Org", "capabilities": ["api"] },
|
|
{ "uuid": "org-chat", "name": "Chat Org", "capabilities": ["chat"] }
|
|
]
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let org = try ClaudeWebAPIFetcher._parseOrganizationsResponseForTesting(data)
|
|
#expect(org.id == "org-chat")
|
|
#expect(org.name == "Chat Org")
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API organizations prefers hybrid chat org`() throws {
|
|
let json = """
|
|
[
|
|
{ "uuid": "org-api", "name": "API Org", "capabilities": ["api"] },
|
|
{ "uuid": "org-hybrid", "name": "Hybrid Org", "capabilities": ["api", "chat"] }
|
|
]
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let org = try ClaudeWebAPIFetcher._parseOrganizationsResponseForTesting(data)
|
|
#expect(org.id == "org-hybrid")
|
|
#expect(org.name == "Hybrid Org")
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API account info`() {
|
|
let json = """
|
|
{
|
|
"email_address": "steipete@gmail.com",
|
|
"memberships": [
|
|
{
|
|
"organization": {
|
|
"uuid": "org-123",
|
|
"name": "Example Org",
|
|
"rate_limit_tier": "default_claude_max_20x",
|
|
"billing_type": "stripe_subscription"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let info = ClaudeWebAPIFetcher._parseAccountInfoForTesting(data, orgId: "org-123")
|
|
#expect(info?.email == "steipete@gmail.com")
|
|
#expect(info?.loginMethod == "Claude Max 20x")
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API account info selects matching org`() {
|
|
let json = """
|
|
{
|
|
"email_address": "steipete@gmail.com",
|
|
"memberships": [
|
|
{
|
|
"organization": {
|
|
"uuid": "org-other",
|
|
"name": "Other Org",
|
|
"rate_limit_tier": "claude_pro",
|
|
"billing_type": "stripe_subscription"
|
|
}
|
|
},
|
|
{
|
|
"organization": {
|
|
"uuid": "org-123",
|
|
"name": "Example Org",
|
|
"rate_limit_tier": "claude_team",
|
|
"billing_type": "stripe_subscription"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let info = ClaudeWebAPIFetcher._parseAccountInfoForTesting(data, orgId: "org-123")
|
|
#expect(info?.loginMethod == "Claude Team")
|
|
}
|
|
|
|
@Test
|
|
func `parses claude web API account info falls back to first membership`() {
|
|
let json = """
|
|
{
|
|
"email_address": "steipete@gmail.com",
|
|
"memberships": [
|
|
{
|
|
"organization": {
|
|
"uuid": "org-first",
|
|
"name": "First Org",
|
|
"rate_limit_tier": "claude_enterprise",
|
|
"billing_type": "invoice"
|
|
}
|
|
},
|
|
{
|
|
"organization": {
|
|
"uuid": "org-second",
|
|
"name": "Second Org",
|
|
"rate_limit_tier": "claude_pro",
|
|
"billing_type": "stripe_subscription"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let info = ClaudeWebAPIFetcher._parseAccountInfoForTesting(data, orgId: nil)
|
|
#expect(info?.loginMethod == "Claude Enterprise")
|
|
}
|
|
|
|
@Test
|
|
func `claude usage fetcher init with data sources`() {
|
|
// Verify we can create fetchers with both configurations
|
|
let browserDetection = BrowserDetection(cacheTTL: 0)
|
|
let defaultFetcher = ClaudeUsageFetcher(browserDetection: browserDetection)
|
|
let webFetcher = ClaudeUsageFetcher(browserDetection: browserDetection, dataSource: .web)
|
|
let cliFetcher = ClaudeUsageFetcher(browserDetection: browserDetection, dataSource: .cli)
|
|
// Both should be valid instances (no crashes)
|
|
let defaultVersion = defaultFetcher.detectVersion()
|
|
let webVersion = webFetcher.detectVersion()
|
|
let cliVersion = cliFetcher.detectVersion()
|
|
#expect(defaultVersion?.isEmpty != true)
|
|
#expect(webVersion?.isEmpty != true)
|
|
#expect(cliVersion?.isEmpty != true)
|
|
}
|
|
}
|
|
|
|
extension ClaudeUsageTests {
|
|
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))
|
|
}
|
|
}
|
|
|
|
struct ClaudeOAuthUsageMappingTests {
|
|
@Test
|
|
func `oauth usage falls back to weekly window when five hour is absent`() throws {
|
|
let json = """
|
|
{
|
|
"seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" },
|
|
"seven_day_sonnet": { "utilization": 17, "resets_at": "2025-12-29T23:00:00.000Z" }
|
|
}
|
|
"""
|
|
let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
|
|
#expect(snapshot.primary.usedPercent == 42)
|
|
#expect(snapshot.primary.windowMinutes == 7 * 24 * 60)
|
|
#expect(snapshot.secondary?.usedPercent == 42)
|
|
#expect(snapshot.opus?.usedPercent == 17)
|
|
}
|
|
|
|
@Test
|
|
func `oauth usage falls back when five hour has no utilization`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "resets_at": "2025-12-23T16:00:00.000Z" },
|
|
"seven_day": { "utilization": 9, "resets_at": "2025-12-29T23:00:00.000Z" }
|
|
}
|
|
"""
|
|
let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
|
|
#expect(snapshot.primary.usedPercent == 9)
|
|
#expect(snapshot.primary.windowMinutes == 7 * 24 * 60)
|
|
}
|
|
|
|
@Test
|
|
func `oauth usage throws when no usable windows are present`() {
|
|
let json = "{}"
|
|
|
|
#expect(throws: ClaudeUsageError.self) {
|
|
try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suite(.serialized)
|
|
struct ClaudeAutoFetcherCharacterizationTests {
|
|
private final class RequestLog: @unchecked Sendable {
|
|
private var paths: [String] = []
|
|
private let lock = NSLock()
|
|
|
|
func append(_ path: String) {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
self.paths.append(path)
|
|
}
|
|
|
|
func current() -> [String] {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
return self.paths
|
|
}
|
|
}
|
|
|
|
private final class InvocationLog: @unchecked Sendable {
|
|
let url: URL
|
|
|
|
init(url: URL) {
|
|
self.url = url
|
|
}
|
|
|
|
func contents() -> String {
|
|
(try? String(contentsOf: self.url, encoding: .utf8)) ?? ""
|
|
}
|
|
}
|
|
|
|
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 static func makeFakeClaudeCLI(logURL: URL) throws -> URL {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("claude-auto-\(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)'
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
*"/usage"*)
|
|
printf 'usage\\n' >> "$LOG_FILE"
|
|
cat <<'EOF'
|
|
Current session
|
|
93% left
|
|
Dec 23 at 4:00PM
|
|
Current week (all models)
|
|
79% left
|
|
Dec 29 at 11:00PM
|
|
EOF
|
|
;;
|
|
*"/status"*)
|
|
printf 'status\\n' >> "$LOG_FILE"
|
|
cat <<'EOF'
|
|
Account: cli@example.com
|
|
Org: CLI Org
|
|
EOF
|
|
;;
|
|
esac
|
|
done
|
|
"""
|
|
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
|
|
try FileManager.default.setAttributes(
|
|
[.posixPermissions: NSNumber(value: Int16(0o755))],
|
|
ofItemAtPath: scriptURL.path)
|
|
return scriptURL
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
fileprivate static func makeJSONResponse(
|
|
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))
|
|
}
|
|
|
|
@Test
|
|
func `auto prefers OAuth even when web and CLI appear available`() async throws {
|
|
let usageResponse = try Self.makeOAuthUsageResponse()
|
|
let cliLogURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("claude-auto-cli-log-\(UUID().uuidString).txt")
|
|
let log = InvocationLog(url: cliLogURL)
|
|
let fakeCLI = try Self.makeFakeClaudeCLI(logURL: cliLogURL)
|
|
let webRequests = RequestLog()
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [
|
|
ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token",
|
|
ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile",
|
|
],
|
|
runtime: .app,
|
|
dataSource: .auto,
|
|
manualCookieHeader: "sessionKey=sk-ant-session-token")
|
|
|
|
try await ClaudeCLISession.withIsolatedSessionForTesting {
|
|
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) {
|
|
try await self.withClaudeWebStub(handler: { request in
|
|
webRequests.append(request.url?.path ?? "<missing>")
|
|
let url = try #require(request.url)
|
|
return Self.makeJSONResponse(url: url, body: "{}")
|
|
}, operation: {
|
|
let fetchOverride: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in
|
|
usageResponse
|
|
}
|
|
let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(
|
|
fetchOverride,
|
|
operation: {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
})
|
|
|
|
#expect(snapshot.primary.usedPercent == 7)
|
|
#expect(snapshot.secondary?.usedPercent == 21)
|
|
#expect(log.contents().isEmpty)
|
|
let requests = webRequests.current()
|
|
#expect(requests.isEmpty)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `app runtime auto prefers CLI before web when OAuth unavailable`() async throws {
|
|
let cliLogURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("claude-auto-web-log-\(UUID().uuidString).txt")
|
|
let log = InvocationLog(url: cliLogURL)
|
|
let fakeCLI = try Self.makeFakeClaudeCLI(logURL: cliLogURL)
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: ["CLAUDE_CLI_PATH": fakeCLI.path],
|
|
runtime: .app,
|
|
dataSource: .auto,
|
|
manualCookieHeader: "sessionKey=sk-ant-session-token")
|
|
|
|
try await ClaudeCLISession.withIsolatedSessionForTesting {
|
|
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) {
|
|
try await self.withNoOAuthCredentials {
|
|
try await self.withClaudeWebStub(handler: { request in
|
|
let url = try #require(request.url)
|
|
switch url.path {
|
|
case "/api/organizations":
|
|
return Self.makeJSONResponse(
|
|
url: url,
|
|
body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#)
|
|
case "/api/organizations/org-123/usage":
|
|
let body = """
|
|
{
|
|
"five_hour": { "utilization": 11, "resets_at": "2025-12-23T16:00:00.000Z" },
|
|
"seven_day": { "utilization": 22, "resets_at": "2025-12-29T23:00:00.000Z" },
|
|
"seven_day_opus": { "utilization": 33 }
|
|
}
|
|
"""
|
|
return Self.makeJSONResponse(
|
|
url: url,
|
|
body: body)
|
|
case "/api/account":
|
|
let body = """
|
|
{
|
|
"email_address": "web@example.com",
|
|
"memberships": [
|
|
{
|
|
"organization": {
|
|
"uuid": "org-123",
|
|
"name": "Test Org",
|
|
"rate_limit_tier": "claude_max",
|
|
"billing_type": "stripe"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
return Self.makeJSONResponse(
|
|
url: url,
|
|
body: body)
|
|
case "/api/organizations/org-123/overage_spend_limit":
|
|
let body = """
|
|
{"monthly_credit_limit":5000,"currency":"USD","used_credits":1200,"is_enabled":true}
|
|
"""
|
|
return Self.makeJSONResponse(
|
|
url: url,
|
|
body: body)
|
|
default:
|
|
return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
}, operation: {
|
|
let snapshot = try await fetcher.loadLatestUsage(model: "sonnet")
|
|
|
|
#expect(snapshot.rawText != nil)
|
|
#expect(log.contents().contains("usage"))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `CLI runtime auto prefers web before CLI when OAuth unavailable`() async throws {
|
|
let cliLogURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("claude-auto-cli-runtime-web-log-\(UUID().uuidString).txt")
|
|
let log = InvocationLog(url: cliLogURL)
|
|
let fakeCLI = try Self.makeFakeClaudeCLI(logURL: cliLogURL)
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: ["CLAUDE_CLI_PATH": fakeCLI.path],
|
|
runtime: .cli,
|
|
dataSource: .auto,
|
|
manualCookieHeader: "sessionKey=sk-ant-session-token")
|
|
|
|
try await ClaudeCLISession.withIsolatedSessionForTesting {
|
|
try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) {
|
|
try await self.withNoOAuthCredentials {
|
|
try await self.withClaudeWebStub(handler: { request in
|
|
let url = try #require(request.url)
|
|
switch url.path {
|
|
case "/api/organizations":
|
|
return Self.makeJSONResponse(
|
|
url: url,
|
|
body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#)
|
|
case "/api/organizations/org-123/usage":
|
|
let body = """
|
|
{
|
|
"five_hour": { "utilization": 11, "resets_at": "2025-12-23T16:00:00.000Z" },
|
|
"seven_day": { "utilization": 22, "resets_at": "2025-12-29T23:00:00.000Z" },
|
|
"seven_day_opus": { "utilization": 33 }
|
|
}
|
|
"""
|
|
return Self.makeJSONResponse(url: url, body: body)
|
|
case "/api/account":
|
|
let body = """
|
|
{
|
|
"email_address": "web@example.com",
|
|
"memberships": [
|
|
{
|
|
"organization": {
|
|
"uuid": "org-123",
|
|
"name": "Test Org",
|
|
"rate_limit_tier": "claude_max",
|
|
"billing_type": "stripe"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
return Self.makeJSONResponse(url: url, body: body)
|
|
case "/api/organizations/org-123/overage_spend_limit":
|
|
let body = """
|
|
{"monthly_credit_limit":5000,"currency":"USD","used_credits":1200,"is_enabled":true}
|
|
"""
|
|
return Self.makeJSONResponse(url: url, body: body)
|
|
default:
|
|
return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
}, operation: {
|
|
let snapshot = try await fetcher.loadLatestUsage(model: "sonnet")
|
|
|
|
#expect(snapshot.primary.usedPercent == 11)
|
|
#expect(snapshot.secondary?.usedPercent == 22)
|
|
#expect(snapshot.opus?.usedPercent == 33)
|
|
#expect(snapshot.accountEmail == "web@example.com")
|
|
#expect(snapshot.loginMethod == "Claude Max")
|
|
#expect(log.contents().isEmpty)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `app runtime auto fails deterministically when planner has no executable steps`() async {
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: ["CLAUDE_CLI_PATH": "/definitely/missing/claude"],
|
|
runtime: .app,
|
|
dataSource: .auto,
|
|
manualCookieHeader: "foo=bar")
|
|
|
|
await self.withNoOAuthCredentials {
|
|
await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") {
|
|
do {
|
|
_ = try await fetcher.loadLatestUsage(model: "sonnet")
|
|
Issue.record("Expected app auto no-source fetch to fail.")
|
|
} catch let error as ClaudeUsageError {
|
|
#expect(error.localizedDescription.contains("Claude planner produced no executable steps."))
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `CLI runtime auto fails deterministically when planner has no executable steps`() async {
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: ["CLAUDE_CLI_PATH": "/definitely/missing/claude"],
|
|
runtime: .cli,
|
|
dataSource: .auto,
|
|
manualCookieHeader: "foo=bar")
|
|
|
|
await self.withNoOAuthCredentials {
|
|
await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/definitely/missing/claude") {
|
|
do {
|
|
_ = try await fetcher.loadLatestUsage(model: "sonnet")
|
|
Issue.record("Expected CLI auto no-source fetch to fail.")
|
|
} catch let error as ClaudeUsageError {
|
|
#expect(error.localizedDescription.contains("Claude planner produced no executable steps."))
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class ClaudeAutoFetcherStubURLProtocol: URLProtocol {
|
|
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
|
|
|
override static func canInit(with request: URLRequest) -> Bool {
|
|
request.url?.host == "claude.ai"
|
|
}
|
|
|
|
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() {}
|
|
}
|
|
|
|
extension ClaudeAutoFetcherCharacterizationTests {
|
|
@Test
|
|
func `web fetcher uses configured target organization`() async throws {
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
dataSource: .web,
|
|
manualCookieHeader: "sessionKey=sk-ant-session-token",
|
|
webOrganizationID: "org-team")
|
|
|
|
try await self.withClaudeWebStub(handler: { request in
|
|
let url = try #require(request.url)
|
|
switch url.path {
|
|
case "/api/organizations":
|
|
let body = """
|
|
[
|
|
{ "uuid": "org-personal", "name": "Personal", "capabilities": ["chat"] },
|
|
{ "uuid": "org-team", "name": "Team Org", "capabilities": ["chat"] }
|
|
]
|
|
"""
|
|
return Self.makeJSONResponse(url: url, body: body)
|
|
case "/api/organizations/org-team/usage":
|
|
let body = """
|
|
{
|
|
"five_hour": { "utilization": 14, "resets_at": "2025-12-23T16:00:00.000Z" },
|
|
"seven_day": { "utilization": 28, "resets_at": "2025-12-29T23:00:00.000Z" }
|
|
}
|
|
"""
|
|
return Self.makeJSONResponse(url: url, body: body)
|
|
case "/api/account":
|
|
let body = """
|
|
{
|
|
"email_address": "linked@example.com",
|
|
"memberships": [
|
|
{
|
|
"organization": {
|
|
"uuid": "org-personal",
|
|
"name": "Personal",
|
|
"rate_limit_tier": "claude_max",
|
|
"billing_type": "stripe"
|
|
}
|
|
},
|
|
{
|
|
"organization": {
|
|
"uuid": "org-team",
|
|
"name": "Team Org",
|
|
"rate_limit_tier": "enterprise",
|
|
"billing_type": "invoice"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
return Self.makeJSONResponse(url: url, body: body)
|
|
case "/api/organizations/org-team/overage_spend_limit":
|
|
return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404)
|
|
default:
|
|
return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
}, operation: {
|
|
let snapshot = try await fetcher.loadLatestUsage(model: "sonnet")
|
|
|
|
#expect(snapshot.primary.usedPercent == 14)
|
|
#expect(snapshot.secondary?.usedPercent == 28)
|
|
#expect(snapshot.accountOrganization == "Team Org")
|
|
#expect(snapshot.accountEmail == "linked@example.com")
|
|
#expect(snapshot.loginMethod == "Claude Enterprise")
|
|
})
|
|
}
|
|
}
|
|
|
|
extension ClaudeUsageTests {
|
|
@Test
|
|
func `parses claude web API organizations honors target organization`() throws {
|
|
let json = """
|
|
[
|
|
{ "uuid": "org-personal", "name": "Personal", "capabilities": ["chat"] },
|
|
{ "uuid": "org-team", "name": "Team", "capabilities": ["chat"] }
|
|
]
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let org = try ClaudeWebAPIFetcher._parseOrganizationsResponseForTesting(
|
|
data,
|
|
targetOrganizationID: "org-team")
|
|
#expect(org.id == "org-team")
|
|
#expect(org.name == "Team")
|
|
}
|
|
|
|
@Test
|
|
func `oauth delegated retry experimental background respects only on user action suppression`() async throws {
|
|
let loadCounter = AsyncCounter()
|
|
let delegatedCounter = AsyncCounter()
|
|
let usageResponse = try Self.makeOAuthUsageResponse()
|
|
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true,
|
|
allowBackgroundDelegatedRefresh: false)
|
|
|
|
let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in usageResponse }
|
|
let delegatedOverride: (@Sendable (
|
|
Date,
|
|
TimeInterval,
|
|
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, _ in
|
|
_ = await delegatedCounter.increment()
|
|
return .attemptedSucceeded
|
|
}
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
_ = await loadCounter.increment()
|
|
throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI
|
|
}
|
|
|
|
await #expect(throws: ClaudeUsageError.self) {
|
|
try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
|
.securityCLIExperimental,
|
|
operation: {
|
|
try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderInteractionContext.$current.withValue(.background) {
|
|
try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) {
|
|
try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
|
|
try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue(
|
|
delegatedOverride)
|
|
{
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
|
|
loadCredsOverride)
|
|
{
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
#expect(await loadCounter.current() == 1)
|
|
#expect(await delegatedCounter.current() == 0)
|
|
}
|
|
|
|
@Test
|
|
func `oauth load experimental background fallback blocked propagates O auth failure`() async throws {
|
|
final class FlagBox: @unchecked Sendable {
|
|
var respectPromptCooldownFlags: [Bool] = []
|
|
}
|
|
let flags = FlagBox()
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true,
|
|
allowBackgroundDelegatedRefresh: false)
|
|
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, respectKeychainPromptCooldown in
|
|
flags.respectPromptCooldownFlags.append(respectKeychainPromptCooldown)
|
|
throw ClaudeOAuthCredentialsError.notFound
|
|
}
|
|
|
|
await #expect(throws: ClaudeUsageError.self) {
|
|
try await ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
|
|
.securityCLIExperimental,
|
|
operation: {
|
|
try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) {
|
|
try await ProviderInteractionContext.$current.withValue(.background) {
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
#expect(flags.respectPromptCooldownFlags == [true])
|
|
}
|
|
}
|