460 lines
17 KiB
Swift
460 lines
17 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct ClaudeWebFetchDeadlineTests {
|
|
@Test
|
|
func `CLI auto descriptor defers browser probe and falls back after web deadline`() async throws {
|
|
let planningProbe = ClaudeWebPlanningAvailabilityProbe()
|
|
let webProbe = ClaudeWebDeadlineProbe()
|
|
let cliPath = try Self.makeLoggedInClaudeCLI()
|
|
defer { try? FileManager.default.removeItem(atPath: cliPath) }
|
|
let context = Self.makeContext(
|
|
sourceMode: .auto,
|
|
webTimeout: 0.01,
|
|
cookieSource: .auto,
|
|
env: ["CLAUDE_CLI_PATH": cliPath])
|
|
let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in
|
|
planningProbe.stallAndReportUnavailable()
|
|
}
|
|
let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in
|
|
await webProbe.waitUntilReleased()
|
|
return Self.makeClaudeUsage()
|
|
}
|
|
let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot =
|
|
{ _, _, _ in Self.makeClaudeStatus() }
|
|
|
|
let outcome = await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue(
|
|
availabilityOverride)
|
|
{
|
|
await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) {
|
|
await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) {
|
|
await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome(
|
|
context: context,
|
|
provider: .claude)
|
|
}
|
|
}
|
|
}
|
|
await webProbe.release()
|
|
let result = try outcome.result.get()
|
|
|
|
#expect(!planningProbe.wasInvoked)
|
|
#expect(result.strategyID == "claude.cli")
|
|
#expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"])
|
|
#expect(outcome.attempts.map(\.wasAvailable) == [true, true])
|
|
#expect(outcome.attempts.first?.errorDescription?.contains("Claude web usage fetch timed out") == true)
|
|
}
|
|
|
|
@Test
|
|
func `stalled app auto browser probe does not delay CLI success`() async throws {
|
|
let planningProbe = ClaudeWebPlanningAvailabilityProbe()
|
|
let cliPath = try Self.makeLoggedInClaudeCLI()
|
|
defer { try? FileManager.default.removeItem(atPath: cliPath) }
|
|
let context = Self.makeContext(
|
|
runtime: .app,
|
|
sourceMode: .auto,
|
|
webTimeout: 60,
|
|
cookieSource: .auto,
|
|
env: [
|
|
"CLAUDE_CLI_PATH": cliPath,
|
|
ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token",
|
|
])
|
|
let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in
|
|
planningProbe.stallAndReportUnavailable()
|
|
}
|
|
let oauthLoadOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
throw ClaudeUsageError.oauthFailed("stub OAuth failure")
|
|
}
|
|
let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot =
|
|
{ _, _, _ in Self.makeClaudeStatus() }
|
|
|
|
let outcome = await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue(
|
|
availabilityOverride)
|
|
{
|
|
await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) {
|
|
await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) {
|
|
await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome(
|
|
context: context,
|
|
provider: .claude)
|
|
}
|
|
}
|
|
}
|
|
let result = try outcome.result.get()
|
|
|
|
#expect(result.strategyID == "claude.cli")
|
|
#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"])
|
|
#expect(!planningProbe.wasInvoked)
|
|
}
|
|
|
|
@Test
|
|
func `caller cancellation during deferred app auto browser probe stops web fallback`() async {
|
|
let planningProbe = ClaudeWebPlanningAvailabilityProbe()
|
|
let webFetchProbe = ClaudeWebPlanningAvailabilityProbe()
|
|
let context = Self.makeContext(
|
|
runtime: .app,
|
|
sourceMode: .auto,
|
|
webTimeout: 60,
|
|
cookieSource: .auto,
|
|
env: [
|
|
"CLAUDE_CLI_PATH": "/usr/bin/true",
|
|
ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token",
|
|
])
|
|
let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in
|
|
planningProbe.stallAndReportUnavailable()
|
|
}
|
|
let oauthLoadOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
throw ClaudeUsageError.oauthFailed("stub OAuth failure")
|
|
}
|
|
let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in
|
|
throw ClaudeUsageError.parseFailed("stub CLI failure")
|
|
}
|
|
let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in
|
|
webFetchProbe.recordInvocation()
|
|
return Self.makeClaudeUsage()
|
|
}
|
|
|
|
let fetchTask = Task {
|
|
await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue(availabilityOverride) {
|
|
await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) {
|
|
await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) {
|
|
await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) {
|
|
await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome(
|
|
context: context,
|
|
provider: .claude)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
while !planningProbe.wasInvoked {
|
|
await Task.yield()
|
|
}
|
|
fetchTask.cancel()
|
|
let outcome = await fetchTask.value
|
|
planningProbe.release()
|
|
|
|
switch outcome.result {
|
|
case .success:
|
|
Issue.record("Expected caller cancellation to stop the deferred web fallback")
|
|
case let .failure(error):
|
|
#expect(error is CancellationError)
|
|
}
|
|
#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"])
|
|
#expect(webFetchProbe.invocationCount == 0)
|
|
}
|
|
|
|
@Test
|
|
func `app auto availability and fetch share one web deadline`() async {
|
|
let deadlineClock = ClaudeWebDeadlineClock()
|
|
let usageProbe = ClaudeWebDeadlineProbe()
|
|
let context = Self.makeContext(
|
|
runtime: .app,
|
|
sourceMode: .auto,
|
|
webTimeout: 1,
|
|
cookieSource: .auto)
|
|
let availabilityOverride: @Sendable (ProviderFetchContext, BrowserDetection) -> Bool = { _, _ in
|
|
deadlineClock.advance(by: .milliseconds(990))
|
|
return true
|
|
}
|
|
let strategy = ClaudeWebFetchStrategy(
|
|
browserDetection: context.browserDetection,
|
|
usageLoader: { _ in
|
|
await usageProbe.waitUntilReleased()
|
|
return Self.makeClaudeUsage()
|
|
},
|
|
deadlineNow: { deadlineClock.now() })
|
|
|
|
let available = await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue(
|
|
availabilityOverride)
|
|
{
|
|
await strategy.isAvailable(context)
|
|
}
|
|
#expect(available)
|
|
|
|
let startedAt = ContinuousClock.now
|
|
do {
|
|
_ = try await strategy.fetch(context)
|
|
Issue.record("Expected the stalled load to consume only the remaining web deadline")
|
|
} catch let error as ClaudeWebFetchStrategyError {
|
|
#expect(error == .timedOut(seconds: 1))
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
let elapsed = startedAt.duration(to: ContinuousClock.now)
|
|
await usageProbe.release()
|
|
|
|
#expect(elapsed < .milliseconds(300))
|
|
}
|
|
|
|
@Test
|
|
func `CLI auto timeout cancels web and falls back to CLI`() async throws {
|
|
let probe = ClaudeWebDeadlineProbe()
|
|
let web = Self.makeTimedOutWebStrategy(probe: probe)
|
|
let pipeline = ProviderFetchPipeline { _ in [web, ClaudeWebDeadlineCLIStrategy()] }
|
|
let context = Self.makeContext(sourceMode: .auto, webTimeout: 0.01)
|
|
|
|
let outcome = await pipeline.fetch(context: context, provider: .claude)
|
|
await probe.release()
|
|
let result = try outcome.result.get()
|
|
|
|
#expect(result.strategyID == "claude.cli")
|
|
#expect(outcome.attempts.map(\.strategyID) == ["claude.web", "claude.cli"])
|
|
#expect(outcome.attempts.first?.errorDescription?.contains("Claude web usage fetch timed out") == true)
|
|
}
|
|
|
|
@Test
|
|
func `explicit web timeout surfaces without CLI fallback`() async {
|
|
let probe = ClaudeWebDeadlineProbe()
|
|
let web = Self.makeTimedOutWebStrategy(probe: probe)
|
|
let pipeline = ProviderFetchPipeline { _ in [web, ClaudeWebDeadlineCLIStrategy()] }
|
|
let context = Self.makeContext(sourceMode: .web, webTimeout: 0.01)
|
|
|
|
let outcome = await pipeline.fetch(context: context, provider: .claude)
|
|
await probe.release()
|
|
|
|
switch outcome.result {
|
|
case .success:
|
|
Issue.record("Expected the explicit web deadline to fail")
|
|
case let .failure(error):
|
|
#expect(error as? ClaudeWebFetchStrategyError == .timedOut(seconds: 0.01))
|
|
}
|
|
#expect(outcome.attempts.map(\.strategyID) == ["claude.web"])
|
|
}
|
|
|
|
@Test
|
|
func `caller cancellation does not fall back to CLI`() async {
|
|
let probe = ClaudeWebDeadlineProbe()
|
|
let web = Self.makeTimedOutWebStrategy(probe: probe)
|
|
let pipeline = ProviderFetchPipeline { _ in [web, ClaudeWebDeadlineCLIStrategy()] }
|
|
let context = Self.makeContext(sourceMode: .auto, webTimeout: 60)
|
|
let fetchTask = Task {
|
|
await pipeline.fetch(context: context, provider: .claude)
|
|
}
|
|
|
|
await probe.waitUntilStarted()
|
|
fetchTask.cancel()
|
|
let outcome = await fetchTask.value
|
|
await probe.release()
|
|
|
|
switch outcome.result {
|
|
case .success:
|
|
Issue.record("Expected caller cancellation to stop the fetch pipeline")
|
|
case let .failure(error):
|
|
#expect(error is CancellationError)
|
|
}
|
|
#expect(outcome.attempts.map(\.strategyID) == ["claude.web"])
|
|
}
|
|
|
|
@Test
|
|
func `unsafe timeout is rejected before starting web work`() async {
|
|
let strategy = ClaudeWebFetchStrategy(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
usageLoader: { _ in
|
|
Issue.record("Unsafe timeout should be rejected before invoking the loader")
|
|
return Self.makeClaudeUsage()
|
|
})
|
|
|
|
for timeout in [-1, .nan, .infinity, .greatestFiniteMagnitude] {
|
|
do {
|
|
_ = try await strategy.fetch(Self.makeContext(sourceMode: .web, webTimeout: timeout))
|
|
Issue.record("Expected timeout \(timeout) to be rejected")
|
|
} catch let error as ClaudeWebFetchStrategyError {
|
|
#expect(error == .invalidTimeout)
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func makeTimedOutWebStrategy(probe: ClaudeWebDeadlineProbe) -> ClaudeWebFetchStrategy {
|
|
ClaudeWebFetchStrategy(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
usageLoader: { _ in
|
|
await probe.waitUntilReleased()
|
|
return self.makeClaudeUsage()
|
|
})
|
|
}
|
|
|
|
private static func makeLoggedInClaudeCLI() throws -> String {
|
|
let url = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("claude-auth-status-\(UUID().uuidString)")
|
|
let script = """
|
|
#!/bin/sh
|
|
printf '%s\\n' '{"loggedIn":true}'
|
|
"""
|
|
try Data(script.utf8).write(to: url)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
|
|
return url.path
|
|
}
|
|
|
|
private static func makeContext(
|
|
runtime: ProviderRuntime = .cli,
|
|
sourceMode: ProviderSourceMode,
|
|
webTimeout: TimeInterval,
|
|
cookieSource: ProviderCookieSource = .manual,
|
|
env: [String: String] = [:]) -> ProviderFetchContext
|
|
{
|
|
let browserDetection = BrowserDetection(cacheTTL: 0)
|
|
return ProviderFetchContext(
|
|
runtime: runtime,
|
|
sourceMode: sourceMode,
|
|
includeCredits: false,
|
|
webTimeout: webTimeout,
|
|
webDebugDumpHTML: false,
|
|
verbose: false,
|
|
env: env,
|
|
settings: ProviderSettingsSnapshot.make(claude: .init(
|
|
usageDataSource: sourceMode == .web ? .web : .auto,
|
|
webExtrasEnabled: false,
|
|
cookieSource: cookieSource,
|
|
manualCookieHeader: cookieSource == .manual ? "sessionKey=sk-ant-session-token" : nil)),
|
|
fetcher: UsageFetcher(),
|
|
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
|
|
browserDetection: browserDetection)
|
|
}
|
|
|
|
private static func makeClaudeUsage() -> ClaudeUsageSnapshot {
|
|
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)
|
|
}
|
|
|
|
private static func makeClaudeStatus() -> ClaudeStatusSnapshot {
|
|
ClaudeStatusSnapshot(
|
|
sessionPercentLeft: 80,
|
|
weeklyPercentLeft: nil,
|
|
opusPercentLeft: nil,
|
|
accountEmail: nil,
|
|
accountOrganization: nil,
|
|
loginMethod: nil,
|
|
primaryResetDescription: nil,
|
|
secondaryResetDescription: nil,
|
|
opusResetDescription: nil,
|
|
rawText: "stub")
|
|
}
|
|
}
|
|
|
|
private final class ClaudeWebPlanningAvailabilityProbe: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private let releaseSemaphore = DispatchSemaphore(value: 0)
|
|
private var invocations = 0
|
|
|
|
var invocationCount: Int {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
return self.invocations
|
|
}
|
|
|
|
var wasInvoked: Bool {
|
|
self.invocationCount > 0
|
|
}
|
|
|
|
func stallAndReportUnavailable() -> Bool {
|
|
self.recordInvocation()
|
|
_ = self.releaseSemaphore.wait(timeout: .now() + 1)
|
|
return false
|
|
}
|
|
|
|
func recordInvocation() {
|
|
self.lock.lock()
|
|
self.invocations += 1
|
|
self.lock.unlock()
|
|
}
|
|
|
|
func release() {
|
|
self.releaseSemaphore.signal()
|
|
}
|
|
}
|
|
|
|
private final class ClaudeWebDeadlineClock: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var instant = ContinuousClock.now
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
private actor ClaudeWebDeadlineProbe {
|
|
private var started = false
|
|
private var released = false
|
|
private var startWaiter: CheckedContinuation<Void, Never>?
|
|
private var releaseWaiter: CheckedContinuation<Void, Never>?
|
|
|
|
func waitUntilReleased() async {
|
|
if !self.started {
|
|
self.started = true
|
|
self.startWaiter?.resume()
|
|
self.startWaiter = nil
|
|
}
|
|
guard !self.released else { return }
|
|
await withCheckedContinuation { continuation in
|
|
self.releaseWaiter = continuation
|
|
}
|
|
}
|
|
|
|
func waitUntilStarted() async {
|
|
guard !self.started else { return }
|
|
await withCheckedContinuation { continuation in
|
|
self.startWaiter = continuation
|
|
}
|
|
}
|
|
|
|
func release() {
|
|
self.released = true
|
|
self.releaseWaiter?.resume()
|
|
self.releaseWaiter = nil
|
|
}
|
|
}
|
|
|
|
private struct ClaudeWebDeadlineCLIStrategy: ProviderFetchStrategy {
|
|
let id = "claude.cli"
|
|
let kind: ProviderFetchKind = .cli
|
|
|
|
func isAvailable(_: ProviderFetchContext) async -> Bool {
|
|
true
|
|
}
|
|
|
|
func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
|
|
self.makeResult(
|
|
usage: UsageSnapshot(
|
|
primary: RateWindow(
|
|
usedPercent: 20,
|
|
windowMinutes: 300,
|
|
resetsAt: nil,
|
|
resetDescription: nil),
|
|
secondary: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_800_000_100)),
|
|
sourceLabel: "claude")
|
|
}
|
|
|
|
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
|
false
|
|
}
|
|
}
|