import XCTest import Foundation /// Executable-level integration tests invoking the compiled ReynaCLIHost binary. /// These prove persistent-pipe and malformed-request behavior. final class HostIntegrationTests: XCTestCase { // MARK: - Helpers func hostExecutableURL() throws -> URL { let fm = FileManager.default // When `swift test` runs, cwd is package root. But be robust. // Portable candidates only: package-relative .build locations for common triples. let candidates: [String] = [ ".build/debug/ReynaCLIHost", ".build/arm64-apple-macosx/debug/ReynaCLIHost", ".build/x86_64-apple-macosx/debug/ReynaCLIHost", ] let cwd = fm.currentDirectoryPath var tried: [String] = [] for c in candidates { let url = URL(fileURLWithPath: cwd).appendingPathComponent(c) tried.append(url.path) if fm.isExecutableFile(atPath: url.path) { return url } } // Try surrounding .build directories walked upward from cwd var parent = URL(fileURLWithPath: cwd) for _ in 0..<6 { let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost") tried.append(p1.path) if fm.isExecutableFile(atPath: p1.path) { return p1 } let p2 = parent.appendingPathComponent(".build/arm64-apple-macosx/debug/ReynaCLIHost") tried.append(p2.path) if fm.isExecutableFile(atPath: p2.path) { return p2 } parent = parent.deletingLastPathComponent() } throw NSError(domain: "HostIntegrationTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "ReynaCLIHost binary not found. Tried:\n" + tried.joined(separator: "\n")]) } /// Run host with given stdin string, return stdout lines (non-empty trimmed) after process exits. func runHost(input: String, timeout: TimeInterval = 5) throws -> [String] { let exe = try hostExecutableURL() let process = Process() process.executableURL = exe let stdinPipe = Pipe() let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardInput = stdinPipe process.standardOutput = stdoutPipe process.standardError = stderrPipe try process.run() // Write input then close if let data = input.data(using: .utf8) { stdinPipe.fileHandleForWriting.write(data) } stdinPipe.fileHandleForWriting.closeFile() // Wait with timeout let deadline = Date().addingTimeInterval(timeout) while process.isRunning && Date() < deadline { usleep(100_000) // 0.1s } if process.isRunning { process.terminate() throw NSError(domain: "HostIntegrationTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host process timed out after \(timeout)s. stderr: \(String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "")"]) } let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let outStr = String(data: outData, encoding: .utf8) ?? "" // Split by newline, keep non-empty raw lines but preserve for debugging let lines = outStr.split(separator: "\n", omittingEmptySubsequences: false).map { String($0) }.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return lines } func decodeResponse(_ line: String) throws -> [String: Any] { guard let data = line.data(using: .utf8), let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NSError(domain: "HostIntegrationTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Line is not valid JSON: \(line)"]) } return obj } // MARK: - Tests func testPersistentPipeHandlesTwoHealthRequests() throws { // Two well-formed health requests on persistent stdin must yield two responses. let req1 = #"{"id":"1","operation":"service.health","arguments":{}}"# let req2 = #"{"id":"2","operation":"service.health","arguments":{}}"# let input = req1 + "\n" + req2 + "\n" let lines = try runHost(input: input) XCTAssertEqual(lines.count, 2, "Expected 2 responses for 2 requests, got \(lines.count). Output: \(lines)") let resp1 = try decodeResponse(lines[0]) XCTAssertEqual(resp1["id"] as? String, "1") XCTAssertEqual(resp1["ok"] as? Bool, true) let resp2 = try decodeResponse(lines[1]) XCTAssertEqual(resp2["id"] as? String, "2") XCTAssertEqual(resp2["ok"] as? Bool, true) } func testMalformedJsonProducesInvalidRequestResponseWithoutId() throws { // Malformed nonempty JSON must produce a response with ok:false, error.code invalid_request, id = "" let bad = "not json at all" let input = bad + "\n" let lines = try runHost(input: input) XCTAssertEqual(lines.count, 1, "Malformed JSON should produce one error response, got \(lines.count). Output: \(lines)") let resp = try decodeResponse(lines[0]) XCTAssertEqual(resp["ok"] as? Bool, false, "Malformed JSON should be ok:false") XCTAssertEqual(resp["id"] as? String, "", "When id cannot be recovered, id should be empty string") if let err = resp["error"] as? [String: Any] { XCTAssertEqual(err["code"] as? String, "invalid_request") } else { XCTFail("Missing error object in response: \(resp)") } } func testMalformedJsonPreservesIdWhenPossible() throws { // When malformed JSON still contains an id field, preserve it. let bad = #"{"id":"keep-me","operation":}"# // invalid JSON but id extractable let input = bad + "\n" let lines = try runHost(input: input) XCTAssertEqual(lines.count, 1, "Expected 1 error response for malformed JSON with id, got \(lines)") let resp = try decodeResponse(lines[0]) XCTAssertEqual(resp["ok"] as? Bool, false) XCTAssertEqual(resp["id"] as? String, "keep-me", "Should preserve id when recoverable") if let err = resp["error"] as? [String: Any] { XCTAssertEqual(err["code"] as? String, "invalid_request") } else { XCTFail("Missing error object") } } func testEmptyLinesAreIgnored() throws { // Empty lines should not produce responses or break subsequent messages. let req1 = #"{"id":"a","operation":"service.health","arguments":{}}"# let req2 = #"{"id":"b","operation":"service.health","arguments":{}}"# let input = "\n" + req1 + "\n\n\n" + req2 + "\n\n" let lines = try runHost(input: input) XCTAssertEqual(lines.count, 2, "Empty lines should be ignored, expected 2 responses got \(lines.count): \(lines)") let ids = try lines.map { try decodeResponse($0)["id"] as? String } XCTAssertEqual(ids, ["a", "b"]) } }