Add tmux attention regression tests
This commit is contained in:
parent
39c03c9b07
commit
d4811650d7
6 changed files with 1188 additions and 0 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue