Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions macapp/WorkroomAppTests/Session/AgentControlPlaneTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import Darwin
import XCTest

@testable import Workroom
@testable import WorkroomSessionProtocol

/// `AgentControlClient` against a real `wr-agent serve`.
///
/// The agent has its own integration tests in Rust and they are cheaper to run, so these cover only
/// what those structurally cannot: the boundary between two languages. The greeting, the envelope
/// and the descriptor list are each **encoded in Rust and decoded by hand in Swift**, and a
/// disagreement about a field width or a byte order would pass every test on either side alone
/// while presenting to a user as a permanently empty sidebar.
///
/// Note what is deliberately absent. The app never sends an `Attach` frame — it hands libghostty an
/// attach command to run as the pane's shell — so attach, resize, input and exit are the Rust
/// suite's to cover, and duplicating them here would test a path nothing ships.
final class AgentControlPlaneTests: XCTestCase {
func testGreetingAndEnvelopeRoundTripAgainstAFreshAgent() throws {
let harness = try AgentHarness.start()
defer { harness.stop() }

let client = AgentControlClient(socketPath: harness.socketPath)
// An empty list is a real answer, not a failure to connect: reaching it means the greeting was
// exchanged, the version was accepted, and a zero-count descriptor list decoded.
XCTAssertEqual(client.list().count, 0)
XCTAssertTrue(client.killAll(), "killAll on an empty agent still acknowledges")
}

/// The payoff. Rust's `encode_descriptor_list` and Swift's `SessionDescriptor.decodeList` were
/// written separately against the same prose description of a wire format.
func testASessionTheAgentHoldsIsDecodableByTheApp() throws {
let harness = try AgentHarness.start()
defer { harness.stop() }

let client = AgentControlClient(socketPath: harness.socketPath)
let identifier = UUID()
try harness.startSession(identifier: identifier)

XCTAssertTrue(
harness.wait { client.list().count == 1 },
"the attach never produced a session the app could see")

let listed = try XCTUnwrap(client.list().first)
XCTAssertEqual(listed.identifier.uuid, identifier, "the identifier survived the round trip")
XCTAssertGreaterThan(listed.shellProcessID, 1, "and so did the shell's pid")
XCTAssertTrue(listed.isAttached, "a session with a live client reports as attached")

// `info` is served by filtering `list`, so this also pins that an identifier decoded from the
// wire compares equal to the one the app minted — a byte-order slip would fail here and
// nowhere else.
XCTAssertNotNil(client.info(identifier: listed.identifier))
XCTAssertNil(
client.info(identifier: SessionIdentifier(UUID())),
"an identifier nothing holds finds nothing")
}

func testKillAndKillAllReachTheAgentsSessions() throws {
let harness = try AgentHarness.start()
defer { harness.stop() }

let client = AgentControlClient(socketPath: harness.socketPath)
let first = UUID()
let second = UUID()
try harness.startSession(identifier: first)
try harness.startSession(identifier: second)
XCTAssertTrue(
harness.wait { client.list().count == 2 }, "both sessions should be listed")

XCTAssertTrue(client.kill(identifier: SessionIdentifier(first)))
XCTAssertTrue(
harness.wait { client.list().count == 1 }, "kill should remove exactly one")
XCTAssertEqual(client.list().first?.identifier.uuid, second)

XCTAssertTrue(client.killAll())
XCTAssertTrue(harness.wait { client.list().isEmpty }, "killAll should empty the agent")
}

/// `decodeHello` throws `notAnAgent` rather than returning nil specifically so that a peer which
/// is not an agent fails at its second byte instead of waiting out the two-second timeout. A
/// login banner or an MOTD on a remote stream is the case that motivates it, and it is reachable
/// locally with any socket that greets first.
func testAPeerThatIsNotAnAgentIsRejectedPromptly() throws {
signal(SIGPIPE, SIG_IGN)
let directory = URL(
fileURLWithPath: "/tmp/wra-banner-\(UUID().uuidString.prefix(8))", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let socketPath = directory.appendingPathComponent("b.sock").path

let listener = try Self.listen(at: socketPath)
defer { close(listener) }
let greeter = Thread {
let accepted = accept(listener, nil, nil)
guard accepted >= 0 else { return }
let banner = Array("Welcome to ubuntu 24.04 LTS\n".utf8)
_ = banner.withUnsafeBytes { Darwin.send(accepted, $0.baseAddress, $0.count, 0) }
// Hold the connection open: giving up must come from recognising the banner, not from the
// peer hanging up, or this would pass against the timeout it exists to avoid.
Thread.sleep(forTimeInterval: 3)
close(accepted)
}
greeter.start()

let started = Date()
let client = AgentControlClient(socketPath: socketPath)
XCTAssertTrue(client.list().isEmpty)
XCTAssertLessThan(
Date().timeIntervalSince(started), 1.5,
"a banner must be recognised as not-an-agent, not waited out")
}

private static func listen(at socketPath: String) throws -> Int32 {
let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
guard descriptor >= 0 else { throw XCTSkip("socket() failed") }
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let pathBytes = Array(socketPath.utf8)
guard pathBytes.count < MemoryLayout.size(ofValue: address.sun_path) else {
close(descriptor)
throw NSError(domain: "AgentControlPlaneTests", code: 1)
}
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: pathBytes) }
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
let bound = withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(descriptor, $0, size)
}
}
guard bound == 0, Darwin.listen(descriptor, 1) == 0 else {
close(descriptor)
throw NSError(
domain: "AgentControlPlaneTests", code: 2,
userInfo: [NSLocalizedDescriptionKey: "bind/listen failed: \(errno)"])
}
return descriptor
}
}
133 changes: 133 additions & 0 deletions macapp/WorkroomAppTests/Session/AgentHarness.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import Foundation

