import Foundation import Testing @testable import CodexBarCore @Suite(.serialized) struct TTYCommandRunnerEnvTests { private static let harnessPTYTimeout: TimeInterval = 10 private final class CallbackCounter: @unchecked Sendable { private let lock = NSLock() private var count = 0 func increment() { self.lock.lock() self.count += 1 self.lock.unlock() } func value() -> Int { self.lock.lock() defer { self.lock.unlock() } return self.count } } @Test func `shutdown fence drains tracked TTY processes`() { TTYCommandRunner._test_resetTrackedProcesses() defer { TTYCommandRunner._test_resetTrackedProcesses() } #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) } @Test func `cached CLI sessions share shutdown tracking`() { 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) 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._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() } #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) } @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 { fenceSet.signal() } 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) } @Test func `shutdown resolver skips host process group fallback`() { let hostGroup: pid_t = 4242 let targets: [(pid: pid_t, binary: String, processGroup: pid_t?)] = [ (pid: 100, binary: "codex", processGroup: nil), (pid: 101, binary: "codex", processGroup: hostGroup), (pid: 102, binary: "codex", processGroup: 7777), ] let resolved = TTYCommandRunner._test_resolveShutdownTargets( targets, hostProcessGroup: hostGroup, groupResolver: { pid in pid == 100 ? hostGroup : -1 }) #expect(resolved.count == 3) #expect(resolved[0].processGroup == nil) #expect(resolved[1].processGroup == nil) #expect(resolved[2].processGroup == 7777) } @Test func `descendant resolver walks process tree once`() { let children: [pid_t: [pid_t]] = [ 100: [101, 102], 101: [103], 102: [103], 103: [100], ] let descendants = TTYProcessTreeTerminator.descendantPIDs(of: 100) { children[$0] ?? [] } #expect(Set(descendants) == Set([101, 102, 103])) #expect(descendants.count == 3) } @Test func `process tree termination signals escaped descendants`() { let children: [pid_t: [pid_t]] = [ 100: [101, 102], 102: [103], ] var signaled: [(pid: pid_t, signal: Int32)] = [] TTYProcessTreeTerminator.terminateProcessTree( rootPID: 100, processGroup: 200, signal: 15, childResolver: { children[$0] ?? [] }, signalSender: { pid, signal in signaled.append((pid: pid, signal: signal)) }) #expect(Set(signaled.map(\.pid)) == Set([100, 101, 102, 103, -200])) #expect(signaled.allSatisfy { $0.signal == 15 }) #expect(signaled.last?.pid == 100) } @Test func `preserves environment and sets term`() { let baseEnv: [String: String] = [ "PATH": "/custom/bin", "HOME": "/Users/tester", "LANG": "en_US.UTF-8", ] let merged = TTYCommandRunner.enrichedEnvironment( baseEnv: baseEnv, loginPATH: nil, home: "/Users/tester") #expect(merged["HOME"] == "/Users/tester") #expect(merged["LANG"] == "en_US.UTF-8") #expect(merged["TERM"] == "xterm-256color") #expect(merged["PATH"] == "/custom/bin") } @Test func `backfills home when missing`() { let merged = TTYCommandRunner.enrichedEnvironment( baseEnv: ["PATH": "/custom/bin"], loginPATH: nil, home: "/Users/fallback") #expect(merged["HOME"] == "/Users/fallback") #expect(merged["TERM"] == "xterm-256color") } @Test func `preserves existing term and custom vars`() { let merged = TTYCommandRunner.enrichedEnvironment( baseEnv: [ "PATH": "/custom/bin", "TERM": "vt100", "BUN_INSTALL": "/Users/tester/.bun", "SHELL": "/bin/zsh", ], loginPATH: nil, home: "/Users/tester") #expect(merged["TERM"] == "vt100") #expect(merged["BUN_INSTALL"] == "/Users/tester/.bun") #expect(merged["SHELL"] == "/bin/zsh") #expect((merged["PATH"] ?? "").contains("/custom/bin")) } @Test func `codex status probe uses non persistent thread storage`() { let stateHome = URL(fileURLWithPath: "/tmp/codexbar status \"state\"", isDirectory: true) let args = CodexStatusProbeIsolation.codexArguments(stateHome: stateHome) #expect(args.starts(with: ["-s", "read-only", "-a", "untrusted"])) #expect(args.contains("history.persistence=\"none\"")) #expect(args.contains("experimental_thread_store={type=\"in_memory\",id=\"codexbar-status\"}")) #expect(args.contains("sqlite_home=\"/tmp/codexbar status \\\"state\\\"\"")) } @Test func `codex status probe avoids root working directory when home exists`() { let home = "/Users/tester" let workingDirectory = CodexStatusProbeIsolation.workingDirectory(environment: ["HOME": home]) #expect(workingDirectory?.path == home) } @Test func `sets working directory when provided`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) let runner = TTYCommandRunner() let result = try runner.run( binary: "/bin/pwd", send: "", options: .init(timeout: Self.harnessPTYTimeout, workingDirectory: dir)) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains(dir.path)) } @Test func `claude runner keeps normal working directory by default`() throws { let runner = TTYCommandRunner() let fakeClaude = try Self.makeFakeClaudeCLI() let result = try runner.run( binary: fakeClaude.path, send: "", options: .init(timeout: Self.harnessPTYTimeout, stopOnSubstrings: ["deep-link-enabled"])) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains("deep-link-enabled")) } @Test func `claude runner uses probe directory with deep link registration disabled when requested`() throws { let runner = TTYCommandRunner() let fakeClaude = try Self.makeFakeClaudeCLI() let result = try runner.run( binary: fakeClaude.path, send: "", options: .init( timeout: Self.harnessPTYTimeout, stopOnSubstrings: ["deep-link-disabled"], useClaudeProbeWorkingDirectory: true)) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains("deep-link-disabled")) } @Test func `claude runner uses probe directory for versioned CLI override`() throws { let runner = TTYCommandRunner() let fakeClaude = try Self.makeFakeClaudeCLI(fileName: "2.1.114") var env = ProcessInfo.processInfo.environment env["CLAUDE_CLI_PATH"] = fakeClaude.path let result = try runner.run( binary: fakeClaude.path, send: "", options: .init( timeout: Self.harnessPTYTimeout, baseEnvironment: env, stopOnSubstrings: ["deep-link-disabled"], useClaudeProbeWorkingDirectory: true)) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains("deep-link-disabled")) } @Test func `auto responds to trust prompt`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? fm.removeItem(at: dir) } let scriptURL = dir.appendingPathComponent("trust.sh") let script = """ #!/bin/sh echo \"Do you trust the files in this folder?\" echo \"\" echo \"/Users/example/project\" IFS= read -r ans if [ \"$ans\" = \"y\" ] || [ \"$ans\" = \"Y\" ]; then echo \"accepted\" else echo \"rejected:$ans\" fi """ try script.write(to: scriptURL, atomically: true, encoding: .utf8) try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) let runner = TTYCommandRunner() let result = try runner.run( binary: scriptURL.path, send: "", options: .init( timeout: 15, // Use LF for portability: some PTY/termios setups do not translate CR → NL for shell reads. sendOnSubstrings: ["trust the files in this folder?": "y\n"], stopOnSubstrings: ["accepted", "rejected"], settleAfterStop: 0.1)) #expect(result.text.contains("accepted")) } private static func makeFakeClaudeCLI(fileName: String = "claude") throws -> URL { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) let scriptURL = dir.appendingPathComponent(fileName) let script = """ #!/bin/sh settings="$PWD/.claude/settings.local.json" if [ -f "$settings" ] \ && grep -q '"disableDeepLinkRegistration"' "$settings" \ && grep -q '"disable"' "$settings"; then echo "deep-link-disabled" else echo "deep-link-enabled" fi """ try script.write(to: scriptURL, atomically: true, encoding: .utf8) try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) return scriptURL } @Test func `post-exit drain processes trailing chunk through callback path`() { let callbackCounter = CallbackCounter() var reads: [TTYCommandRunner.DrainReadResult] = [ .wouldBlock, .wouldBlock, .data(Data("https://example.com/auth".utf8)), .closed, ] TTYCommandRunner.drainRemainingOutput( until: Date().addingTimeInterval(1), readChunk: { if reads.isEmpty { return .closed } return reads.removeFirst() }, processChunk: { data in if data.range(of: Data("https://".utf8)) != nil { callbackCounter.increment() } }, sleep: { _ in }) #expect(callbackCounter.value() == 1) } @Test func `post-exit drain keeps harvesting after late success marker`() { var readCount = 0 var processedChunks: [String] = [] var reads: [TTYCommandRunner.DrainReadResult] = [ .data(Data("accepted".utf8)), .wouldBlock, .data(Data(" trailing".utf8)), .closed, ] TTYCommandRunner.drainRemainingOutput( until: Date().addingTimeInterval(1), readChunk: { readCount += 1 if reads.isEmpty { return .closed } return reads.removeFirst() }, processChunk: { data in processedChunks.append(String(bytes: data, encoding: .utf8) ?? "") }, sleep: { _ in }) #expect(readCount == 4) #expect(processedChunks == ["accepted", " trailing"]) } @Test func `post-exit drain stops once the PTY reports closure`() { var readCount = 0 TTYCommandRunner.drainRemainingOutput( until: Date().addingTimeInterval(1), readChunk: { readCount += 1 return .closed }, processChunk: { _ in }, sleep: { _ in }) #expect(readCount == 1) } @Test func `interrupted drain reads are treated as retryable`() { let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: -1, errno: EINTR) if case .wouldBlock = result { #expect(Bool(true)) } else { Issue.record("Expected interrupted read to remain retryable during drain") } } @Test func `EOF beats stale would-block errno during drain classification`() { let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: 0, errno: EAGAIN) if case .closed = result { #expect(Bool(true)) } else { Issue.record("Expected EOF reads to stop draining even if errno still holds EAGAIN") } } @Test func `stops when output is idle`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? fm.removeItem(at: dir) } let scriptURL = dir.appendingPathComponent("idle.sh") let script = """ #!/bin/sh echo "hello" sleep 30 """ try script.write(to: scriptURL, atomically: true, encoding: .utf8) try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) let runner = TTYCommandRunner() let timeout: TimeInterval = 6 var fastestElapsed = TimeInterval.greatestFiniteMagnitude // CI can occasionally pause a test process long enough to miss an idle window. // Retry once and assert that at least one run exits well before timeout. for _ in 0..<2 { let startedAt = Date() let result = try runner.run( binary: scriptURL.path, send: "", options: .init(timeout: timeout, idleTimeout: 0.2)) let elapsed = Date().timeIntervalSince(startedAt) #expect(result.text.contains("hello")) fastestElapsed = min(fastestElapsed, elapsed) } #expect(fastestElapsed < (timeout - 1.0)) } @Test func `rolling buffer detects needle across boundary`() { var scanner = TTYCommandRunner.RollingBuffer(maxNeedle: 6) let needle = Data("hello".utf8) let first = scanner.append(Data("he".utf8)) #expect(first.range(of: needle) == nil) let second = scanner.append(Data("llo!".utf8)) #expect(second.range(of: needle) != nil) } @Test func `lowercased ASCII only touches ascii`() { let data = Data("UpDaTe".utf8) let lowered = TTYCommandRunner.lowercasedASCII(data) #expect(String(data: lowered, encoding: .utf8) == "update") } }