9fd04b0ce4
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
150 lines
6.9 KiB
Swift
150 lines
6.9 KiB
Swift
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"])
|
|
}
|
|
}
|