Files
2026-07-13 12:22:33 +08:00

485 lines
17 KiB
Swift

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