Add tmux attention regression tests

This commit is contained in:
Lawrence Chen 2026-03-20 20:18:33 -07:00
parent 39c03c9b07
commit d4811650d7
No known key found for this signature in database
6 changed files with 1188 additions and 0 deletions

View file

@ -469,3 +469,714 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
)
}
}
final class CLINotifyProcessIntegrationTests: XCTestCase {
private struct ProcessRunResult {
let status: Int32
let stdout: String
let stderr: String
let timedOut: Bool
}
private final class MockSocketServerState: @unchecked Sendable {
private let lock = NSLock()
private(set) var commands: [String] = []
func append(_ command: String) {
lock.lock()
commands.append(command)
lock.unlock()
}
}
private func makeSocketPath(_ name: String) -> String {
let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8)
return URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("cli-\(name.prefix(6))-\(shortID).sock")
.path
}
private func bundledCLIPath() throws -> String {
let fileManager = FileManager.default
let appBundleURL = Bundle(for: Self.self)
.bundleURL
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let enumerator = fileManager.enumerator(
at: appBundleURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)
while let item = enumerator?.nextObject() as? URL {
guard item.lastPathComponent == "cmux",
item.path.contains(".app/Contents/Resources/bin/cmux") else {
continue
}
return item.path
}
throw XCTSkip("Bundled cmux CLI not found in \(appBundleURL.path)")
}
private func runProcess(
executablePath: String,
arguments: [String],
environment: [String: String],
timeout: TimeInterval
) -> ProcessRunResult {
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.environment = environment
process.standardInput = FileHandle.nullDevice
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do {
try process.run()
} catch {
return ProcessRunResult(
status: -1,
stdout: "",
stderr: String(describing: error),
timedOut: false
)
}
let exitSignal = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
exitSignal.signal()
}
let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut
if timedOut {
process.terminate()
_ = exitSignal.wait(timeout: .now() + 1)
}
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return ProcessRunResult(
status: process.terminationStatus,
stdout: stdout,
stderr: stderr,
timedOut: timedOut
)
}
private func bindUnixSocket(at path: String) throws -> Int32 {
unlink(path)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
throw NSError(
domain: NSPOSIXErrorDomain,
code: Int(errno),
userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"]
)
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(pathBuf, ptr, maxPathLength - 1)
}
}
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard bindResult == 0 else {
let code = Int(errno)
Darwin.close(fd)
throw NSError(
domain: NSPOSIXErrorDomain,
code: code,
userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"]
)
}
guard Darwin.listen(fd, 1) == 0 else {
let code = Int(errno)
Darwin.close(fd)
throw NSError(
domain: NSPOSIXErrorDomain,
code: code,
userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"]
)
}
return fd
}
private func startMockServer(
listenerFD: Int32,
state: MockSocketServerState,
handler: @escaping @Sendable (String) -> String
) -> XCTestExpectation {
let handled = expectation(description: "cli mock socket handled")
DispatchQueue.global(qos: .userInitiated).async {
var clientAddr = sockaddr_un()
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen)
}
}
guard clientFD >= 0 else {
handled.fulfill()
return
}
defer {
Darwin.close(clientFD)
handled.fulfill()
}
var pending = Data()
var buffer = [UInt8](repeating: 0, count: 4096)
while true {
let count = Darwin.read(clientFD, &buffer, buffer.count)
if count < 0 {
if errno == EINTR { continue }
return
}
if count == 0 { return }
pending.append(buffer, count: count)
while let newlineRange = pending.firstRange(of: Data([0x0A])) {
let lineData = pending.subdata(in: 0..<newlineRange.lowerBound)
pending.removeSubrange(0...newlineRange.lowerBound)
guard let line = String(data: lineData, encoding: .utf8) else { continue }
state.append(line)
let response = handler(line) + "\n"
_ = response.withCString { ptr in
Darwin.write(clientFD, ptr, strlen(ptr))
}
}
}
}
return handled
}
private func v2Response(
id: String,
ok: Bool,
result: [String: Any]? = nil,
error: [String: Any]? = nil
) -> String {
var payload: [String: Any] = ["id": id, "ok": ok]
if let result {
payload["result"] = result
}
if let error {
payload["error"] = error
}
let data = try? JSONSerialization.data(withJSONObject: payload, options: [])
return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}"
}
@MainActor
func testNotifyFallsBackFromStaleCallerWorkspaceAndSurfaceIDs() throws {
let cliPath = try bundledCLIPath()
let socketPath = makeSocketPath("notify")
let listenerFD = try bindUnixSocket(at: socketPath)
let state = MockSocketServerState()
let currentWorkspace = "11111111-1111-1111-1111-111111111111"
let currentSurface = "22222222-2222-2222-2222-222222222222"
let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"
let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"
defer {
Darwin.close(listenerFD)
unlink(socketPath)
}
let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in
if let data = line.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let id = payload["id"] as? String,
let method = payload["method"] as? String {
let params = payload["params"] as? [String: Any] ?? [:]
switch method {
case "surface.list":
let workspaceId = params["workspace_id"] as? String
if workspaceId == staleWorkspace {
return self.v2Response(
id: id,
ok: false,
error: ["code": "not_found", "message": "Workspace not found"]
)
}
if workspaceId == currentWorkspace {
return self.v2Response(
id: id,
ok: true,
result: [
"surfaces": [
[
"id": currentSurface,
"ref": "surface:1",
"index": 0,
"focused": true
]
]
]
)
}
case "workspace.current":
return self.v2Response(
id: id,
ok: true,
result: ["workspace_id": currentWorkspace]
)
default:
break
}
return self.v2Response(
id: id,
ok: false,
error: ["code": "unexpected", "message": "Unexpected method \(method)"]
)
}
if line == "notify_target \(currentWorkspace) \(currentSurface) Notification||" {
return "OK"
}
return "ERROR: Unexpected command \(line)"
}
var environment = ProcessInfo.processInfo.environment
environment["CMUX_SOCKET_PATH"] = socketPath
environment["CMUX_WORKSPACE_ID"] = staleWorkspace
environment["CMUX_SURFACE_ID"] = staleSurface
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
let result = runProcess(
executablePath: cliPath,
arguments: ["notify"],
environment: environment,
timeout: 5
)
wait(for: [serverHandled], timeout: 5)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertEqual(result.stdout, "OK\n")
XCTAssertTrue(result.stderr.isEmpty, result.stderr)
XCTAssertTrue(
state.commands.contains("notify_target \(currentWorkspace) \(currentSurface) Notification||"),
"Expected notify_target to use current workspace and surface, saw \(state.commands)"
)
}
@MainActor
func testTriggerFlashFallsBackFromStaleCallerWorkspaceAndSurfaceIDs() throws {
let cliPath = try bundledCLIPath()
let socketPath = makeSocketPath("flash")
let listenerFD = try bindUnixSocket(at: socketPath)
let state = MockSocketServerState()
let currentWorkspace = "11111111-1111-1111-1111-111111111111"
let currentSurface = "22222222-2222-2222-2222-222222222222"
let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"
let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"
defer {
Darwin.close(listenerFD)
unlink(socketPath)
}
let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in
guard let data = line.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let id = payload["id"] as? String,
let method = payload["method"] as? String else {
return self.v2Response(
id: "unknown",
ok: false,
error: ["code": "unexpected", "message": "Unexpected payload"]
)
}
let params = payload["params"] as? [String: Any] ?? [:]
switch method {
case "surface.list":
let workspaceId = params["workspace_id"] as? String
if workspaceId == staleWorkspace {
return self.v2Response(
id: id,
ok: false,
error: ["code": "not_found", "message": "Workspace not found"]
)
}
if workspaceId == currentWorkspace {
return self.v2Response(
id: id,
ok: true,
result: [
"surfaces": [
[
"id": currentSurface,
"ref": "surface:1",
"index": 0,
"focused": true
]
]
]
)
}
case "workspace.current":
return self.v2Response(
id: id,
ok: true,
result: ["workspace_id": currentWorkspace]
)
case "surface.trigger_flash":
let workspaceId = params["workspace_id"] as? String
let surfaceId = params["surface_id"] as? String
if workspaceId == currentWorkspace, surfaceId == currentSurface {
return self.v2Response(id: id, ok: true, result: [:])
}
default:
break
}
return self.v2Response(
id: id,
ok: false,
error: ["code": "unexpected", "message": "Unexpected method \(method)"]
)
}
var environment = ProcessInfo.processInfo.environment
environment["CMUX_SOCKET_PATH"] = socketPath
environment["CMUX_WORKSPACE_ID"] = staleWorkspace
environment["CMUX_SURFACE_ID"] = staleSurface
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
let result = runProcess(
executablePath: cliPath,
arguments: ["trigger-flash"],
environment: environment,
timeout: 5
)
wait(for: [serverHandled], timeout: 5)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertEqual(result.stdout, "OK\n")
XCTAssertTrue(result.stderr.isEmpty, result.stderr)
XCTAssertTrue(
state.commands.contains { command in
guard let data = command.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let method = payload["method"] as? String,
method == "surface.trigger_flash" else {
return false
}
let params = payload["params"] as? [String: Any] ?? [:]
return (params["workspace_id"] as? String) == currentWorkspace
&& (params["surface_id"] as? String) == currentSurface
},
"Expected surface.trigger_flash to use current workspace and surface, saw \(state.commands)"
)
}
@MainActor
func testNotifyPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws {
let cliPath = try bundledCLIPath()
let socketPath = makeSocketPath("notify-tty")
let listenerFD = try bindUnixSocket(at: socketPath)
let state = MockSocketServerState()
let callerTTY = "/dev/ttys777"
let workspaceId = "11111111-1111-1111-1111-111111111111"
let callerSurface = "22222222-2222-2222-2222-222222222222"
let focusedSurface = "33333333-3333-3333-3333-333333333333"
let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"
let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"
defer {
Darwin.close(listenerFD)
unlink(socketPath)
}
let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in
if line == "notify_target \(workspaceId) \(callerSurface) Notification||" {
return "OK"
}
guard let data = line.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let id = payload["id"] as? String,
let method = payload["method"] as? String else {
return "ERROR: Unexpected command \(line)"
}
let params = payload["params"] as? [String: Any] ?? [:]
switch method {
case "surface.list":
let requestedWorkspace = params["workspace_id"] as? String
if requestedWorkspace == staleWorkspace {
return self.v2Response(
id: id,
ok: false,
error: ["code": "not_found", "message": "Workspace not found"]
)
}
if requestedWorkspace == workspaceId {
return self.v2Response(
id: id,
ok: true,
result: [
"surfaces": [
[
"id": callerSurface,
"ref": "surface:1",
"index": 0,
"focused": false
],
[
"id": focusedSurface,
"ref": "surface:2",
"index": 1,
"focused": true
]
]
]
)
}
case "workspace.current":
return self.v2Response(
id: id,
ok: true,
result: ["workspace_id": workspaceId]
)
case "debug.terminals":
return self.v2Response(
id: id,
ok: true,
result: [
"count": 2,
"terminals": [
[
"workspace_id": workspaceId,
"surface_id": callerSurface,
"tty": callerTTY
],
[
"workspace_id": workspaceId,
"surface_id": focusedSurface,
"tty": "/dev/ttys778"
]
]
]
)
default:
break
}
return self.v2Response(
id: id,
ok: false,
error: ["code": "unexpected", "message": "Unexpected method \(method)"]
)
}
var environment = ProcessInfo.processInfo.environment
environment["CMUX_SOCKET_PATH"] = socketPath
environment["CMUX_WORKSPACE_ID"] = staleWorkspace
environment["CMUX_SURFACE_ID"] = staleSurface
environment["CMUX_CLI_TTY_NAME"] = callerTTY
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
let result = runProcess(
executablePath: cliPath,
arguments: ["notify"],
environment: environment,
timeout: 5
)
wait(for: [serverHandled], timeout: 5)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertEqual(result.stdout, "OK\n")
XCTAssertTrue(result.stderr.isEmpty, result.stderr)
XCTAssertTrue(
state.commands.contains("notify_target \(workspaceId) \(callerSurface) Notification||"),
"Expected notify_target to use caller tty surface, saw \(state.commands)"
)
XCTAssertFalse(
state.commands.contains("notify_target \(workspaceId) \(focusedSurface) Notification||"),
"Focused surface should not win over caller tty, saw \(state.commands)"
)
}
@MainActor
func testTriggerFlashPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws {
let cliPath = try bundledCLIPath()
let socketPath = makeSocketPath("flash-tty")
let listenerFD = try bindUnixSocket(at: socketPath)
let state = MockSocketServerState()
let callerTTY = "/dev/ttys777"
let workspaceId = "11111111-1111-1111-1111-111111111111"
let callerSurface = "22222222-2222-2222-2222-222222222222"
let focusedSurface = "33333333-3333-3333-3333-333333333333"
let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"
let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"
defer {
Darwin.close(listenerFD)
unlink(socketPath)
}
let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in
guard let data = line.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let id = payload["id"] as? String,
let method = payload["method"] as? String else {
return self.v2Response(
id: "unknown",
ok: false,
error: ["code": "unexpected", "message": "Unexpected payload"]
)
}
let params = payload["params"] as? [String: Any] ?? [:]
switch method {
case "surface.list":
let requestedWorkspace = params["workspace_id"] as? String
if requestedWorkspace == staleWorkspace {
return self.v2Response(
id: id,
ok: false,
error: ["code": "not_found", "message": "Workspace not found"]
)
}
if requestedWorkspace == workspaceId {
return self.v2Response(
id: id,
ok: true,
result: [
"surfaces": [
[
"id": callerSurface,
"ref": "surface:1",
"index": 0,
"focused": false
],
[
"id": focusedSurface,
"ref": "surface:2",
"index": 1,
"focused": true
]
]
]
)
}
case "workspace.current":
return self.v2Response(
id: id,
ok: true,
result: ["workspace_id": workspaceId]
)
case "debug.terminals":
return self.v2Response(
id: id,
ok: true,
result: [
"count": 2,
"terminals": [
[
"workspace_id": workspaceId,
"surface_id": callerSurface,
"tty": callerTTY
],
[
"workspace_id": workspaceId,
"surface_id": focusedSurface,
"tty": "/dev/ttys778"
]
]
]
)
case "surface.trigger_flash":
let requestedWorkspace = params["workspace_id"] as? String
let requestedSurface = params["surface_id"] as? String
if requestedWorkspace == workspaceId, requestedSurface == callerSurface {
return self.v2Response(id: id, ok: true, result: [:])
}
default:
break
}
return self.v2Response(
id: id,
ok: false,
error: ["code": "unexpected", "message": "Unexpected method \(method)"]
)
}
var environment = ProcessInfo.processInfo.environment
environment["CMUX_SOCKET_PATH"] = socketPath
environment["CMUX_WORKSPACE_ID"] = staleWorkspace
environment["CMUX_SURFACE_ID"] = staleSurface
environment["CMUX_CLI_TTY_NAME"] = callerTTY
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
let result = runProcess(
executablePath: cliPath,
arguments: ["trigger-flash"],
environment: environment,
timeout: 5
)
wait(for: [serverHandled], timeout: 5)
XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertEqual(result.stdout, "OK\n")
XCTAssertTrue(result.stderr.isEmpty, result.stderr)
XCTAssertTrue(
state.commands.contains { command in
guard let data = command.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let method = payload["method"] as? String,
method == "surface.trigger_flash" else {
return false
}
let params = payload["params"] as? [String: Any] ?? [:]
return (params["workspace_id"] as? String) == workspaceId
&& (params["surface_id"] as? String) == callerSurface
},
"Expected surface.trigger_flash to use caller tty surface, saw \(state.commands)"
)
XCTAssertFalse(
state.commands.contains { command in
guard let data = command.data(using: .utf8),
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let method = payload["method"] as? String,
method == "surface.trigger_flash" else {
return false
}
let params = payload["params"] as? [String: Any] ?? [:]
return (params["workspace_id"] as? String) == workspaceId
&& (params["surface_id"] as? String) == focusedSurface
},
"Focused surface should not win over caller tty, saw \(state.commands)"
)
}
}