From d4811650d76783846a2f0ff186bfb5d7774a4394 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 20 Mar 2026 20:18:33 -0700 Subject: [PATCH] Add tmux attention regression tests --- cmuxTests/NotificationAndMenuBarTests.swift | 99 +++ cmuxTests/TabManagerUnitTests.swift | 104 +++ cmuxTests/WindowAndDragTests.swift | 56 ++ .../WorkspaceContentViewVisibilityTests.swift | 82 ++ .../WorkspaceRemoteConnectionTests.swift | 711 ++++++++++++++++++ cmuxTests/WorkspaceUnitTests.swift | 136 ++++ 6 files changed, 1188 insertions(+) diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index fa358cf6..c0432c8e 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -767,6 +767,105 @@ final class MenuBarBadgeLabelFormatterTests: XCTestCase { } } +@MainActor +final class FocusedNotificationIndicatorTests: XCTestCase { + func testFocusedNotificationIndicatorRemainsVisibleAfterFocusedNotificationIsRead() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + store.addNotification( + tabId: workspace.id, + surfaceId: panelId, + title: "Focused", + subtitle: "", + body: "" + ) + + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + + store.markRead(forTabId: workspace.id, surfaceId: panelId) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + + store.clearFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + } + + func testNewNotificationOnDifferentSurfaceClearsPreviousFocusedReadIndicator() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split workspace setup") + return + } + + workspace.focusPanel(rightPanel.id) + + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: rightPanel.id) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id)) + + store.addNotification( + tabId: workspace.id, + surfaceId: leftPanelId, + title: "Left", + subtitle: "", + body: "" + ) + + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: rightPanel.id)) + } +} + final class NotificationMenuSnapshotBuilderTests: XCTestCase { func testSnapshotCountsUnreadAndLimitsRecentItems() { diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index d6942378..2c9acbcc 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -806,6 +806,110 @@ final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { } +@MainActor +final class TabManagerFocusedNotificationIndicatorTests: XCTestCase { + func testDismissNotificationOnDirectInteractionClearsFocusedNotificationIndicator() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + + XCTAssertTrue( + manager.dismissNotificationOnDirectInteraction(tabId: workspace.id, surfaceId: panelId) + ) + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + } + + func testDismissNotificationOnDirectInteractionTriggersDismissFlashForFocusedIndicatorOnly() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let defaults = UserDefaults.standard + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey) + let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey) + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = true + defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) + defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalExperimentEnabled { + defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey) + } + if let originalExperimentTarget { + defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey) + } + } + + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + XCTAssertTrue(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0) + + XCTAssertTrue( + manager.dismissNotificationOnDirectInteraction(tabId: workspace.id, surfaceId: panelId) + ) + + XCTAssertFalse(store.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: panelId)) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashToken, + 1, + "Expected dismissing a focused-read indicator to emit a dismiss flash even when unread is already cleared" + ) + XCTAssertEqual(workspace.tmuxWorkspaceFlashPanelId, panelId) + XCTAssertEqual(workspace.tmuxWorkspaceFlashReason, .notificationDismiss) + } +} + @MainActor final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 6707eda4..16507eb8 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1248,4 +1248,60 @@ final class MarkdownPanelPointerObserverViewTests: XCTestCase { XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30))) } } + +@MainActor +final class TmuxWorkspacePaneOverlayTests: XCTestCase { + func testTmuxWorkspacePaneOverlayModelTracksFlashReason() { + let model = TmuxWorkspacePaneOverlayModel() + let initialState = TmuxWorkspacePaneOverlayRenderState( + workspaceId: UUID(), + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 1, + flashReason: .notificationArrival + ) + let laterState = TmuxWorkspacePaneOverlayRenderState( + workspaceId: initialState.workspaceId, + unreadRects: [], + flashRect: CGRect(x: 10, y: 20, width: 300, height: 200), + flashToken: 2, + flashReason: .navigation + ) + + model.apply(initialState) + model.apply(laterState) + + XCTAssertEqual(model.flashReason, .navigation) + } + + func testNavigationFlashUsesNonNotificationPresentation() { + XCTAssertNotEqual( + WorkspaceAttentionCoordinator.flashStyle(for: .navigation), + WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival) + ) + } + + func testTmuxWorkspacePaneExactRectReturnsContentRelativeFrameForDescendantView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected contentView") + return + } + + let targetView = NSView(frame: NSRect(x: 120, y: 48, width: 300, height: 200)) + contentView.addSubview(targetView) + + XCTAssertEqual( + ContentView.tmuxWorkspacePaneExactRect(for: targetView, in: contentView), + CGRect(x: 120, y: 48, width: 300, height: 200) + ) + } +} #endif diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index d3759a0c..2d6e8fa5 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -1,4 +1,6 @@ import XCTest +import CoreGraphics +import Bonsplit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -76,4 +78,84 @@ final class WorkspaceContentViewVisibilityTests: XCTestCase { ) ) } + + func testTmuxWorkspacePaneOverlayRectReturnsMatchingPaneFrame() { + let paneID = PaneID(id: UUID()) + let snapshot = LayoutSnapshot( + containerFrame: PixelRect(x: 200, y: 32, width: 1200, height: 800), + panes: [ + PaneGeometry( + paneId: paneID.id.uuidString, + frame: PixelRect(x: 877.5, y: 32, width: 500, height: 320), + selectedTabId: nil, + tabIds: [] + ) + ], + focusedPaneId: paneID.id.uuidString, + timestamp: 0 + ) + + XCTAssertEqual( + WorkspaceContentView.tmuxWorkspacePaneOverlayRect( + layoutSnapshot: snapshot, + paneId: paneID + ), + CGRect(x: 677.5, y: 30, width: 500, height: 290) + ) + } + + @MainActor + func testTmuxWorkspacePaneUnreadRectsIncludeFocusedReadIndicator() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let surfaceId = workspace.surfaceIdFromPanelId(panelId), + let paneId = workspace.paneId(forPanelId: panelId) else { + XCTFail("Expected selected workspace geometry") + return + } + + store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) + + let snapshot = LayoutSnapshot( + containerFrame: PixelRect(x: 200, y: 32, width: 1200, height: 800), + panes: [ + PaneGeometry( + paneId: paneId.id.uuidString, + frame: PixelRect(x: 877.5, y: 32, width: 500, height: 320), + selectedTabId: surfaceId.uuid.uuidString, + tabIds: [surfaceId.uuid.uuidString] + ) + ], + focusedPaneId: paneId.id.uuidString, + timestamp: 0 + ) + + XCTAssertEqual( + WorkspaceContentView.tmuxWorkspacePaneUnreadRects( + workspace: workspace, + notificationStore: store, + layoutSnapshot: snapshot + ), + [CGRect(x: 677.5, y: 30, width: 500, height: 290)] + ) + } } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 797c96a9..c46abd4e 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -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.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.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.. 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)" + ) + } +} diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 16e04e82..4a766a9d 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -1071,6 +1071,142 @@ final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { } +@MainActor +final class WorkspaceAttentionFlashTests: XCTestCase { + func testMoveFocusTriggersWholePaneFlashTokenWhenWholePaneModeEnabled() { + let defaults = UserDefaults.standard + let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey) + let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey) + + defer { + if let originalExperimentEnabled { + defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey) + } + if let originalExperimentTarget { + defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey) + } + } + + defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) + defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + XCTAssertEqual(workspace.tmuxWorkspaceFlashToken, 0) + XCTAssertNil(workspace.tmuxWorkspaceFlashPanelId) + + workspace.moveFocus(direction: .left) + + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashToken, + 1, + "Expected moving focus left to advance the workspace-pane flash token" + ) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashPanelId, + leftPanelId, + "Expected moving focus left to target the newly focused pane for whole-pane flash" + ) + + workspace.moveFocus(direction: .right) + + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashToken, + 2, + "Expected moving focus right to advance the workspace-pane flash token again" + ) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashPanelId, + rightPanel.id, + "Expected moving focus right to retarget the whole-pane flash to the new pane" + ) + } + + func testMoveFocusSuppressesWorkspacePaneFlashWhenAnotherPaneOwnsUnreadAttention() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let notificationStore = TerminalNotificationStore.shared + let defaults = UserDefaults.standard + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalExperimentEnabled = defaults.object(forKey: TmuxOverlayExperimentSettings.enabledKey) + let originalExperimentTarget = defaults.object(forKey: TmuxOverlayExperimentSettings.targetKey) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + defer { + notificationStore.replaceNotificationsForTesting([]) + notificationStore.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalExperimentEnabled { + defaults.set(originalExperimentEnabled, forKey: TmuxOverlayExperimentSettings.enabledKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.enabledKey) + } + if let originalExperimentTarget { + defaults.set(originalExperimentTarget, forKey: TmuxOverlayExperimentSettings.targetKey) + } else { + defaults.removeObject(forKey: TmuxOverlayExperimentSettings.targetKey) + } + } + + notificationStore.replaceNotificationsForTesting([]) + notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = notificationStore + AppFocusState.overrideIsFocused = true + defaults.set(true, forKey: TmuxOverlayExperimentSettings.enabledKey) + defaults.set(TmuxOverlayExperimentTarget.bonsplitPane.rawValue, forKey: TmuxOverlayExperimentSettings.targetKey) + + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + + workspace.moveFocus(direction: .left) + + notificationStore.addNotification( + tabId: workspace.id, + surfaceId: leftPanelId, + title: "Unread", + subtitle: "", + body: "Left pane owns notification attention" + ) + + XCTAssertTrue( + notificationStore.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: leftPanelId), + "Expected the left pane to own visible notification attention before moving focus" + ) + + let flashTokenBeforeNavigation = workspace.tmuxWorkspaceFlashToken + + workspace.moveFocus(direction: .right) + + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + XCTAssertEqual( + workspace.tmuxWorkspaceFlashToken, + flashTokenBeforeNavigation, + "Expected navigation flash to be suppressed while another pane owns notification attention" + ) + } +} + + @MainActor final class WorkspaceBrowserProfileSelectionTests: XCTestCase { private final class RejectingCreateTabDelegate: BonsplitDelegate {