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

462 lines
22 KiB
Swift

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"))
}
}