From a3cac151f9c05b9a33822e9116a7363b6b63dd36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 05:27:42 +0100 Subject: [PATCH] test: isolate Claude CLI gating fixtures --- .../Host/PTY/TTYCommandRunner.swift | 212 ++++++++++++------ .../Claude/ClaudeCLIAuthStatusProbe.swift | 12 +- .../ClaudeBaselineCharacterizationTests.swift | 36 +-- .../ClaudeLoginRunnerTests.swift | 17 +- .../CodexBarTests/CodexLoginRunnerTests.swift | 10 +- .../CodexBarTests/KiroStatusProbeTests.swift | 6 +- .../CodexBarTests/TTYCommandRunnerTests.swift | 105 +++++---- TestsLinux/PlatformGatingTests.swift | 17 +- 8 files changed, 266 insertions(+), 149 deletions(-) diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index ff7c480749..4d80bbd189 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -8,101 +8,159 @@ import Musl import Foundation private enum TTYCommandRunnerActiveProcessRegistry { - private static let condition = NSCondition() - private nonisolated(unsafe) static var processes: [pid_t: ProcessInfo] = [:] - private nonisolated(unsafe) static var isShuttingDown = false - private nonisolated(unsafe) static var launchesInProgress = 0 - private struct ProcessInfo { let binary: String var processGroup: pid_t? } - @discardableResult + private final class State: @unchecked Sendable { + private let condition = NSCondition() + private var processes: [pid_t: ProcessInfo] = [:] + private var isShuttingDown = false + private var launchesInProgress = 0 + + @discardableResult + func register(pid: pid_t, binary: String) -> Bool { + guard pid > 0 else { return false } + self.condition.lock() + defer { self.condition.unlock() } + guard !self.isShuttingDown else { return false } + self.processes[pid] = ProcessInfo(binary: binary, processGroup: nil) + return true + } + + func beginLaunch() -> Bool { + self.condition.lock() + defer { self.condition.unlock() } + guard !self.isShuttingDown else { return false } + self.launchesInProgress += 1 + return true + } + + func endLaunch() { + self.condition.lock() + self.launchesInProgress = max(0, self.launchesInProgress - 1) + if self.launchesInProgress == 0 { + self.condition.broadcast() + } + self.condition.unlock() + } + + func updateProcessGroup(pid: pid_t, processGroup: pid_t?) { + guard pid > 0 else { return } + self.condition.lock() + guard var existing = self.processes[pid] else { + self.condition.unlock() + return + } + existing.processGroup = processGroup + self.processes[pid] = existing + self.condition.unlock() + } + + func unregister(pid: pid_t) { + guard pid > 0 else { return } + self.condition.lock() + self.processes.removeValue(forKey: pid) + self.condition.unlock() + } + + func drainForShutdown( + onFenceSet: (() -> Void)? = nil) + -> [(pid: pid_t, binary: String, processGroup: pid_t?)] + { + self.condition.lock() + self.isShuttingDown = true + onFenceSet?() + while self.launchesInProgress > 0 { + self.condition.wait() + } + let drained = self.processes.map { + (pid: $0.key, binary: $0.value.binary, processGroup: $0.value.processGroup) + } + self.processes.removeAll() + self.condition.unlock() + return drained + } + + func reset() { + self.condition.lock() + self.processes.removeAll() + self.isShuttingDown = false + self.launchesInProgress = 0 + self.condition.broadcast() + self.condition.unlock() + } + + func count() -> Int { + self.condition.lock() + let count = self.processes.count + self.condition.unlock() + return count + } + + func testTrackProcess(pid: pid_t, binary: String, processGroup: pid_t?) { + guard pid > 0 else { return } + self.condition.lock() + self.processes[pid] = ProcessInfo(binary: binary, processGroup: processGroup) + self.condition.unlock() + } + } + + private static let shared = State() + @TaskLocal private static var stateOverrideForTesting: State? + + private static var current: State { + self.stateOverrideForTesting ?? self.shared + } + static func register(pid: pid_t, binary: String) -> Bool { - guard pid > 0 else { return false } - self.condition.lock() - defer { self.condition.unlock() } - guard !self.isShuttingDown else { return false } - self.processes[pid] = ProcessInfo(binary: binary, processGroup: nil) - return true + self.current.register(pid: pid, binary: binary) } static func beginLaunch() -> Bool { - self.condition.lock() - defer { self.condition.unlock() } - guard !self.isShuttingDown else { return false } - self.launchesInProgress += 1 - return true + self.current.beginLaunch() } static func endLaunch() { - self.condition.lock() - self.launchesInProgress = max(0, self.launchesInProgress - 1) - if self.launchesInProgress == 0 { - self.condition.broadcast() - } - self.condition.unlock() + self.current.endLaunch() } static func updateProcessGroup(pid: pid_t, processGroup: pid_t?) { - guard pid > 0 else { return } - self.condition.lock() - guard var existing = self.processes[pid] else { - self.condition.unlock() - return - } - existing.processGroup = processGroup - self.processes[pid] = existing - self.condition.unlock() + self.current.updateProcessGroup(pid: pid, processGroup: processGroup) } static func unregister(pid: pid_t) { - guard pid > 0 else { return } - self.condition.lock() - self.processes.removeValue(forKey: pid) - self.condition.unlock() + self.current.unregister(pid: pid) } - static func drainForShutdown( - onFenceSet: (() -> Void)? = nil) + static func drainForShutdown(onFenceSet: (() -> Void)? = nil) -> [(pid: pid_t, binary: String, processGroup: pid_t?)] { - self.condition.lock() - self.isShuttingDown = true - onFenceSet?() - while self.launchesInProgress > 0 { - self.condition.wait() - } - let drained = self.processes.map { - (pid: $0.key, binary: $0.value.binary, processGroup: $0.value.processGroup) - } - self.processes.removeAll() - self.condition.unlock() - return drained + self.current.drainForShutdown(onFenceSet: onFenceSet) } static func reset() { - self.condition.lock() - self.processes.removeAll() - self.isShuttingDown = false - self.launchesInProgress = 0 - self.condition.broadcast() - self.condition.unlock() + self.current.reset() } static func count() -> Int { - self.condition.lock() - let count = self.processes.count - self.condition.unlock() - return count + self.current.count() } static func testTrackProcess(pid: pid_t, binary: String, processGroup: pid_t?) { - guard pid > 0 else { return } - self.condition.lock() - self.processes[pid] = ProcessInfo(binary: binary, processGroup: processGroup) - self.condition.unlock() + self.current.testTrackProcess(pid: pid, binary: binary, processGroup: processGroup) + } + + static func withIsolatedStateForTesting(_ operation: () throws -> T) rethrows -> T { + try self.$stateOverrideForTesting.withValue(State(), operation: operation) + } + + static func makeDrainOperationForTesting(onFenceSet: (@Sendable () -> Void)? = nil) + -> @Sendable () -> [(pid: pid_t, binary: String, processGroup: pid_t?)] + { + let state = self.current + return { state.drainForShutdown(onFenceSet: onFenceSet) } } } @@ -206,6 +264,10 @@ enum TTYProcessTreeTerminator { } } +private enum TTYCommandRunnerTestingOverrides { + @TaskLocal static var postDeadlineDrainDuration: TimeInterval? +} + /// Executes an interactive CLI inside a pseudo-terminal and returns all captured text. /// Keeps it minimal so we can reuse for Codex and Claude without tmux. public struct TTYCommandRunner { @@ -910,7 +972,9 @@ public struct TTYCommandRunner { } else { // PTY-backed scripts can exit before their final echo becomes readable on the parent side. // Give the kernel a brief non-blocking drain window so we don't lose the last line of output. - drainNonCodexOutput(for: min(0.5, max(0.2, options.settleAfterStop))) + let defaultDrainDuration = min(0.5, max(0.2, options.settleAfterStop)) + let drainDuration = TTYCommandRunnerTestingOverrides.postDeadlineDrainDuration ?? defaultDrainDuration + drainNonCodexOutput(for: drainDuration) } try checkOutputLimit() @@ -1102,6 +1166,17 @@ public struct TTYCommandRunner { } extension TTYCommandRunner { + static func withIsolatedActiveProcessRegistryForTesting(_ operation: () throws -> T) rethrows -> T { + try TTYCommandRunnerActiveProcessRegistry.withIsolatedStateForTesting(operation) + } + + static func withPostDeadlineDrainDurationOverrideForTesting( + _ duration: TimeInterval, + operation: () throws -> T) rethrows -> T + { + try TTYCommandRunnerTestingOverrides.$postDeadlineDrainDuration.withValue(duration, operation: operation) + } + public static func which(_ tool: String) -> String? { if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { return located @@ -1254,6 +1329,13 @@ extension TTYCommandRunner { TTYCommandRunnerActiveProcessRegistry.drainForShutdown(onFenceSet: onFenceSet) } + static func _test_makeDrainTrackedProcessesForShutdownOperation( + onFenceSet: (@Sendable () -> Void)? = nil) + -> @Sendable () -> [(pid: pid_t, binary: String, processGroup: pid_t?)] + { + TTYCommandRunnerActiveProcessRegistry.makeDrainOperationForTesting(onFenceSet: onFenceSet) + } + static func _test_resolveShutdownTargets( _ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)], hostProcessGroup: pid_t, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift index d243903595..4149481d9b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLIAuthStatusProbe.swift @@ -6,6 +6,7 @@ enum ClaudeCLIAuthStatusProbe { } @TaskLocal private static var resultOverrideForTesting: Bool? + @TaskLocal private static var timeoutOverrideForTesting: TimeInterval? static func withResultOverrideForTesting( _ result: Bool?, @@ -16,6 +17,15 @@ enum ClaudeCLIAuthStatusProbe { } } + static func withTimeoutOverrideForTesting( + _ timeout: TimeInterval, + operation: () async throws -> T) async rethrows -> T + { + try await self.$timeoutOverrideForTesting.withValue(timeout) { + try await operation() + } + } + static func isLoggedIn( binary: String, environment: [String: String], @@ -29,7 +39,7 @@ enum ClaudeCLIAuthStatusProbe { binary: binary, arguments: ["auth", "status", "--json"], environment: ClaudeCLISession.launchEnvironment(baseEnv: environment), - timeout: timeout, + timeout: self.timeoutOverrideForTesting ?? timeout, standardInput: FileHandle.nullDevice, label: "claude-auth-status") return self.parseLoggedIn(result.stdout) diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index afc2d07b1f..94a42e7c87 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -207,17 +207,19 @@ struct ClaudeBaselineCharacterizationTests { let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) let env = ["CLAUDE_CLI_PATH": stubCLIPath] - await self.withBackgroundKeychainAccess { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await self.withNoOAuthCredentials { - let outcome = await self.fetchOutcome( - runtime: .app, - sourceMode: .auto, - env: env, - settings: settings) - - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(20) { + await self.withBackgroundKeychainAccess { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await self.withNoOAuthCredentials { + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .auto, + env: env, + settings: settings) + + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + } } } } @@ -323,11 +325,13 @@ struct ClaudeBaselineCharacterizationTests { rawText: nil) } - let outcome = await self.withBackgroundKeychainAccess { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await self.withNoOAuthCredentials { - await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) { - await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let outcome = await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(20) { + await self.withBackgroundKeychainAccess { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await self.withNoOAuthCredentials { + await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) { + await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) + } } } } diff --git a/Tests/CodexBarTests/ClaudeLoginRunnerTests.swift b/Tests/CodexBarTests/ClaudeLoginRunnerTests.swift index 7785ccbb88..86a4c9b6d9 100644 --- a/Tests/CodexBarTests/ClaudeLoginRunnerTests.swift +++ b/Tests/CodexBarTests/ClaudeLoginRunnerTests.swift @@ -17,13 +17,14 @@ struct ClaudeLoginRunnerTests { defer { fixture.remove() } let result = await ClaudeLoginRunner.run( - timeout: 2, + timeout: 10, binary: fixture.executable.path, environment: fixture.environment, onPhaseChange: { _ in }) guard case .success = result.outcome else { - Issue.record("Expected success, got \(String(describing: result.outcome))") + Issue.record( + "Expected success, got \(String(describing: result.outcome)); output=\(result.output.debugDescription)") return } #expect(result.output.contains("args:auth login --claudeai")) @@ -40,13 +41,15 @@ struct ClaudeLoginRunnerTests { defer { fixture.remove() } let result = await ClaudeLoginRunner.run( - timeout: 1, + timeout: 3, binary: fixture.executable.path, environment: fixture.environment, onPhaseChange: { _ in }) guard case .timedOut = result.outcome else { - Issue.record("Expected timeout, got \(String(describing: result.outcome))") + let message = "Expected timeout, got \(String(describing: result.outcome)); " + + "output=\(result.output.debugDescription)" + Issue.record(Comment(rawValue: message)) return } #expect(result.authLink == "https://claude.ai/oauth/authorize?test=1") @@ -62,13 +65,15 @@ struct ClaudeLoginRunnerTests { defer { fixture.remove() } let result = await ClaudeLoginRunner.run( - timeout: 2, + timeout: 10, binary: fixture.executable.path, environment: fixture.environment, onPhaseChange: { _ in }) guard case .failed(status: 7) = result.outcome else { - Issue.record("Expected status 7, got \(String(describing: result.outcome))") + let message = "Expected status 7, got \(String(describing: result.outcome)); " + + "output=\(result.output.debugDescription)" + Issue.record(Comment(rawValue: message)) return } #expect(result.output.contains("login failed")) diff --git a/Tests/CodexBarTests/CodexLoginRunnerTests.swift b/Tests/CodexBarTests/CodexLoginRunnerTests.swift index d5c59dfa8f..062fe13494 100644 --- a/Tests/CodexBarTests/CodexLoginRunnerTests.swift +++ b/Tests/CodexBarTests/CodexLoginRunnerTests.swift @@ -60,11 +60,11 @@ struct CodexLoginRunnerTests { let codex = binDir.appendingPathComponent("codex") let script = """ #!/bin/sh - /bin/sh -c 'trap "" TERM; /bin/sleep 5' & + /bin/sh -c 'trap "" TERM; /bin/sleep 20' & child_pid=$! printf '%s\\n' "$child_pid" > "$CODEXBAR_TEST_CHILD_PID_FILE" printf 'login-started\\n' - /bin/sleep 5 + /bin/sleep 20 """ try script.write(to: codex, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: codex.path) @@ -72,8 +72,8 @@ struct CodexLoginRunnerTests { let start = Date() let result = await CodexLoginRunner.run( homePath: homeDir.path, - timeout: 1, - outputDrainTimeout: 0.2, + timeout: 5, + outputDrainTimeout: 0.5, environment: [ "CODEXBAR_TEST_CHILD_PID_FILE": childPIDFile.path, "PATH": binDir.path, @@ -83,6 +83,6 @@ struct CodexLoginRunnerTests { #expect(result.outcome == .timedOut) #expect(result.output.contains("login-started")) - #expect(elapsed < 3.0, "Output drain should stay bounded, took \(elapsed)s") + #expect(elapsed < 8.0, "Output drain should stay bounded, took \(elapsed)s") } } diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 5a9737fa78..fc56f41ab4 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -136,8 +136,8 @@ struct KiroStatusProbeTests { let startedAt = clock.now let probe = KiroStatusProbe( cliBinaryResolver: { cliURL.path }, - usageProbeTimeout: 0.8, - pipeTimeoutCap: 0.4) + usageProbeTimeout: 4, + pipeTimeoutCap: 2) await #expect { _ = try await probe.fetch() @@ -146,7 +146,7 @@ struct KiroStatusProbeTests { return true } - #expect(startedAt.duration(to: clock.now) < .seconds(2)) + #expect(startedAt.duration(to: clock.now) < .seconds(7)) #expect(!FileManager.default.fileExists(atPath: ptyMarker.path)) let pipePIDText = try String(contentsOf: pipePIDFile, encoding: .utf8) let pipePID = try #require(pid_t(pipePIDText.trimmingCharacters(in: .whitespacesAndNewlines))) diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index 9f052b21c3..eb3f4b760d 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -25,73 +25,84 @@ struct TTYCommandRunnerEnvTests { @Test func `shutdown fence drains tracked TTY processes`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } - #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 1001, binary: "codex")) - #expect(TTYCommandRunner._test_trackedProcessCount() == 1) + #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 1001, binary: "codex")) + #expect(TTYCommandRunner._test_trackedProcessCount() == 1) - let drained = TTYCommandRunner._test_drainTrackedProcessesForShutdown() - #expect(drained.count == 1) - #expect(drained[0].pid == 1001) - #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + let drained = TTYCommandRunner._test_drainTrackedProcessesForShutdown() + #expect(drained.count == 1) + #expect(drained[0].pid == 1001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } } @Test func `cached CLI sessions share shutdown tracking`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } - #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex")) - TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001) - #expect(TTYCommandRunner._test_trackedProcessCount() == 1) + #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex")) + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 1) - TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001) - #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } } @Test func `tracked process helpers ignore invalid PID`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } - TTYCommandRunner._test_trackProcess(pid: 0, binary: "codex", processGroup: nil) - #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + TTYCommandRunner._test_trackProcess(pid: 0, binary: "codex", processGroup: nil) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } } @Test func `shutdown fence rejects new registrations`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } - #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 2001, binary: "codex")) - let drained = TTYCommandRunner._test_drainTrackedProcessesForShutdown() - #expect(drained.count == 1) + #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 2001, binary: "codex")) + let drained = TTYCommandRunner._test_drainTrackedProcessesForShutdown() + #expect(drained.count == 1) - #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 2002, binary: "codex") == false) - #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + #expect(TTYCommandRunner._test_registerTrackedProcess(pid: 2002, binary: "codex") == false) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } } @Test func `shutdown waits for launch cleanup before draining`() { - TTYCommandRunner._test_resetTrackedProcesses() - defer { TTYCommandRunner._test_resetTrackedProcesses() } - - #expect(TTYCommandRunner._test_beginTrackedProcessLaunch()) - let fenceSet = DispatchSemaphore(value: 0) - let completed = DispatchSemaphore(value: 0) - Thread.detachNewThread { - _ = TTYCommandRunner._test_drainTrackedProcessesForShutdown { + TTYCommandRunner.withIsolatedActiveProcessRegistryForTesting { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } + + #expect(TTYCommandRunner._test_beginTrackedProcessLaunch()) + let fenceSet = DispatchSemaphore(value: 0) + let completed = DispatchSemaphore(value: 0) + let drain = TTYCommandRunner._test_makeDrainTrackedProcessesForShutdownOperation { fenceSet.signal() } - completed.signal() - } + Thread.detachNewThread { + _ = drain() + completed.signal() + } - #expect(fenceSet.wait(timeout: .now() + 1) == .success) - #expect(completed.wait(timeout: .now() + 0.05) == .timedOut) - #expect(!TTYCommandRunner._test_registerTrackedProcess(pid: 2002, binary: "codex")) - TTYCommandRunner._test_endTrackedProcessLaunch() - #expect(completed.wait(timeout: .now() + 1) == .success) + #expect(fenceSet.wait(timeout: .now() + 1) == .success) + #expect(completed.wait(timeout: .now() + 0.05) == .timedOut) + #expect(!TTYCommandRunner._test_registerTrackedProcess(pid: 2002, binary: "codex")) + TTYCommandRunner._test_endTrackedProcessLaunch() + #expect(completed.wait(timeout: .now() + 1) == .success) + } } @Test @@ -435,10 +446,12 @@ struct TTYCommandRunnerEnvTests { try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) let runner = TTYCommandRunner() - let result = try runner.run( - binary: scriptURL.path, - send: "", - options: .init(timeout: 0.01, initialDelay: 0, settleAfterStop: 0.5)) + let result = try TTYCommandRunner.withPostDeadlineDrainDurationOverrideForTesting(10) { + try runner.run( + binary: scriptURL.path, + send: "", + options: .init(timeout: 0.01, initialDelay: 0, settleAfterStop: 0.5)) + } #expect(result.completion == .deadlineExceeded) #expect(result.text.contains("https://claude.ai/oauth/authorize?test=late")) @@ -465,7 +478,7 @@ struct TTYCommandRunnerEnvTests { let result = try runner.run( binary: scriptURL.path, send: "", - options: .init(timeout: 4, initialDelay: 0, returnOnEmptyProcessExit: true)) + options: .init(timeout: 10, initialDelay: 0, returnOnEmptyProcessExit: true)) #expect(result.completion == .processExited(status: 0)) #expect(result.text.isEmpty) diff --git a/TestsLinux/PlatformGatingTests.swift b/TestsLinux/PlatformGatingTests.swift index 84031b51c8..9586b69699 100644 --- a/TestsLinux/PlatformGatingTests.swift +++ b/TestsLinux/PlatformGatingTests.swift @@ -3,7 +3,7 @@ import Testing @testable import CodexBarCLI @testable import CodexBarCore -@Suite +@Suite(.serialized) struct PlatformGatingTests { @Test func `shell probe requests a detached Linux session`() { @@ -85,7 +85,7 @@ struct PlatformGatingTests { #endif } - @Test(.serialized, arguments: [ProviderSourceMode.auto, .cli]) + @Test(arguments: [ProviderSourceMode.auto, .cli]) func `Claude CLI runtime skips logged out interactive fallback`(sourceMode: ProviderSourceMode) async throws { #if os(Linux) let invocationLog = FileManager.default.temporaryDirectory @@ -103,10 +103,12 @@ struct PlatformGatingTests { return Self.makeClaudeStatus() } - let outcome = await ClaudeStatusProbe.withFetchOverrideForTesting(cliFetchOverride) { - await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( - context: context, - provider: .claude) + let outcome = await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(20) { + await ClaudeStatusProbe.withFetchOverrideForTesting(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } } switch outcome.result { @@ -125,7 +127,8 @@ struct PlatformGatingTests { let expectedStrategyIDs = sourceMode == .auto ? ["claude.web", "claude.cli"] : ["claude.cli"] #expect(outcome.attempts.map(\.strategyID) == expectedStrategyIDs) #expect(outcome.attempts.allSatisfy { !$0.wasAvailable }) - #expect(try String(contentsOf: invocationLog, encoding: .utf8) == "auth status --json\n") + let invocations = try String(contentsOf: invocationLog, encoding: .utf8) + #expect(invocations == "auth status --json\n") #else #expect(Bool(true)) #endif