462 lines
22 KiB
Swift
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"))
|
|
}
|
|
}
|