diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 091bcf00..bb16f7dd 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2433,6 +2433,50 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertFalse(log.contains("set-environment -g CMUX_PANEL_ID"), log) } + func testShellIntegrationClearsStaleSurfaceScopedTmuxEnvironmentAutomatically() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-tmux-clear-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let logPath = root.appendingPathComponent("tmux.log", isDirectory: false) + + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeExecutableScript( + at: binDir.appendingPathComponent("tmux", isDirectory: false), + contents: """ + #!/bin/sh + if [ "$1" = "show-environment" ] && [ "$2" = "-g" ]; then + printf '%s\\n' 'CMUX_SURFACE_ID=99999999-9999-9999-9999-999999999999' + printf '%s\\n' 'CMUX_PANEL_ID=99999999-9999-9999-9999-999999999999' + exit 0 + fi + printf '%s\\n' "$*" >> "\(logPath.path)" + exit 0 + """ + ) + + _ = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: "_cmux_preexec tmux; print -r -- READY", + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_SOCKET_PATH": "/tmp/cmux-current.sock", + "CMUX_TAG": "feat-tmux-notification-attention-state", + "CMUX_WORKSPACE_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_SURFACE_ID": "22222222-2222-2222-2222-222222222222", + "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_PANEL_ID": "22222222-2222-2222-2222-222222222222", + ] + ) + + let log = (try? String(contentsOf: logPath, encoding: .utf8)) ?? "" + XCTAssertTrue(log.contains("set-environment -gu CMUX_SURFACE_ID"), log) + XCTAssertTrue(log.contains("set-environment -gu CMUX_PANEL_ID"), log) + } + func testShellIntegrationRefreshesWorkspaceScopedCmuxEnvironmentFromTmuxWithoutOverwritingSurfaceScope() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -2481,6 +2525,54 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { ) } + func testShellIntegrationReportsTTYFromTmuxWithoutUsingPanelScope() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-zsh-tmux-report-tty-\(UUID().uuidString)") + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let socketPath = root.appendingPathComponent("cmux-test.sock", isDirectory: false) + let logPath = root.appendingPathComponent("tty.log", isDirectory: false) + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + try fileManager.createDirectory(at: binDir, withIntermediateDirectories: true) + let listenerFD = try bindUnixSocket(at: socketPath.path) + defer { + Darwin.close(listenerFD) + unlink(socketPath.path) + try? fileManager.removeItem(at: root) + } + + try writeExecutableScript( + at: binDir.appendingPathComponent("ncat", isDirectory: false), + contents: """ + #!/bin/sh + cat > "\(logPath.path)" + exit 0 + """ + ) + + _ = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: false, + cmuxLoadShellIntegration: true, + command: """ + _CMUX_TTY_NAME=ttys999 + _cmux_report_tty_once + sleep 0.05 + print -r -- READY + """, + extraEnvironment: [ + "PATH": "\(binDir.path):/usr/bin:/bin:/usr/sbin:/sbin", + "TMUX": "/tmp/tmux-current,123,0", + "CMUX_SOCKET_PATH": socketPath.path, + "CMUX_TAB_ID": "11111111-1111-1111-1111-111111111111", + "CMUX_PANEL_ID": "99999999-9999-9999-9999-999999999999", + ] + ) + + let log = (try? String(contentsOf: logPath, encoding: .utf8)) ?? "" + XCTAssertEqual(log, "report_tty ttys999 --tab=11111111-1111-1111-1111-111111111111\n") + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { try runInteractiveZsh( cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, @@ -2581,6 +2673,56 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { try contents.write(to: url, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) } + + 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.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 + } } final class BrowserInstallDetectorTests: XCTestCase { diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index c46abd4e..6123d350 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -1024,6 +1024,125 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @MainActor + func testNotifyInTmuxPrefersCallerTTYOverStaleValidSurfaceID() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("notify-tmux-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 staleSurface = "33333333-3333-3333-3333-333333333333" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if line == "notify_target \(workspaceId) \(callerSurface) Notification||" { + return "OK" + } + if line == "notify_target \(workspaceId) \(staleSurface) 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 == workspaceId { + return self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": callerSurface, + "ref": "surface:1", + "index": 0, + "focused": false + ], + [ + "id": staleSurface, + "ref": "surface:2", + "index": 1, + "focused": true + ] + ] + ] + ) + } + 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": staleSurface, + "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"] = workspaceId + environment["CMUX_SURFACE_ID"] = staleSurface + environment["CMUX_CLI_TTY_NAME"] = callerTTY + environment["TMUX"] = "/tmp/tmux-current,123,0" + 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 in tmux, saw \(state.commands)" + ) + XCTAssertFalse( + state.commands.contains("notify_target \(workspaceId) \(staleSurface) Notification||"), + "Stale env surface should not win inside tmux, saw \(state.commands)" + ) + } + @MainActor func testTriggerFlashPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { let cliPath = try bundledCLIPath() @@ -1179,4 +1298,147 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { "Focused surface should not win over caller tty, saw \(state.commands)" ) } + + @MainActor + func testTriggerFlashInTmuxPrefersCallerTTYOverStaleValidSurfaceID() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("flash-tmux-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 staleSurface = "33333333-3333-3333-3333-333333333333" + + 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 == workspaceId { + return self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + [ + "id": callerSurface, + "ref": "surface:1", + "index": 0, + "focused": false + ], + [ + "id": staleSurface, + "ref": "surface:2", + "index": 1, + "focused": true + ] + ] + ] + ) + } + 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": staleSurface, + "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 || requestedSurface == staleSurface) { + 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"] = workspaceId + environment["CMUX_SURFACE_ID"] = staleSurface + environment["CMUX_CLI_TTY_NAME"] = callerTTY + environment["TMUX"] = "/tmp/tmux-current,123,0" + 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 trigger-flash to use caller tty surface in tmux, 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) == staleSurface + }, + "Stale env surface should not win inside tmux, saw \(state.commands)" + ) + } }