/// Launches a throwaway `wr-agent serve` against a temp socket, and can put real sessions on it.
///
/// The Rust side has its own integration tests and they are cheaper, so this exists for the one
/// thing they structurally cannot cover: whether the bytes the agent writes are the bytes the
/// **app** reads. `AgentControlClient` hand-rolls the greeting, the envelope and the descriptor
/// decode in Swift against encoders written in Rust, and a field-width disagreement between them
/// would not fail anything on either side alone — it would show up as an empty sidebar.
final class AgentHarness {
let socketPath: String
private let process: Process
private let directory: URL
private var attachments: [Process] = []

static func start() throws -> AgentHarness {
// sun_path is 104 bytes; NSTemporaryDirectory() + a UUID overflows it.
let directory = URL(
fileURLWithPath: "/tmp/wra-\(UUID().uuidString.prefix(8))", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let socketPath = directory.appendingPathComponent("a.sock").path
let binary = try binaryURL()

let process = Process()
process.executableURL = binary
// The idle timeout has to outlast the whole test: an agent with no sessions and no clients
// exits on purpose, and between two assertions it is briefly both.
process.arguments = ["serve", "--socket", socketPath, "--idle-timeout", "120"]
process.standardOutput = FileHandle.nullDevice
let errorURL = directory.appendingPathComponent("agent.err")
FileManager.default.createFile(atPath: errorURL.path, contents: nil)
process.standardError = FileHandle(forWritingAtPath: errorURL.path)
try process.run()

let deadline = Date().addingTimeInterval(5)
while Date() < deadline {
if FileManager.default.fileExists(atPath: socketPath) { break }
if !process.isRunning { break }
Thread.sleep(forTimeInterval: 0.02)
}
guard FileManager.default.fileExists(atPath: socketPath) else {
let error = (try? String(contentsOf: errorURL, encoding: .utf8)) ?? ""
let status = process.isRunning ? "running" : "exited \(process.terminationStatus)"
process.terminate()
throw NSError(
domain: "AgentHarness", code: 1,
userInfo: [
NSLocalizedDescriptionKey:
"agent socket never appeared at \(socketPath) (\(status), binary=\(binary.path), stderr=\(error))"
])
}
return AgentHarness(socketPath: socketPath, process: process, directory: directory)
}

private init(socketPath: String, process: Process, directory: URL) {
self.socketPath = socketPath
self.process = process
self.directory = directory
}

/// Puts a real session on the agent, the way the app does: by running `wr-agent attach` with the
/// session's configuration in its environment.
///
/// Deliberately not by sending an `Attach` frame. The app never does that — it hands libghostty
/// an attach command to run as the pane's shell — so driving the socket directly would be testing
/// a path nothing uses and skipping the one that ships.
@discardableResult
func startSession(identifier: UUID, command: String = "cat") throws -> Process {
let process = Process()
process.executableURL = try Self.binaryURL()
process.arguments = ["attach"]
// No flags, exactly as `PersistentSessionService.attachCommand()` builds it: the environment
// is the whole contract on the app's side, so passing `--socket` here would test a path the
// app never takes.
process.environment = [
"WORKROOM_SESSION_SOCKET": socketPath,
"WORKROOM_SESSION_ID": identifier.uuidString,
"WORKROOM_SESSION_COMMAND": command,
"WORKROOM_SESSION_SHELL": "/bin/sh",
"WORKROOM_SESSION_CWD": NSTemporaryDirectory(),
"TERM": "dumb",
"PATH": "/bin:/usr/bin",
]
process.standardInput = Pipe()
process.standardOutput = Pipe()
process.standardError = FileHandle.nullDevice
try process.run()
attachments.append(process)
return process
}

/// Waits until `condition` holds, so tests never sleep a fixed amount for a session to appear.
func wait(seconds: TimeInterval = 5, until condition: () -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(seconds)
while Date() < deadline {
if condition() { return true }
Thread.sleep(forTimeInterval: 0.05)
}
return condition()
}

func stop() {
for attachment in attachments where attachment.isRunning {
attachment.terminate()
}
attachments.removeAll()
process.terminate()
process.waitUntilExit()
try? FileManager.default.removeItem(at: directory)
}

static func binaryURL() throws -> URL {
for name in ["BUILT_PRODUCTS_DIR", "TARGET_BUILD_DIR"] {
if let directory = ProcessInfo.processInfo.environment[name] {
let url = URL(fileURLWithPath: directory).appendingPathComponent("wr-agent")
if FileManager.default.isExecutableFile(atPath: url.path) { return url }
}
}
// These are host-based tests, so `Bundle.main` is the app itself and the agent is where the
// build phase embedded it — the same place `PersistentSessionPaths.binaryURL(for:)` looks.
let embedded = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/wr-agent")
if FileManager.default.isExecutableFile(atPath: embedded.path) { return embedded }
var candidate = Bundle(for: AgentHarness.self).bundleURL
for _ in 0..<6 {
let url = candidate.appendingPathComponent("wr-agent")
if FileManager.default.isExecutableFile(atPath: url.path) { return url }
candidate.deleteLastPathComponent()
}
throw NSError(
domain: "AgentHarness", code: 2,
userInfo: [NSLocalizedDescriptionKey: "wr-agent binary not found"])
}
}
Loading
Loading