Slice 2 (Phase 1a) of the M9 Monterey retarget. Converts all pure Core suites (argv builders, CGATS/CUPS/profcheck/gamut-mesh parsers, colour math, file/artefact helpers, classifiers) to XCTestCase, and adds the shared assertAsyncThrows helper for the async-throws sites that Slice 3 will convert. Deferred to Slice 3 (kept on Swift Testing in mixed files): ProcessManagerTests, ArgyllRunner*Tests, ProcessRunSupportTests, store/UI suites, and the runner suites embedded in TargenTests / PrinttargTests / ProcessManagerTests. Also qualifies six bare `throw .case` expressions in CGATSParser with CGATSParseError — they only compiled under typed throws, which Slice 1 demoted to untyped `throws`; without this the ICCeryCore target fails to build and no test gate can run. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
471 lines
19 KiB
Swift
471 lines
19 KiB
Swift
import Testing
|
|
import XCTest
|
|
import Foundation
|
|
@testable import ICCeryCore
|
|
|
|
/// Helpers shared across ProcessManager tests. Fixture binaries are shell
|
|
/// scripts written to a temp dir — no resource bundling required.
|
|
@Suite("ProcessManager", .serialized)
|
|
struct ProcessManagerTests {
|
|
|
|
// MARK: - Fixture plumbing
|
|
|
|
private static let fixtureDir: URL = {
|
|
let dir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("iccery-pm-tests-\(UUID().uuidString)")
|
|
try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
}()
|
|
|
|
/// Writes a shell script fixture and returns its executable URL.
|
|
private func script(_ name: String, _ body: String) throws -> URL {
|
|
let url = Self.fixtureDir.appendingPathComponent(name)
|
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
|
try FileManager.default.setAttributes(
|
|
[.posixPermissions: 0o755], ofItemAtPath: url.path
|
|
)
|
|
return url
|
|
}
|
|
|
|
/// Collects events for `id` until `.exit`, `timeout` seconds max.
|
|
private func collect(
|
|
_ manager: ProcessManager,
|
|
id: String,
|
|
timeout: TimeInterval = 10
|
|
) async -> [ProcessEvent] {
|
|
await withCheckedContinuation { cont in
|
|
let box = Box()
|
|
Task {
|
|
for await event in manager.events() {
|
|
guard event.id == id else { continue }
|
|
box.append(event)
|
|
if case .exit = event { break }
|
|
}
|
|
if box.finish() { cont.resume(returning: box.events) }
|
|
}
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(timeout))
|
|
if box.finish() { cont.resume(returning: box.events) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class Box: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var _events: [ProcessEvent] = []
|
|
private var finished = false
|
|
var events: [ProcessEvent] { lock.lock(); defer { lock.unlock() }; return _events }
|
|
func append(_ e: ProcessEvent) { lock.lock(); _events.append(e); lock.unlock() }
|
|
func finish() -> Bool { lock.lock(); defer { lock.unlock() }; if finished { return false }; finished = true; return true }
|
|
}
|
|
|
|
/// Subscribes synchronously (registration happens inside `events()`)
|
|
/// then records every event for `id` until the task is cancelled.
|
|
/// Unlike `collect`, observation continues past `.exit` so tests can
|
|
/// prove exactly-once exit emission.
|
|
private func observe(
|
|
_ manager: ProcessManager,
|
|
id: String,
|
|
into box: Box
|
|
) -> Task<Void, Never> {
|
|
let stream = manager.events()
|
|
return Task {
|
|
for await event in stream {
|
|
guard event.id == id else { continue }
|
|
box.append(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func exitCount(in box: Box) -> Int {
|
|
box.events.filter { if case .exit = $0 { return true }; return false }.count
|
|
}
|
|
|
|
private func waitForExit(in box: Box, timeout: TimeInterval = 10) async -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if exitCount(in: box) > 0 { return true }
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func waitForFile(_ url: URL, timeout: TimeInterval = 5) async -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if FileManager.default.fileExists(atPath: url.path) { return true }
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func waitForRunning(
|
|
_ manager: ProcessManager,
|
|
id: String,
|
|
timeout: TimeInterval = 5
|
|
) async -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if await manager.isRunning(id) { return true }
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@Test func streamsStdoutAndEmitsExit() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
|
|
async let events = collect(pm, id: "t1")
|
|
try await pm.runStreaming(id: "t1", binary: bin, arguments: [])
|
|
let evs = await events
|
|
let lines = evs.compactMap { e -> String? in
|
|
if case .stdout(_, let l) = e { return l }; return nil
|
|
}
|
|
#expect(lines == ["hello", "world"])
|
|
#expect(evs.contains(.exit(id: "t1", code: 0)))
|
|
}
|
|
|
|
@Test func routesStderrSeparately() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
|
|
async let evs = collect(pm, id: "t2")
|
|
try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
|
|
let events = await evs
|
|
#expect(events.contains(.stdout(id: "t2", line: "out")))
|
|
#expect(events.contains(.stderr(id: "t2", line: "oops")))
|
|
}
|
|
|
|
@Test func stripsRowColorsJSONPrefix() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script(
|
|
"rows.sh",
|
|
"#!/bin/sh\necho 'ROW_COLORS_JSON: {\"row\":1}'\necho plain\n"
|
|
)
|
|
async let evs = collect(pm, id: "t3")
|
|
try await pm.runStreaming(id: "t3", binary: bin, arguments: [])
|
|
let events = await evs
|
|
let rows = events.compactMap { e -> String? in
|
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
|
return nil
|
|
}
|
|
#expect(rows == ["{\"row\":1}"])
|
|
#expect(events.contains(.stdout(id: "t3", line: "plain")))
|
|
// Prefixed lines must not leak into stdout.
|
|
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
|
|
}
|
|
|
|
@Test func unterminatedTailFlushesOnExit() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
|
|
async let evs = collect(pm, id: "t4")
|
|
try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
|
|
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline")))
|
|
}
|
|
|
|
@Test func stdinRoundTrip() async throws {
|
|
let pm = ProcessManager()
|
|
// Read two lines then exit naturally — a killed sh would lose its
|
|
// buffered stdio output, which is exactly the chartread pattern.
|
|
let bin = try script(
|
|
"echo.sh",
|
|
"#!/bin/sh\nIFS= read -r a; echo \"got:$a\"\nIFS= read -r b; echo \"got:$b\"\n"
|
|
)
|
|
async let evs = collect(pm, id: "t5")
|
|
try await pm.runStreaming(id: "t5", binary: bin, arguments: [])
|
|
try await pm.sendStdin(id: "t5", text: " \n")
|
|
try await pm.sendStdin(id: "t5", text: "d\n")
|
|
let events = await evs
|
|
#expect(events.contains(.stdout(id: "t5", line: "got: ")))
|
|
#expect(events.contains(.stdout(id: "t5", line: "got:d")))
|
|
}
|
|
|
|
@Test func duplicateIDRejected() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
|
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
|
await #expect(throws: ProcessError.duplicateID("t6")) {
|
|
try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
|
|
}
|
|
await pm.kill(id: "t6")
|
|
}
|
|
|
|
@Test func killEmitsExitAndClosesStdin() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
|
|
async let evs = collect(pm, id: "t7")
|
|
try await pm.runStreaming(id: "t7", binary: bin, arguments: [])
|
|
await pm.kill(id: "t7")
|
|
let events = await evs
|
|
// exit emitted exactly once
|
|
let exits = events.filter { if case .exit = $0 { return true }; return false }
|
|
#expect(exits.count == 1)
|
|
await #expect(throws: ProcessError.unknownID("t7")) {
|
|
try await pm.sendStdin(id: "t7", text: "d\n")
|
|
}
|
|
}
|
|
|
|
@Test func killAllCountsSignaled() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
|
|
try await pm.runStreaming(id: "a", binary: bin, arguments: [])
|
|
try await pm.runStreaming(id: "b", binary: bin, arguments: [])
|
|
let count = await pm.killAll()
|
|
#expect(count == 2)
|
|
}
|
|
|
|
@Test func capturedRunReturnsBothStreams() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
|
|
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
|
|
#expect(result.stdout.contains("out-data"))
|
|
#expect(result.stderr.contains("err-data"))
|
|
#expect(result.exitCode == 3)
|
|
}
|
|
|
|
@Test func capturedRunFastExit() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
|
|
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
|
|
#expect(result.exitCode == 7)
|
|
#expect(result.stdout == "")
|
|
#expect(result.stderr == "")
|
|
}
|
|
|
|
@Test func capturedRunStderrOnly() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
|
|
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
|
|
#expect(result.exitCode == 1)
|
|
#expect(result.stdout == "")
|
|
#expect(result.stderr.contains("mock lp failure"))
|
|
}
|
|
|
|
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws {
|
|
let pm = ProcessManager()
|
|
// 5000 lines each stream exceeds the 64 KiB pipe buffer.
|
|
let bin = try script(
|
|
"big.sh",
|
|
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
|
|
)
|
|
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
|
|
#expect(result.stdout.contains("out-4999"))
|
|
#expect(result.stderr.contains("err-4999"))
|
|
}
|
|
|
|
@Test func argyllEnvVarIsSet() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
|
|
async let evs = collect(pm, id: "t10")
|
|
try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
|
|
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1")))
|
|
}
|
|
|
|
@Test func unknownIDStdinThrows() async throws {
|
|
let pm = ProcessManager()
|
|
await #expect(throws: ProcessError.unknownID("nope")) {
|
|
try await pm.sendStdin(id: "nope", text: "d\n")
|
|
}
|
|
}
|
|
|
|
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws {
|
|
let pm = ProcessManager()
|
|
let marker = Self.fixtureDir
|
|
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
|
|
let bin = try script(
|
|
"partial-row.sh",
|
|
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":9}'\ntouch \"$1\"\nsleep 30\n"
|
|
)
|
|
let box = Box()
|
|
let observer = observe(pm, id: "t11", into: box)
|
|
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
|
|
#expect(await waitForFile(marker))
|
|
// Retry the flush so the pipe-ingest task can win the actor race
|
|
// on a loaded host; the first successful flush emits the row.
|
|
var flushed = false
|
|
for _ in 0..<50 {
|
|
await pm.flushPartialLine(id: "t11")
|
|
if box.events.contains(where: { if case .jsonRow = $0 { return true }; return false }) {
|
|
flushed = true
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(flushed)
|
|
await pm.kill(id: "t11")
|
|
#expect(await waitForExit(in: box))
|
|
observer.cancel()
|
|
let events = box.events
|
|
let rows = events.compactMap { e -> String? in
|
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
|
return nil
|
|
}
|
|
#expect(rows == ["{\"row\":9}"])
|
|
// Prefixed tails must not leak into stdout, even via finalize.
|
|
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
|
|
#expect(exitCount(in: box) == 1)
|
|
}
|
|
|
|
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script(
|
|
"row-tail.sh",
|
|
"#!/bin/sh\nprintf 'ROW_COLORS_JSON: {\"row\":42}'\n"
|
|
)
|
|
let box = Box()
|
|
let observer = observe(pm, id: "t12", into: box)
|
|
try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
|
|
#expect(await waitForExit(in: box))
|
|
observer.cancel()
|
|
let events = box.events
|
|
let rows = events.compactMap { e -> String? in
|
|
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
|
|
return nil
|
|
}
|
|
#expect(rows == ["{\"row\":42}"])
|
|
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
|
|
let rowIndex = events.firstIndex {
|
|
if case .jsonRow = $0 { return true }; return false
|
|
}
|
|
let exitIndexes = events.indices.filter {
|
|
if case .exit = events[$0] { return true }; return false
|
|
}
|
|
#expect(exitIndexes.count == 1)
|
|
if let rowIndex, let exitIndex = exitIndexes.first {
|
|
#expect(rowIndex < exitIndex)
|
|
} else {
|
|
Issue.record("expected a jsonRow before the exit event")
|
|
}
|
|
}
|
|
|
|
@Test func fastStreamingExitEmitsExactlyOneExit() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
|
|
let box = Box()
|
|
let observer = observe(pm, id: "t13", into: box)
|
|
try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
|
|
#expect(await waitForExit(in: box))
|
|
// The grace window must outlast the 2 s finalize watchdog so a
|
|
// duplicate emission from it would be observed.
|
|
try await Task.sleep(for: .milliseconds(2500))
|
|
observer.cancel()
|
|
#expect(box.events == [.exit(id: "t13", code: 0)])
|
|
}
|
|
|
|
@Test func fastCapturedExitEmitsExactlyOneExit() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
|
|
let box = Box()
|
|
let observer = observe(pm, id: "t14", into: box)
|
|
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
|
|
#expect(result.exitCode == 7)
|
|
// Both the termination handler and the waitUntilExit watchdog
|
|
// resume the same box; give the slower path time to fire.
|
|
try await Task.sleep(for: .milliseconds(500))
|
|
observer.cancel()
|
|
#expect(box.events == [.exit(id: "t14", code: 7)])
|
|
}
|
|
|
|
@Test func capturedRunSetsArgyllNotInteractive() async throws {
|
|
let pm = ProcessManager()
|
|
let bin = try script(
|
|
"cap-env.sh",
|
|
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
|
|
)
|
|
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
|
|
#expect(result.stdout == "ANI=1\n")
|
|
}
|
|
|
|
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws {
|
|
let pm = ProcessManager()
|
|
let marker = Self.fixtureDir
|
|
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
|
|
let slowBin = try script("mixed-slow.sh", "#!/bin/sh\nsleep 30\n")
|
|
let capBin = try script("mixed-cap.sh", "#!/bin/sh\ntouch \"$1\"\nsleep 30\n")
|
|
let streamBox = Box()
|
|
let capBox = Box()
|
|
let streamObserver = observe(pm, id: "t16", into: streamBox)
|
|
let capObserver = observe(pm, id: "t17", into: capBox)
|
|
try await pm.runStreaming(id: "t16", binary: slowBin, arguments: [])
|
|
let capTask = Task {
|
|
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
|
|
}
|
|
#expect(await waitForFile(marker))
|
|
#expect(await waitForRunning(pm, id: "t16"))
|
|
#expect(await waitForRunning(pm, id: "t17"))
|
|
#expect(await pm.killAll() == 2)
|
|
_ = try await capTask.value
|
|
#expect(await waitForExit(in: streamBox))
|
|
#expect(await waitForExit(in: capBox))
|
|
// Grace window outlasts the streaming finalize watchdog.
|
|
try await Task.sleep(for: .milliseconds(2500))
|
|
streamObserver.cancel()
|
|
capObserver.cancel()
|
|
#expect(!(await pm.isRunning("t16")))
|
|
#expect(!(await pm.isRunning("t17")))
|
|
#expect(exitCount(in: streamBox) == 1)
|
|
#expect(exitCount(in: capBox) == 1)
|
|
}
|
|
}
|
|
|
|
final class ProcessLineDecoderTests: XCTestCase {
|
|
func testSplitsAcrossChunkBoundaries() {
|
|
var d = ProcessLineDecoder()
|
|
XCTAssertEqual(d.feed(Data("he".utf8)), [])
|
|
XCTAssertEqual(d.feed(Data("llo\nwor".utf8)), ["hello"])
|
|
XCTAssertEqual(d.feed(Data("ld\n".utf8)), ["world"])
|
|
XCTAssertNil(d.finish())
|
|
}
|
|
|
|
func testCrlfIsStripped() {
|
|
var d = ProcessLineDecoder()
|
|
XCTAssertEqual(d.feed(Data("a\r\nb\r\n".utf8)), ["a", "b"])
|
|
}
|
|
|
|
func testFinishReturnsRemainder() {
|
|
var d = ProcessLineDecoder()
|
|
_ = d.feed(Data("x".utf8))
|
|
XCTAssertEqual(d.finish(), "x")
|
|
XCTAssertNil(d.finish())
|
|
}
|
|
}
|
|
|
|
final class JSONAccumulatorTests: XCTestCase {
|
|
func testMultilinePrettyJSON() {
|
|
var acc = JSONAccumulator()
|
|
XCTAssertNil(acc.feed(line: "{"))
|
|
XCTAssertNil(acc.feed(line: " \"k\": 1"))
|
|
let done = acc.feed(line: "}")
|
|
XCTAssertNotNil(done)
|
|
let obj = try? JSONSerialization.jsonObject(with: done!) as? [String: Int]
|
|
XCTAssertEqual(obj?["k"], 1)
|
|
}
|
|
|
|
func testNonJSONLinesIgnored() {
|
|
var acc = JSONAccumulator()
|
|
XCTAssertNil(acc.feed(line: "Reading instrument..."))
|
|
XCTAssertNil(acc.feed(line: "still text"))
|
|
XCTAssertNil(acc.completeData)
|
|
}
|
|
|
|
func testDecodeTyped() {
|
|
struct Doc: Decodable { let n: Int }
|
|
var acc = JSONAccumulator()
|
|
// Split so the doc completes on the second feed.
|
|
XCTAssertNil(acc.feed(line: "{\"n\":"))
|
|
let data = acc.feed(line: "7}")
|
|
XCTAssertNotNil(data)
|
|
let doc = data.flatMap { try? JSONDecoder().decode(Doc.self, from: $0) }
|
|
XCTAssertEqual(doc?.n, 7)
|
|
XCTAssertTrue(acc.isEmpty)
|
|
}
|
|
}
|
|
|
|
final class LogSanitizerTests: XCTestCase {
|
|
func testHomeIsRewritten() {
|
|
let path = "\(NSHomeDirectory())/Documents/foo.ti1"
|
|
XCTAssertEqual(LogSanitizer.sanitize(path), "~/Documents/foo.ti1")
|
|
}
|
|
}
|