cmux/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Eray Bozoglu 2712cabac9
Fix orphaned child processes when closing workspace tabs (#889)
* Fix orphaned child processes when closing workspace tabs

When closing a workspace tab via the sidebar X button, child processes
(login → zsh → claude) survived as orphans because TabManager.closeWorkspace()
only removed the workspace from the tabs array without explicitly freeing
Ghostty surfaces. It relied on ARC to cascade deallocation, but SwiftUI views
and Combine publishers held references, delaying or preventing
ghostty_surface_free() (which sends SIGHUP) from ever running.

This adds explicit teardown on the workspace close path:
- TerminalSurface.teardownSurface(): idempotent method to free the Ghostty
  runtime surface eagerly, matching the existing deinit logic
- TerminalPanel.close() now calls teardownSurface() to ensure SIGHUP is sent
- Workspace.teardownAllPanels() iterates all panels and closes them
- TabManager.closeWorkspace() calls teardownAllPanels() before removing
  the workspace from the tabs array

* Harden workspace teardown and ownership checks

* Address follow-up teardown review feedback

---------

Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
2026-03-04 20:00:35 -08:00

9979 lines
374 KiB
Swift

import XCTest
import AppKit
import SwiftUI
import WebKit
import SwiftUI
import ObjectiveC.runtime
import Bonsplit
import UserNotifications
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
private var cmuxUnitTestInspectorOverrideInstalled = false
private extension CmuxWebView {
@objc func cmuxUnitTestInspector() -> NSObject? {
objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject
}
}
private extension WKWebView {
func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
objc_setAssociatedObject(
self,
&cmuxUnitTestInspectorAssociationKey,
inspector,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}
private func installCmuxUnitTestInspectorOverride() {
guard !cmuxUnitTestInspectorOverrideInstalled else { return }
guard let replacementMethod = class_getInstanceMethod(
CmuxWebView.self,
#selector(CmuxWebView.cmuxUnitTestInspector)
) else {
fatalError("Unable to locate test inspector replacement method")
}
let added = class_addMethod(
CmuxWebView.self,
NSSelectorFromString("_inspector"),
method_getImplementation(replacementMethod),
method_getTypeEncoding(replacementMethod)
)
guard added else {
fatalError("Unable to install CmuxWebView _inspector test override")
}
cmuxUnitTestInspectorOverrideInstalled = true
}
final class SplitShortcutTransientFocusGuardTests: XCTestCase {
func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() {
XCTAssertTrue(
shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
firstResponderIsWindow: true,
hostedSize: CGSize(width: 79, height: 0),
hostedHiddenInHierarchy: false,
hostedAttachedToWindow: true
)
)
}
func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() {
XCTAssertTrue(
shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
firstResponderIsWindow: true,
hostedSize: CGSize(width: 1051.5, height: 1207),
hostedHiddenInHierarchy: false,
hostedAttachedToWindow: false
)
)
}
func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() {
XCTAssertFalse(
shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
firstResponderIsWindow: true,
hostedSize: CGSize(width: 1051.5, height: 1207),
hostedHiddenInHierarchy: false,
hostedAttachedToWindow: true
)
)
}
func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() {
XCTAssertFalse(
shouldSuppressSplitShortcutForTransientTerminalFocusInputs(
firstResponderIsWindow: false,
hostedSize: CGSize(width: 79, height: 0),
hostedHiddenInHierarchy: false,
hostedAttachedToWindow: true
)
)
}
}
final class CmuxWebViewKeyEquivalentTests: XCTestCase {
private final class ActionSpy: NSObject {
private(set) var invoked: Bool = false
@objc func didInvoke(_ sender: Any?) {
invoked = true
}
}
private final class FirstResponderView: NSView {
override var acceptsFirstResponder: Bool { true }
}
private final class DelegateProbeTextView: NSTextView {
private(set) var delegateReadCount = 0
override var delegate: NSTextViewDelegate? {
get {
delegateReadCount += 1
return super.delegate
}
set {
super.delegate = newValue
}
}
}
private final class FieldEditorProbeTextView: NSTextView {
private(set) var delegateReadCount = 0
override var delegate: NSTextViewDelegate? {
get {
delegateReadCount += 1
return super.delegate
}
set {
super.delegate = newValue
}
}
override var isFieldEditor: Bool {
get { true }
set {}
}
}
func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() {
let spy = ActionSpy()
installMenu(spy: spy, key: "n", modifiers: [.command])
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let event = makeKeyDownEvent(key: "n", modifiers: [.command], keyCode: 45) // kVK_ANSI_N
XCTAssertNotNil(event)
XCTAssertTrue(webView.performKeyEquivalent(with: event!))
XCTAssertTrue(spy.invoked)
}
func testCmdWRoutesToMainMenuWhenWebViewIsFirstResponder() {
let spy = ActionSpy()
installMenu(spy: spy, key: "w", modifiers: [.command])
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let event = makeKeyDownEvent(key: "w", modifiers: [.command], keyCode: 13) // kVK_ANSI_W
XCTAssertNotNil(event)
XCTAssertTrue(webView.performKeyEquivalent(with: event!))
XCTAssertTrue(spy.invoked)
}
func testCmdRRoutesToMainMenuWhenWebViewIsFirstResponder() {
let spy = ActionSpy()
installMenu(spy: spy, key: "r", modifiers: [.command])
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let event = makeKeyDownEvent(key: "r", modifiers: [.command], keyCode: 15) // kVK_ANSI_R
XCTAssertNotNil(event)
XCTAssertTrue(webView.performKeyEquivalent(with: event!))
XCTAssertTrue(spy.invoked)
}
func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
let spy = ActionSpy()
installMenu(spy: spy, key: "\r", modifiers: [])
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return
XCTAssertNotNil(event)
XCTAssertFalse(webView.performKeyEquivalent(with: event!))
XCTAssertFalse(spy.invoked)
}
func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
let spy = ActionSpy()
installMenu(spy: spy, key: "\r", modifiers: [.command])
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return
XCTAssertNotNil(event)
XCTAssertFalse(webView.performKeyEquivalent(with: event!))
XCTAssertFalse(spy.invoked)
}
func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() {
let spy = ActionSpy()
installMenu(spy: spy, key: "\r", modifiers: [])
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter
XCTAssertNotNil(event)
XCTAssertFalse(webView.performKeyEquivalent(with: event!))
XCTAssertFalse(spy.invoked)
}
@MainActor
func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() {
_ = NSApplication.shared
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)
window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }
webView.allowsFirstResponderAcquisition = true
XCTAssertTrue(window.makeFirstResponder(webView))
_ = window.makeFirstResponder(nil)
webView.allowsFirstResponderAcquisition = false
XCTAssertFalse(webView.becomeFirstResponder())
_ = window.makeFirstResponder(webView)
if let firstResponderView = window.firstResponder as? NSView {
XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView))
}
}
@MainActor
func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() {
_ = NSApplication.shared
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)
window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }
webView.allowsFirstResponderAcquisition = false
_ = window.makeFirstResponder(nil)
XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy")
webView.withPointerFocusAllowance {
XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy")
}
_ = window.makeFirstResponder(nil)
XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary")
}
@MainActor
func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)
let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
webView.addSubview(descendant)
window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }
webView.allowsFirstResponderAcquisition = true
XCTAssertTrue(window.makeFirstResponder(descendant))
_ = window.makeFirstResponder(nil)
webView.allowsFirstResponderAcquisition = false
XCTAssertFalse(window.makeFirstResponder(descendant))
if let firstResponderView = window.firstResponder as? NSView {
XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView))
}
}
@MainActor
func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)
let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
webView.addSubview(descendant)
window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }
webView.allowsFirstResponderAcquisition = false
_ = window.makeFirstResponder(nil)
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance")
_ = window.makeFirstResponder(nil)
webView.withPointerFocusAllowance {
XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard")
}
_ = window.makeFirstResponder(nil)
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary")
}
@MainActor
func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)
let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
webView.addSubview(descendant)
window.makeKeyAndOrderFront(nil)
defer {
AppDelegate.clearWindowFirstResponderGuardTesting()
window.orderOut(nil)
}
webView.allowsFirstResponderAcquisition = false
_ = window.makeFirstResponder(nil)
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context")
let timestamp = ProcessInfo.processInfo.systemUptime
let pointerDownEvent = NSEvent.mouseEvent(
with: .leftMouseDown,
location: NSPoint(x: 5, y: 5),
modifierFlags: [],
timestamp: timestamp,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 1,
clickCount: 1,
pressure: 1.0
)
XCTAssertNotNil(pointerDownEvent)
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant)
_ = window.makeFirstResponder(nil)
XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy")
AppDelegate.clearWindowFirstResponderGuardTesting()
_ = window.makeFirstResponder(nil)
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context")
}
@MainActor
func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusForPortalHostedWebView() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 240, height: 150))
container.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
webView.addSubview(descendant)
window.makeKeyAndOrderFront(nil)
container.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1)
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
defer {
BrowserWindowPortalRegistry.detach(webView: webView)
AppDelegate.clearWindowFirstResponderGuardTesting()
window.orderOut(nil)
}
webView.allowsFirstResponderAcquisition = false
_ = window.makeFirstResponder(nil)
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context")
let timestamp = ProcessInfo.processInfo.systemUptime
let pointerPointInContent = NSPoint(x: anchor.frame.midX, y: anchor.frame.midY)
let pointerPointInWindow = container.convert(pointerPointInContent, to: nil)
let pointerDownEvent = NSEvent.mouseEvent(
with: .leftMouseDown,
location: pointerPointInWindow,
modifierFlags: [],
timestamp: timestamp,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 1,
clickCount: 1,
pressure: 1.0
)
XCTAssertNotNil(pointerDownEvent)
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
_ = window.makeFirstResponder(nil)
XCTAssertTrue(
window.makeFirstResponder(descendant),
"Expected portal-hosted pointer click context to bypass blocked policy"
)
}
@MainActor
func testWindowFirstResponderGuardAvoidsTextViewDelegateLookupForWebViewResolution() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let textView = DelegateProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 40))
container.addSubview(textView)
window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }
_ = window.makeFirstResponder(nil)
_ = window.makeFirstResponder(textView)
XCTAssertEqual(
textView.delegateReadCount,
0,
"WebView ownership resolution should not touch NSTextView.delegate (unsafe-unretained in AppKit)"
)
}
@MainActor
func testWindowFirstResponderGuardResolvesTrackedWebViewForFieldEditorResponder() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
container.addSubview(webView)
let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
webView.addSubview(descendant)
let fieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
window.makeKeyAndOrderFront(nil)
defer {
AppDelegate.clearWindowFirstResponderGuardTesting()
window.orderOut(nil)
}
webView.allowsFirstResponderAcquisition = true
XCTAssertTrue(window.makeFirstResponder(descendant))
let timestamp = ProcessInfo.processInfo.systemUptime
let pointerDownEvent = NSEvent.mouseEvent(
with: .leftMouseDown,
location: NSPoint(x: 5, y: 5),
modifierFlags: [],
timestamp: timestamp,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 1,
clickCount: 1,
pressure: 1.0
)
XCTAssertNotNil(pointerDownEvent)
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant)
XCTAssertTrue(window.makeFirstResponder(fieldEditor))
AppDelegate.clearWindowFirstResponderGuardTesting()
_ = window.makeFirstResponder(nil)
webView.allowsFirstResponderAcquisition = false
XCTAssertFalse(window.makeFirstResponder(fieldEditor))
XCTAssertEqual(
fieldEditor.delegateReadCount,
0,
"Field-editor webview ownership should come from tracked associations, not NSTextView.delegate"
)
}
@MainActor
func testWindowFirstResponderBypassBlocksSwizzledMakeFirstResponder() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let responder = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 80, height: 40))
container.addSubview(responder)
window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }
_ = window.makeFirstResponder(nil)
cmuxWithWindowFirstResponderBypass {
XCTAssertFalse(
window.makeFirstResponder(responder),
"Bypass scope should block transient first-responder changes during devtools auto-restore"
)
}
XCTAssertTrue(window.makeFirstResponder(responder))
}
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
let mainMenu = NSMenu()
let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "")
let fileMenu = NSMenu(title: "File")
let item = NSMenuItem(title: "Test Item", action: #selector(ActionSpy.didInvoke(_:)), keyEquivalent: key)
item.keyEquivalentModifierMask = modifiers
item.target = spy
fileMenu.addItem(item)
mainMenu.addItem(fileItem)
mainMenu.setSubmenu(fileMenu, for: fileItem)
// Ensure NSApp exists and has a menu for performKeyEquivalent to consult.
_ = NSApplication.shared
NSApp.mainMenu = mainMenu
}
private func makeKeyDownEvent(key: String, modifiers: NSEvent.ModifierFlags, keyCode: UInt16) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: modifiers,
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: 0,
context: nil,
characters: key,
charactersIgnoringModifiers: key,
isARepeat: false,
keyCode: keyCode
)
}
}
@MainActor
final class AppDelegateWindowContextRoutingTests: XCTestCase {
private func makeMainWindow(id: UUID) -> NSWindow {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)")
return window
}
func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() {
_ = NSApplication.shared
let app = AppDelegate()
let windowAId = UUID()
let windowBId = UUID()
let windowA = makeMainWindow(id: windowAId)
let windowB = makeMainWindow(id: windowBId)
defer {
windowA.orderOut(nil)
windowB.orderOut(nil)
}
let managerA = TabManager()
let managerB = TabManager()
app.registerMainWindow(
windowA,
windowId: windowAId,
tabManager: managerA,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
app.registerMainWindow(
windowB,
windowId: windowBId,
tabManager: managerB,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
windowB.makeKeyAndOrderFront(nil)
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB)
XCTAssertTrue(app.tabManager === managerB)
windowA.makeKeyAndOrderFront(nil)
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager")
XCTAssertTrue(app.tabManager === managerA)
}
func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() {
_ = NSApplication.shared
let app = AppDelegate()
let windowAId = UUID()
let windowBId = UUID()
let windowA = makeMainWindow(id: windowAId)
let windowB = makeMainWindow(id: windowBId)
defer {
windowA.orderOut(nil)
windowB.orderOut(nil)
}
let managerA = TabManager()
let managerB = TabManager()
app.registerMainWindow(
windowA,
windowId: windowAId,
tabManager: managerA,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
app.registerMainWindow(
windowB,
windowId: windowBId,
tabManager: managerB,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
// Seed active manager and clear focus windows to force fallback routing.
windowA.makeKeyAndOrderFront(nil)
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
XCTAssertTrue(app.tabManager === managerA)
windowA.orderOut(nil)
windowB.orderOut(nil)
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil)
XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window")
XCTAssertTrue(app.tabManager === managerA)
}
func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() {
_ = NSApplication.shared
let app = AppDelegate()
let windowId = UUID()
let window = makeMainWindow(id: windowId)
defer { window.orderOut(nil) }
let manager = TabManager()
app.registerMainWindow(
window,
windowId: windowId,
tabManager: manager,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
// SwiftUI can replace the NSWindow identifier string at runtime.
window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged")
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window)
XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed")
XCTAssertTrue(app.tabManager === manager)
}
}
@MainActor
final class AppDelegateLaunchServicesRegistrationTests: XCTestCase {
func testScheduleLaunchServicesRegistrationDefersRegisterWork() {
_ = NSApplication.shared
let app = AppDelegate()
var scheduledWork: (@Sendable () -> Void)?
var registerCallCount = 0
app.scheduleLaunchServicesBundleRegistrationForTesting(
bundleURL: URL(fileURLWithPath: "/tmp/../tmp/cmux-launch-services-test.app"),
scheduler: { work in
scheduledWork = work
},
register: { _ in
registerCallCount += 1
return noErr
}
)
XCTAssertEqual(registerCallCount, 0, "Registration should not run inline on the startup call path")
XCTAssertNotNil(scheduledWork, "Registration work should be handed to the scheduler")
scheduledWork?()
XCTAssertEqual(registerCallCount, 1)
}
}
final class FocusFlashPatternTests: XCTestCase {
func testFocusFlashPatternMatchesTerminalDoublePulseShape() {
XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0])
XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1])
XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001)
XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn])
XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001)
XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001)
}
func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() {
let segments = FocusFlashPattern.segments
XCTAssertEqual(segments.count, 4)
XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001)
XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001)
XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001)
XCTAssertEqual(segments[0].curve, .easeOut)
XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001)
XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001)
XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001)
XCTAssertEqual(segments[1].curve, .easeIn)
XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001)
XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001)
XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001)
XCTAssertEqual(segments[2].curve, .easeOut)
XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001)
XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001)
XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001)
XCTAssertEqual(segments[3].curve, .easeIn)
}
}
@MainActor
final class CmuxWebViewContextMenuTests: XCTestCase {
private func makeRightMouseDownEvent() -> NSEvent {
guard let event = NSEvent.mouseEvent(
with: .rightMouseDown,
location: .zero,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: 0,
context: nil,
eventNumber: 0,
clickCount: 1,
pressure: 1.0
) else {
fatalError("Failed to create rightMouseDown event")
}
return event
}
func testWillOpenMenuAddsOpenLinkInDefaultBrowserAndRoutesSelectionToDefaultBrowserOpener() {
_ = NSApplication.shared
let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: WKWebViewConfiguration())
let menu = NSMenu()
let openLinkItem = NSMenuItem(title: "Open Link", action: nil, keyEquivalent: "")
openLinkItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierOpenLink")
menu.addItem(openLinkItem)
menu.addItem(NSMenuItem(title: "Copy Link", action: nil, keyEquivalent: ""))
var openedURL: URL?
webView.contextMenuLinkURLProvider = { _, _, completion in
completion(URL(string: "https://example.com/docs")!)
}
webView.contextMenuDefaultBrowserOpener = { url in
openedURL = url
return true
}
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
guard let defaultBrowserItemIndex = menu.items.firstIndex(where: { $0.title == "Open Link in Default Browser" }) else {
XCTFail("Expected Open Link in Default Browser item in context menu")
return
}
guard let openLinkIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" }) else {
XCTFail("Expected Open Link item in context menu")
return
}
XCTAssertEqual(defaultBrowserItemIndex, openLinkIndex + 1)
let defaultBrowserItem = menu.items[defaultBrowserItemIndex]
XCTAssertTrue(defaultBrowserItem.target === webView)
XCTAssertNotNil(defaultBrowserItem.action)
let dispatched = NSApp.sendAction(
defaultBrowserItem.action!,
to: defaultBrowserItem.target,
from: defaultBrowserItem
)
XCTAssertTrue(dispatched)
XCTAssertEqual(openedURL?.absoluteString, "https://example.com/docs")
}
func testWillOpenMenuSkipsDefaultBrowserItemWhenContextHasNoOpenLinkEntry() {
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Back", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Forward", action: nil, keyEquivalent: ""))
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" })
}
func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() {
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let menu = NSMenu()
let originalTarget = NSObject()
let originalAction = NSSelectorFromString("downloadImageToDisk:")
let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "")
downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk")
downloadItem.target = originalTarget
menu.addItem(downloadItem)
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
XCTAssertTrue(downloadItem.target === webView)
XCTAssertNotNil(downloadItem.action)
XCTAssertNotEqual(downloadItem.action, originalAction)
}
func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() {
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let menu = NSMenu()
let originalTarget = NSObject()
let originalAction = NSSelectorFromString("downloadLinkToDisk:")
let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "")
downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk")
downloadItem.target = originalTarget
menu.addItem(downloadItem)
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
XCTAssertTrue(downloadItem.target === webView)
XCTAssertNotNil(downloadItem.action)
XCTAssertNotEqual(downloadItem.action, originalAction)
}
}
final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
private func makeIsolatedDefaults() -> UserDefaults {
let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
fatalError("Failed to create defaults suite")
}
defaults.removePersistentDomain(forName: suiteName)
addTeardownBlock {
defaults.removePersistentDomain(forName: suiteName)
}
return defaults
}
func testIconCatalogIncludesExpandedChoices() {
XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10)
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal))
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe))
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare))
}
func testIconOptionFallsBackToDefaultForUnknownRawValue() {
let defaults = makeIsolatedDefaults()
defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
XCTAssertEqual(
BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults),
BrowserDevToolsButtonDebugSettings.defaultIcon
)
}
func testColorOptionFallsBackToDefaultForUnknownRawValue() {
let defaults = makeIsolatedDefaults()
defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
XCTAssertEqual(
BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults),
BrowserDevToolsButtonDebugSettings.defaultColor
)
}
func testCopyPayloadUsesPersistedValues() {
let defaults = makeIsolatedDefaults()
defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
XCTAssertTrue(payload.contains("browserDevToolsIconName=scope"))
XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive"))
}
}
final class BrowserThemeSettingsTests: XCTestCase {
private func makeIsolatedDefaults() -> UserDefaults {
let suiteName = "BrowserThemeSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
fatalError("Failed to create defaults suite")
}
defaults.removePersistentDomain(forName: suiteName)
addTeardownBlock {
defaults.removePersistentDomain(forName: suiteName)
}
return defaults
}
func testDefaultsMatchConfiguredFallbacks() {
let defaults = makeIsolatedDefaults()
XCTAssertEqual(
BrowserThemeSettings.mode(defaults: defaults),
BrowserThemeSettings.defaultMode
)
}
func testModeReadsPersistedValue() {
let defaults = makeIsolatedDefaults()
defaults.set(BrowserThemeMode.dark.rawValue, forKey: BrowserThemeSettings.modeKey)
XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark)
defaults.set(BrowserThemeMode.light.rawValue, forKey: BrowserThemeSettings.modeKey)
XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .light)
}
func testModeMigratesLegacyForcedDarkModeFlag() {
let defaults = makeIsolatedDefaults()
defaults.set(true, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey)
XCTAssertEqual(BrowserThemeSettings.mode(defaults: defaults), .dark)
XCTAssertEqual(defaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.dark.rawValue)
let otherDefaults = makeIsolatedDefaults()
otherDefaults.set(false, forKey: BrowserThemeSettings.legacyForcedDarkModeEnabledKey)
XCTAssertEqual(BrowserThemeSettings.mode(defaults: otherDefaults), .system)
XCTAssertEqual(otherDefaults.string(forKey: BrowserThemeSettings.modeKey), BrowserThemeMode.system.rawValue)
}
}
final class BrowserPanelChromeBackgroundColorTests: XCTestCase {
func testLightModeUsesThemeBackgroundColor() {
assertResolvedColorMatchesTheme(for: .light)
}
func testDarkModeUsesThemeBackgroundColor() {
assertResolvedColorMatchesTheme(for: .dark)
}
private func assertResolvedColorMatchesTheme(
for colorScheme: ColorScheme,
file: StaticString = #filePath,
line: UInt = #line
) {
let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0)
guard
let actual = resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackground
).usingColorSpace(.sRGB),
let expected = themeBackground.usingColorSpace(.sRGB)
else {
XCTFail("Expected sRGB-convertible colors", file: file, line: line)
return
}
XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line)
}
}
final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase {
func testLightModeSlightlyDarkensThemeBackground() {
assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04)
}
func testDarkModeSlightlyDarkensThemeBackground() {
assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05)
}
private func assertResolvedColorMatchesExpectedBlend(
for colorScheme: ColorScheme,
darkenMix: CGFloat,
file: StaticString = #filePath,
line: UInt = #line
) {
let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0)
let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground
guard
let actual = resolvedBrowserOmnibarPillBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackground
).usingColorSpace(.sRGB),
let expectedSRGB = expected.usingColorSpace(.sRGB),
let themeSRGB = themeBackground.usingColorSpace(.sRGB)
else {
XCTFail("Expected sRGB-convertible colors", file: file, line: line)
return
}
XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line)
XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line)
}
}
final class SidebarActiveForegroundColorTests: XCTestCase {
func testLightAppearanceUsesBlackWithRequestedOpacity() {
guard let lightAppearance = NSAppearance(named: .aqua),
let color = sidebarActiveForegroundNSColor(
opacity: 0.8,
appAppearance: lightAppearance
).usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible color")
return
}
XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001)
XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001)
XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001)
}
func testDarkAppearanceUsesWhiteWithRequestedOpacity() {
guard let darkAppearance = NSAppearance(named: .darkAqua),
let color = sidebarActiveForegroundNSColor(
opacity: 0.65,
appAppearance: darkAppearance
).usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible color")
return
}
XCTAssertEqual(color.redComponent, 1, accuracy: 0.001)
XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001)
XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001)
XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001)
}
}
final class SidebarSelectedWorkspaceColorTests: XCTestCase {
func testLightModeUsesConfiguredSelectedWorkspaceBackgroundColor() {
guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .light).usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible color")
return
}
XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001)
XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001)
}
func testDarkModeUsesConfiguredSelectedWorkspaceBackgroundColor() {
guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark).usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible color")
return
}
XCTAssertEqual(color.redComponent, 0, accuracy: 0.001)
XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001)
XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001)
}
func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() {
guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible color")
return
}
XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001)
XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001)
XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001)
XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001)
}
}
final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase {
func testSafariDefaultShortcutForToggleDeveloperTools() {
let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
XCTAssertEqual(shortcut.key, "i")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.option)
XCTAssertFalse(shortcut.shift)
XCTAssertFalse(shortcut.control)
}
func testSafariDefaultShortcutForShowJavaScriptConsole() {
let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
XCTAssertEqual(shortcut.key, "c")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.option)
XCTAssertFalse(shortcut.shift)
XCTAssertFalse(shortcut.control)
}
}
final class WorkspaceRenameShortcutDefaultsTests: XCTestCase {
func testRenameTabShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab")
XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab")
let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut
XCTAssertEqual(shortcut.key, "r")
XCTAssertTrue(shortcut.command)
XCTAssertFalse(shortcut.shift)
XCTAssertFalse(shortcut.option)
XCTAssertFalse(shortcut.control)
}
func testCloseWindowShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window")
XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow")
let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut
XCTAssertEqual(shortcut.key, "w")
XCTAssertTrue(shortcut.command)
XCTAssertFalse(shortcut.shift)
XCTAssertFalse(shortcut.option)
XCTAssertTrue(shortcut.control)
}
func testRenameWorkspaceShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace")
XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace")
let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
XCTAssertEqual(shortcut.key, "r")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.shift)
XCTAssertFalse(shortcut.option)
XCTAssertFalse(shortcut.control)
}
func testRenameWorkspaceShortcutConvertsToMenuShortcut() {
let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut
XCTAssertNotNil(shortcut.keyEquivalent)
XCTAssertTrue(shortcut.eventModifiers.contains(.command))
XCTAssertTrue(shortcut.eventModifiers.contains(.shift))
XCTAssertFalse(shortcut.eventModifiers.contains(.option))
XCTAssertFalse(shortcut.eventModifiers.contains(.control))
}
func testCloseWorkspaceShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace")
XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace")
let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
XCTAssertEqual(shortcut.key, "w")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.shift)
XCTAssertFalse(shortcut.option)
XCTAssertFalse(shortcut.control)
}
func testCloseWorkspaceShortcutConvertsToMenuShortcut() {
let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut
XCTAssertNotNil(shortcut.keyEquivalent)
XCTAssertTrue(shortcut.eventModifiers.contains(.command))
XCTAssertTrue(shortcut.eventModifiers.contains(.shift))
XCTAssertFalse(shortcut.eventModifiers.contains(.option))
XCTAssertFalse(shortcut.eventModifiers.contains(.control))
}
func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace")
XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace")
XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab")
XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab")
let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
XCTAssertEqual(nextShortcut.key, "]")
XCTAssertTrue(nextShortcut.command)
XCTAssertFalse(nextShortcut.shift)
XCTAssertFalse(nextShortcut.option)
XCTAssertTrue(nextShortcut.control)
let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
XCTAssertEqual(prevShortcut.key, "[")
XCTAssertTrue(prevShortcut.command)
XCTAssertFalse(prevShortcut.shift)
XCTAssertFalse(prevShortcut.option)
XCTAssertTrue(prevShortcut.control)
}
func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() {
let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut
XCTAssertNotNil(nextShortcut.keyEquivalent)
XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]")
XCTAssertTrue(nextShortcut.eventModifiers.contains(.command))
XCTAssertTrue(nextShortcut.eventModifiers.contains(.control))
let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut
XCTAssertNotNil(prevShortcut.keyEquivalent)
XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[")
XCTAssertTrue(prevShortcut.eventModifiers.contains(.command))
XCTAssertTrue(prevShortcut.eventModifiers.contains(.control))
}
func testToggleTerminalCopyModeShortcutDefaultsAndMetadata() {
XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalCopyMode.label, "Toggle Terminal Copy Mode")
XCTAssertEqual(
KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultsKey,
"shortcut.toggleTerminalCopyMode"
)
let shortcut = KeyboardShortcutSettings.Action.toggleTerminalCopyMode.defaultShortcut
XCTAssertEqual(shortcut.key, "m")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.shift)
XCTAssertFalse(shortcut.option)
XCTAssertFalse(shortcut.control)
}
func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() {
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertNotNil(StoredShortcut(key: "", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent)
XCTAssertEqual(
StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent,
"\t"
)
}
func testShortcutDefaultsKeysRemainUnique() {
let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey)
XCTAssertEqual(Set(keys).count, keys.count)
}
}
final class TerminalKeyboardCopyModeActionTests: XCTestCase {
func testCopyModeBypassAllowsOnlyCommandShortcuts() {
XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command]))
XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .shift]))
XCTAssertTrue(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.command, .option]))
XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option]))
XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.option, .shift]))
XCTAssertFalse(terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: [.control]))
}
func testJKWithoutSelectionScrollByLine() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 38,
charactersIgnoringModifiers: "j",
modifierFlags: [],
hasSelection: false
),
.scrollLines(1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 40,
charactersIgnoringModifiers: "k",
modifierFlags: [],
hasSelection: false
),
.scrollLines(-1)
)
}
func testCapsLockDoesNotBlockLetterMappings() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 38,
charactersIgnoringModifiers: "j",
modifierFlags: [.capsLock],
hasSelection: false
),
.scrollLines(1)
)
}
func testJKWithSelectionAdjustSelection() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 38,
charactersIgnoringModifiers: "j",
modifierFlags: [],
hasSelection: true
),
.adjustSelection(.down)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 40,
charactersIgnoringModifiers: "k",
modifierFlags: [],
hasSelection: true
),
.adjustSelection(.up)
)
}
func testControlPagingSupportsPrintableAndControlCharacters() {
// Ctrl+U = half-page up (vim standard).
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{15}",
modifierFlags: [.control],
hasSelection: false
),
.scrollHalfPage(-1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{04}",
modifierFlags: [.control],
hasSelection: true
),
.adjustSelection(.pageDown)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{02}",
modifierFlags: [.control],
hasSelection: false
),
.scrollPage(-1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{06}",
modifierFlags: [.control],
hasSelection: true
),
.adjustSelection(.pageDown)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{19}",
modifierFlags: [.control],
hasSelection: false
),
.scrollLines(-1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 0,
charactersIgnoringModifiers: "\u{05}",
modifierFlags: [.control],
hasSelection: true
),
.adjustSelection(.down)
)
}
func testVGYMapping() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 9,
charactersIgnoringModifiers: "v",
modifierFlags: [],
hasSelection: false
),
.startSelection
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 9,
charactersIgnoringModifiers: "v",
modifierFlags: [],
hasSelection: true
),
.clearSelection
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 16,
charactersIgnoringModifiers: "y",
modifierFlags: [],
hasSelection: true
),
.copyAndExit
)
}
func testGAndShiftGMapping() {
// Bare "g" is a prefix key (gg), not an immediate action.
XCTAssertNil(
terminalKeyboardCopyModeAction(
keyCode: 5,
charactersIgnoringModifiers: "g",
modifierFlags: [],
hasSelection: false
)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 5,
charactersIgnoringModifiers: "g",
modifierFlags: [.shift],
hasSelection: false
),
.scrollToBottom
)
}
func testLineBoundaryPromptAndSearchMappings() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 29,
charactersIgnoringModifiers: "0",
modifierFlags: [],
hasSelection: true
),
.adjustSelection(.beginningOfLine)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 20,
charactersIgnoringModifiers: "^",
modifierFlags: [.shift],
hasSelection: true
),
.adjustSelection(.beginningOfLine)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 21,
charactersIgnoringModifiers: "4",
modifierFlags: [.shift],
hasSelection: true
),
.adjustSelection(.endOfLine)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 33,
charactersIgnoringModifiers: "[",
modifierFlags: [.shift],
hasSelection: false
),
.jumpToPrompt(-1)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 30,
charactersIgnoringModifiers: "]",
modifierFlags: [.shift],
hasSelection: false
),
.jumpToPrompt(1)
)
XCTAssertNil(
terminalKeyboardCopyModeAction(
keyCode: 21,
charactersIgnoringModifiers: "4",
modifierFlags: [],
hasSelection: true
)
)
XCTAssertNil(
terminalKeyboardCopyModeAction(
keyCode: 33,
charactersIgnoringModifiers: "[",
modifierFlags: [],
hasSelection: false
)
)
XCTAssertNil(
terminalKeyboardCopyModeAction(
keyCode: 30,
charactersIgnoringModifiers: "]",
modifierFlags: [],
hasSelection: false
)
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 44,
charactersIgnoringModifiers: "/",
modifierFlags: [],
hasSelection: false
),
.startSearch
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 45,
charactersIgnoringModifiers: "n",
modifierFlags: [],
hasSelection: false
),
.searchNext
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 45,
charactersIgnoringModifiers: "n",
modifierFlags: [.shift],
hasSelection: false
),
.searchPrevious
)
}
func testShiftVMatchesVisualToggleBehavior() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 9,
charactersIgnoringModifiers: "v",
modifierFlags: [.shift],
hasSelection: false
),
.startSelection
)
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 9,
charactersIgnoringModifiers: "v",
modifierFlags: [.shift],
hasSelection: true
),
.clearSelection
)
}
func testEscapeAlwaysExits() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 53,
charactersIgnoringModifiers: "",
modifierFlags: [],
hasSelection: false
),
.exit
)
}
func testQAlwaysExits() {
XCTAssertEqual(
terminalKeyboardCopyModeAction(
keyCode: 12, // kVK_ANSI_Q
charactersIgnoringModifiers: "q",
modifierFlags: [],
hasSelection: false
),
.exit
)
}
}
final class TerminalKeyboardCopyModeResolveTests: XCTestCase {
private func resolve(
_ keyCode: UInt16,
chars: String,
modifiers: NSEvent.ModifierFlags = [],
hasSelection: Bool,
state: inout TerminalKeyboardCopyModeInputState
) -> TerminalKeyboardCopyModeResolution {
terminalKeyboardCopyModeResolve(
keyCode: keyCode,
charactersIgnoringModifiers: chars,
modifierFlags: modifiers,
hasSelection: hasSelection,
state: &state
)
}
func testCountPrefixAppliesToMotion() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 3))
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
func testZeroAppendsCountOrActsAsMotion() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(19, chars: "2", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(29, chars: "0", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(40, chars: "k", hasSelection: false, state: &state), .perform(.scrollLines(-1), count: 20))
var selectionState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(
resolve(29, chars: "0", hasSelection: true, state: &selectionState),
.perform(.adjustSelection(.beginningOfLine), count: 1)
)
}
func testYankLineOperatorSupportsYYAndYWithCounts() {
var yyState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .consume)
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &yyState), .perform(.copyLineAndExit, count: 1))
var countedState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(21, chars: "4", hasSelection: false, state: &countedState), .consume)
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .consume)
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &countedState), .perform(.copyLineAndExit, count: 4))
var shiftYState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &shiftYState), .consume)
XCTAssertEqual(
resolve(16, chars: "y", modifiers: [.shift], hasSelection: false, state: &shiftYState),
.perform(.copyLineAndExit, count: 3)
)
}
func testPendingYankLineDoesNotSwallowNextCommand() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(16, chars: "y", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1))
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
func testSearchAndPromptMotionsUseCounts() {
var promptState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(20, chars: "3", hasSelection: false, state: &promptState), .consume)
XCTAssertEqual(
resolve(30, chars: "]", modifiers: [.shift], hasSelection: false, state: &promptState),
.perform(.jumpToPrompt(1), count: 3)
)
var searchState = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &searchState), .consume)
XCTAssertEqual(resolve(45, chars: "n", hasSelection: false, state: &searchState), .perform(.searchNext, count: 2))
}
func testInvalidKeyClearsPendingState() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(18, chars: "2", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(7, chars: "x", hasSelection: false, state: &state), .consume)
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
// MARK: - gg (scroll to top via two-key sequence)
func testGGScrollsToTop() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 1))
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
func testGGWithSelectionAdjustsToHome() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .consume)
XCTAssertEqual(resolve(5, chars: "g", hasSelection: true, state: &state), .perform(.adjustSelection(.home), count: 1))
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
func testCountedGG() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(22, chars: "5", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .perform(.scrollToTop, count: 5))
}
func testPendingGCancelledByOtherKey() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(resolve(5, chars: "g", hasSelection: false, state: &state), .consume)
XCTAssertEqual(resolve(38, chars: "j", hasSelection: false, state: &state), .perform(.scrollLines(1), count: 1))
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
func testShiftGStillWorksImmediately() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(
resolve(5, chars: "g", modifiers: [.shift], hasSelection: false, state: &state),
.perform(.scrollToBottom, count: 1)
)
XCTAssertEqual(state, TerminalKeyboardCopyModeInputState())
}
// MARK: - Ctrl+U/D half-page scroll
func testCtrlUHalfPage() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(
resolve(32, chars: "u", modifiers: [.control], hasSelection: false, state: &state),
.perform(.scrollHalfPage(-1), count: 1)
)
}
func testCtrlDHalfPage() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(
resolve(2, chars: "d", modifiers: [.control], hasSelection: false, state: &state),
.perform(.scrollHalfPage(1), count: 1)
)
}
func testCtrlBFullPage() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(
resolve(11, chars: "b", modifiers: [.control], hasSelection: false, state: &state),
.perform(.scrollPage(-1), count: 1)
)
}
func testCtrlFFullPage() {
var state = TerminalKeyboardCopyModeInputState()
XCTAssertEqual(
resolve(3, chars: "f", modifiers: [.control], hasSelection: false, state: &state),
.perform(.scrollPage(1), count: 1)
)
}
}
final class TerminalKeyboardCopyModeViewportRowTests: XCTestCase {
func testInitialViewportRowUsesImePointBaseline() {
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 24,
imeCellHeight: 24
),
0
)
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 240,
imeCellHeight: 24
),
9
)
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 48,
imeCellHeight: 24,
topPadding: 24
),
0
)
}
func testInitialViewportRowClampsBoundsAndFallsBackWhenHeightMissing() {
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 0,
imeCellHeight: 24
),
0
)
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 9999,
imeCellHeight: 24
),
23
)
XCTAssertEqual(
terminalKeyboardCopyModeInitialViewportRow(
rows: 24,
imePointY: 123,
imeCellHeight: 0
),
23
)
}
}
@MainActor
final class BrowserDeveloperToolsConfigurationTests: XCTestCase {
func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() {
let panel = BrowserPanel(workspaceId: UUID())
let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool
XCTAssertEqual(developerExtras, true)
if #available(macOS 13.3, *) {
XCTAssertTrue(panel.webView.isInspectable)
}
}
func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() {
let panel = BrowserPanel(workspaceId: UUID())
let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0)
let updatedOpacity = 0.57
NotificationCenter.default.post(
name: .ghosttyDefaultBackgroundDidChange,
object: nil,
userInfo: [
GhosttyNotificationKey.backgroundColor: updatedColor,
GhosttyNotificationKey.backgroundOpacity: updatedOpacity
]
)
guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB),
let expected = updatedColor.withAlphaComponent(updatedOpacity).usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible under-page background colors")
return
}
XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005)
XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005)
XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005)
XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005)
}
func testBrowserPanelStartsAsNewTabWithoutLoadingAboutBlank() {
let panel = BrowserPanel(workspaceId: UUID())
XCTAssertEqual(panel.displayTitle, "New tab")
XCTAssertFalse(panel.shouldRenderWebView)
XCTAssertTrue(panel.isShowingNewTabPage)
XCTAssertNil(panel.webView.url)
XCTAssertNil(panel.currentURL)
}
func testBrowserPanelLeavesNewTabPageStateWhenNavigationStarts() {
let panel = BrowserPanel(workspaceId: UUID())
XCTAssertTrue(panel.isShowingNewTabPage)
panel.navigate(to: URL(string: "https://example.com")!)
XCTAssertFalse(panel.isShowingNewTabPage)
}
func testBrowserPanelThemeModeUpdatesWebViewAppearance() {
let panel = BrowserPanel(workspaceId: UUID())
panel.setBrowserThemeMode(.dark)
XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.darkAqua, .aqua]), .darkAqua)
panel.setBrowserThemeMode(.light)
XCTAssertEqual(panel.webView.appearance?.bestMatch(from: [.aqua, .darkAqua]), .aqua)
panel.setBrowserThemeMode(.system)
XCTAssertNil(panel.webView.appearance)
}
func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() {
let panel = BrowserPanel(workspaceId: UUID())
let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0)
NotificationCenter.default.post(
name: .ghosttyDefaultBackgroundDidChange,
object: nil,
userInfo: [
GhosttyNotificationKey.backgroundColor: updatedColor,
GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57),
]
)
guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB),
let expected = updatedColor.withAlphaComponent(0.57).usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible under-page background colors")
return
}
XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005)
XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005)
XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005)
XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005)
}
}
final class GhosttyBackgroundThemeTests: XCTestCase {
func testColorClampsOpacity() {
let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0)
let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0)
XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001)
let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0)
XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001)
}
func testColorFromNotificationUsesBackgroundAndOpacity() {
let fallbackColor = NSColor.black
let fallbackOpacity = 1.0
let notification = Notification(
name: .ghosttyDefaultBackgroundDidChange,
object: nil,
userInfo: [
GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0),
GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57),
]
)
let actual = GhosttyBackgroundTheme.color(
from: notification,
fallbackColor: fallbackColor,
fallbackOpacity: fallbackOpacity
)
guard let srgb = actual.usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible color")
return
}
XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005)
XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005)
XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005)
XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005)
}
func testColorFromNotificationFallsBackWhenPayloadMissing() {
let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0)
let fallbackOpacity = 0.42
let notification = Notification(name: .ghosttyDefaultBackgroundDidChange)
let actual = GhosttyBackgroundTheme.color(
from: notification,
fallbackColor: fallbackColor,
fallbackOpacity: fallbackOpacity
)
guard let srgb = actual.usingColorSpace(.sRGB) else {
XCTFail("Expected sRGB-convertible color")
return
}
XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005)
XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005)
XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005)
XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005)
}
}
@MainActor
final class BrowserInsecureHTTPAlertPresentationTests: XCTestCase {
private final class BrowserInsecureHTTPAlertSpy: NSAlert {
private(set) var beginSheetModalCallCount = 0
private(set) var runModalCallCount = 0
var nextResponse: NSApplication.ModalResponse = .alertThirdButtonReturn
override func beginSheetModal(
for sheetWindow: NSWindow,
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
) {
beginSheetModalCallCount += 1
handler?(nextResponse)
}
override func runModal() -> NSApplication.ModalResponse {
runModalCallCount += 1
return nextResponse
}
}
func testInsecureHTTPPromptUsesSheetWhenWindowIsAvailable() {
let panel = BrowserPanel(workspaceId: UUID())
defer { panel.resetInsecureHTTPAlertHooksForTesting() }
let alertSpy = BrowserInsecureHTTPAlertSpy()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled],
backing: .buffered,
defer: false
)
panel.configureInsecureHTTPAlertHooksForTesting(
alertFactory: { alertSpy },
windowProvider: { window }
)
panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!)
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
XCTAssertEqual(alertSpy.runModalCallCount, 0)
}
func testInsecureHTTPPromptFallsBackToRunModalWithoutWindow() {
let panel = BrowserPanel(workspaceId: UUID())
defer { panel.resetInsecureHTTPAlertHooksForTesting() }
let alertSpy = BrowserInsecureHTTPAlertSpy()
panel.configureInsecureHTTPAlertHooksForTesting(
alertFactory: { alertSpy },
windowProvider: { nil }
)
panel.presentInsecureHTTPAlertForTesting(url: URL(string: "http://example.com")!)
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
XCTAssertEqual(alertSpy.runModalCallCount, 1)
}
}
final class BrowserNavigationNewTabDecisionTests: XCTestCase {
func testLinkActivatedCmdClickOpensInNewTab() {
XCTAssertTrue(
browserNavigationShouldOpenInNewTab(
navigationType: .linkActivated,
modifierFlags: [.command],
buttonNumber: 0
)
)
}
func testLinkActivatedMiddleClickOpensInNewTab() {
XCTAssertTrue(
browserNavigationShouldOpenInNewTab(
navigationType: .linkActivated,
modifierFlags: [],
buttonNumber: 2
)
)
}
func testLinkActivatedPlainLeftClickStaysInCurrentTab() {
XCTAssertFalse(
browserNavigationShouldOpenInNewTab(
navigationType: .linkActivated,
modifierFlags: [],
buttonNumber: 0
)
)
}
func testOtherNavigationMiddleClickOpensInNewTab() {
XCTAssertTrue(
browserNavigationShouldOpenInNewTab(
navigationType: .other,
modifierFlags: [],
buttonNumber: 2
)
)
}
func testOtherNavigationLeftClickStaysInCurrentTab() {
XCTAssertFalse(
browserNavigationShouldOpenInNewTab(
navigationType: .other,
modifierFlags: [],
buttonNumber: 0
)
)
}
func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() {
XCTAssertFalse(
browserNavigationShouldOpenInNewTab(
navigationType: .linkActivated,
modifierFlags: [],
buttonNumber: 4,
hasRecentMiddleClickIntent: false
)
)
}
func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() {
XCTAssertTrue(
browserNavigationShouldOpenInNewTab(
navigationType: .linkActivated,
modifierFlags: [],
buttonNumber: 4,
hasRecentMiddleClickIntent: true
)
)
}
func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() {
XCTAssertTrue(
browserNavigationShouldOpenInNewTab(
navigationType: .linkActivated,
modifierFlags: [],
buttonNumber: 0,
currentEventType: .otherMouseUp,
currentEventButtonNumber: 2
)
)
}
func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() {
XCTAssertFalse(
browserNavigationShouldOpenInNewTab(
navigationType: .reload,
modifierFlags: [],
buttonNumber: 0,
currentEventType: .otherMouseUp,
currentEventButtonNumber: 2
)
)
}
func testNonLinkNavigationNeverForcesNewTab() {
XCTAssertFalse(
browserNavigationShouldOpenInNewTab(
navigationType: .reload,
modifierFlags: [.command],
buttonNumber: 2
)
)
}
}
@MainActor
final class BrowserJavaScriptDialogDelegateTests: XCTestCase {
func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() {
let panel = BrowserPanel(workspaceId: UUID())
guard let uiDelegate = panel.webView.uiDelegate as? NSObject else {
XCTFail("Expected BrowserPanel webView.uiDelegate to be an NSObject")
return
}
XCTAssertTrue(
uiDelegate.responds(
to: #selector(
WKUIDelegate.webView(
_:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:
)
)
),
"Browser UI delegate must implement JavaScript alert handling"
)
XCTAssertTrue(
uiDelegate.responds(
to: #selector(
WKUIDelegate.webView(
_:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:
)
)
),
"Browser UI delegate must implement JavaScript confirm handling"
)
XCTAssertTrue(
uiDelegate.responds(
to: #selector(
WKUIDelegate.webView(
_:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:
)
)
),
"Browser UI delegate must implement JavaScript prompt handling"
)
}
}
@MainActor
final class BrowserSessionHistoryRestoreTests: XCTestCase {
func testSessionNavigationHistorySnapshotUsesRestoredStacks() {
let panel = BrowserPanel(workspaceId: UUID())
panel.restoreSessionNavigationHistory(
backHistoryURLStrings: [
"https://example.com/a",
"https://example.com/b"
],
forwardHistoryURLStrings: [
"https://example.com/d"
],
currentURLString: "https://example.com/c"
)
XCTAssertTrue(panel.canGoBack)
XCTAssertTrue(panel.canGoForward)
let snapshot = panel.sessionNavigationHistorySnapshot()
XCTAssertEqual(
snapshot.backHistoryURLStrings,
["https://example.com/a", "https://example.com/b"]
)
XCTAssertEqual(
snapshot.forwardHistoryURLStrings,
["https://example.com/d"]
)
}
func testSessionNavigationHistoryBackAndForwardUpdateStacks() {
let panel = BrowserPanel(workspaceId: UUID())
panel.restoreSessionNavigationHistory(
backHistoryURLStrings: [
"https://example.com/a",
"https://example.com/b"
],
forwardHistoryURLStrings: [
"https://example.com/d"
],
currentURLString: "https://example.com/c"
)
panel.goBack()
let afterBack = panel.sessionNavigationHistorySnapshot()
XCTAssertEqual(afterBack.backHistoryURLStrings, ["https://example.com/a"])
XCTAssertEqual(
afterBack.forwardHistoryURLStrings,
["https://example.com/c", "https://example.com/d"]
)
XCTAssertTrue(panel.canGoBack)
XCTAssertTrue(panel.canGoForward)
panel.goForward()
let afterForward = panel.sessionNavigationHistorySnapshot()
XCTAssertEqual(
afterForward.backHistoryURLStrings,
["https://example.com/a", "https://example.com/b"]
)
XCTAssertEqual(afterForward.forwardHistoryURLStrings, ["https://example.com/d"])
XCTAssertTrue(panel.canGoBack)
XCTAssertTrue(panel.canGoForward)
}
func testWebViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() {
let panel = BrowserPanel(
workspaceId: UUID(),
initialURL: URL(string: "https://example.com")
)
let oldWebView = panel.webView
let oldInstanceID = panel.webViewInstanceID
panel.debugSimulateWebContentProcessTermination()
XCTAssertFalse(panel.webView === oldWebView)
XCTAssertNotEqual(panel.webViewInstanceID, oldInstanceID)
XCTAssertNotNil(panel.webView.navigationDelegate)
XCTAssertNotNil(panel.webView.uiDelegate)
}
func testWebViewReplacementPreservesEmptyNewTabRenderState() {
let panel = BrowserPanel(workspaceId: UUID())
XCTAssertFalse(panel.shouldRenderWebView)
panel.debugSimulateWebContentProcessTermination()
XCTAssertFalse(panel.shouldRenderWebView)
}
}
@MainActor
final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
private final class FakeInspector: NSObject {
private(set) var showCount = 0
private(set) var closeCount = 0
private var visible = false
@objc func isVisible() -> Bool {
visible
}
@objc func show() {
showCount += 1
visible = true
}
@objc func close() {
closeCount += 1
visible = false
}
}
override class func setUp() {
super.setUp()
installCmuxUnitTestInspectorOverride()
}
private func makePanelWithInspector() -> (BrowserPanel, FakeInspector) {
let panel = BrowserPanel(workspaceId: UUID())
let inspector = FakeInspector()
panel.webView.cmuxSetUnitTestInspector(inspector)
return (panel, inspector)
}
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
// Simulate WebKit closing inspector during detach/reattach churn.
inspector.close()
XCTAssertFalse(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.closeCount, 1)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 2)
}
func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertEqual(inspector.showCount, 1)
// Simulate user closing inspector before detach.
inspector.close()
panel.syncDeveloperToolsPreferenceFromInspector()
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertFalse(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
}
func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertEqual(inspector.showCount, 1)
// Simulate a transient close caused by view detach, not user intent.
inspector.close()
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 2)
}
func testForcedRefreshAfterAttachKeepsVisibleInspectorState() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
XCTAssertEqual(inspector.closeCount, 0)
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.closeCount, 0)
XCTAssertEqual(inspector.showCount, 1)
// The force-refresh request should be one-shot.
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertEqual(inspector.closeCount, 0)
XCTAssertEqual(inspector.showCount, 1)
}
func testRefreshRequestTracksPendingStateUntilRestoreRuns() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach())
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
}
func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() {
let (panel, _) = makePanelWithInspector()
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
XCTAssertTrue(panel.hideDeveloperTools())
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
}
func testWebViewDismantleDetachesPortalHostedWebViewWhenDeveloperToolsIntentIsVisible() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let anchor = NSView(frame: NSRect(x: 30, y: 30, width: 180, height: 140))
window.contentView?.addSubview(anchor)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
window.contentView?.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1)
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
XCTAssertNotNil(panel.webView.superview)
let representable = WebViewRepresentable(
panel: panel,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator)
XCTAssertNil(panel.webView.superview)
window.orderOut(nil)
}
func testWebViewDismantleDetachesPortalHostedWebViewWhenDeveloperToolsIntentIsHidden() {
let (panel, _) = makePanelWithInspector()
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 150))
window.contentView?.addSubview(anchor)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
window.contentView?.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
BrowserWindowPortalRegistry.bind(webView: panel.webView, to: anchor, visibleInUI: true, zPriority: 1)
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
XCTAssertNotNil(panel.webView.superview)
let representable = WebViewRepresentable(
panel: panel,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator)
XCTAssertNil(panel.webView.superview)
window.orderOut(nil)
}
}
final class WorkspaceShortcutMapperTests: XCTestCase {
func testCommandNineMapsToLastWorkspaceIndex() {
XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0)
XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 4), 3)
XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 12), 11)
}
func testCommandDigitBadgesUseNineForLastWorkspaceWhenNeeded() {
XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 0, workspaceCount: 12), 1)
XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 7, workspaceCount: 12), 8)
XCTAssertEqual(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 11, workspaceCount: 12), 9)
XCTAssertNil(WorkspaceShortcutMapper.commandDigitForWorkspace(at: 8, workspaceCount: 12))
}
}
final class BrowserOmnibarCommandNavigationTests: XCTestCase {
func testArrowNavigationDeltaRequiresFocusedAddressBarAndNoModifierFlags() {
XCTAssertNil(
browserOmnibarSelectionDeltaForArrowNavigation(
hasFocusedAddressBar: false,
flags: [],
keyCode: 126
)
)
XCTAssertNil(
browserOmnibarSelectionDeltaForArrowNavigation(
hasFocusedAddressBar: true,
flags: [.command],
keyCode: 126
)
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForArrowNavigation(
hasFocusedAddressBar: true,
flags: [],
keyCode: 126
),
-1
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForArrowNavigation(
hasFocusedAddressBar: true,
flags: [],
keyCode: 125
),
1
)
}
func testArrowNavigationDeltaIgnoresCapsLockModifier() {
XCTAssertEqual(
browserOmnibarSelectionDeltaForArrowNavigation(
hasFocusedAddressBar: true,
flags: [.capsLock],
keyCode: 126
),
-1
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForArrowNavigation(
hasFocusedAddressBar: true,
flags: [.capsLock],
keyCode: 125
),
1
)
}
func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() {
XCTAssertNil(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: false,
flags: [.command],
chars: "n"
)
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.command],
chars: "n"
),
1
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.command],
chars: "p"
),
-1
)
XCTAssertNil(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.command, .shift],
chars: "n"
)
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.control],
chars: "p"
),
-1
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.control],
chars: "n"
),
1
)
}
func testCommandNavigationDeltaIgnoresCapsLockModifier() {
XCTAssertEqual(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.control, .capsLock],
chars: "n"
),
1
)
XCTAssertEqual(
browserOmnibarSelectionDeltaForCommandNavigation(
hasFocusedAddressBar: true,
flags: [.command, .capsLock],
chars: "p"
),
-1
)
}
func testSubmitOnReturnIgnoresCapsLockModifier() {
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: []))
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift]))
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock]))
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock]))
XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock]))
}
}
final class BrowserReturnKeyDownRoutingTests: XCTestCase {
func testRoutesForReturnWhenBrowserFirstResponder() {
XCTAssertTrue(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 36,
firstResponderIsBrowser: true,
flags: []
)
)
}
func testRoutesForKeypadEnterWhenBrowserFirstResponder() {
XCTAssertTrue(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 76,
firstResponderIsBrowser: true,
flags: []
)
)
}
func testDoesNotRouteForNonEnterKey() {
XCTAssertFalse(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 13,
firstResponderIsBrowser: true,
flags: []
)
)
}
func testDoesNotRouteWhenFirstResponderIsNotBrowser() {
XCTAssertFalse(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 36,
firstResponderIsBrowser: false,
flags: []
)
)
}
func testRoutesForShiftReturnWhenBrowserFirstResponder() {
XCTAssertTrue(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 36,
firstResponderIsBrowser: true,
flags: [.shift]
)
)
}
func testDoesNotRouteForCommandShiftReturnWhenBrowserFirstResponder() {
XCTAssertFalse(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 36,
firstResponderIsBrowser: true,
flags: [.command, .shift]
)
)
}
func testDoesNotRouteForCommandReturnWhenBrowserFirstResponder() {
XCTAssertFalse(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 36,
firstResponderIsBrowser: true,
flags: [.command]
)
)
}
func testDoesNotRouteForOptionReturnWhenBrowserFirstResponder() {
XCTAssertFalse(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 36,
firstResponderIsBrowser: true,
flags: [.option]
)
)
}
func testDoesNotRouteForControlReturnWhenBrowserFirstResponder() {
XCTAssertFalse(
shouldDispatchBrowserReturnViaFirstResponderKeyDown(
keyCode: 36,
firstResponderIsBrowser: true,
flags: [.control]
)
)
}
}
final class FullScreenShortcutTests: XCTestCase {
func testMatchesCommandControlF() {
XCTAssertTrue(
shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: [.command, .control],
chars: "f",
keyCode: 3
)
)
}
func testMatchesCommandControlFFromKeyCodeWhenCharsAreUnavailable() {
XCTAssertTrue(
shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: [.command, .control],
chars: "",
keyCode: 3
)
)
}
func testIgnoresCapsLockForCommandControlF() {
XCTAssertTrue(
shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: [.command, .control, .capsLock],
chars: "f",
keyCode: 3
)
)
}
func testRejectsWhenControlIsMissing() {
XCTAssertFalse(
shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: [.command],
chars: "f",
keyCode: 3
)
)
}
func testRejectsAdditionalModifiers() {
XCTAssertFalse(
shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: [.command, .control, .shift],
chars: "f",
keyCode: 3
)
)
XCTAssertFalse(
shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: [.command, .control, .option],
chars: "f",
keyCode: 3
)
)
}
func testRejectsWhenCommandIsMissing() {
XCTAssertFalse(
shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: [.control],
chars: "f",
keyCode: 3
)
)
}
func testRejectsNonFKey() {
XCTAssertFalse(
shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: [.command, .control],
chars: "r",
keyCode: 15
)
)
}
}
final class BrowserZoomShortcutActionTests: XCTestCase {
func testZoomInSupportsEqualsAndPlusVariants() {
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command], chars: "=", keyCode: 24),
.zoomIn
)
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 24),
.zoomIn
)
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24),
.zoomIn
)
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30),
.zoomIn
)
}
func testZoomOutSupportsMinusAndUnderscoreVariants() {
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command], chars: "-", keyCode: 27),
.zoomOut
)
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command, .shift], chars: "_", keyCode: 27),
.zoomOut
)
}
func testZoomInSupportsShiftedLiteralFromDifferentPhysicalKey() {
XCTAssertEqual(
browserZoomShortcutAction(
flags: [.command, .shift],
chars: ";",
keyCode: 41,
literalChars: "+"
),
.zoomIn
)
XCTAssertNil(
browserZoomShortcutAction(
flags: [.command, .shift],
chars: ";",
keyCode: 41
)
)
}
func testZoomRequiresCommandWithoutOptionOrControl() {
XCTAssertNil(browserZoomShortcutAction(flags: [], chars: "=", keyCode: 24))
XCTAssertNil(browserZoomShortcutAction(flags: [.command, .option], chars: "=", keyCode: 24))
XCTAssertNil(browserZoomShortcutAction(flags: [.command, .control], chars: "-", keyCode: 27))
}
func testResetSupportsCommandZero() {
XCTAssertEqual(
browserZoomShortcutAction(flags: [.command], chars: "0", keyCode: 29),
.reset
)
}
}
final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
func testRoutesWhenGhosttyIsFirstResponderAndShortcutIsZoom() {
XCTAssertTrue(
shouldRouteTerminalFontZoomShortcutToGhostty(
firstResponderIsGhostty: true,
flags: [.command],
chars: "=",
keyCode: 24
)
)
XCTAssertTrue(
shouldRouteTerminalFontZoomShortcutToGhostty(
firstResponderIsGhostty: true,
flags: [.command],
chars: "-",
keyCode: 27
)
)
XCTAssertTrue(
shouldRouteTerminalFontZoomShortcutToGhostty(
firstResponderIsGhostty: true,
flags: [.command],
chars: "0",
keyCode: 29
)
)
}
func testDoesNotRouteWhenFirstResponderIsNotGhostty() {
XCTAssertFalse(
shouldRouteTerminalFontZoomShortcutToGhostty(
firstResponderIsGhostty: false,
flags: [.command],
chars: "=",
keyCode: 24
)
)
}
func testDoesNotRouteForNonZoomShortcuts() {
XCTAssertFalse(
shouldRouteTerminalFontZoomShortcutToGhostty(
firstResponderIsGhostty: true,
flags: [.command],
chars: "n",
keyCode: 45
)
)
}
func testRoutesForShiftedLiteralZoomShortcut() {
XCTAssertTrue(
shouldRouteTerminalFontZoomShortcutToGhostty(
firstResponderIsGhostty: true,
flags: [.command, .shift],
chars: ";",
keyCode: 41,
literalChars: "+"
)
)
}
}
final class TerminalCommandShortcutRoutingPolicyTests: XCTestCase {
func testRoutesCommandCToTerminalWhenNoSelection() {
XCTAssertTrue(
shouldRouteTerminalCommandShortcutToGhostty(
flags: [.command],
chars: "c",
keyCode: 8, // kVK_ANSI_C
terminalHasSelection: false
)
)
}
func testKeepsCommandCCopyMenuRoutedWhenSelectionExists() {
XCTAssertFalse(
shouldRouteTerminalCommandShortcutToGhostty(
flags: [.command],
chars: "c",
keyCode: 8, // kVK_ANSI_C
terminalHasSelection: true
)
)
}
func testKeepsCommandCommaMenuRoutedForPreferences() {
XCTAssertFalse(
shouldRouteTerminalCommandShortcutToGhostty(
flags: [.command],
chars: ",",
keyCode: 43, // kVK_ANSI_Comma
terminalHasSelection: false
)
)
}
func testRequiresCommandModifier() {
XCTAssertFalse(
shouldRouteTerminalCommandShortcutToGhostty(
flags: [.control],
chars: "c",
keyCode: 8,
terminalHasSelection: false
)
)
}
func testRoutesOtherCommandShortcutsToTerminal() {
XCTAssertTrue(
shouldRouteTerminalCommandShortcutToGhostty(
flags: [.command, .option],
chars: "c",
keyCode: 8,
terminalHasSelection: false
)
)
XCTAssertTrue(
shouldRouteTerminalCommandShortcutToGhostty(
flags: [.command],
chars: "v",
keyCode: 9, // kVK_ANSI_V
terminalHasSelection: false
)
)
}
}
final class GhosttyResponderResolutionTests: XCTestCase {
private final class FocusProbeView: NSView {
override var acceptsFirstResponder: Bool { true }
}
func testResolvesGhosttyViewFromDescendantResponder() {
let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
ghosttyView.addSubview(descendant)
XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView)
}
func testResolvesGhosttyViewFromGhosttyResponder() {
let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView)
}
func testReturnsNilForUnrelatedResponder() {
let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
XCTAssertNil(cmuxOwningGhosttyView(for: view))
}
}
final class CommandPaletteKeyboardNavigationTests: XCTestCase {
func testArrowKeysMoveSelectionWithoutModifiers() {
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [],
chars: "",
keyCode: 125
),
1
)
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [],
chars: "",
keyCode: 126
),
-1
)
XCTAssertNil(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.shift],
chars: "",
keyCode: 125
)
)
}
func testControlLetterNavigationSupportsPrintableAndControlChars() {
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "n",
keyCode: 45
),
1
)
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "\u{0e}",
keyCode: 45
),
1
)
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "p",
keyCode: 35
),
-1
)
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "\u{10}",
keyCode: 35
),
-1
)
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "j",
keyCode: 38
),
1
)
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "\u{0a}",
keyCode: 38
),
1
)
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "k",
keyCode: 40
),
-1
)
XCTAssertEqual(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "\u{0b}",
keyCode: 40
),
-1
)
}
func testIgnoresUnsupportedModifiersAndKeys() {
XCTAssertNil(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.command],
chars: "n",
keyCode: 45
)
)
XCTAssertNil(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control, .shift],
chars: "n",
keyCode: 45
)
)
XCTAssertNil(
commandPaletteSelectionDeltaForKeyboardNavigation(
flags: [.control],
chars: "x",
keyCode: 7
)
)
}
}
final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase {
func testDoesNotConsumeWhenPaletteIsNotVisible() {
XCTAssertFalse(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: false,
normalizedFlags: [.command],
chars: "n",
keyCode: 45
)
)
}
func testConsumesAppCommandShortcutsWhenPaletteIsVisible() {
XCTAssertTrue(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [.command],
chars: "n",
keyCode: 45
)
)
XCTAssertTrue(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [.command],
chars: "t",
keyCode: 17
)
)
XCTAssertTrue(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [.command, .shift],
chars: ",",
keyCode: 43
)
)
}
func testAllowsClipboardAndUndoShortcutsForPaletteTextEditing() {
XCTAssertFalse(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [.command],
chars: "v",
keyCode: 9
)
)
XCTAssertFalse(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [.command],
chars: "z",
keyCode: 6
)
)
XCTAssertFalse(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [.command, .shift],
chars: "z",
keyCode: 6
)
)
}
func testAllowsArrowAndDeleteEditingCommandsForPaletteTextEditing() {
XCTAssertFalse(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [.command],
chars: "",
keyCode: 123
)
)
XCTAssertFalse(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [.command],
chars: "",
keyCode: 51
)
)
}
func testConsumesEscapeWhenPaletteIsVisible() {
XCTAssertTrue(
shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: true,
normalizedFlags: [],
chars: "",
keyCode: 53
)
)
}
}
final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase {
func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() {
let panelId = UUID()
XCTAssertTrue(
ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: true,
focusedBrowserAddressBarPanelId: panelId,
focusedPanelId: panelId
)
)
}
func testDoesNotRestoreBrowserAddressBarWhenFocusedPanelIsNotBrowser() {
let panelId = UUID()
XCTAssertFalse(
ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: false,
focusedBrowserAddressBarPanelId: panelId,
focusedPanelId: panelId
)
)
}
func testDoesNotRestoreBrowserAddressBarWhenAnotherPanelHadAddressBarFocus() {
XCTAssertFalse(
ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: true,
focusedBrowserAddressBarPanelId: UUID(),
focusedPanelId: UUID()
)
)
}
}
final class CommandPaletteRenameSelectionSettingsTests: XCTestCase {
private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)"
private func makeDefaults() -> UserDefaults {
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
func testDefaultsToSelectAllWhenUnset() {
let defaults = makeDefaults()
XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
}
func testReturnsFalseWhenStoredFalse() {
let defaults = makeDefaults()
defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
}
func testReturnsTrueWhenStoredTrue() {
let defaults = makeDefaults()
defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
}
}
final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase {
func testFirstEntryPinsToTopAnchor() {
let anchor = ContentView.commandPaletteScrollPositionAnchor(
selectedIndex: 0,
resultCount: 20
)
XCTAssertEqual(anchor, UnitPoint.top)
}
func testLastEntryPinsToBottomAnchor() {
let anchor = ContentView.commandPaletteScrollPositionAnchor(
selectedIndex: 19,
resultCount: 20
)
XCTAssertEqual(anchor, UnitPoint.bottom)
}
func testMiddleEntryUsesNilAnchorForMinimalScroll() {
let anchor = ContentView.commandPaletteScrollPositionAnchor(
selectedIndex: 6,
resultCount: 20
)
XCTAssertNil(anchor)
}
func testEmptyResultsProduceNoAnchor() {
let anchor = ContentView.commandPaletteScrollPositionAnchor(
selectedIndex: 0,
resultCount: 0
)
XCTAssertNil(anchor)
}
}
final class SidebarCommandHintPolicyTests: XCTestCase {
func testCommandHintRequiresCommandOnlyModifier() {
withDefaultsSuite { defaults in
defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command], defaults: defaults))
XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [], defaults: defaults))
XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults))
XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .option], defaults: defaults))
XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command, .control], defaults: defaults))
}
}
func testCommandHintCanBeDisabledInSettings() {
withDefaultsSuite { defaults in
defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
XCTAssertFalse(SidebarCommandHintPolicy.shouldShowHints(for: [.command], defaults: defaults))
}
}
func testCommandHintDefaultsToEnabledWhenSettingMissing() {
withDefaultsSuite { defaults in
defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command], defaults: defaults))
}
}
func testCommandHintUsesIntentionalHoldDelay() {
XCTAssertGreaterThanOrEqual(SidebarCommandHintPolicy.intentionalHoldDelay, 0.25)
}
func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() {
XCTAssertTrue(
SidebarCommandHintPolicy.isCurrentWindow(
hostWindowNumber: 42,
hostWindowIsKey: true,
eventWindowNumber: 42,
keyWindowNumber: 42
)
)
XCTAssertFalse(
SidebarCommandHintPolicy.isCurrentWindow(
hostWindowNumber: 42,
hostWindowIsKey: true,
eventWindowNumber: 7,
keyWindowNumber: 42
)
)
XCTAssertFalse(
SidebarCommandHintPolicy.isCurrentWindow(
hostWindowNumber: 42,
hostWindowIsKey: false,
eventWindowNumber: 42,
keyWindowNumber: 42
)
)
}
func testWindowScopedCommandHintsUseKeyWindowWhenNoEventWindowIsAvailable() {
withDefaultsSuite { defaults in
defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
XCTAssertTrue(
SidebarCommandHintPolicy.shouldShowHints(
for: [.command],
hostWindowNumber: 42,
hostWindowIsKey: true,
eventWindowNumber: nil,
keyWindowNumber: 42,
defaults: defaults
)
)
XCTAssertFalse(
SidebarCommandHintPolicy.shouldShowHints(
for: [.command],
hostWindowNumber: 42,
hostWindowIsKey: true,
eventWindowNumber: nil,
keyWindowNumber: 7,
defaults: defaults
)
)
}
}
private func withDefaultsSuite(_ body: (UserDefaults) -> Void) {
let suiteName = "SidebarCommandHintPolicyTests-\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create defaults suite")
return
}
defaults.removePersistentDomain(forName: suiteName)
body(defaults)
defaults.removePersistentDomain(forName: suiteName)
}
}
final class ShortcutHintDebugSettingsTests: XCTestCase {
func testClampKeepsValuesWithinSupportedRange() {
XCTAssertEqual(ShortcutHintDebugSettings.clamped(0.0), 0.0)
XCTAssertEqual(ShortcutHintDebugSettings.clamped(4.0), 4.0)
XCTAssertEqual(ShortcutHintDebugSettings.clamped(-100.0), ShortcutHintDebugSettings.offsetRange.lowerBound)
XCTAssertEqual(ShortcutHintDebugSettings.clamped(100.0), ShortcutHintDebugSettings.offsetRange.upperBound)
}
func testDefaultOffsetsMatchCurrentBadgePlacements() {
XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintX, 0.0)
XCTAssertEqual(ShortcutHintDebugSettings.defaultSidebarHintY, 0.0)
XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintX, 4.0)
XCTAssertEqual(ShortcutHintDebugSettings.defaultTitlebarHintY, 0.0)
XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintX, 0.0)
XCTAssertEqual(ShortcutHintDebugSettings.defaultPaneHintY, 0.0)
XCTAssertFalse(ShortcutHintDebugSettings.defaultAlwaysShowHints)
XCTAssertTrue(ShortcutHintDebugSettings.defaultShowHintsOnCommandHold)
}
func testShowHintsOnCommandHoldSettingRespectsStoredValue() {
let suiteName = "ShortcutHintDebugSettingsTests-\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create defaults suite")
return
}
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
XCTAssertFalse(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
XCTAssertTrue(ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults))
}
}
final class ShortcutHintLanePlannerTests: XCTestCase {
func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() {
let intervals: [ClosedRange<CGFloat>] = [0...20, 28...40, 48...64]
XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 0, 0])
}
func testAssignLanesStacksOverlappingIntervalsIntoAdditionalLanes() {
let intervals: [ClosedRange<CGFloat>] = [0...20, 18...34, 22...38, 40...56]
XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 1, 2, 0])
}
}
final class ShortcutHintHorizontalPlannerTests: XCTestCase {
func testAssignRightEdgesResolvesOverlapWithMinimumSpacing() {
let intervals: [ClosedRange<CGFloat>] = [0...20, 18...34, 30...46]
let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 6)
XCTAssertEqual(rightEdges.count, intervals.count)
let adjustedIntervals = zip(intervals, rightEdges).map { interval, rightEdge in
let width = interval.upperBound - interval.lowerBound
return (rightEdge - width)...rightEdge
}
XCTAssertGreaterThanOrEqual(adjustedIntervals[1].lowerBound - adjustedIntervals[0].upperBound, 6)
XCTAssertGreaterThanOrEqual(adjustedIntervals[2].lowerBound - adjustedIntervals[1].upperBound, 6)
}
func testAssignRightEdgesKeepsAlreadySeparatedIntervalsInPlace() {
let intervals: [ClosedRange<CGFloat>] = [0...12, 20...32, 40...52]
let rightEdges = ShortcutHintHorizontalPlanner.assignRightEdges(for: intervals, minSpacing: 4)
XCTAssertEqual(rightEdges, [12, 32, 52])
}
}
final class WorkspacePlacementSettingsTests: XCTestCase {
func testCurrentPlacementDefaultsToAfterCurrentWhenUnset() {
let suiteName = "WorkspacePlacementSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent)
}
func testCurrentPlacementReadsStoredValidValueAndFallsBackForInvalid() {
let suiteName = "WorkspacePlacementSettingsTests.Stored.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(NewWorkspacePlacement.top.rawValue, forKey: WorkspacePlacementSettings.placementKey)
XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .top)
defaults.set("nope", forKey: WorkspacePlacementSettings.placementKey)
XCTAssertEqual(WorkspacePlacementSettings.current(defaults: defaults), .afterCurrent)
}
func testInsertionIndexTopInsertsBeforeUnpinned() {
let index = WorkspacePlacementSettings.insertionIndex(
placement: .top,
selectedIndex: 4,
selectedIsPinned: false,
pinnedCount: 2,
totalCount: 7
)
XCTAssertEqual(index, 2)
}
func testInsertionIndexAfterCurrentHandlesPinnedAndUnpinnedSelection() {
let afterUnpinned = WorkspacePlacementSettings.insertionIndex(
placement: .afterCurrent,
selectedIndex: 3,
selectedIsPinned: false,
pinnedCount: 2,
totalCount: 6
)
XCTAssertEqual(afterUnpinned, 4)
let afterPinned = WorkspacePlacementSettings.insertionIndex(
placement: .afterCurrent,
selectedIndex: 0,
selectedIsPinned: true,
pinnedCount: 2,
totalCount: 6
)
XCTAssertEqual(afterPinned, 2)
}
func testInsertionIndexEndAndNoSelectionAppend() {
let endIndex = WorkspacePlacementSettings.insertionIndex(
placement: .end,
selectedIndex: 1,
selectedIsPinned: false,
pinnedCount: 1,
totalCount: 5
)
XCTAssertEqual(endIndex, 5)
let noSelectionIndex = WorkspacePlacementSettings.insertionIndex(
placement: .afterCurrent,
selectedIndex: nil,
selectedIsPinned: false,
pinnedCount: 0,
totalCount: 5
)
XCTAssertEqual(noSelectionIndex, 5)
}
}
@MainActor
final class WorkspaceCreationPlacementTests: XCTestCase {
func testAddWorkspaceDefaultPlacementMatchesCurrentSetting() {
let currentPlacement = WorkspacePlacementSettings.current()
let defaultManager = makeManagerWithThreeWorkspaces()
let defaultBaselineOrder = defaultManager.tabs.map(\.id)
let defaultInserted = defaultManager.addWorkspace()
guard let defaultInsertedIndex = defaultManager.tabs.firstIndex(where: { $0.id == defaultInserted.id }) else {
XCTFail("Expected inserted workspace in tab list")
return
}
XCTAssertEqual(defaultManager.tabs.map(\.id).filter { $0 != defaultInserted.id }, defaultBaselineOrder)
let explicitManager = makeManagerWithThreeWorkspaces()
let explicitBaselineOrder = explicitManager.tabs.map(\.id)
let explicitInserted = explicitManager.addWorkspace(placementOverride: currentPlacement)
guard let explicitInsertedIndex = explicitManager.tabs.firstIndex(where: { $0.id == explicitInserted.id }) else {
XCTFail("Expected inserted workspace in tab list")
return
}
XCTAssertEqual(explicitManager.tabs.map(\.id).filter { $0 != explicitInserted.id }, explicitBaselineOrder)
XCTAssertEqual(defaultInsertedIndex, explicitInsertedIndex)
}
func testAddWorkspaceEndOverrideAlwaysAppends() {
let manager = makeManagerWithThreeWorkspaces()
let baselineCount = manager.tabs.count
guard baselineCount >= 3 else {
XCTFail("Expected at least three workspaces for placement regression test")
return
}
let inserted = manager.addWorkspace(placementOverride: .end)
guard let insertedIndex = manager.tabs.firstIndex(where: { $0.id == inserted.id }) else {
XCTFail("Expected inserted workspace in tab list")
return
}
XCTAssertEqual(insertedIndex, baselineCount)
}
private func makeManagerWithThreeWorkspaces() -> TabManager {
let manager = TabManager()
_ = manager.addWorkspace()
_ = manager.addWorkspace()
if let first = manager.tabs.first {
manager.selectWorkspace(first)
}
return manager
}
}
final class WorkspaceTabColorSettingsTests: XCTestCase {
func testNormalizedHexAcceptsAndNormalizesValidInput() {
XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123")
XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF")
XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234"))
XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234"))
}
func testBuiltInPaletteMatchesOriginalPRPalette() {
let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults)
XCTAssertEqual(palette.count, 16)
XCTAssertEqual(palette.first?.name, "Red")
XCTAssertEqual(palette.first?.hex, "#C0392B")
XCTAssertEqual(palette.last?.name, "Charcoal")
XCTAssertFalse(palette.contains(where: { $0.name == "Gold" }))
}
func testDefaultOverrideRoundTripFallsBackWhenResetToBase() {
let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let first = WorkspaceTabColorSettings.defaultPalette[0]
XCTAssertEqual(
WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
first.hex
)
WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults)
XCTAssertEqual(
WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
"#00AA33"
)
WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults)
XCTAssertEqual(
WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
first.hex
)
}
func testAddCustomColorPersistsAndDeduplicatesByMostRecent() {
let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertEqual(
WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults),
"#00AA33"
)
XCTAssertEqual(
WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults),
"#112233"
)
XCTAssertEqual(
WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults),
"#00AA33"
)
XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults))
XCTAssertEqual(
WorkspaceTabColorSettings.customColors(defaults: defaults),
["#00AA33", "#112233"]
)
}
func testPaletteIncludesCustomEntriesAndResetClearsAll() {
let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let first = WorkspaceTabColorSettings.defaultPalette[0]
WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults)
_ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults)
let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults)
XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1)
XCTAssertEqual(paletteBeforeReset[0].hex, "#334455")
XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1")
XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899")
WorkspaceTabColorSettings.reset(defaults: defaults)
XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), [])
XCTAssertEqual(
WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults),
first.hex
)
}
func testDisplayColorLightModeKeepsOriginalHex() {
let originalHex = "#1A5276"
let rendered = WorkspaceTabColorSettings.displayNSColor(
hex: originalHex,
colorScheme: .light
)
XCTAssertEqual(rendered?.hexString(), originalHex)
}
func testDisplayColorDarkModeBrightensColor() {
let originalHex = "#1A5276"
guard let base = NSColor(hex: originalHex),
let rendered = WorkspaceTabColorSettings.displayNSColor(
hex: originalHex,
colorScheme: .dark
) else {
XCTFail("Expected valid color conversion")
return
}
XCTAssertNotEqual(rendered.hexString(), originalHex)
XCTAssertGreaterThan(rendered.luminance, base.luminance)
}
func testDisplayColorDarkModeKeepsGrayscaleNeutral() {
let originalHex = "#808080"
guard let base = NSColor(hex: originalHex),
let rendered = WorkspaceTabColorSettings.displayNSColor(
hex: originalHex,
colorScheme: .dark
),
let renderedSRGB = rendered.usingColorSpace(.sRGB) else {
XCTFail("Expected valid color conversion")
return
}
XCTAssertGreaterThan(rendered.luminance, base.luminance)
XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003)
XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003)
}
func testDisplayColorForceBrightensInLightMode() {
let originalHex = "#1A5276"
guard let base = NSColor(hex: originalHex),
let rendered = WorkspaceTabColorSettings.displayNSColor(
hex: originalHex,
colorScheme: .light,
forceBright: true
) else {
XCTFail("Expected valid color conversion")
return
}
XCTAssertNotEqual(rendered.hexString(), originalHex)
XCTAssertGreaterThan(rendered.luminance, base.luminance)
}
}
final class WorkspaceAutoReorderSettingsTests: XCTestCase {
func testDefaultIsEnabled() {
let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
}
func testDisabledWhenSetToFalse() {
let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: WorkspaceAutoReorderSettings.key)
XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
}
func testEnabledWhenSetToTrue() {
let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: WorkspaceAutoReorderSettings.key)
XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
}
}
final class SidebarBranchLayoutSettingsTests: XCTestCase {
func testDefaultUsesVerticalLayout() {
let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
}
func testStoredPreferenceOverridesDefault() {
let suiteName = "SidebarBranchLayoutSettingsTests.Stored.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: SidebarBranchLayoutSettings.key)
XCTAssertFalse(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
defaults.set(true, forKey: SidebarBranchLayoutSettings.key)
XCTAssertTrue(SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults))
}
}
final class SidebarActiveTabIndicatorSettingsTests: XCTestCase {
func testDefaultStyleWhenUnset() {
let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey)
XCTAssertEqual(
SidebarActiveTabIndicatorSettings.current(defaults: defaults),
SidebarActiveTabIndicatorSettings.defaultStyle
)
}
func testStoredStyleParsesAndInvalidFallsBack() {
let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey)
XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail)
defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey)
XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail)
defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey)
XCTAssertEqual(
SidebarActiveTabIndicatorSettings.current(defaults: defaults),
SidebarActiveTabIndicatorSettings.defaultStyle
)
}
}
final class AppearanceSettingsTests: XCTestCase {
func testResolvedModeDefaultsToSystemWhenUnset() {
let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.removeObject(forKey: AppearanceSettings.appearanceModeKey)
let resolved = AppearanceSettings.resolvedMode(defaults: defaults)
XCTAssertEqual(resolved, .system)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
}
}
final class QuitWarningSettingsTests: XCTestCase {
func testDefaultWarnBeforeQuitIsEnabledWhenUnset() {
let suiteName = "QuitWarningSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.removeObject(forKey: QuitWarningSettings.warnBeforeQuitKey)
XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults))
}
func testStoredPreferenceOverridesDefault() {
let suiteName = "QuitWarningSettingsTests.Stored.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: QuitWarningSettings.warnBeforeQuitKey)
XCTAssertFalse(QuitWarningSettings.isEnabled(defaults: defaults))
defaults.set(true, forKey: QuitWarningSettings.warnBeforeQuitKey)
XCTAssertTrue(QuitWarningSettings.isEnabled(defaults: defaults))
}
}
final class UpdateChannelSettingsTests: XCTestCase {
func testResolvedFeedFallsBackWhenInfoFeedMissing() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
XCTAssertFalse(resolved.isNightly)
XCTAssertTrue(resolved.usedFallback)
}
func testResolvedFeedFallsBackWhenInfoFeedEmpty() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: "")
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
XCTAssertFalse(resolved.isNightly)
XCTAssertTrue(resolved.usedFallback)
}
func testResolvedFeedUsesInfoFeedForStableChannel() {
let infoFeed = "https://example.com/custom/appcast.xml"
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
XCTAssertEqual(resolved.url, infoFeed)
XCTAssertFalse(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
func testResolvedFeedDetectsNightlyFromInfoFeedURL() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(
infoFeedURL: "https://example.com/nightly/appcast.xml"
)
XCTAssertEqual(resolved.url, "https://example.com/nightly/appcast.xml")
XCTAssertTrue(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
}
final class WorkspaceReorderTests: XCTestCase {
@MainActor
func testReorderWorkspaceMovesWorkspaceToRequestedIndex() {
let manager = TabManager()
let first = manager.tabs[0]
let second = manager.addWorkspace()
let third = manager.addWorkspace()
manager.selectWorkspace(second)
XCTAssertEqual(manager.selectedTabId, second.id)
XCTAssertTrue(manager.reorderWorkspace(tabId: second.id, toIndex: 0))
XCTAssertEqual(manager.tabs.map(\.id), [second.id, first.id, third.id])
XCTAssertEqual(manager.selectedTabId, second.id)
}
@MainActor
func testReorderWorkspaceClampsOutOfRangeTargetIndex() {
let manager = TabManager()
let first = manager.tabs[0]
let second = manager.addWorkspace()
let third = manager.addWorkspace()
XCTAssertTrue(manager.reorderWorkspace(tabId: first.id, toIndex: 999))
XCTAssertEqual(manager.tabs.map(\.id), [second.id, third.id, first.id])
}
@MainActor
func testReorderWorkspaceReturnsFalseForUnknownWorkspace() {
let manager = TabManager()
XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0))
}
}
@MainActor
final class TabManagerChildExitCloseTests: XCTestCase {
func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() {
let manager = TabManager()
let first = manager.tabs[0]
let second = manager.addWorkspace()
let third = manager.addWorkspace()
manager.selectWorkspace(second)
XCTAssertEqual(manager.selectedTabId, second.id)
guard let secondPanelId = second.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId)
XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id])
XCTAssertEqual(
manager.selectedTabId,
third.id,
"Expected selection to stay at the same index after deleting the selected workspace"
)
}
func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() {
let manager = TabManager()
let first = manager.tabs[0]
let second = manager.addWorkspace()
manager.selectWorkspace(second)
XCTAssertEqual(manager.selectedTabId, second.id)
guard let secondPanelId = second.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId)
XCTAssertEqual(manager.tabs.map(\.id), [first.id])
XCTAssertEqual(
manager.selectedTabId,
first.id,
"Expected previous workspace to be selected after closing the last-index workspace"
)
}
func testChildExitOnNonLastPanelClosesOnlyPanel() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let initialPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with focused panel")
return
}
guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else {
XCTFail("Expected split terminal panel to be created")
return
}
let panelCountBefore = workspace.panels.count
manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id)
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(manager.tabs.first?.id, workspace.id)
XCTAssertEqual(workspace.panels.count, panelCountBefore - 1)
XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain")
}
}
@MainActor
final class WorkspaceTeardownTests: XCTestCase {
func testTeardownAllPanelsClearsPanelMetadataCaches() {
let workspace = Workspace()
guard let initialPanelId = workspace.focusedPanelId else {
XCTFail("Expected focused panel in new workspace")
return
}
workspace.setPanelCustomTitle(panelId: initialPanelId, title: "Initial custom title")
workspace.setPanelPinned(panelId: initialPanelId, pinned: true)
guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else {
XCTFail("Expected split panel to be created")
return
}
workspace.setPanelCustomTitle(panelId: splitPanel.id, title: "Split custom title")
workspace.setPanelPinned(panelId: splitPanel.id, pinned: true)
workspace.markPanelUnread(initialPanelId)
XCTAssertFalse(workspace.panels.isEmpty)
XCTAssertFalse(workspace.panelTitles.isEmpty)
XCTAssertFalse(workspace.panelCustomTitles.isEmpty)
XCTAssertFalse(workspace.pinnedPanelIds.isEmpty)
XCTAssertFalse(workspace.manualUnreadPanelIds.isEmpty)
workspace.teardownAllPanels()
XCTAssertTrue(workspace.panels.isEmpty)
XCTAssertTrue(workspace.panelTitles.isEmpty)
XCTAssertTrue(workspace.panelCustomTitles.isEmpty)
XCTAssertTrue(workspace.pinnedPanelIds.isEmpty)
XCTAssertTrue(workspace.manualUnreadPanelIds.isEmpty)
}
}
@MainActor
final class TabManagerWorkspaceOwnershipTests: XCTestCase {
func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() {
let manager = TabManager()
_ = manager.addWorkspace()
let initialTabIds = manager.tabs.map(\.id)
let initialSelectedTabId = manager.selectedTabId
let externalWorkspace = Workspace(title: "External workspace")
let externalPanelCountBefore = externalWorkspace.panels.count
let externalPanelTitlesBefore = externalWorkspace.panelTitles
manager.closeWorkspace(externalWorkspace)
XCTAssertEqual(manager.tabs.map(\.id), initialTabIds)
XCTAssertEqual(manager.selectedTabId, initialSelectedTabId)
XCTAssertEqual(externalWorkspace.panels.count, externalPanelCountBefore)
XCTAssertEqual(externalWorkspace.panelTitles, externalPanelTitlesBefore)
}
}
@MainActor
final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
let tabId = UUID()
XCTAssertFalse(
TabManager.shouldUnfocusPendingWorkspace(
pendingTabId: tabId,
selectedTabId: tabId
)
)
}
func testUnfocusesWhenPendingTabIsNotSelected() {
XCTAssertTrue(
TabManager.shouldUnfocusPendingWorkspace(
pendingTabId: UUID(),
selectedTabId: UUID()
)
)
XCTAssertTrue(
TabManager.shouldUnfocusPendingWorkspace(
pendingTabId: UUID(),
selectedTabId: nil
)
)
}
}
@MainActor
final class TabManagerSurfaceCreationTests: XCTestCase {
func testNewSurfaceFocusesCreatedSurface() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace else {
XCTFail("Expected a selected workspace")
return
}
let beforePanels = Set(workspace.panels.keys)
manager.newSurface()
let afterPanels = Set(workspace.panels.keys)
let createdPanels = afterPanels.subtracting(beforePanels)
XCTAssertEqual(createdPanels.count, 1, "Expected one new surface for Cmd+T path")
guard let createdPanelId = createdPanels.first else { return }
XCTAssertEqual(
workspace.focusedPanelId,
createdPanelId,
"Expected newly created surface to be focused"
)
}
func testOpenBrowserInsertAtEndPlacesNewBrowserAtPaneEnd() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let paneId = workspace.bonsplitController.focusedPaneId else {
XCTFail("Expected focused workspace and pane")
return
}
// Add one extra surface so we verify append-to-end rather than first insert behavior.
_ = workspace.newTerminalSurface(inPane: paneId, focus: false)
guard let browserPanelId = manager.openBrowser(insertAtEnd: true) else {
XCTFail("Expected browser panel to be created")
return
}
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
guard let lastSurfaceId = tabs.last?.id else {
XCTFail("Expected at least one surface in pane")
return
}
XCTAssertEqual(
workspace.panelIdFromSurfaceId(lastSurfaceId),
browserPanelId,
"Expected Cmd+Shift+B/Cmd+L open path to append browser surface at end"
)
XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused")
}
func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() {
let manager = TabManager()
guard let initialWorkspace = manager.selectedWorkspace else {
XCTFail("Expected initial selected workspace")
return
}
guard let url = URL(string: "https://example.com/pull/123") else {
XCTFail("Expected test URL to be valid")
return
}
let targetWorkspace = manager.addWorkspace(select: false)
manager.selectWorkspace(initialWorkspace)
let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count
let initialPanelCount = targetWorkspace.panels.count
guard let browserPanelId = manager.openBrowser(
inWorkspace: targetWorkspace.id,
url: url,
preferSplitRight: true,
insertAtEnd: true
) else {
XCTFail("Expected browser panel to be created in target workspace")
return
}
XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected")
XCTAssertEqual(
targetWorkspace.bonsplitController.allPaneIds.count,
initialPaneCount + 1,
"Expected split-right browser open to create a new pane"
)
XCTAssertEqual(
targetWorkspace.panels.count,
initialPanelCount + 1,
"Expected browser panel count to increase by one"
)
XCTAssertEqual(
targetWorkspace.focusedPanelId,
browserPanelId,
"Expected created browser panel to be focused in target workspace"
)
XCTAssertTrue(
targetWorkspace.panels[browserPanelId] is BrowserPanel,
"Expected created panel to be a browser panel"
)
}
func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftPanelId = workspace.focusedPanelId,
let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil,
let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id),
let url = URL(string: "https://example.com/pull/456") else {
XCTFail("Expected split setup to succeed")
return
}
let initialPaneCount = workspace.bonsplitController.allPaneIds.count
guard let browserPanelId = manager.openBrowser(
inWorkspace: workspace.id,
url: url,
preferSplitRight: true,
insertAtEnd: true
) else {
XCTFail("Expected browser panel to be created")
return
}
XCTAssertEqual(
workspace.bonsplitController.allPaneIds.count,
initialPaneCount,
"Expected split-right browser open to reuse existing panes"
)
XCTAssertEqual(
workspace.paneId(forPanelId: browserPanelId),
topRightPaneId,
"Expected browser to open in the top-right pane when multiple splits already exist"
)
let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId)
guard let lastSurfaceId = targetPaneTabs.last?.id else {
XCTFail("Expected top-right pane to contain tabs")
return
}
XCTAssertEqual(
workspace.panelIdFromSurfaceId(lastSurfaceId),
browserPanelId,
"Expected browser surface to be appended at end in the reused top-right pane"
)
}
}
@MainActor
final class TabManagerEqualizeSplitsTests: XCTestCase {
func testEqualizeSplitsSetsEverySplitDividerToHalf() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftPanelId = workspace.focusedPanelId,
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else {
XCTFail("Expected nested split setup to succeed")
return
}
let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout")
for (index, split) in initialSplits.enumerated() {
guard let splitId = UUID(uuidString: split.id) else {
XCTFail("Expected split ID to be a UUID")
return
}
let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8
XCTAssertTrue(
workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId),
"Expected to seed divider position for split \(splitId)"
)
}
XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed")
let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
XCTAssertEqual(equalizedSplits.count, initialSplits.count)
for split in equalizedSplits {
XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1)
}
}
private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] {
switch node {
case .pane:
return []
case .split(let split):
return [split] + splitNodes(in: split.first) + splitNodes(in: split.second)
}
}
}
@MainActor
final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftPanelId = workspace.focusedPanelId,
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else {
XCTFail("Expected workspace split setup to succeed")
return
}
// Programmatic split focuses the new right panel by default.
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId)
XCTAssertEqual(
sourcePanel?.id,
leftPanelId,
"Expected inheritance to use the selected terminal in the target pane"
)
}
func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId,
let paneId = workspace.paneId(forPanelId: terminalPanelId),
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
XCTFail("Expected workspace browser setup to succeed")
return
}
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId)
XCTAssertEqual(
sourcePanel?.id,
terminalPanelId,
"Expected inheritance to fall back to a terminal in the pane when browser is selected"
)
}
func testPreferredTerminalPanelWinsWhenProvided() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with a terminal panel")
return
}
let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId)
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
}
func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftTerminalPanelId = workspace.focusedPanelId,
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
XCTFail("Expected split setup to succeed")
return
}
workspace.focusPanel(leftTerminalPanelId)
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId)
XCTAssertEqual(
sourcePanel?.id,
leftTerminalPanelId,
"Expected inheritance to prefer last focused terminal when browser is focused in another pane"
)
}
}
@MainActor
final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
func testUsesFocusedTerminalWhenTerminalIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace with focused terminal")
return
}
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
}
func testFallsBackToTerminalWhenBrowserIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let terminalPanelId = workspace.focusedPanelId,
let paneId = workspace.paneId(forPanelId: terminalPanelId),
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
XCTFail("Expected selected workspace setup to succeed")
return
}
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(
sourcePanel?.id,
terminalPanelId,
"Expected new workspace inheritance source to resolve to the pane terminal when browser is focused"
)
}
func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftTerminalPanelId = workspace.focusedPanelId,
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
XCTFail("Expected split setup to succeed")
return
}
workspace.focusPanel(leftTerminalPanelId)
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
XCTAssertEqual(
sourcePanel?.id,
leftTerminalPanelId,
"Expected workspace inheritance source to use last focused terminal across panes"
)
}
}
@MainActor
final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {
let manager = TabManager()
guard let workspace1 = manager.selectedWorkspace,
let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/ws-switch")) else {
XCTFail("Expected initial workspace and browser panel")
return
}
drainMainQueue()
XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true))
drainMainQueue()
let workspace2 = manager.addWorkspace()
XCTAssertEqual(manager.selectedTabId, workspace2.id)
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
drainMainQueue()
XCTAssertEqual(manager.selectedTabId, workspace1.id)
XCTAssertTrue(isFocusedPanelBrowser(in: workspace1))
}
func testReopenFallsBackToCurrentWorkspaceAndFocusesBrowserWhenOriginalWorkspaceDeleted() {
let manager = TabManager()
guard let originalWorkspace = manager.selectedWorkspace,
let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/deleted-ws")) else {
XCTFail("Expected initial workspace and browser panel")
return
}
drainMainQueue()
XCTAssertTrue(originalWorkspace.closePanel(closedBrowserId, force: true))
drainMainQueue()
let currentWorkspace = manager.addWorkspace()
manager.closeWorkspace(originalWorkspace)
XCTAssertEqual(manager.selectedTabId, currentWorkspace.id)
XCTAssertFalse(manager.tabs.contains(where: { $0.id == originalWorkspace.id }))
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
drainMainQueue()
XCTAssertEqual(manager.selectedTabId, currentWorkspace.id)
XCTAssertTrue(isFocusedPanelBrowser(in: currentWorkspace))
}
func testReopenCollapsedSplitFromDifferentWorkspaceFocusesBrowser() {
let manager = TabManager()
guard let workspace1 = manager.selectedWorkspace,
let sourcePanelId = workspace1.focusedPanelId,
let splitBrowserId = manager.newBrowserSplit(
tabId: workspace1.id,
fromPanelId: sourcePanelId,
orientation: .horizontal,
insertFirst: false,
url: URL(string: "https://example.com/collapsed-split")
) else {
XCTFail("Expected to create browser split")
return
}
drainMainQueue()
XCTAssertTrue(workspace1.closePanel(splitBrowserId, force: true))
drainMainQueue()
let workspace2 = manager.addWorkspace()
XCTAssertEqual(manager.selectedTabId, workspace2.id)
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
drainMainQueue()
XCTAssertEqual(manager.selectedTabId, workspace1.id)
XCTAssertTrue(isFocusedPanelBrowser(in: workspace1))
}
func testReopenFromDifferentWorkspaceWinsAgainstSingleDeferredStaleFocus() {
let manager = TabManager()
guard let workspace1 = manager.selectedWorkspace,
let preReopenPanelId = workspace1.focusedPanelId,
let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-cross-ws")) else {
XCTFail("Expected initial workspace state and browser panel")
return
}
drainMainQueue()
XCTAssertTrue(workspace1.closePanel(closedBrowserId, force: true))
drainMainQueue()
let panelIdsBeforeReopen = Set(workspace1.panels.keys)
let workspace2 = manager.addWorkspace()
XCTAssertEqual(manager.selectedTabId, workspace2.id)
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
guard let reopenedPanelId = singleNewPanelId(in: workspace1, comparedTo: panelIdsBeforeReopen) else {
XCTFail("Expected reopened browser panel ID")
return
}
// Simulate one delayed stale focus callback from the panel that was focused before reopen.
DispatchQueue.main.async {
workspace1.focusPanel(preReopenPanelId)
}
drainMainQueue()
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.selectedTabId, workspace1.id)
XCTAssertEqual(workspace1.focusedPanelId, reopenedPanelId)
XCTAssertTrue(workspace1.panels[reopenedPanelId] is BrowserPanel)
}
func testReopenInSameWorkspaceWinsAgainstSingleDeferredStaleFocus() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let preReopenPanelId = workspace.focusedPanelId,
let closedBrowserId = manager.openBrowser(url: URL(string: "https://example.com/stale-focus-same-ws")) else {
XCTFail("Expected initial workspace state and browser panel")
return
}
drainMainQueue()
XCTAssertTrue(workspace.closePanel(closedBrowserId, force: true))
drainMainQueue()
let panelIdsBeforeReopen = Set(workspace.panels.keys)
XCTAssertTrue(manager.reopenMostRecentlyClosedBrowserPanel())
guard let reopenedPanelId = singleNewPanelId(in: workspace, comparedTo: panelIdsBeforeReopen) else {
XCTFail("Expected reopened browser panel ID")
return
}
// Simulate one delayed stale focus callback from the panel that was focused before reopen.
DispatchQueue.main.async {
workspace.focusPanel(preReopenPanelId)
}
drainMainQueue()
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.selectedTabId, workspace.id)
XCTAssertEqual(workspace.focusedPanelId, reopenedPanelId)
XCTAssertTrue(workspace.panels[reopenedPanelId] is BrowserPanel)
}
private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool {
guard let focusedPanelId = workspace.focusedPanelId else { return false }
return workspace.panels[focusedPanelId] is BrowserPanel
}
private func singleNewPanelId(in workspace: Workspace, comparedTo previousPanelIds: Set<UUID>) -> UUID? {
let newPanelIds = Set(workspace.panels.keys).subtracting(previousPanelIds)
guard newPanelIds.count == 1 else { return nil }
return newPanelIds.first
}
private func drainMainQueue() {
let expectation = expectation(description: "drain main queue")
DispatchQueue.main.async {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
}
}
@MainActor
final class WorkspacePanelGitBranchTests: XCTestCase {
private func drainMainQueue() {
let expectation = expectation(description: "drain main queue")
DispatchQueue.main.async {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
}
func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() {
let workspace = Workspace()
guard let originalFocusedPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
guard let browserSplitPanel = workspace.newBrowserSplit(
from: originalFocusedPanelId,
orientation: .horizontal,
focus: false
) else {
XCTFail("Expected browser split panel to be created")
return
}
drainMainQueue()
XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId)
XCTAssertEqual(
workspace.focusedPanelId,
originalFocusedPanelId,
"Expected non-focus browser split to preserve pre-split focus"
)
}
func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() {
let workspace = Workspace()
guard let originalFocusedPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
guard let terminalSplitPanel = workspace.newTerminalSplit(
from: originalFocusedPanelId,
orientation: .horizontal,
focus: false
) else {
XCTFail("Expected terminal split panel to be created")
return
}
drainMainQueue()
XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId)
XCTAssertEqual(
workspace.focusedPanelId,
originalFocusedPanelId,
"Expected non-focus terminal split to preserve pre-split focus"
)
}
func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() {
let workspace = Workspace()
guard let panelId = workspace.focusedPanelId,
let paneId = workspace.paneId(forPanelId: panelId) else {
XCTFail("Expected initial panel and pane")
return
}
XCTAssertEqual(workspace.panels.count, 1)
#if DEBUG
let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount
#endif
guard let detached = workspace.detachSurface(panelId: panelId) else {
XCTFail("Expected detach of last surface to succeed")
return
}
XCTAssertEqual(detached.panelId, panelId)
XCTAssertTrue(
workspace.panels.isEmpty,
"Detaching the last surface should not auto-create a replacement panel"
)
XCTAssertNil(workspace.surfaceIdFromPanelId(panelId))
XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0)
drainMainQueue()
drainMainQueue()
#if DEBUG
XCTAssertEqual(
workspace.debugFocusReconcileScheduledDuringDetachCount,
baselineFocusReconcileDuringDetach,
"Detaching during cross-workspace moves should not schedule delayed source focus reconciliation"
)
#endif
let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false)
XCTAssertEqual(restoredPanelId, panelId)
XCTAssertEqual(workspace.panels.count, 1)
}
func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() {
let workspace = Workspace()
guard let originalPanelId = workspace.focusedPanelId,
let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else {
XCTFail("Expected two panels before detach")
return
}
drainMainQueue()
drainMainQueue()
#if DEBUG
let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount
#endif
guard let detached = workspace.detachSurface(panelId: movedPanel.id) else {
XCTFail("Expected detach to succeed")
return
}
XCTAssertEqual(detached.panelId, movedPanel.id)
XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel")
XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach")
drainMainQueue()
drainMainQueue()
#if DEBUG
XCTAssertEqual(
workspace.debugFocusReconcileScheduledDuringDetachCount,
baselineFocusReconcileDuringDetach,
"Detaching into another workspace should not enqueue delayed source focus reconciliation"
)
#endif
}
func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() {
let source = Workspace()
guard let panelId = source.focusedPanelId else {
XCTFail("Expected source focused panel")
return
}
XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title"))
guard let detached = source.detachSurface(panelId: panelId) else {
XCTFail("Expected detach to succeed")
return
}
XCTAssertEqual(detached.cachedTitle, "detached-runtime-title")
XCTAssertNil(detached.customTitle)
XCTAssertEqual(
detached.title,
"detached-runtime-title",
"Detached transfer should carry the cached non-custom title"
)
let destination = Workspace()
guard let destinationPane = destination.bonsplitController.allPaneIds.first else {
XCTFail("Expected destination pane")
return
}
let attachedPanelId = destination.attachDetachedSurface(
detached,
inPane: destinationPane,
focus: false
)
XCTAssertEqual(attachedPanelId, panelId)
XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title")
guard let attachedTabId = destination.surfaceIdFromPanelId(panelId),
let attachedTab = destination.bonsplitController.tab(attachedTabId) else {
XCTFail("Expected attached tab mapping")
return
}
XCTAssertEqual(attachedTab.title, "detached-runtime-title")
XCTAssertFalse(attachedTab.hasCustomTitle)
}
func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() {
let workspace = Workspace()
guard let originalFocusedPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else {
XCTFail("Expected focused pane for initial panel")
return
}
guard let browserSplitPanel = workspace.newBrowserSplit(
from: originalFocusedPanelId,
orientation: .horizontal,
focus: false
) else {
XCTFail("Expected browser split panel to be created")
return
}
guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id),
let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id),
let splitTab = workspace.bonsplitController
.tabs(inPane: splitPaneId)
.first(where: { $0.id == splitTabId }) else {
XCTFail("Expected split pane/tab mapping")
return
}
// Simulate one delayed stale split-selection callback from bonsplit.
DispatchQueue.main.async {
workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId)
}
drainMainQueue()
drainMainQueue()
drainMainQueue()
XCTAssertEqual(
workspace.focusedPanelId,
originalFocusedPanelId,
"Expected non-focus split to reassert the pre-split focused panel"
)
XCTAssertEqual(
workspace.bonsplitController.focusedPaneId,
originalPaneId,
"Expected focused pane to converge back to the pre-split pane"
)
XCTAssertEqual(
workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id,
workspace.surfaceIdFromPanelId(originalFocusedPanelId),
"Expected selected tab to converge back to the pre-split focused panel"
)
}
func testBrowserSplitWithFocusFalseAllowsSubsequentExplicitFocusOnSplitPanel() {
let workspace = Workspace()
guard let originalFocusedPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
guard let browserSplitPanel = workspace.newBrowserSplit(
from: originalFocusedPanelId,
orientation: .horizontal,
focus: false
) else {
XCTFail("Expected browser split panel to be created")
return
}
workspace.focusPanel(browserSplitPanel.id)
drainMainQueue()
drainMainQueue()
drainMainQueue()
XCTAssertEqual(
workspace.focusedPanelId,
browserSplitPanel.id,
"Expected explicit focus intent to keep the split panel focused"
)
}
func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() {
let workspace = Workspace()
guard let firstPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false)
guard let secondPanel = workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) else {
XCTFail("Expected split panel to be created")
return
}
workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/bugfix", isDirty: true)
XCTAssertEqual(workspace.focusedPanelId, secondPanel.id, "Expected split panel to be focused")
XCTAssertEqual(workspace.gitBranch?.branch, "feature/bugfix")
XCTAssertEqual(workspace.gitBranch?.isDirty, true)
XCTAssertTrue(workspace.closePanel(secondPanel.id, force: true), "Expected split panel close to succeed")
XCTAssertEqual(workspace.focusedPanelId, firstPanelId, "Expected surviving panel to become focused")
XCTAssertEqual(workspace.gitBranch?.branch, "main")
XCTAssertEqual(workspace.gitBranch?.isDirty, false)
}
func testSidebarGitBranchesFollowLeftToRightSplitOrder() {
let workspace = Workspace()
guard let leftPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "main", isDirty: false)
guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
XCTFail("Expected split panel to be created")
return
}
workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "feature/sidebar", isDirty: true)
let ordered = workspace.sidebarGitBranchesInDisplayOrder()
XCTAssertEqual(ordered.map(\.branch), ["main", "feature/sidebar"])
XCTAssertEqual(ordered.map(\.isDirty), [false, true])
}
func testSidebarOrderingUsesPaneOrderThenTabOrderWithBranchDeduping() {
let workspace = Workspace()
guard let leftFirstPanelId = workspace.focusedPanelId,
let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId),
let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal),
let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id),
let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false),
let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else {
XCTFail("Expected panes and panels for ordering test")
return
}
XCTAssertTrue(workspace.reorderSurface(panelId: leftFirstPanelId, toIndex: 0))
XCTAssertTrue(workspace.reorderSurface(panelId: leftSecondPanel.id, toIndex: 1))
XCTAssertTrue(workspace.reorderSurface(panelId: rightFirstPanel.id, toIndex: 0))
XCTAssertTrue(workspace.reorderSurface(panelId: rightSecondPanel.id, toIndex: 1))
workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false)
workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: false)
workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "main", isDirty: true)
workspace.updatePanelGitBranch(panelId: rightSecondPanel.id, branch: "feature/right", isDirty: false)
XCTAssertEqual(
workspace.sidebarOrderedPanelIds(),
[leftFirstPanelId, leftSecondPanel.id, rightFirstPanel.id, rightSecondPanel.id]
)
let branches = workspace.sidebarGitBranchesInDisplayOrder()
XCTAssertEqual(branches.map(\.branch), ["main", "feature/left", "feature/right"])
XCTAssertEqual(branches.map(\.isDirty), [true, false, false])
}
func testSidebarDerivedCollectionsMatchWhenUsingPrecomputedPanelOrder() {
let workspace = Workspace()
guard let leftFirstPanelId = workspace.focusedPanelId,
let leftPaneId = workspace.paneId(forPanelId: leftFirstPanelId),
let rightFirstPanel = workspace.newTerminalSplit(from: leftFirstPanelId, orientation: .horizontal),
let rightPaneId = workspace.paneId(forPanelId: rightFirstPanel.id),
let leftSecondPanel = workspace.newTerminalSurface(inPane: leftPaneId, focus: false),
let rightSecondPanel = workspace.newTerminalSurface(inPane: rightPaneId, focus: false) else {
XCTFail("Expected panes and panels for precomputed ordering test")
return
}
workspace.updatePanelGitBranch(panelId: leftFirstPanelId, branch: "main", isDirty: false)
workspace.updatePanelGitBranch(panelId: leftSecondPanel.id, branch: "feature/left", isDirty: true)
workspace.updatePanelGitBranch(panelId: rightFirstPanel.id, branch: "release/right", isDirty: false)
workspace.updatePanelDirectory(panelId: leftFirstPanelId, directory: "/repo/left/root")
workspace.updatePanelDirectory(panelId: leftSecondPanel.id, directory: "/repo/left/feature")
workspace.updatePanelDirectory(panelId: rightFirstPanel.id, directory: "/repo/right/root")
workspace.updatePanelDirectory(panelId: rightSecondPanel.id, directory: "/repo/right/extra")
workspace.updatePanelPullRequest(
panelId: leftFirstPanelId,
number: 101,
label: "PR",
url: URL(string: "https://github.com/manaflow-ai/cmux/pull/101")!,
status: .open
)
workspace.updatePanelPullRequest(
panelId: rightFirstPanel.id,
number: 18,
label: "MR",
url: URL(string: "https://gitlab.com/manaflow/cmux/-/merge_requests/18")!,
status: .merged
)
let orderedPanelIds = workspace.sidebarOrderedPanelIds()
XCTAssertEqual(
workspace.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { "\($0.branch)|\($0.isDirty)" },
workspace.sidebarGitBranchesInDisplayOrder().map { "\($0.branch)|\($0.isDirty)" }
)
XCTAssertEqual(
workspace.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds),
workspace.sidebarBranchDirectoryEntriesInDisplayOrder()
)
XCTAssertEqual(
workspace.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds),
workspace.sidebarPullRequestsInDisplayOrder()
)
}
func testClosingPaneDropsBranchesFromClosedSide() {
let workspace = Workspace()
guard let leftPanelId = workspace.focusedPanelId,
let leftPaneId = workspace.paneId(forPanelId: leftPanelId),
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
XCTFail("Expected left/right split panes")
return
}
workspace.updatePanelGitBranch(panelId: leftPanelId, branch: "branch1", isDirty: false)
workspace.updatePanelGitBranch(panelId: rightPanel.id, branch: "branch2", isDirty: false)
XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch1", "branch2"])
XCTAssertTrue(workspace.bonsplitController.closePane(leftPaneId))
XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["branch2"])
}
}
final class SidebarBranchOrderingTests: XCTestCase {
func testOrderedUniqueBranchesDedupesByNameAndMergesDirtyState() {
let first = UUID()
let second = UUID()
let third = UUID()
let branches = SidebarBranchOrdering.orderedUniqueBranches(
orderedPanelIds: [first, second, third],
panelBranches: [
first: SidebarGitBranchState(branch: "main", isDirty: false),
second: SidebarGitBranchState(branch: "feature", isDirty: false),
third: SidebarGitBranchState(branch: "main", isDirty: true)
],
fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false)
)
XCTAssertEqual(
branches,
[
SidebarBranchOrdering.BranchEntry(name: "main", isDirty: true),
SidebarBranchOrdering.BranchEntry(name: "feature", isDirty: false)
]
)
}
func testOrderedUniqueBranchesUsesFallbackWhenNoPanelBranchesExist() {
let branches = SidebarBranchOrdering.orderedUniqueBranches(
orderedPanelIds: [],
panelBranches: [:],
fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: true)
)
XCTAssertEqual(
branches,
[SidebarBranchOrdering.BranchEntry(name: "fallback", isDirty: true)]
)
}
func testOrderedUniqueBranchDirectoryEntriesDedupesPairsAndMergesDirtyState() {
let first = UUID()
let second = UUID()
let third = UUID()
let fourth = UUID()
let fifth = UUID()
let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
orderedPanelIds: [first, second, third, fourth, fifth],
panelBranches: [
first: SidebarGitBranchState(branch: "main", isDirty: false),
second: SidebarGitBranchState(branch: "feature", isDirty: false),
third: SidebarGitBranchState(branch: "main", isDirty: true),
fourth: SidebarGitBranchState(branch: "main", isDirty: false)
],
panelDirectories: [
first: "/repo/a",
second: "/repo/b",
third: "/repo/a",
fourth: "/repo/d",
fifth: "/repo/e"
],
defaultDirectory: "/repo/default",
fallbackBranch: SidebarGitBranchState(branch: "fallback", isDirty: false)
)
XCTAssertEqual(
rows,
[
SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/a"),
SidebarBranchOrdering.BranchDirectoryEntry(branch: "feature", isDirty: false, directory: "/repo/b"),
SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/d"),
SidebarBranchOrdering.BranchDirectoryEntry(branch: nil, isDirty: false, directory: "/repo/e")
]
)
}
func testOrderedUniqueBranchDirectoryEntriesUsesFallbackBranchWhenPanelBranchesMissing() {
let first = UUID()
let second = UUID()
let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
orderedPanelIds: [first, second],
panelBranches: [:],
panelDirectories: [
first: "/repo/one",
second: "/repo/two"
],
defaultDirectory: "/repo/default",
fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: true)
)
XCTAssertEqual(
rows,
[
SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/one"),
SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: true, directory: "/repo/two")
]
)
}
func testOrderedUniqueBranchDirectoryEntriesFallsBackWhenNoPanelsExist() {
let rows = SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
orderedPanelIds: [],
panelBranches: [:],
panelDirectories: [:],
defaultDirectory: "/repo/default",
fallbackBranch: SidebarGitBranchState(branch: "main", isDirty: false)
)
XCTAssertEqual(
rows,
[SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")]
)
}
func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() {
let first = UUID()
let second = UUID()
let third = UUID()
let fourth = UUID()
let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
orderedPanelIds: [first, second, third, fourth],
panelPullRequests: [
first: pullRequestState(
number: 337,
label: "PR",
url: "https://github.com/manaflow-ai/cmux/pull/337",
status: .open
),
second: pullRequestState(
number: 18,
label: "MR",
url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18",
status: .open
),
third: pullRequestState(
number: 337,
label: "PR",
url: "https://github.com/manaflow-ai/cmux/pull/337",
status: .merged
),
fourth: pullRequestState(
number: 92,
label: "PR",
url: "https://bitbucket.org/manaflow/cmux/pull-requests/92",
status: .closed
)
],
fallbackPullRequest: pullRequestState(
number: 1,
label: "PR",
url: "https://example.invalid/fallback/1",
status: .open
)
)
XCTAssertEqual(
pullRequests.map { "\($0.label)#\($0.number)" },
["PR#337", "MR#18", "PR#92"]
)
XCTAssertEqual(
pullRequests.map(\.status),
[.merged, .open, .closed]
)
}
func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() {
let first = UUID()
let second = UUID()
let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
orderedPanelIds: [first, second],
panelPullRequests: [
first: pullRequestState(
number: 42,
label: "PR",
url: "https://github.com/manaflow-ai/cmux/pull/42",
status: .open
),
second: pullRequestState(
number: 42,
label: "MR",
url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42",
status: .open
)
],
fallbackPullRequest: nil
)
XCTAssertEqual(
pullRequests.map { "\($0.label)#\($0.number)" },
["PR#42", "MR#42"]
)
}
func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() {
let first = UUID()
let second = UUID()
let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
orderedPanelIds: [first, second],
panelPullRequests: [
first: pullRequestState(
number: 42,
label: "PR",
url: "https://github.com/manaflow-ai/cmux/pull/42",
status: .open
),
second: pullRequestState(
number: 42,
label: "PR",
url: "https://github.com/manaflow-ai/other-repo/pull/42",
status: .open
)
],
fallbackPullRequest: nil
)
XCTAssertEqual(
pullRequests.map(\.url.absoluteString),
[
"https://github.com/manaflow-ai/cmux/pull/42",
"https://github.com/manaflow-ai/other-repo/pull/42"
]
)
}
func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() {
let fallback = pullRequestState(
number: 11,
label: "PR",
url: "https://github.com/manaflow-ai/cmux/pull/11",
status: .open
)
let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
orderedPanelIds: [],
panelPullRequests: [:],
fallbackPullRequest: fallback
)
XCTAssertEqual(pullRequests, [fallback])
}
private func pullRequestState(
number: Int,
label: String,
url: String,
status: SidebarPullRequestStatus
) -> SidebarPullRequestState {
SidebarPullRequestState(
number: number,
label: label,
url: URL(string: url)!,
status: status
)
}
}
@MainActor
final class BrowserPanelAddressBarFocusRequestTests: XCTestCase {
func testRequestPersistsUntilAcknowledged() {
let panel = BrowserPanel(workspaceId: UUID())
XCTAssertNil(panel.pendingAddressBarFocusRequestId)
let requestId = panel.requestAddressBarFocus()
XCTAssertEqual(panel.pendingAddressBarFocusRequestId, requestId)
XCTAssertTrue(panel.shouldSuppressWebViewFocus())
panel.acknowledgeAddressBarFocusRequest(requestId)
XCTAssertNil(panel.pendingAddressBarFocusRequestId)
// Acknowledgement only clears the durable request; focus suppression follows
// explicit blur state transitions.
XCTAssertTrue(panel.shouldSuppressWebViewFocus())
panel.endSuppressWebViewFocusForAddressBar()
XCTAssertFalse(panel.shouldSuppressWebViewFocus())
}
func testRequestCoalescesWhilePending() {
let panel = BrowserPanel(workspaceId: UUID())
let firstRequest = panel.requestAddressBarFocus()
let secondRequest = panel.requestAddressBarFocus()
XCTAssertEqual(firstRequest, secondRequest)
XCTAssertEqual(panel.pendingAddressBarFocusRequestId, firstRequest)
}
func testStaleAcknowledgementDoesNotClearNewestRequest() {
let panel = BrowserPanel(workspaceId: UUID())
let firstRequest = panel.requestAddressBarFocus()
panel.acknowledgeAddressBarFocusRequest(firstRequest)
let secondRequest = panel.requestAddressBarFocus()
XCTAssertNotEqual(firstRequest, secondRequest)
XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest)
panel.acknowledgeAddressBarFocusRequest(firstRequest)
XCTAssertEqual(panel.pendingAddressBarFocusRequestId, secondRequest)
panel.acknowledgeAddressBarFocusRequest(secondRequest)
XCTAssertNil(panel.pendingAddressBarFocusRequestId)
}
}
final class SidebarDropPlannerTests: XCTestCase {
func testNoIndicatorForNoOpEdges() {
let first = UUID()
let second = UUID()
let third = UUID()
let tabIds = [first, second, third]
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: first,
targetTabId: first,
tabIds: tabIds
)
)
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: third,
targetTabId: nil,
tabIds: tabIds
)
)
}
func testNoIndicatorWhenOnlyOneTabExists() {
let only = UUID()
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: only,
targetTabId: nil,
tabIds: [only]
)
)
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: only,
targetTabId: only,
tabIds: [only]
)
)
}
func testIndicatorAppearsForRealMoveToEnd() {
let first = UUID()
let second = UUID()
let third = UUID()
let tabIds = [first, second, third]
let indicator = SidebarDropPlanner.indicator(
draggedTabId: second,
targetTabId: nil,
tabIds: tabIds
)
XCTAssertEqual(indicator?.tabId, nil)
XCTAssertEqual(indicator?.edge, .bottom)
}
func testTargetIndexForMoveToEndFromMiddle() {
let first = UUID()
let second = UUID()
let third = UUID()
let tabIds = [first, second, third]
let index = SidebarDropPlanner.targetIndex(
draggedTabId: second,
targetTabId: nil,
indicator: SidebarDropIndicator(tabId: nil, edge: .bottom),
tabIds: tabIds
)
XCTAssertEqual(index, 2)
}
func testNoIndicatorForSelfDropInMiddle() {
let first = UUID()
let second = UUID()
let third = UUID()
let tabIds = [first, second, third]
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: second,
targetTabId: second,
tabIds: tabIds
)
)
}
func testPointerEdgeTopCanSuppressNoOpWhenDraggingFirstOverSecond() {
let first = UUID()
let second = UUID()
let third = UUID()
let tabIds = [first, second, third]
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: first,
targetTabId: second,
tabIds: tabIds,
pointerY: 2,
targetHeight: 40
)
)
}
func testPointerEdgeBottomAllowsMoveWhenDraggingFirstOverSecond() {
let first = UUID()
let second = UUID()
let third = UUID()
let tabIds = [first, second, third]
let indicator = SidebarDropPlanner.indicator(
draggedTabId: first,
targetTabId: second,
tabIds: tabIds,
pointerY: 38,
targetHeight: 40
)
XCTAssertEqual(indicator?.tabId, third)
XCTAssertEqual(indicator?.edge, .top)
XCTAssertEqual(
SidebarDropPlanner.targetIndex(
draggedTabId: first,
targetTabId: second,
indicator: indicator,
tabIds: tabIds
),
1
)
}
func testEquivalentBoundaryInputsResolveToSingleCanonicalIndicator() {
let first = UUID()
let second = UUID()
let third = UUID()
let tabIds = [first, second, third]
let fromBottomOfFirst = SidebarDropPlanner.indicator(
draggedTabId: third,
targetTabId: first,
tabIds: tabIds,
pointerY: 38,
targetHeight: 40
)
let fromTopOfSecond = SidebarDropPlanner.indicator(
draggedTabId: third,
targetTabId: second,
tabIds: tabIds,
pointerY: 2,
targetHeight: 40
)
XCTAssertEqual(fromBottomOfFirst?.tabId, second)
XCTAssertEqual(fromBottomOfFirst?.edge, .top)
XCTAssertEqual(fromTopOfSecond?.tabId, second)
XCTAssertEqual(fromTopOfSecond?.edge, .top)
}
func testPointerEdgeBottomSuppressesNoOpWhenDraggingLastOverSecond() {
let first = UUID()
let second = UUID()
let third = UUID()
let tabIds = [first, second, third]
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: third,
targetTabId: second,
tabIds: tabIds,
pointerY: 38,
targetHeight: 40
)
)
}
}
final class SidebarDragAutoScrollPlannerTests: XCTestCase {
func testAutoScrollPlanTriggersNearTopAndBottomOnly() {
let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12)
XCTAssertEqual(topPlan?.direction, .up)
XCTAssertNotNil(topPlan)
let bottomPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 96, distanceToBottom: 4, edgeInset: 44, minStep: 2, maxStep: 12)
XCTAssertEqual(bottomPlan?.direction, .down)
XCTAssertNotNil(bottomPlan)
XCTAssertNil(
SidebarDragAutoScrollPlanner.plan(distanceToTop: 60, distanceToBottom: 60, edgeInset: 44, minStep: 2, maxStep: 12)
)
}
func testAutoScrollPlanSpeedsUpCloserToEdge() {
let nearTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 1, distanceToBottom: 99, edgeInset: 44, minStep: 2, maxStep: 12)
let midTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: 22, distanceToBottom: 78, edgeInset: 44, minStep: 2, maxStep: 12)
XCTAssertNotNil(nearTop)
XCTAssertNotNil(midTop)
XCTAssertGreaterThan(nearTop?.pointsPerTick ?? 0, midTop?.pointsPerTick ?? 0)
}
func testAutoScrollPlanStillTriggersWhenPointerIsPastEdge() {
let aboveTop = SidebarDragAutoScrollPlanner.plan(distanceToTop: -500, distanceToBottom: 600, edgeInset: 44, minStep: 2, maxStep: 12)
XCTAssertEqual(aboveTop?.direction, .up)
XCTAssertEqual(aboveTop?.pointsPerTick, 12)
let belowBottom = SidebarDragAutoScrollPlanner.plan(distanceToTop: 600, distanceToBottom: -500, edgeInset: 44, minStep: 2, maxStep: 12)
XCTAssertEqual(belowBottom?.direction, .down)
XCTAssertEqual(belowBottom?.pointsPerTick, 12)
}
}
final class FinderServicePathResolverTests: XCTestCase {
func testOrderedUniqueDirectoriesUsesParentForFilesAndDedupes() {
let input: [URL] = [
URL(fileURLWithPath: "/tmp/cmux-services/project", isDirectory: true),
URL(fileURLWithPath: "/tmp/cmux-services/project/README.md", isDirectory: false),
URL(fileURLWithPath: "/tmp/cmux-services/../cmux-services/project", isDirectory: true),
URL(fileURLWithPath: "/tmp/cmux-services/other", isDirectory: true),
]
let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input)
XCTAssertEqual(
directories,
[
"/tmp/cmux-services/project",
"/tmp/cmux-services/other",
]
)
}
func testOrderedUniqueDirectoriesPreservesFirstSeenOrder() {
let input: [URL] = [
URL(fileURLWithPath: "/tmp/cmux-services/b", isDirectory: true),
URL(fileURLWithPath: "/tmp/cmux-services/a/file.txt", isDirectory: false),
URL(fileURLWithPath: "/tmp/cmux-services/a", isDirectory: true),
URL(fileURLWithPath: "/tmp/cmux-services/b/file.txt", isDirectory: false),
]
let directories = FinderServicePathResolver.orderedUniqueDirectories(from: input)
XCTAssertEqual(
directories,
[
"/tmp/cmux-services/b",
"/tmp/cmux-services/a",
]
)
}
}
final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase {
private func environment(
existingPaths: Set<String>,
homeDirectoryPath: String = "/Users/tester"
) -> TerminalDirectoryOpenTarget.DetectionEnvironment {
TerminalDirectoryOpenTarget.DetectionEnvironment(
homeDirectoryPath: homeDirectoryPath,
fileExistsAtPath: { existingPaths.contains($0) },
isExecutableFileAtPath: { existingPaths.contains($0) }
)
}
func testAvailableTargetsDetectSystemApplications() {
let env = environment(
existingPaths: [
"/Applications/Visual Studio Code.app",
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel",
"/System/Library/CoreServices/Finder.app",
"/System/Applications/Utilities/Terminal.app",
"/Applications/Zed Preview.app",
]
)
let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
XCTAssertTrue(availableTargets.contains(.vscode))
XCTAssertTrue(availableTargets.contains(.finder))
XCTAssertTrue(availableTargets.contains(.terminal))
XCTAssertTrue(availableTargets.contains(.zed))
XCTAssertFalse(availableTargets.contains(.cursor))
}
func testAvailableTargetsFallbackToUserApplications() {
let env = environment(
existingPaths: [
"/Users/tester/Applications/Cursor.app",
"/Users/tester/Applications/Warp.app",
"/Users/tester/Applications/Android Studio.app",
]
)
let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
XCTAssertTrue(availableTargets.contains(.cursor))
XCTAssertTrue(availableTargets.contains(.warp))
XCTAssertTrue(availableTargets.contains(.androidStudio))
XCTAssertFalse(availableTargets.contains(.vscode))
}
func testVSCodeRequiresCodeTunnelExecutable() {
let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"])
XCTAssertFalse(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env))
}
func testITerm2DetectsLegacyBundleName() {
let env = environment(existingPaths: ["/Applications/iTerm.app"])
XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env))
}
func testTowerDetected() {
let env = environment(existingPaths: ["/Applications/Tower.app"])
XCTAssertTrue(TerminalDirectoryOpenTarget.tower.isAvailable(in: env))
}
func testCommandPaletteShortcutsExcludeGenericIDEEntry() {
let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets
XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" }))
XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" }))
}
}
final class VSCodeServeWebURLBuilderTests: XCTestCase {
func testExtractWebUIURLParsesServeWebOutput() {
let output = """
*
* Visual Studio Code Server
*
Web UI available at http://127.0.0.1:5555?tkn=test-token
"""
let url = VSCodeServeWebURLBuilder.extractWebUIURL(from: output)
XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:5555?tkn=test-token")
}
func testOpenFolderURLAppendsFolderQueryWhilePreservingToken() {
let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token")!
let url = VSCodeServeWebURLBuilder.openFolderURL(
baseWebUIURL: baseURL,
directoryPath: "/Users/tester/Projects/cmux"
)
let components = URLComponents(url: url!, resolvingAgainstBaseURL: false)
XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "tkn" })?.value, "test-token")
XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "folder" })?.value, "/Users/tester/Projects/cmux")
}
func testOpenFolderURLReplacesExistingFolderQuery() {
let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token&folder=/tmp/old")!
let url = VSCodeServeWebURLBuilder.openFolderURL(
baseWebUIURL: baseURL,
directoryPath: "/Users/tester/New Folder"
)
let components = URLComponents(url: url!, resolvingAgainstBaseURL: false)
XCTAssertEqual(
components?.queryItems?.filter { $0.name == "folder" }.count,
1
)
XCTAssertEqual(
components?.queryItems?.first(where: { $0.name == "folder" })?.value,
"/Users/tester/New Folder"
)
}
}
final class VSCodeCLILaunchConfigurationBuilderTests: XCTestCase {
func testLaunchConfigurationUsesCodeTunnelBinary() {
let appURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true)
let expectedExecutablePath = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel"
let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
vscodeApplicationURL: appURL,
baseEnvironment: [:],
isExecutableAtPath: { $0 == expectedExecutablePath }
)
XCTAssertEqual(configuration?.executableURL.path, expectedExecutablePath)
XCTAssertEqual(configuration?.argumentsPrefix, [])
XCTAssertEqual(configuration?.environment["ELECTRON_RUN_AS_NODE"], "1")
}
func testLaunchConfigurationMapsNodeEnvironmentVariables() {
let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true),
baseEnvironment: [
"PATH": "/usr/bin:/bin",
"NODE_OPTIONS": "--max-old-space-size=4096",
"NODE_REPL_EXTERNAL_MODULE": "module-name"
],
isExecutableAtPath: { _ in true }
)
XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin")
XCTAssertEqual(configuration?.environment["VSCODE_NODE_OPTIONS"], "--max-old-space-size=4096")
XCTAssertEqual(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"], "module-name")
XCTAssertNil(configuration?.environment["NODE_OPTIONS"])
XCTAssertNil(configuration?.environment["NODE_REPL_EXTERNAL_MODULE"])
}
func testLaunchConfigurationClearsStaleVSCodeNodeVariablesWhenNodeVariablesAreAbsent() {
let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true),
baseEnvironment: [
"PATH": "/usr/bin:/bin",
"VSCODE_NODE_OPTIONS": "--stale",
"VSCODE_NODE_REPL_EXTERNAL_MODULE": "stale-module"
],
isExecutableAtPath: { _ in true }
)
XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin")
XCTAssertNil(configuration?.environment["VSCODE_NODE_OPTIONS"])
XCTAssertNil(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"])
}
}
final class ServeWebOutputCollectorTests: XCTestCase {
func testWaitForURLReturnsFalseAfterProcessExitSignal() {
let collector = ServeWebOutputCollector()
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
collector.markProcessExited()
}
let start = Date()
let resolved = collector.waitForURL(timeoutSeconds: 1)
let elapsed = Date().timeIntervalSince(start)
XCTAssertFalse(resolved)
XCTAssertLessThan(elapsed, 0.5)
}
func testWaitForURLReturnsTrueWhenURLIsCollected() {
let collector = ServeWebOutputCollector()
let urlLine = "Web UI available at http://127.0.0.1:7777?tkn=test-token\n"
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
collector.append(Data(urlLine.utf8))
}
XCTAssertTrue(collector.waitForURL(timeoutSeconds: 1))
XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:7777?tkn=test-token")
}
func testMarkProcessExitedParsesFinalURLWithoutTrailingNewline() {
let collector = ServeWebOutputCollector()
let finalChunk = "Web UI available at http://127.0.0.1:9001?tkn=final-token"
collector.append(Data(finalChunk.utf8))
collector.markProcessExited()
XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1))
XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:9001?tkn=final-token")
}
}
final class VSCodeServeWebControllerTests: XCTestCase {
func testStopDuringInFlightLaunchDoesNotDropNextGenerationCompletion() {
let firstLaunchStarted = expectation(description: "first launch started")
let firstCompletionCalled = expectation(description: "first generation completion called")
let secondCompletionCalled = expectation(description: "second generation completion called")
let launchGate = DispatchSemaphore(value: 0)
let launchCallLock = NSLock()
var launchCallCount = 0
let controller = VSCodeServeWebController.makeForTesting { _, _ in
launchCallLock.lock()
launchCallCount += 1
let callNumber = launchCallCount
launchCallLock.unlock()
if callNumber == 1 {
firstLaunchStarted.fulfill()
_ = launchGate.wait(timeout: .now() + 1)
}
return nil
}
let callbackLock = NSLock()
var firstGenerationCallbacks: [URL?] = []
var secondGenerationCallbacks: [URL?] = []
let vscodeAppURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true)
controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in
callbackLock.lock()
firstGenerationCallbacks.append(url)
callbackLock.unlock()
firstCompletionCalled.fulfill()
}
wait(for: [firstLaunchStarted], timeout: 1)
controller.stop()
controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in
callbackLock.lock()
secondGenerationCallbacks.append(url)
callbackLock.unlock()
secondCompletionCalled.fulfill()
}
launchGate.signal()
wait(for: [firstCompletionCalled, secondCompletionCalled], timeout: 2)
callbackLock.lock()
let firstSnapshot = firstGenerationCallbacks
let secondSnapshot = secondGenerationCallbacks
callbackLock.unlock()
launchCallLock.lock()
let launchCalls = launchCallCount
launchCallLock.unlock()
XCTAssertEqual(firstSnapshot.count, 1)
if firstSnapshot.count == 1 {
XCTAssertNil(firstSnapshot[0])
}
XCTAssertEqual(secondSnapshot.count, 1)
if secondSnapshot.count == 1 {
XCTAssertNil(secondSnapshot[0])
}
XCTAssertEqual(launchCalls, 2)
}
}
final class BrowserSearchEngineTests: XCTestCase {
func testGoogleSearchURL() throws {
let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world"))
XCTAssertEqual(url.host, "www.google.com")
XCTAssertEqual(url.path, "/search")
XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
}
func testDuckDuckGoSearchURL() throws {
let url = try XCTUnwrap(BrowserSearchEngine.duckduckgo.searchURL(query: "hello world"))
XCTAssertEqual(url.host, "duckduckgo.com")
XCTAssertEqual(url.path, "/")
XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
}
func testBingSearchURL() throws {
let url = try XCTUnwrap(BrowserSearchEngine.bing.searchURL(query: "hello world"))
XCTAssertEqual(url.host, "www.bing.com")
XCTAssertEqual(url.path, "/search")
XCTAssertTrue(url.absoluteString.contains("q=hello%20world"))
}
}
final class BrowserSearchSettingsTests: XCTestCase {
func testCurrentSearchSuggestionsEnabledDefaultsToTrueWhenUnset() {
let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.removeObject(forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
}
func testCurrentSearchSuggestionsEnabledHonorsExplicitValue() {
let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.set(false, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
XCTAssertFalse(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
defaults.set(true, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
}
}
final class BrowserHistoryStoreTests: XCTestCase {
func testRecordVisitDedupesAndSuggests() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let fileURL = tempDir.appendingPathComponent("browser_history.json")
let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
let u1 = try XCTUnwrap(URL(string: "https://example.com/foo"))
let u2 = try XCTUnwrap(URL(string: "https://example.com/bar"))
await MainActor.run {
store.recordVisit(url: u1, title: "Example Foo")
store.recordVisit(url: u2, title: "Example Bar")
store.recordVisit(url: u1, title: "Example Foo Updated")
}
let suggestions = await MainActor.run { store.suggestions(for: "foo", limit: 10) }
XCTAssertEqual(suggestions.first?.url, "https://example.com/foo")
XCTAssertEqual(suggestions.first?.visitCount, 2)
XCTAssertEqual(suggestions.first?.title, "Example Foo Updated")
}
func testSuggestionsLoadsPersistedHistoryImmediatelyOnFirstQuery() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let fileURL = tempDir.appendingPathComponent("browser_history.json")
let now = Date()
let seededEntries = [
BrowserHistoryStore.Entry(
id: UUID(),
url: "https://go.dev/",
title: "The Go Programming Language",
lastVisited: now,
visitCount: 3
),
BrowserHistoryStore.Entry(
id: UUID(),
url: "https://www.google.com/",
title: "Google",
lastVisited: now.addingTimeInterval(-120),
visitCount: 2
),
]
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
let data = try encoder.encode(seededEntries)
try data.write(to: fileURL, options: [.atomic])
let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
let suggestions = await MainActor.run { store.suggestions(for: "go", limit: 10) }
XCTAssertGreaterThanOrEqual(suggestions.count, 2)
XCTAssertEqual(suggestions.first?.url, "https://go.dev/")
XCTAssertTrue(suggestions.contains(where: { $0.url == "https://www.google.com/" }))
}
}
final class OmnibarStateMachineTests: XCTestCase {
func testEscapeRevertsWhenEditingThenBlursOnSecondEscape() throws {
var state = OmnibarState()
var effects = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
XCTAssertTrue(state.isFocused)
XCTAssertEqual(state.buffer, "https://example.com/")
XCTAssertFalse(state.isUserEditing)
XCTAssertTrue(effects.shouldSelectAll)
effects = omnibarReduce(state: &state, event: .bufferChanged("exam"))
XCTAssertTrue(state.isUserEditing)
XCTAssertEqual(state.buffer, "exam")
XCTAssertTrue(effects.shouldRefreshSuggestions)
// Simulate an open popup.
effects = omnibarReduce(
state: &state,
event: .suggestionsUpdated([.search(engineName: "Google", query: "exam")])
)
XCTAssertEqual(state.suggestions.count, 1)
XCTAssertFalse(effects.shouldSelectAll)
// First escape: revert + close popup + select-all.
effects = omnibarReduce(state: &state, event: .escape)
XCTAssertEqual(state.buffer, "https://example.com/")
XCTAssertFalse(state.isUserEditing)
XCTAssertTrue(state.suggestions.isEmpty)
XCTAssertTrue(effects.shouldSelectAll)
XCTAssertFalse(effects.shouldBlurToWebView)
// Second escape: blur (since we're not editing and popup is closed).
effects = omnibarReduce(state: &state, event: .escape)
XCTAssertTrue(effects.shouldBlurToWebView)
}
func testPanelURLChangeDoesNotClobberUserBufferWhileEditing() throws {
var state = OmnibarState()
_ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://a.test/"))
_ = omnibarReduce(state: &state, event: .bufferChanged("hello"))
XCTAssertTrue(state.isUserEditing)
_ = omnibarReduce(state: &state, event: .panelURLChanged(currentURLString: "https://b.test/"))
XCTAssertEqual(state.currentURLString, "https://b.test/")
XCTAssertEqual(state.buffer, "hello")
XCTAssertTrue(state.isUserEditing)
let effects = omnibarReduce(state: &state, event: .escape)
XCTAssertEqual(state.buffer, "https://b.test/")
XCTAssertTrue(effects.shouldSelectAll)
}
func testFocusLostRevertsUnlessSuppressed() throws {
var state = OmnibarState()
_ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
_ = omnibarReduce(state: &state, event: .bufferChanged("typed"))
XCTAssertEqual(state.buffer, "typed")
_ = omnibarReduce(state: &state, event: .focusLostPreserveBuffer(currentURLString: "https://example.com/"))
XCTAssertEqual(state.buffer, "typed")
_ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
_ = omnibarReduce(state: &state, event: .bufferChanged("typed2"))
_ = omnibarReduce(state: &state, event: .focusLostRevertBuffer(currentURLString: "https://example.com/"))
XCTAssertEqual(state.buffer, "https://example.com/")
}
func testSuggestionsUpdateKeepsSelectionAcrossNonEmptyListRefresh() throws {
var state = OmnibarState()
_ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
_ = omnibarReduce(state: &state, event: .bufferChanged("go"))
let base: [OmnibarSuggestion] = [
.search(engineName: "Google", query: "go"),
.remoteSearchSuggestion("go tutorial"),
.remoteSearchSuggestion("go json"),
]
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(base))
XCTAssertEqual(state.selectedSuggestionIndex, 0)
_ = omnibarReduce(state: &state, event: .moveSelection(delta: 2))
XCTAssertEqual(state.selectedSuggestionIndex, 2)
// Simulate remote merge update for the same query while popup remains open.
let merged: [OmnibarSuggestion] = [
.search(engineName: "Google", query: "go"),
.remoteSearchSuggestion("go tutorial"),
.remoteSearchSuggestion("go json"),
.remoteSearchSuggestion("go fmt"),
]
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(merged))
XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected selection to remain stable while list stays open")
}
func testSuggestionsReopenResetsSelectionToFirstRow() throws {
var state = OmnibarState()
_ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
_ = omnibarReduce(state: &state, event: .bufferChanged("go"))
let rows: [OmnibarSuggestion] = [
.search(engineName: "Google", query: "go"),
.remoteSearchSuggestion("go tutorial"),
]
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
_ = omnibarReduce(state: &state, event: .moveSelection(delta: 1))
XCTAssertEqual(state.selectedSuggestionIndex, 1)
_ = omnibarReduce(state: &state, event: .suggestionsUpdated([]))
XCTAssertEqual(state.selectedSuggestionIndex, 0)
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
XCTAssertEqual(state.selectedSuggestionIndex, 0, "Expected reopened popup to focus first row")
}
func testSuggestionsUpdatePrefersAutocompleteMatchWhenSelectionNotTracked() throws {
var state = OmnibarState()
_ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
_ = omnibarReduce(state: &state, event: .bufferChanged("gm"))
let rows: [OmnibarSuggestion] = [
.search(engineName: "Google", query: "gm"),
.history(url: "https://google.com/", title: "Google"),
.history(url: "https://gmail.com/", title: "Gmail"),
]
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected autocomplete candidate to become selected without explicit index state.")
XCTAssertEqual(state.selectedSuggestionID, rows[2].id)
XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[state.selectedSuggestionIndex]))
XCTAssertEqual(state.suggestions[state.selectedSuggestionIndex].completion, "https://gmail.com/")
}
}
final class OmnibarRemoteSuggestionMergeTests: XCTestCase {
func testMergeRemoteSuggestionsInsertsBelowSearchAndDedupes() {
let now = Date()
let entries: [BrowserHistoryStore.Entry] = [
BrowserHistoryStore.Entry(
id: UUID(),
url: "https://go.dev/",
title: "The Go Programming Language",
lastVisited: now,
visitCount: 10
),
]
let merged = buildOmnibarSuggestions(
query: "go",
engineName: "Google",
historyEntries: entries,
openTabMatches: [],
remoteQueries: ["go tutorial", "go.dev", "go json"],
resolvedURL: nil,
limit: 8
)
let completions = merged.compactMap { $0.completion }
XCTAssertGreaterThanOrEqual(completions.count, 5)
XCTAssertEqual(completions[0], "https://go.dev/")
XCTAssertEqual(completions[1], "go")
let remoteCompletions = Array(completions.dropFirst(2))
XCTAssertEqual(Set(remoteCompletions), Set(["go tutorial", "go.dev", "go json"]))
XCTAssertEqual(remoteCompletions.count, 3)
}
func testStaleRemoteSuggestionsKeptForNearbyEdits() {
let stale = staleOmnibarRemoteSuggestionsForDisplay(
query: "go t",
previousRemoteQuery: "go",
previousRemoteSuggestions: ["go tutorial", "go json", "golang tips"],
limit: 8
)
XCTAssertEqual(stale, ["go tutorial", "go json", "golang tips"])
}
func testStaleRemoteSuggestionsTrimAndRespectLimit() {
let stale = staleOmnibarRemoteSuggestionsForDisplay(
query: "gooo",
previousRemoteQuery: "goo",
previousRemoteSuggestions: [" go tutorial ", "", "go json", " ", "go fmt"],
limit: 2
)
XCTAssertEqual(stale, ["go tutorial", "go json"])
}
func testStaleRemoteSuggestionsDroppedForUnrelatedQuery() {
let stale = staleOmnibarRemoteSuggestionsForDisplay(
query: "python",
previousRemoteQuery: "go",
previousRemoteSuggestions: ["go tutorial", "go json"],
limit: 8
)
XCTAssertTrue(stale.isEmpty)
}
}
final class OmnibarSuggestionRankingTests: XCTestCase {
private var fixedNow: Date {
Date(timeIntervalSinceReferenceDate: 10_000_000)
}
func testSingleCharacterQueryPromotesAutocompletionMatchToFirstRow() {
let entries: [BrowserHistoryStore.Entry] = [
.init(
id: UUID(),
url: "https://news.ycombinator.com/",
title: "News.YC",
lastVisited: fixedNow,
visitCount: 12,
typedCount: 1,
lastTypedAt: fixedNow
),
.init(
id: UUID(),
url: "https://www.google.com/",
title: "Google",
lastVisited: fixedNow - 200,
visitCount: 8,
typedCount: 2,
lastTypedAt: fixedNow - 200
),
]
let results = buildOmnibarSuggestions(
query: "n",
engineName: "Google",
historyEntries: entries,
openTabMatches: [],
remoteQueries: ["search google for n", "news"],
resolvedURL: nil,
limit: 8,
now: fixedNow
)
XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/")
XCTAssertNotEqual(results.map(\.completion).first, "n")
XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "n", suggestion: $0) } ?? false)
}
func testGmAutocompleteCandidateIsFirstOnExactQueryMatch() {
let entries: [BrowserHistoryStore.Entry] = [
.init(
id: UUID(),
url: "https://google.com/",
title: "Google",
lastVisited: fixedNow,
visitCount: 4,
typedCount: 1,
lastTypedAt: fixedNow
),
.init(
id: UUID(),
url: "https://gmail.com/",
title: "Gmail",
lastVisited: fixedNow,
visitCount: 10,
typedCount: 2,
lastTypedAt: fixedNow
),
]
let results = buildOmnibarSuggestions(
query: "gm",
engineName: "Google",
historyEntries: entries,
openTabMatches: [],
remoteQueries: ["gmail", "gmail.com", "google mail"],
resolvedURL: nil,
limit: 8,
now: fixedNow
)
XCTAssertEqual(results.first?.completion, "https://gmail.com/")
XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
let inlineCompletion = omnibarInlineCompletionForDisplay(
typedText: "gm",
suggestions: results,
isFocused: true,
selectionRange: NSRange(location: 2, length: 0),
hasMarkedText: false
)
XCTAssertNotNil(inlineCompletion)
}
func testAutocompletionCandidateWinsOverRemoteAndSearchRowsForTwoLetterQuery() {
let entries: [BrowserHistoryStore.Entry] = [
.init(
id: UUID(),
url: "https://google.com/",
title: "Google",
lastVisited: fixedNow,
visitCount: 4,
typedCount: 1,
lastTypedAt: fixedNow
),
.init(
id: UUID(),
url: "https://gmail.com/",
title: "Gmail",
lastVisited: fixedNow,
visitCount: 10,
typedCount: 2,
lastTypedAt: fixedNow
),
]
let results = buildOmnibarSuggestions(
query: "gm",
engineName: "Google",
historyEntries: entries,
openTabMatches: [
.init(
tabId: UUID(),
panelId: UUID(),
url: "https://gmail.com/",
title: "Gmail",
isKnownOpenTab: true
),
],
remoteQueries: ["Search google for gm", "gmail", "gmail.com", "Google mail"],
resolvedURL: nil,
limit: 8,
now: fixedNow
)
XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
XCTAssertEqual(results.first?.completion, "https://gmail.com/")
}
func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() {
let entries: [BrowserHistoryStore.Entry] = [
.init(
id: UUID(),
url: "https://google.com/",
title: "Google",
lastVisited: fixedNow,
visitCount: 4,
typedCount: 1,
lastTypedAt: fixedNow
),
.init(
id: UUID(),
url: "https://gmail.com/",
title: "Gmail",
lastVisited: fixedNow,
visitCount: 10,
typedCount: 2,
lastTypedAt: fixedNow
),
]
let results = buildOmnibarSuggestions(
query: "gm",
engineName: "Google",
historyEntries: entries,
openTabMatches: [],
remoteQueries: ["Search google for gm", "gmail", "gmail.com"],
resolvedURL: nil,
limit: 8,
now: fixedNow
)
var state = OmnibarState()
let _ = omnibarReduce(state: &state, event: .focusGained(currentURLString: ""))
let _ = omnibarReduce(state: &state, event: .bufferChanged("gm"))
let _ = omnibarReduce(state: &state, event: .suggestionsUpdated(results))
XCTAssertEqual(state.selectedSuggestionIndex, 0)
XCTAssertEqual(state.selectedSuggestionID, results[0].id)
XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: state.suggestions[0]))
}
func testTwoCharQueryWithRemoteSuggestionsStillPromotesAutocompletionMatch() {
let entries: [BrowserHistoryStore.Entry] = [
.init(
id: UUID(),
url: "https://news.ycombinator.com/",
title: "News.YC",
lastVisited: fixedNow,
visitCount: 12,
typedCount: 1,
lastTypedAt: fixedNow
),
.init(
id: UUID(),
url: "https://www.google.com/",
title: "Google",
lastVisited: fixedNow - 200,
visitCount: 8,
typedCount: 2,
lastTypedAt: fixedNow - 200
),
]
let results = buildOmnibarSuggestions(
query: "ne",
engineName: "Google",
historyEntries: entries,
openTabMatches: [],
remoteQueries: ["netflix", "new york times", "newegg"],
resolvedURL: nil,
limit: 8,
now: fixedNow
)
// The autocompletable history entry (news.ycombinator.com) should be first despite remote results.
XCTAssertEqual(results.first?.completion, "https://news.ycombinator.com/")
XCTAssertTrue(results.first.map { omnibarSuggestionSupportsAutocompletion(query: "ne", suggestion: $0) } ?? false)
// Remote suggestions should still appear in the results (two-char queries include them).
let remoteCompletions = results.filter {
if case .remote = $0.kind { return true }
return false
}.map(\.completion)
XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions to be present for two-char query")
}
func testGmQueryWithRemoteSuggestionsAndOpenTabPromotesAutocompletionMatch() {
let entries: [BrowserHistoryStore.Entry] = [
.init(
id: UUID(),
url: "https://google.com/",
title: "Google",
lastVisited: fixedNow,
visitCount: 4,
typedCount: 1,
lastTypedAt: fixedNow
),
.init(
id: UUID(),
url: "https://gmail.com/",
title: "Gmail",
lastVisited: fixedNow,
visitCount: 10,
typedCount: 2,
lastTypedAt: fixedNow
),
]
let results = buildOmnibarSuggestions(
query: "gm",
engineName: "Google",
historyEntries: entries,
openTabMatches: [
.init(
tabId: UUID(),
panelId: UUID(),
url: "https://google.com/maps",
title: "Google Maps",
isKnownOpenTab: true
),
],
remoteQueries: ["gmail login", "gm stock price", "gmail.com"],
resolvedURL: nil,
limit: 8,
now: fixedNow
)
// Gmail should be first (autocompletable + typed history).
XCTAssertEqual(results.first?.completion, "https://gmail.com/")
XCTAssertTrue(omnibarSuggestionSupportsAutocompletion(query: "gm", suggestion: results[0]))
// Verify remote suggestions are present alongside history/tab matches.
let remoteCompletions = results.filter {
if case .remote = $0.kind { return true }
return false
}.map(\.completion)
XCTAssertFalse(remoteCompletions.isEmpty, "Expected remote suggestions in results")
let hasSearch = results.contains {
if case .search = $0.kind { return true }
return false
}
XCTAssertTrue(hasSearch, "Expected search row in results")
}
func testHistorySuggestionDisplaysTitleAndUrlOnSingleLine() {
let row = OmnibarSuggestion.history(
url: "https://www.example.com/path?q=1",
title: "Example Domain"
)
XCTAssertEqual(row.listText, "Example Domain — example.com/path?q=1")
XCTAssertFalse(row.listText.contains("\n"))
}
func testPublishedBufferTextUsesTypedPrefixWhenInlineSuffixIsSelected() {
let inline = OmnibarInlineCompletion(
typedText: "l",
displayText: "localhost:3000",
acceptedText: "https://localhost:3000/"
)
let published = omnibarPublishedBufferTextForFieldChange(
fieldValue: inline.displayText,
inlineCompletion: inline,
selectionRange: inline.suffixRange,
hasMarkedText: false
)
XCTAssertEqual(published, "l")
}
func testPublishedBufferTextKeepsUserTypedValueWhenDisplayDiffersFromInlineText() {
let inline = OmnibarInlineCompletion(
typedText: "l",
displayText: "localhost:3000",
acceptedText: "https://localhost:3000/"
)
let published = omnibarPublishedBufferTextForFieldChange(
fieldValue: "la",
inlineCompletion: inline,
selectionRange: NSRange(location: 2, length: 0),
hasMarkedText: false
)
XCTAssertEqual(published, "la")
}
func testInlineCompletionRenderIgnoresStaleTypedPrefixMismatch() {
let staleInline = OmnibarInlineCompletion(
typedText: "g",
displayText: "github.com",
acceptedText: "https://github.com/"
)
let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
bufferText: "l",
inlineCompletion: staleInline
)
XCTAssertNil(active)
}
func testInlineCompletionRenderKeepsMatchingTypedPrefix() {
let inline = OmnibarInlineCompletion(
typedText: "l",
displayText: "localhost:3000",
acceptedText: "https://localhost:3000/"
)
let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
bufferText: "l",
inlineCompletion: inline
)
XCTAssertEqual(active, inline)
}
func testInlineCompletionSkipsTitleMatchWhoseURLDoesNotStartWithTypedText() {
// History entry: visited google.com/search?q=localhost:3000 with title
// "localhost:3000 - Google Search". Typing "l" should NOT inline-complete
// to "google.com/..." because that replaces the typed "l" with "g".
let suggestions: [OmnibarSuggestion] = [
.history(
url: "https://www.google.com/search?q=localhost:3000",
title: "localhost:3000 - Google Search"
),
]
let result = omnibarInlineCompletionForDisplay(
typedText: "l",
suggestions: suggestions,
isFocused: true,
selectionRange: NSRange(location: 1, length: 0),
hasMarkedText: false
)
XCTAssertNil(result, "Should not inline-complete when display text does not start with typed prefix")
}
}
@MainActor
final class NotificationDockBadgeTests: XCTestCase {
private final class NotificationSettingsAlertSpy: NSAlert {
private(set) var beginSheetModalCallCount = 0
private(set) var runModalCallCount = 0
var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn
override func beginSheetModal(
for sheetWindow: NSWindow,
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
) {
beginSheetModalCallCount += 1
handler?(nextResponse)
}
override func runModal() -> NSApplication.ModalResponse {
runModalCallCount += 1
return nextResponse
}
}
override func tearDown() {
TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting()
TerminalNotificationStore.shared.replaceNotificationsForTesting([])
super.tearDown()
}
func testDockBadgeLabelEnabledAndCounted() {
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1")
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42")
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 100, isEnabled: true), "99+")
}
func testDockBadgeLabelHiddenWhenDisabledOrZero() {
XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true))
XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 5, isEnabled: false))
}
func testDockBadgeLabelShowsRunTagEvenWithoutUnread() {
XCTAssertEqual(
TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true, runTag: "verify-tag"),
"verify-tag"
)
}
func testDockBadgeLabelCombinesRunTagAndUnreadCount() {
XCTAssertEqual(
TerminalNotificationStore.dockBadgeLabel(unreadCount: 7, isEnabled: true, runTag: "verify"),
"verify:7"
)
XCTAssertEqual(
TerminalNotificationStore.dockBadgeLabel(unreadCount: 120, isEnabled: true, runTag: "verify"),
"verify:99+"
)
}
func testNotificationBadgePreferenceDefaultsToEnabled() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
defaults.set(false, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
XCTAssertFalse(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
}
func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
defaults.set("Ping", forKey: NotificationSoundSettings.key)
XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults))
}
func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.set("none", forKey: NotificationSoundSettings.key)
XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
}
func testNotificationCustomFileURLExpandsTildePath() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let rawPath = "~/Library/Sounds/my-custom.wav"
defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey)
let expectedPath = (rawPath as NSString).expandingTildeInPath
XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath)
}
func testNotificationCustomFileSelectionMustBeExplicit() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey)
defaults.set("none", forKey: NotificationSoundSettings.key)
XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
defaults.set("Ping", forKey: NotificationSoundSettings.key)
XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
}
func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let fileManager = FileManager.default
let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Sounds", isDirectory: true)
do {
try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
} catch {
XCTFail("Failed to create sounds directory: \(error)")
return
}
let sourceURL = soundsDirectory.appendingPathComponent(
"cmux-custom-notification-sound.source-\(UUID().uuidString).wav",
isDirectory: false
)
defer {
try? fileManager.removeItem(at: sourceURL)
}
do {
try Data("test".utf8).write(to: sourceURL, options: .atomic)
} catch {
XCTFail("Failed to write source custom sound file: \(error)")
return
}
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
_ = NotificationSoundSettings.sound(defaults: defaults)
guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else {
XCTFail("Expected staged custom sound name")
return
}
let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
defer {
try? fileManager.removeItem(at: stagedURL)
}
XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path))
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-"))
XCTAssertTrue(stagedName.hasSuffix(".wav"))
}
func testNotificationCustomUnsupportedExtensionsStageAsCaf() {
XCTAssertEqual(
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"),
"caf"
)
XCTAssertEqual(
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"),
"caf"
)
XCTAssertEqual(
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"),
"wav"
)
XCTAssertEqual(
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"),
"aiff"
)
let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3")
let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3")
let stagedA = NotificationSoundSettings.stagedCustomSoundFileName(
forSourceURL: sourceA,
destinationExtension: "caf"
)
let stagedB = NotificationSoundSettings.stagedCustomSoundFileName(
forSourceURL: sourceB,
destinationExtension: "caf"
)
XCTAssertNotEqual(stagedA, stagedB)
XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-"))
XCTAssertTrue(stagedA.hasSuffix(".caf"))
}
func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let fileManager = FileManager.default
let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Sounds", isDirectory: true)
do {
try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
} catch {
XCTFail("Failed to create sounds directory: \(error)")
return
}
let sourceURL = soundsDirectory.appendingPathComponent(
"cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav",
isDirectory: false
)
do {
try Data("test".utf8).write(to: sourceURL, options: .atomic)
} catch {
XCTFail("Failed to write source custom sound file: \(error)")
return
}
defer {
try? fileManager.removeItem(at: sourceURL)
}
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path)
let stagedName: String
switch prepareResult {
case .success(let name):
stagedName = name
case .failure(let issue):
XCTFail("Expected custom sound preparation success, got \(issue)")
return
}
let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
let metadataURL = stagedURL.appendingPathExtension("source-metadata")
defer {
try? fileManager.removeItem(at: stagedURL)
try? fileManager.removeItem(at: metadataURL)
}
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path))
}
func testNotificationCustomSoundReturnsNilWhenPreparationFails() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
let invalidSourceURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false)
defer {
try? FileManager.default.removeItem(at: invalidSourceURL)
let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
.appendingPathComponent("Library", isDirectory: true)
.appendingPathComponent("Sounds", isDirectory: true)
.appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false)
try? FileManager.default.removeItem(at: stagedURL)
}
do {
try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic)
} catch {
XCTFail("Failed to write invalid custom sound source: \(error)")
return
}
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
}
func testNotificationCustomPreparationReportsMissingFile() {
let missingPath = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false)
.path
let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath)
switch result {
case .success:
XCTFail("Expected missing file failure")
case .failure(let issue):
guard case .missingFile = issue else {
XCTFail("Expected missingFile issue, got \(issue)")
return
}
}
}
func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() {
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined)
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied)
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized)
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional)
}
func testNotificationAuthorizationStateDeliveryCapability() {
XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery)
XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery)
XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery)
XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery)
XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery)
XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery)
}
func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() {
XCTAssertTrue(
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
status: .notDetermined,
isAppActive: false
)
)
XCTAssertFalse(
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
status: .notDetermined,
isAppActive: true
)
)
XCTAssertFalse(
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
status: .authorized,
isAppActive: false
)
)
}
func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() {
XCTAssertTrue(
TerminalNotificationStore.shouldRequestAuthorization(
isAutomaticRequest: false,
hasRequestedAutomaticAuthorization: true
)
)
XCTAssertTrue(
TerminalNotificationStore.shouldRequestAuthorization(
isAutomaticRequest: true,
hasRequestedAutomaticAuthorization: false
)
)
XCTAssertFalse(
TerminalNotificationStore.shouldRequestAuthorization(
isAutomaticRequest: true,
hasRequestedAutomaticAuthorization: true
)
)
}
func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() {
let store = TerminalNotificationStore.shared
let alertSpy = NotificationSettingsAlertSpy()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled],
backing: .buffered,
defer: false
)
var openedURL: URL?
store.configureNotificationSettingsPromptHooksForTesting(
windowProvider: { window },
alertFactory: { alertSpy },
scheduler: { _, block in block() },
urlOpener: { openedURL = $0 }
)
store.promptToEnableNotificationsForTesting()
let drained = expectation(description: "main queue drained")
DispatchQueue.main.async { drained.fulfill() }
wait(for: [drained], timeout: 1.0)
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
XCTAssertEqual(alertSpy.runModalCallCount, 0)
XCTAssertEqual(
openedURL?.absoluteString,
"x-apple.systempreferences:com.apple.preference.notifications"
)
}
func testNotificationSettingsPromptRetriesUntilWindowExists() {
let store = TerminalNotificationStore.shared
let alertSpy = NotificationSettingsAlertSpy()
alertSpy.nextResponse = .alertSecondButtonReturn
var queuedRetryBlocks: [() -> Void] = []
var promptWindow: NSWindow?
store.configureNotificationSettingsPromptHooksForTesting(
windowProvider: { promptWindow },
alertFactory: { alertSpy },
scheduler: { _, block in queuedRetryBlocks.append(block) },
urlOpener: { _ in XCTFail("Should not open settings for Not Now response") }
)
store.promptToEnableNotificationsForTesting()
let drained = expectation(description: "main queue drained")
DispatchQueue.main.async { drained.fulfill() }
wait(for: [drained], timeout: 1.0)
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
XCTAssertEqual(alertSpy.runModalCallCount, 0)
XCTAssertEqual(queuedRetryBlocks.count, 1)
promptWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled],
backing: .buffered,
defer: false
)
queuedRetryBlocks.removeFirst()()
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
XCTAssertEqual(alertSpy.runModalCallCount, 0)
}
func testNotificationIndexesTrackUnreadCountsByTabAndSurface() {
let tabA = UUID()
let tabB = UUID()
let surfaceA = UUID()
let surfaceB = UUID()
let notificationAUnread = TerminalNotification(
id: UUID(),
tabId: tabA,
surfaceId: surfaceA,
title: "A unread",
subtitle: "",
body: "",
createdAt: Date(),
isRead: false
)
let notificationARead = TerminalNotification(
id: UUID(),
tabId: tabA,
surfaceId: surfaceB,
title: "A read",
subtitle: "",
body: "",
createdAt: Date(),
isRead: true
)
let notificationBUnread = TerminalNotification(
id: UUID(),
tabId: tabB,
surfaceId: nil,
title: "B unread",
subtitle: "",
body: "",
createdAt: Date(),
isRead: false
)
let store = TerminalNotificationStore.shared
store.replaceNotificationsForTesting([
notificationAUnread,
notificationARead,
notificationBUnread
])
XCTAssertEqual(store.unreadCount, 2)
XCTAssertEqual(store.unreadCount(forTabId: tabA), 1)
XCTAssertEqual(store.unreadCount(forTabId: tabB), 1)
XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA))
XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB))
XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil))
XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id)
XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id)
}
func testNotificationIndexesUpdateAfterReadAndClearMutations() {
let tab = UUID()
let surfaceUnread = UUID()
let surfaceRead = UUID()
let unreadNotification = TerminalNotification(
id: UUID(),
tabId: tab,
surfaceId: surfaceUnread,
title: "Unread",
subtitle: "",
body: "",
createdAt: Date(),
isRead: false
)
let readNotification = TerminalNotification(
id: UUID(),
tabId: tab,
surfaceId: surfaceRead,
title: "Read",
subtitle: "",
body: "",
createdAt: Date(),
isRead: true
)
let store = TerminalNotificationStore.shared
store.replaceNotificationsForTesting([unreadNotification, readNotification])
XCTAssertEqual(store.unreadCount(forTabId: tab), 1)
XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
store.markRead(forTabId: tab, surfaceId: surfaceUnread)
XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
XCTAssertEqual(store.latestNotification(forTabId: tab)?.id, unreadNotification.id)
store.clearNotifications(forTabId: tab)
XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
XCTAssertNil(store.latestNotification(forTabId: tab))
}
}
final class MenuBarBadgeLabelFormatterTests: XCTestCase {
func testBadgeLabelFormatting() {
XCTAssertNil(MenuBarBadgeLabelFormatter.badgeText(for: 0))
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 1), "1")
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 9), "9")
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 10), "9+")
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 47), "9+")
}
}
final class NotificationMenuSnapshotBuilderTests: XCTestCase {
func testSnapshotCountsUnreadAndLimitsRecentItems() {
let notifications = (0..<8).map { index in
TerminalNotification(
id: UUID(),
tabId: UUID(),
surfaceId: nil,
title: "N\(index)",
subtitle: "",
body: "",
createdAt: Date(timeIntervalSince1970: TimeInterval(index)),
isRead: index.isMultiple(of: 2)
)
}
let snapshot = NotificationMenuSnapshotBuilder.make(
notifications: notifications,
maxInlineNotificationItems: 3
)
XCTAssertEqual(snapshot.unreadCount, 4)
XCTAssertTrue(snapshot.hasNotifications)
XCTAssertTrue(snapshot.hasUnreadNotifications)
XCTAssertEqual(snapshot.recentNotifications.count, 3)
XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id))
}
func testStateHintTitleHandlesSingularPluralAndZero() {
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications")
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification")
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 2), "2 unread notifications")
}
}
final class MenuBarBuildHintFormatterTests: XCTestCase {
func testReleaseBuildShowsNoHint() {
XCTAssertNil(MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: false))
}
func testDebugBuildWithTagShowsTag() {
XCTAssertEqual(
MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: true),
"Build Tag: menubar-extra"
)
}
func testDebugBuildWithoutTagShowsUntagged() {
XCTAssertEqual(
MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV", isDebugBuild: true),
"Build: DEV (untagged)"
)
}
}
final class MenuBarNotificationLineFormatterTests: XCTestCase {
func testPlainTitleContainsUnreadDotBodyAndTab() {
let notification = TerminalNotification(
id: UUID(),
tabId: UUID(),
surfaceId: nil,
title: "Build finished",
subtitle: "",
body: "All checks passed",
createdAt: Date(timeIntervalSince1970: 0),
isRead: false
)
let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: "workspace-1")
XCTAssertTrue(line.hasPrefix("● Build finished"))
XCTAssertTrue(line.contains("All checks passed"))
XCTAssertTrue(line.contains("workspace-1"))
}
func testPlainTitleFallsBackToSubtitleWhenBodyEmpty() {
let notification = TerminalNotification(
id: UUID(),
tabId: UUID(),
surfaceId: nil,
title: "Deploy",
subtitle: "staging",
body: "",
createdAt: Date(timeIntervalSince1970: 0),
isRead: true
)
let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: nil)
XCTAssertTrue(line.hasPrefix(" Deploy"))
XCTAssertTrue(line.contains("staging"))
}
func testMenuTitleWrapsAndTruncatesToThreeLines() {
let notification = TerminalNotification(
id: UUID(),
tabId: UUID(),
surfaceId: nil,
title: "Extremely long notification title for wrapping behavior validation",
subtitle: "",
body: Array(repeating: "this body should wrap and eventually truncate", count: 8).joined(separator: " "),
createdAt: Date(timeIntervalSince1970: 0),
isRead: false
)
let title = MenuBarNotificationLineFormatter.menuTitle(
notification: notification,
tabTitle: "workspace-with-a-very-long-name",
maxWidth: 120,
maxLines: 3
)
XCTAssertLessThanOrEqual(title.components(separatedBy: "\n").count, 3)
XCTAssertTrue(title.hasSuffix(""))
}
func testMenuTitlePreservesShortTextWithoutEllipsis() {
let notification = TerminalNotification(
id: UUID(),
tabId: UUID(),
surfaceId: nil,
title: "Done",
subtitle: "",
body: "All checks passed",
createdAt: Date(timeIntervalSince1970: 0),
isRead: false
)
let title = MenuBarNotificationLineFormatter.menuTitle(
notification: notification,
tabTitle: "w1",
maxWidth: 320,
maxLines: 3
)
XCTAssertFalse(title.hasSuffix(""))
}
}
final class MenuBarIconDebugSettingsTests: XCTestCase {
func testDisplayedUnreadCountUsesPreviewOverrideWhenEnabled() {
let suiteName = "MenuBarIconDebugSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: MenuBarIconDebugSettings.previewEnabledKey)
defaults.set(7, forKey: MenuBarIconDebugSettings.previewCountKey)
XCTAssertEqual(MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: 2, defaults: defaults), 7)
}
func testBadgeRenderConfigClampsInvalidValues() {
let suiteName = "MenuBarIconDebugSettingsTests.Clamp.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(-100, forKey: MenuBarIconDebugSettings.badgeRectXKey)
defaults.set(200, forKey: MenuBarIconDebugSettings.badgeRectYKey)
defaults.set(-100, forKey: MenuBarIconDebugSettings.singleDigitFontSizeKey)
defaults.set(100, forKey: MenuBarIconDebugSettings.multiDigitXAdjustKey)
let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
XCTAssertEqual(config.badgeRect.origin.x, 0, accuracy: 0.001)
XCTAssertEqual(config.badgeRect.origin.y, 20, accuracy: 0.001)
XCTAssertEqual(config.singleDigitFontSize, 6, accuracy: 0.001)
XCTAssertEqual(config.multiDigitXAdjust, 4, accuracy: 0.001)
}
func testBadgeRenderConfigUsesLegacySingleDigitXAdjustWhenNewKeyMissing() {
let suiteName = "MenuBarIconDebugSettingsTests.LegacyX.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(2.5, forKey: MenuBarIconDebugSettings.legacySingleDigitXAdjustKey)
let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
XCTAssertEqual(config.singleDigitXAdjust, 2.5, accuracy: 0.001)
}
}
@MainActor
final class MenuBarIconRendererTests: XCTestCase {
func testImageWidthDoesNotShiftWhenBadgeAppears() {
let noBadge = MenuBarIconRenderer.makeImage(unreadCount: 0)
let withBadge = MenuBarIconRenderer.makeImage(unreadCount: 2)
XCTAssertEqual(noBadge.size.width, 18, accuracy: 0.001)
XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001)
}
}
final class WorkspaceMountPolicyTests: XCTestCase {
func testDefaultPolicyMountsOnlySelectedWorkspace() {
let a = UUID()
let b = UUID()
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces
)
XCTAssertEqual(next, [b])
}
func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() {
let a = UUID()
let b = UUID()
let c = UUID()
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b, c],
selected: c,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [c, a])
}
func testMissingWorkspacesArePruned() {
let a = UUID()
let b = UUID()
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [b, a],
selected: nil,
pinnedIds: [],
orderedTabIds: [a],
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [a])
}
func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
let a = UUID()
let b = UUID()
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [b, a])
}
func testMaxMountedIsClampedToAtLeastOne() {
let a = UUID()
let b = UUID()
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b],
selected: nil,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 0
)
XCTAssertEqual(next, [a])
}
func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() {
let a = UUID()
let b = UUID()
let c = UUID()
let d = UUID()
let orderedTabIds: [UUID] = [a, b, c, d]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: c,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: true,
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
)
XCTAssertEqual(next, [c])
}
func testCycleHotModeRespectsMaxMountedLimit() {
let a = UUID()
let b = UUID()
let c = UUID()
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a, b, c],
selected: b,
pinnedIds: [],
orderedTabIds: orderedTabIds,
isCycleHot: true,
maxMounted: 2
)
XCTAssertEqual(next, [b])
}
func testPinnedIdsAreRetainedAcrossReconcile() {
let a = UUID()
let b = UUID()
let c = UUID()
let orderedTabIds: [UUID] = [a, b, c]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: c,
pinnedIds: [a],
orderedTabIds: orderedTabIds,
isCycleHot: false,
maxMounted: 2
)
XCTAssertEqual(next, [c, a])
}
func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() {
let a = UUID()
let b = UUID()
let orderedTabIds: [UUID] = [a, b]
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
current: [a],
selected: b,
pinnedIds: [a],
orderedTabIds: orderedTabIds,
isCycleHot: true,
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
)
XCTAssertEqual(next, [b, a])
}
}
@MainActor
final class WindowTerminalHostViewTests: XCTestCase {
private final class CapturingView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() {
let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
XCTAssertNil(host.hitTest(NSPoint(x: 10, y: 10)))
}
func testHostViewReturnsSubviewWhenSubviewIsHit() {
let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
let child = CapturingView(frame: NSRect(x: 20, y: 15, width: 40, height: 30))
host.addSubview(child)
XCTAssertTrue(host.hitTest(NSPoint(x: 25, y: 20)) === child)
XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100)))
}
func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let splitView = NSSplitView(frame: contentView.bounds)
splitView.autoresizingMask = [.width, .height]
splitView.isVertical = true
splitView.dividerStyle = .thin
let splitDelegate = BonsplitMockSplitDelegate()
splitView.delegate = splitDelegate
let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))
let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height))
splitView.addSubview(first)
splitView.addSubview(second)
contentView.addSubview(splitView)
splitView.setPosition(1, ofDividerAt: 0)
splitView.adjustSubviews()
contentView.layoutSubtreeIfNeeded()
let host = WindowTerminalHostView(frame: contentView.bounds)
host.autoresizingMask = [.width, .height]
let child = CapturingView(frame: host.bounds)
child.autoresizingMask = [.width, .height]
host.addSubview(child)
contentView.addSubview(host)
let dividerPointInSplit = NSPoint(
x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5),
y: splitView.bounds.midY
)
let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil)
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5)
XCTAssertNil(
host.hitTest(dividerPointInHost),
"Host view must pass through divider hits even when one pane is nearly collapsed"
)
let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY)
let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil)
let contentPointInHost = host.convert(contentPointInWindow, from: nil)
XCTAssertTrue(host.hitTest(contentPointInHost) === child)
}
}
@MainActor
final class WindowBrowserHostViewTests: XCTestCase {
private final class CapturingView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let splitView = NSSplitView(frame: contentView.bounds)
splitView.autoresizingMask = [.width, .height]
splitView.isVertical = true
splitView.dividerStyle = .thin
let splitDelegate = BonsplitMockSplitDelegate()
splitView.delegate = splitDelegate
let first = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))
let second = NSView(frame: NSRect(x: 121, y: 0, width: 179, height: contentView.bounds.height))
splitView.addSubview(first)
splitView.addSubview(second)
contentView.addSubview(splitView)
splitView.setPosition(1, ofDividerAt: 0)
splitView.adjustSubviews()
contentView.layoutSubtreeIfNeeded()
let host = WindowBrowserHostView(frame: contentView.bounds)
host.autoresizingMask = [.width, .height]
let child = CapturingView(frame: host.bounds)
child.autoresizingMask = [.width, .height]
host.addSubview(child)
contentView.addSubview(host)
let dividerPointInSplit = NSPoint(
x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5),
y: splitView.bounds.midY
)
let dividerPointInWindow = splitView.convert(dividerPointInSplit, to: nil)
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
XCTAssertLessThanOrEqual(splitView.arrangedSubviews[0].frame.width, 1.5)
XCTAssertNil(
host.hitTest(dividerPointInHost),
"Browser host must pass through divider hits even when one pane is nearly collapsed"
)
let contentPointInSplit = NSPoint(x: dividerPointInSplit.x + 40, y: splitView.bounds.midY)
let contentPointInWindow = splitView.convert(contentPointInSplit, to: nil)
let contentPointInHost = host.convert(contentPointInWindow, from: nil)
XCTAssertTrue(host.hitTest(contentPointInHost) === child)
}
}
@MainActor
final class WindowDragHandleHitTests: XCTestCase {
private final class CapturingView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class HostContainerView: NSView {}
private final class BlockingTopHitContainerView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class PassThroughProbeView: NSView {
var onHitTest: (() -> Void)?
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point) else { return nil }
onHitTest?()
return nil
}
}
private final class PassiveHostContainerView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point) else { return nil }
return super.hitTest(point) ?? self
}
}
private final class MutatingSiblingView: NSView {
weak var container: NSView?
private var didMutate = false
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point) else { return nil }
guard !didMutate, let container else { return nil }
didMutate = true
let transient = NSView(frame: .zero)
container.addSubview(transient)
transient.removeFromSuperview()
return nil
}
}
private final class ReentrantDragHandleView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self, eventType: .leftMouseDown, eventWindow: self.window)
return shouldCapture ? self : nil
}
}
/// A sibling view whose hitTest re-enters windowDragHandleShouldCaptureHit,
/// simulating the crash path where sibling.hitTest triggers a SwiftUI layout
/// pass that calls back into the drag handle's hit resolution.
private final class ReentrantSiblingView: NSView {
weak var dragHandle: NSView?
var reenteredResult: Bool?
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point), let dragHandle else { return nil }
// Simulate the re-entry: during sibling hit test, SwiftUI layout
// calls windowDragHandleShouldCaptureHit on the drag handle again.
reenteredResult = windowDragHandleShouldCaptureHit(
point, in: dragHandle, eventType: .leftMouseDown, eventWindow: dragHandle.window
)
return nil
}
}
func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
XCTAssertTrue(
windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
"Empty titlebar space should drag the window"
)
}
func testDragHandleYieldsWhenSiblingClaimsPoint() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
container.addSubview(folderIconHost)
XCTAssertFalse(
windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown),
"Interactive titlebar controls should receive the mouse event"
)
XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown))
}
func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
hidden.isHidden = true
container.addSubview(hidden)
XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown))
}
func testDragHandleDoesNotCaptureOutsideBounds() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle, eventType: .leftMouseDown))
}
func testDragHandleSkipsCaptureForPassivePointerEvents() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
let point = NSPoint(x: 180, y: 18)
XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .mouseMoved))
XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .cursorUpdate))
XCTAssertFalse(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: nil))
XCTAssertTrue(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown))
}
func testDragHandleSkipsForeignLeftMouseDownDuringLaunch() {
let point = NSPoint(x: 180, y: 18)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let container = NSView(frame: contentView.bounds)
container.autoresizingMask = [.width, .height]
contentView.addSubview(container)
let dragHandle = NSView(frame: container.bounds)
dragHandle.autoresizingMask = [.width, .height]
container.addSubview(dragHandle)
let foreignWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
styleMask: [.titled],
backing: .buffered,
defer: false
)
defer { foreignWindow.orderOut(nil) }
XCTAssertFalse(
windowDragHandleShouldCaptureHit(
point,
in: dragHandle,
eventType: .leftMouseDown,
eventWindow: nil
),
"Launch activation events without a matching window should not trigger drag-handle hierarchy walk"
)
XCTAssertFalse(
windowDragHandleShouldCaptureHit(
point,
in: dragHandle,
eventType: .leftMouseDown,
eventWindow: foreignWindow
),
"Left mouse-down events for a different window should be treated as passive"
)
XCTAssertTrue(
windowDragHandleShouldCaptureHit(
point,
in: dragHandle,
eventType: .leftMouseDown,
eventWindow: window
),
"Left mouse-down events for this window should still capture empty titlebar space"
)
}
func testPassiveHostingTopHitClassification() {
XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero)))
XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero)))
}
func testDragHandleIgnoresPassiveHostSiblingHit() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
let passiveHost = PassiveHostContainerView(frame: container.bounds)
container.addSubview(passiveHost)
XCTAssertTrue(
windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
"Passive host wrappers should not block titlebar drag capture"
)
}
func testDragHandleRespectsInteractiveChildInsidePassiveHost() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
let passiveHost = PassiveHostContainerView(frame: container.bounds)
let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
passiveHost.addSubview(folderControl)
container.addSubview(passiveHost)
XCTAssertFalse(
windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle, eventType: .leftMouseDown),
"Interactive controls inside passive host wrappers should still receive hits"
)
}
func testTopHitResolutionStateIsScopedPerWindow() {
let point = NSPoint(x: 100, y: 18)
let outerWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { outerWindow.orderOut(nil) }
guard let outerContentView = outerWindow.contentView else {
XCTFail("Expected outer content view")
return
}
let outerContainer = NSView(frame: outerContentView.bounds)
outerContainer.autoresizingMask = [.width, .height]
outerContentView.addSubview(outerContainer)
let outerDragHandle = NSView(frame: outerContainer.bounds)
outerDragHandle.autoresizingMask = [.width, .height]
outerContainer.addSubview(outerDragHandle)
let nestedWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { nestedWindow.orderOut(nil) }
guard let nestedContentView = nestedWindow.contentView else {
XCTFail("Expected nested content view")
return
}
let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds)
nestedContainer.autoresizingMask = [.width, .height]
nestedContentView.addSubview(nestedContainer)
let nestedDragHandle = NSView(frame: nestedContainer.bounds)
nestedDragHandle.autoresizingMask = [.width, .height]
nestedContainer.addSubview(nestedDragHandle)
XCTAssertFalse(
windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow),
"Nested window drag handle should be blocked by top-hit titlebar container"
)
var nestedCaptureResult: Bool?
let probe = PassThroughProbeView(frame: outerContainer.bounds)
probe.autoresizingMask = [.width, .height]
probe.onHitTest = {
nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle, eventType: .leftMouseDown, eventWindow: nestedWindow)
}
outerContainer.addSubview(probe)
_ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle, eventType: .leftMouseDown, eventWindow: outerWindow)
XCTAssertEqual(
nestedCaptureResult,
false,
"Top-hit recursion in one window must not disable top-hit resolution in another window"
)
}
func testDragHandleRemainsStableWhenSiblingMutatesSubviewsDuringHitTest() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
let mutatingSibling = MutatingSiblingView(frame: container.bounds)
mutatingSibling.container = container
container.addSubview(mutatingSibling)
XCTAssertTrue(
windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle, eventType: .leftMouseDown),
"Subview mutations during hit testing should not crash or break drag-handle capture"
)
}
func testDragHandleSiblingHitTestReentrancyDoesNotCrash() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
let dragHandle = NSView(frame: container.bounds)
container.addSubview(dragHandle)
let reentrantSibling = ReentrantSiblingView(frame: container.bounds)
reentrantSibling.dragHandle = dragHandle
container.addSubview(reentrantSibling)
// The outer call enters the sibling walk, which calls
// reentrantSibling.hitTest(), which re-enters
// windowDragHandleShouldCaptureHit. Without the re-entrancy guard
// this would trigger a Swift exclusive-access violation (SIGABRT).
let outerResult = windowDragHandleShouldCaptureHit(
NSPoint(x: 110, y: 18), in: dragHandle, eventType: .leftMouseDown
)
XCTAssertTrue(outerResult, "Outer call should still capture when sibling returns nil")
XCTAssertEqual(
reentrantSibling.reenteredResult, false,
"Re-entrant call should bail out (return false) instead of crashing"
)
}
func testDragHandleTopHitResolutionSurvivesSameWindowReentrancy() {
let point = NSPoint(x: 180, y: 18)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let container = NSView(frame: contentView.bounds)
container.autoresizingMask = [.width, .height]
contentView.addSubview(container)
let dragHandle = ReentrantDragHandleView(frame: container.bounds)
dragHandle.autoresizingMask = [.width, .height]
container.addSubview(dragHandle)
XCTAssertTrue(
windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown, eventWindow: window),
"Reentrant same-window top-hit resolution should not trigger exclusivity crashes"
)
}
}
#if DEBUG
@MainActor
final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase {
override func setUp() {
super.setUp()
SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting()
}
override func tearDown() {
SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting()
super.tearDown()
}
func testHintWidthCachesRepeatedMeasurements() {
XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 0)
let first = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1")
XCTAssertGreaterThan(first, 0)
XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1)
let second = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1")
XCTAssertEqual(second, first)
XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1)
_ = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘2")
XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 2)
}
func testSlotWidthAppliesMinimumAndDebugInset() {
let nilLabelWidth = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: nil, debugXOffset: 999)
XCTAssertEqual(nilLabelWidth, 28)
let base = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 0)
let widened = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 10)
XCTAssertGreaterThan(widened, base)
}
}
#endif
@MainActor
final class DraggableFolderHitTests: XCTestCase {
func testFolderHitTestReturnsContainerWhenInsideBounds() {
let folderView = DraggableFolderNSView(directory: "/tmp")
folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else {
XCTFail("Expected folder icon to capture inside hit")
return
}
XCTAssertTrue(hit === folderView)
}
func testFolderHitTestReturnsNilOutsideBounds() {
let folderView = DraggableFolderNSView(directory: "/tmp")
folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8)))
}
func testFolderIconDisablesWindowMoveBehavior() {
let folderView = DraggableFolderNSView(directory: "/tmp")
XCTAssertFalse(folderView.mouseDownCanMoveWindow)
}
}
@MainActor
final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase {
func testLeadingInsetViewDoesNotParticipateInHitTesting() {
let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10)))
}
func testLeadingInsetViewCannotMoveWindowViaMouseDown() {
let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
XCTAssertFalse(view.mouseDownCanMoveWindow)
}
}
@MainActor
final class FolderWindowMoveSuppressionTests: XCTestCase {
private func makeWindow() -> NSWindow {
NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
}
func testSuppressionDisablesMovableWindow() {
let window = makeWindow()
window.isMovable = true
let previous = temporarilyDisableWindowDragging(window: window)
XCTAssertEqual(previous, true)
XCTAssertFalse(window.isMovable)
}
func testSuppressionPreservesAlreadyImmovableWindow() {
let window = makeWindow()
window.isMovable = false
let previous = temporarilyDisableWindowDragging(window: window)
XCTAssertEqual(previous, false)
XCTAssertFalse(window.isMovable)
}
func testRestoreAppliesPreviousMovableState() {
let window = makeWindow()
window.isMovable = false
restoreWindowDragging(window: window, previousMovableState: true)
XCTAssertTrue(window.isMovable)
restoreWindowDragging(window: window, previousMovableState: false)
XCTAssertFalse(window.isMovable)
}
func testWindowDragSuppressionDepthLifecycle() {
let window = makeWindow()
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
XCTAssertFalse(isWindowDragSuppressed(window: window))
XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
XCTAssertTrue(isWindowDragSuppressed(window: window))
XCTAssertEqual(endWindowDragSuppression(window: window), 0)
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
XCTAssertFalse(isWindowDragSuppressed(window: window))
}
func testWindowDragSuppressionIsReferenceCounted() {
let window = makeWindow()
XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
XCTAssertEqual(beginWindowDragSuppression(window: window), 2)
XCTAssertEqual(windowDragSuppressionDepth(window: window), 2)
XCTAssertTrue(isWindowDragSuppressed(window: window))
XCTAssertEqual(endWindowDragSuppression(window: window), 1)
XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
XCTAssertTrue(isWindowDragSuppressed(window: window))
XCTAssertEqual(endWindowDragSuppression(window: window), 0)
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
XCTAssertFalse(isWindowDragSuppressed(window: window))
}
func testTemporaryWindowMovableEnableRestoresImmovableWindow() {
let window = makeWindow()
window.isMovable = false
let previous = withTemporaryWindowMovableEnabled(window: window) {
XCTAssertTrue(window.isMovable)
}
XCTAssertEqual(previous, false)
XCTAssertFalse(window.isMovable)
}
func testTemporaryWindowMovableEnablePreservesMovableWindow() {
let window = makeWindow()
window.isMovable = true
let previous = withTemporaryWindowMovableEnabled(window: window) {
XCTAssertTrue(window.isMovable)
}
XCTAssertEqual(previous, true)
XCTAssertTrue(window.isMovable)
}
}
@MainActor
final class WindowMoveSuppressionHitPathTests: XCTestCase {
private func makeWindowWithContentView() -> (NSWindow, NSView) {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = contentView
return (window, contentView)
}
private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
guard let event = NSEvent.mouseEvent(
with: type,
location: location,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 0,
clickCount: 1,
pressure: 1.0
) else {
fatalError("Failed to create \(type) mouse event")
}
return event
}
func testSuppressionHitPathRecognizesFolderView() {
let folderView = DraggableFolderNSView(directory: "/tmp")
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView))
}
func testSuppressionHitPathRecognizesDescendantOfFolderView() {
let folderView = DraggableFolderNSView(directory: "/tmp")
let child = NSView(frame: .zero)
folderView.addSubview(child)
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child))
}
func testSuppressionHitPathIgnoresUnrelatedViews() {
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero)))
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil))
}
func testSuppressionEventPathRecognizesFolderHitInsideWindow() {
let (window, contentView) = makeWindowWithContentView()
window.isMovable = true
let folderView = DraggableFolderNSView(directory: "/tmp")
folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16)
contentView.addSubview(folderView)
let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window)
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event))
}
func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() {
let (window, contentView) = makeWindowWithContentView()
window.isMovable = true
let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
contentView.addSubview(plainView)
let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window)
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down))
let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window)
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged))
}
}
@MainActor
final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase {
func testShouldPromoteWhenBecomingVisible() {
XCTAssertTrue(
CommandPaletteOverlayPromotionPolicy.shouldPromote(
previouslyVisible: false,
isVisible: true
)
)
}
func testShouldNotPromoteWhenAlreadyVisible() {
XCTAssertFalse(
CommandPaletteOverlayPromotionPolicy.shouldPromote(
previouslyVisible: true,
isVisible: true
)
)
}
func testShouldNotPromoteWhenHidden() {
XCTAssertFalse(
CommandPaletteOverlayPromotionPolicy.shouldPromote(
previouslyVisible: true,
isVisible: false
)
)
XCTAssertFalse(
CommandPaletteOverlayPromotionPolicy.shouldPromote(
previouslyVisible: false,
isVisible: false
)
)
}
}
@MainActor
final class GhosttySurfaceOverlayTests: XCTestCase {
private final class ScrollProbeSurfaceView: GhosttyNSView {
private(set) var scrollWheelCallCount = 0
override func scrollWheel(with event: NSEvent) {
scrollWheelCallCount += 1
}
}
private func findEditableTextField(in view: NSView) -> NSTextField? {
if let field = view as? NSTextField, field.isEditable {
return field
}
for subview in view.subviews {
if let field = findEditableTextField(in: subview) {
return field
}
}
return nil
}
func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let surfaceView = ScrollProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView)
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else {
XCTFail("Expected hosted terminal scroll view")
return
}
XCTAssertFalse(
scrollView.acceptsFirstResponder,
"Host scroll view should not become first responder and steal terminal shortcuts"
)
_ = window.makeFirstResponder(nil)
guard let cgEvent = CGEvent(
scrollWheelEvent2Source: nil,
units: .pixel,
wheelCount: 2,
wheel1: 0,
wheel2: -12,
wheel3: 0
), let scrollEvent = NSEvent(cgEvent: cgEvent) else {
XCTFail("Expected scroll wheel event")
return
}
scrollView.scrollWheel(with: scrollEvent)
XCTAssertEqual(
surfaceView.scrollWheelCallCount,
1,
"Trackpad wheel events should be forwarded directly to Ghostty surface scrolling"
)
XCTAssertTrue(
window.firstResponder === surfaceView,
"Scroll wheel handling should keep keyboard focus on terminal surface"
)
}
func testInactiveOverlayVisibilityTracksRequestedState() {
let hostedView = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))
)
hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: true)
var state = hostedView.debugInactiveOverlayState()
XCTAssertFalse(state.isHidden)
XCTAssertEqual(state.alpha, 0.35, accuracy: 0.01)
hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: false)
state = hostedView.debugInactiveOverlayState()
XCTAssertTrue(state.isHidden)
}
func testWindowResignKeyClearsFocusedTerminalFirstResponder() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let hostedView = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
)
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
hostedView.setVisibleInUI(true)
hostedView.setActive(true)
hostedView.moveFocus()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(
hostedView.isSurfaceViewFirstResponder(),
"Expected terminal surface to be first responder before window blur"
)
NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertFalse(
hostedView.isSurfaceViewFirstResponder(),
"Window blur should force terminal surface to resign first responder"
)
}
func testSearchOverlayMountsAndUnmountsWithSearchState() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
XCTAssertFalse(hostedView.debugHasSearchOverlay())
let searchState = TerminalSurface.SearchState(needle: "example")
hostedView.setSearchOverlay(searchState: searchState)
XCTAssertTrue(hostedView.debugHasSearchOverlay())
hostedView.setSearchOverlay(searchState: nil)
XCTAssertFalse(hostedView.debugHasSearchOverlay())
}
func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() {
_ = NSApplication.shared
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer {
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
window.orderOut(nil)
}
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
hostedView.setVisibleInUI(true)
hostedView.setActive(true)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
let searchState = TerminalSurface.SearchState(needle: "")
surface.searchState = searchState
hostedView.setSearchOverlay(searchState: searchState)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let searchField = findEditableTextField(in: hostedView) else {
XCTFail("Expected mounted find text field")
return
}
window.makeFirstResponder(searchField)
var escapeKeyUpCount = 0
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
guard keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 53 else { return }
escapeKeyUpCount += 1
}
let timestamp = ProcessInfo.processInfo.systemUptime
guard let escapeKeyDown = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [],
timestamp: timestamp,
windowNumber: window.windowNumber,
context: nil,
characters: "\u{1b}",
charactersIgnoringModifiers: "\u{1b}",
isARepeat: false,
keyCode: 53
), let escapeKeyUp = NSEvent.keyEvent(
with: .keyUp,
location: .zero,
modifierFlags: [],
timestamp: timestamp + 0.001,
windowNumber: window.windowNumber,
context: nil,
characters: "\u{1b}",
charactersIgnoringModifiers: "\u{1b}",
isARepeat: false,
keyCode: 53
) else {
XCTFail("Failed to construct Escape key events")
return
}
NSApp.sendEvent(escapeKeyDown)
NSApp.sendEvent(escapeKeyUp)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertNil(surface.searchState, "Escape should dismiss find overlay when search text is empty")
XCTAssertEqual(
escapeKeyUpCount,
0,
"Escape used to dismiss find overlay must not pass through to the terminal key-up path"
)
}
func testKeyboardCopyModeIndicatorMountsAndUnmounts() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
hostedView.setKeyboardCopyModeIndicator(visible: true)
XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator())
hostedView.setKeyboardCopyModeIndicator(visible: false)
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
}
func testForceRefreshNoopsAfterSurfaceReleaseDuringGeometryReconcile() throws {
#if DEBUG
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 280),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
hostedView.reconcileGeometryNow()
surface.releaseSurfaceForTesting()
XCTAssertNil(surface.surface, "Surface should be nil after test release helper")
hostedView.reconcileGeometryNow()
surface.forceRefresh()
XCTAssertNil(surface.surface, "Force refresh should no-op when runtime surface is nil")
#else
throw XCTSkip("Debug-only regression test")
#endif
}
func testSearchOverlayMountDoesNotRetainTerminalSurface() {
weak var weakSurface: TerminalSurface?
let hostedView: GhosttySurfaceScrollView = {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
weakSurface = surface
let hostedView = surface.hostedView
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "retain-check"))
return hostedView
}()
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
XCTAssertTrue(hostedView.debugHasSearchOverlay())
XCTAssertNil(weakSurface, "Mounted search overlay must not retain TerminalSurface")
}
func testSearchOverlaySurvivesPortalRebindDuringSplitLikeChurn() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchorA = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 140))
let anchorB = NSView(frame: NSRect(x: 220, y: 20, width: 180, height: 140))
contentView.addSubview(anchorA)
contentView.addSubview(anchorB)
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split"))
XCTAssertTrue(hostedView.debugHasSearchOverlay())
portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true)
XCTAssertTrue(hostedView.debugHasSearchOverlay())
portal.bind(hostedView: hostedView, to: anchorB, visibleInUI: true)
XCTAssertTrue(
hostedView.debugHasSearchOverlay(),
"Split-like anchor churn should not unmount terminal search overlay"
)
}
func testSearchOverlaySurvivesPortalVisibilityToggleDuringWorkspaceSwitchLikeChurn() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 220, height: 160))
contentView.addSubview(anchor)
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace"))
XCTAssertTrue(hostedView.debugHasSearchOverlay())
portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true)
XCTAssertTrue(hostedView.debugHasSearchOverlay())
portal.bind(hostedView: hostedView, to: anchor, visibleInUI: false)
XCTAssertTrue(hostedView.debugHasSearchOverlay())
portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true)
XCTAssertTrue(
hostedView.debugHasSearchOverlay(),
"Workspace-switch-like visibility toggles should not unmount terminal search overlay"
)
}
}
@MainActor
final class TerminalWindowPortalLifecycleTests: XCTestCase {
private final class ContentViewCountingWindow: NSWindow {
var contentViewReadCount = 0
override var contentView: NSView? {
get {
contentViewReadCount += 1
return super.contentView
}
set {
super.contentView = newValue
}
}
}
private func realizeWindowLayout(_ window: NSWindow) {
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
window.contentView?.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
window.contentView?.layoutSubtreeIfNeeded()
}
func testPortalHostInstallsAboveContentViewForVisibility() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
_ = portal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
guard let contentView = window.contentView,
let container = contentView.superview else {
XCTFail("Expected content container")
return
}
guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }),
let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
XCTFail("Expected host/content views in same container")
return
}
XCTAssertGreaterThan(
hostIndex,
contentIndex,
"Portal host must remain above content view so portal-hosted terminals stay visible"
)
}
func testRegistryPrunesPortalWhenWindowCloses() {
let baseline = TerminalWindowPortalRegistry.debugPortalCount()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
_ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window)
XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1)
NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline)
}
func testPruneDeadEntriesDetachesAnchorlessHostedView() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let hosted1 = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
)
var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80))
contentView.addSubview(anchor1!)
portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true)
anchor1?.removeFromSuperview()
anchor1 = nil
let hosted2 = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
)
let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80))
contentView.addSubview(anchor2)
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked")
XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView")
}
func testSynchronizeReusesInstalledTargetWithoutRepeatedContentViewLookup() {
let window = ContentViewCountingWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120))
contentView.addSubview(anchor)
let hosted = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80))
)
portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
let baselineReads = window.contentViewReadCount
for _ in 0..<25 {
portal.synchronizeHostedViewForAnchor(anchor)
}
XCTAssertEqual(
window.contentViewReadCount,
baselineReads,
"Repeated synchronize calls should reuse installed target instead of repeatedly reading window.contentView"
)
}
func testTerminalViewAtWindowPointResolvesPortalHostedSurface() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120))
contentView.addSubview(anchor)
let hosted = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80))
)
portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
let center = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
let windowPoint = anchor.convert(center, to: nil)
XCTAssertNotNil(
portal.terminalViewAtWindowPoint(windowPoint),
"Portal hit-testing should resolve the terminal view for Finder file drops"
)
}
func testVisibilityTransitionBringsHostedViewToFront() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
contentView.addSubview(anchor1)
contentView.addSubview(anchor2)
let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
let overlapInContent = NSPoint(x: 120, y: 100)
let overlapInWindow = contentView.convert(overlapInContent, to: nil)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
"Latest bind should be top-most before visibility transition"
)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
"Becoming visible should refresh z-order for already-hosted view"
)
}
func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let portal = WindowTerminalPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
contentView.addSubview(anchor1)
contentView.addSubview(anchor2)
let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1)
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2)
let overlapInContent = NSPoint(x: 120, y: 100)
let overlapInWindow = contentView.convert(overlapInContent, to: nil)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
"Higher-priority terminal should initially be top-most"
)
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2)
XCTAssertTrue(
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
"Promoting z-priority should bring an already-visible terminal to front"
)
}
func testHiddenPortalDefersRevealUntilFrameHasUsableSize() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let portal = WindowTerminalPortal(window: window)
realizeWindowLayout(window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220))
contentView.addSubview(anchor)
let hosted = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
)
portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible")
// Collapse to a tiny frame first.
anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0)
portal.synchronizeHostedViewForAnchor(anchor)
XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal")
// Then restore to a non-zero but still too-small frame. It should remain hidden.
anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3)
portal.synchronizeHostedViewForAnchor(anchor)
XCTAssertTrue(
hosted.isHidden,
"Portal should defer reveal until geometry reaches a usable size"
)
// Once the frame is large enough again, reveal should resume.
anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40)
portal.synchronizeHostedViewForAnchor(anchor)
XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable")
}
}
@MainActor
final class BrowserWindowPortalLifecycleTests: XCTestCase {
private func realizeWindowLayout(_ window: NSWindow) {
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
window.contentView?.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
window.contentView?.layoutSubtreeIfNeeded()
}
func testPortalHostInstallsAboveContentViewForVisibility() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let portal = WindowBrowserPortal(window: window)
_ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
guard let contentView = window.contentView,
let container = contentView.superview else {
XCTFail("Expected content container")
return
}
guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
XCTFail("Expected host/content views in same container")
return
}
XCTAssertGreaterThan(
hostIndex,
contentIndex,
"Browser portal host must remain above content view so portal-hosted web views stay visible"
)
}
func testAnchorRebindKeepsWebViewInStablePortalSuperview() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120))
contentView.addSubview(anchor1)
contentView.addSubview(anchor2)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor1, visibleInUI: true)
let firstSuperview = webView.superview
XCTAssertNotNil(firstSuperview)
XCTAssertTrue(firstSuperview is WindowBrowserSlotView)
portal.bind(webView: webView, to: anchor2, visibleInUI: true)
XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view")
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor2)
guard let slot = webView.superview as? WindowBrowserSlotView,
let host = slot.superview as? WindowBrowserHostView else {
XCTFail("Expected browser slot + host views")
return
}
let expectedFrame = host.convert(anchor2.bounds, from: anchor2)
XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5)
XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5)
}
func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
// Simulate a transient oversized anchor rect during split churn.
let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected web view slot")
return
}
XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible")
XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5)
XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5)
}
func testPortalSyncNormalizesOutOfBoundsWebFrame() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected browser slot")
return
}
// Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds.
webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height)
XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY)
portal.synchronizeWebViewForAnchor(anchor)
XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5)
XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5)
XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5)
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
}
func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView,
let host = slot.superview as? WindowBrowserHostView else {
XCTFail("Expected portal slot + host views")
return
}
XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync")
XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync")
}
func testRegistryDetachRemovesPortalHostedWebView() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
XCTAssertNotNil(webView.superview)
BrowserWindowPortalRegistry.detach(webView: webView)
XCTAssertNil(webView.superview)
}
}
final class BrowserLinkOpenSettingsTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!
override func setUp() {
super.setUp()
suiteName = "BrowserLinkOpenSettingsTests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)
defaults.removePersistentDomain(forName: suiteName)
}
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
defaults = nil
suiteName = nil
super.tearDown()
}
func testTerminalLinksDefaultToCmuxBrowser() {
XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
}
func testTerminalLinksPreferenceUsesStoredValue() {
defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
XCTAssertFalse(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
}
func testSidebarPullRequestLinksDefaultToCmuxBrowser() {
XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
}
func testSidebarPullRequestLinksPreferenceUsesStoredValue() {
defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
}
func testOpenCommandInterceptionDefaultsToCmuxBrowser() {
XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
}
func testOpenCommandInterceptionUsesStoredValue() {
defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
}
func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() {
defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
}
func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() {
defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey)
XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults))
}
func testExternalOpenPatternsDefaultToEmpty() {
XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty)
}
func testExternalOpenLiteralPatternMatchesCaseInsensitively() {
defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
XCTAssertTrue(
BrowserLinkOpenSettings.shouldOpenExternally(
"https://platform.OPENAI.com/account/usage",
defaults: defaults
)
)
}
func testExternalOpenRegexPatternMatchesCaseInsensitively() {
defaults.set(
"re:^https?://[^/]*\\.example\\.com/(billing|usage)",
forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
)
XCTAssertTrue(
BrowserLinkOpenSettings.shouldOpenExternally(
"https://FOO.example.com/BILLING",
defaults: defaults
)
)
}
func testExternalOpenRegexPatternSupportsDigitCharacterClass() {
defaults.set(
"re:^https://example\\.com/usage/\\d+$",
forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey
)
XCTAssertTrue(
BrowserLinkOpenSettings.shouldOpenExternally(
"https://example.com/usage/42",
defaults: defaults
)
)
}
func testExternalOpenPatternsIgnoreInvalidRegexEntries() {
defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey)
XCTAssertTrue(
BrowserLinkOpenSettings.shouldOpenExternally(
"https://example.com/path",
defaults: defaults
)
)
}
}
final class TerminalOpenURLTargetResolutionTests: XCTestCase {
func testResolvesHTTPSAsEmbeddedBrowser() throws {
let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https://example.com/path?q=1"))
switch target {
case let .embeddedBrowser(url):
XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "example.com")
XCTAssertEqual(url.path, "/path")
default:
XCTFail("Expected web URL to route to embedded browser")
}
}
func testResolvesBareDomainAsEmbeddedBrowser() throws {
let target = try XCTUnwrap(resolveTerminalOpenURLTarget("example.com/docs"))
switch target {
case let .embeddedBrowser(url):
XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "example.com")
XCTAssertEqual(url.path, "/docs")
default:
XCTFail("Expected bare domain to be normalized as an HTTPS browser URL")
}
}
func testResolvesFileSchemeAsExternal() throws {
let target = try XCTUnwrap(resolveTerminalOpenURLTarget("file:///tmp/cmux.txt"))
switch target {
case let .external(url):
XCTAssertTrue(url.isFileURL)
XCTAssertEqual(url.path, "/tmp/cmux.txt")
default:
XCTFail("Expected file URL to open externally")
}
}
func testResolvesAbsolutePathAsExternalFileURL() throws {
let target = try XCTUnwrap(resolveTerminalOpenURLTarget("/tmp/cmux-path.txt"))
switch target {
case let .external(url):
XCTAssertTrue(url.isFileURL)
XCTAssertEqual(url.path, "/tmp/cmux-path.txt")
default:
XCTFail("Expected absolute file path to open externally")
}
}
func testResolvesNonWebSchemeAsExternal() throws {
let target = try XCTUnwrap(resolveTerminalOpenURLTarget("mailto:test@example.com"))
switch target {
case let .external(url):
XCTAssertEqual(url.scheme, "mailto")
default:
XCTFail("Expected non-web scheme to open externally")
}
}
func testResolvesHostlessHTTPSAsExternal() throws {
let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https:///tmp/cmux.txt"))
switch target {
case let .external(url):
XCTAssertEqual(url.scheme, "https")
XCTAssertNil(url.host)
XCTAssertEqual(url.path, "/tmp/cmux.txt")
default:
XCTFail("Expected hostless HTTPS URL to open externally")
}
}
}
final class BrowserNavigableURLResolutionTests: XCTestCase {
func testResolvesFileSchemeAsNavigableURL() throws {
let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html"))
XCTAssertTrue(resolved.isFileURL)
XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html")
}
func testRejectsNonWebNonFileScheme() {
XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com"))
XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html"))
}
func testRejectsHostOnlyFileURL() {
XCTAssertNil(resolveBrowserNavigableURL("file://example.html"))
}
}
final class BrowserReadAccessURLTests: XCTestCase {
func testUsesParentDirectoryForFileURL() throws {
let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
let file = dir.appendingPathComponent("sample.html")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
try "<html></html>".write(to: file, atomically: true, encoding: .utf8)
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file))
XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
}
func testUsesDirectoryURLWhenTargetIsDirectory() throws {
let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir))
XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
}
func testUsesParentDirectoryWhenFileDoesNotExist() throws {
let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html")
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing))
XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL)
}
func testReturnsNilForHostOnlyFileURL() throws {
let hostOnly = try XCTUnwrap(URL(string: "file://example.html"))
XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly))
}
}
final class BrowserExternalNavigationSchemeTests: XCTestCase {
func testCustomAppSchemesOpenExternally() throws {
let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc"))
let slack = try XCTUnwrap(URL(string: "slack://open"))
let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join"))
let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com"))
XCTAssertTrue(browserShouldOpenURLExternally(discord))
XCTAssertTrue(browserShouldOpenURLExternally(slack))
XCTAssertTrue(browserShouldOpenURLExternally(zoom))
XCTAssertTrue(browserShouldOpenURLExternally(mailto))
}
func testEmbeddedBrowserSchemesStayInWebView() throws {
let https = try XCTUnwrap(URL(string: "https://example.com"))
let http = try XCTUnwrap(URL(string: "http://example.com"))
let about = try XCTUnwrap(URL(string: "about:blank"))
let data = try XCTUnwrap(URL(string: "data:text/plain,hello"))
let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html"))
let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"))
let javascript = try XCTUnwrap(URL(string: "javascript:void(0)"))
let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page"))
XCTAssertFalse(browserShouldOpenURLExternally(https))
XCTAssertFalse(browserShouldOpenURLExternally(http))
XCTAssertFalse(browserShouldOpenURLExternally(about))
XCTAssertFalse(browserShouldOpenURLExternally(data))
XCTAssertFalse(browserShouldOpenURLExternally(file))
XCTAssertFalse(browserShouldOpenURLExternally(blob))
XCTAssertFalse(browserShouldOpenURLExternally(javascript))
XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal))
}
}
final class BrowserHostWhitelistTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!
override func setUp() {
super.setUp()
suiteName = "BrowserHostWhitelistTests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)
defaults.removePersistentDomain(forName: suiteName)
}
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
defaults = nil
suiteName = nil
super.tearDown()
}
func testEmptyWhitelistAllowsAll() {
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
}
func testExactMatch() {
defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
}
func testExactMatchIsCaseInsensitive() {
defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults))
}
func testWildcardSuffix() {
defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults))
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
}
func testWildcardIsCaseInsensitive() {
defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults))
}
func testBlankLinesAndWhitespaceIgnored() {
defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
}
func testMixedExactAndWildcard() {
defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults))
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults))
}
func testDefaultWhitelistIsEmpty() {
let patterns = BrowserLinkOpenSettings.hostWhitelist(defaults: defaults)
XCTAssertTrue(patterns.isEmpty)
}
func testWildcardRequiresDotBoundary() {
defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults))
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults))
}
func testWhitelistNormalizesSchemesPortsAndTrailingDots() {
defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults))
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults))
}
func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() {
defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults))
}
func testUnicodeWhitelistEntryMatchesPunycodeHost() {
defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey)
XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults))
}
}
final class TerminalControllerSidebarDedupeTests: XCTestCase {
func testShouldReplaceStatusEntryReturnsFalseForUnchangedPayload() {
let current = SidebarStatusEntry(
key: "agent",
value: "idle",
icon: "bolt",
color: "#ffffff",
timestamp: Date(timeIntervalSince1970: 123)
)
XCTAssertFalse(
TerminalController.shouldReplaceStatusEntry(
current: current,
key: "agent",
value: "idle",
icon: "bolt",
color: "#ffffff",
url: nil,
priority: 0,
format: .plain
)
)
}
func testShouldReplaceStatusEntryReturnsTrueWhenValueChanges() {
let current = SidebarStatusEntry(
key: "agent",
value: "idle",
icon: "bolt",
color: "#ffffff",
timestamp: Date(timeIntervalSince1970: 123)
)
XCTAssertTrue(
TerminalController.shouldReplaceStatusEntry(
current: current,
key: "agent",
value: "running",
icon: "bolt",
color: "#ffffff",
url: nil,
priority: 0,
format: .plain
)
)
}
func testShouldReplaceProgressReturnsFalseForUnchangedPayload() {
XCTAssertFalse(
TerminalController.shouldReplaceProgress(
current: SidebarProgressState(value: 0.42, label: "indexing"),
value: 0.42,
label: "indexing"
)
)
}
func testShouldReplaceGitBranchReturnsFalseForUnchangedPayload() {
XCTAssertFalse(
TerminalController.shouldReplaceGitBranch(
current: SidebarGitBranchState(branch: "main", isDirty: true),
branch: "main",
isDirty: true
)
)
}
func testShouldReplacePortsIgnoresOrderAndDuplicates() {
XCTAssertFalse(
TerminalController.shouldReplacePorts(
current: [9229, 3000],
next: [3000, 9229, 3000]
)
)
XCTAssertTrue(
TerminalController.shouldReplacePorts(
current: [9229, 3000],
next: [3000]
)
)
}
func testExplicitSocketScopeParsesValidUUIDTabAndPanel() {
let workspaceId = UUID()
let panelId = UUID()
let scope = TerminalController.explicitSocketScope(
options: [
"tab": workspaceId.uuidString,
"panel": panelId.uuidString
]
)
XCTAssertEqual(scope?.workspaceId, workspaceId)
XCTAssertEqual(scope?.panelId, panelId)
}
func testExplicitSocketScopeAcceptsSurfaceAlias() {
let workspaceId = UUID()
let panelId = UUID()
let scope = TerminalController.explicitSocketScope(
options: [
"tab": workspaceId.uuidString,
"surface": panelId.uuidString
]
)
XCTAssertEqual(scope?.workspaceId, workspaceId)
XCTAssertEqual(scope?.panelId, panelId)
}
func testExplicitSocketScopeRejectsMissingOrInvalidValues() {
XCTAssertNil(TerminalController.explicitSocketScope(options: [:]))
XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": "workspace:1", "panel": UUID().uuidString]))
XCTAssertNil(TerminalController.explicitSocketScope(options: ["tab": UUID().uuidString, "panel": "surface:1"]))
}
func testNormalizeReportedDirectoryTrimsWhitespace() {
XCTAssertEqual(
TerminalController.normalizeReportedDirectory(" /Users/cmux/project "),
"/Users/cmux/project"
)
}
func testNormalizeReportedDirectoryResolvesFileURL() {
XCTAssertEqual(
TerminalController.normalizeReportedDirectory("file:///Users/cmux/project"),
"/Users/cmux/project"
)
}
func testNormalizeReportedDirectoryLeavesInvalidURLTrimmed() {
XCTAssertEqual(
TerminalController.normalizeReportedDirectory(" file://bad host "),
"file://bad host"
)
}
}
final class TerminalControllerSocketTextChunkTests: XCTestCase {
func testSocketTextChunksReturnsSingleChunkForPlainText() {
XCTAssertEqual(
TerminalController.socketTextChunks("echo hello"),
[.text("echo hello")]
)
}
func testSocketTextChunksSplitsControlScalars() {
XCTAssertEqual(
TerminalController.socketTextChunks("abc\rdef\tghi"),
[
.text("abc"),
.control("\r".unicodeScalars.first!),
.text("def"),
.control("\t".unicodeScalars.first!),
.text("ghi")
]
)
}
func testSocketTextChunksDoesNotEmitEmptyTextChunksAroundConsecutiveControls() {
XCTAssertEqual(
TerminalController.socketTextChunks("\r\n\t"),
[
.control("\r".unicodeScalars.first!),
.control("\n".unicodeScalars.first!),
.control("\t".unicodeScalars.first!)
]
)
}
}
final class BrowserOmnibarFocusPolicyTests: XCTestCase {
func testReacquiresFocusWhenWebViewSuppressionIsActiveAndNextResponderIsNotAnotherTextField() {
XCTAssertTrue(
browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: true,
nextResponderIsOtherTextField: false
)
)
}
func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() {
XCTAssertFalse(
browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: true,
nextResponderIsOtherTextField: true
)
)
}
func testDoesNotReacquireFocusWhenWebViewSuppressionIsInactive() {
XCTAssertFalse(
browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: false,
nextResponderIsOtherTextField: false
)
)
}
}
final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
func testImmediateStateUpdateAllowedWhenHostNotInWindow() {
XCTAssertTrue(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
hostedViewHasSuperview: true,
isBoundToCurrentHost: false
)
)
}
func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() {
XCTAssertTrue(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
hostedViewHasSuperview: true,
isBoundToCurrentHost: true
)
)
}
func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() {
XCTAssertFalse(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
hostedViewHasSuperview: true,
isBoundToCurrentHost: false
)
)
}
func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() {
XCTAssertTrue(
GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate(
hostedViewHasSuperview: false,
isBoundToCurrentHost: false
)
)
}
}
final class TerminalControllerSocketListenerHealthTests: XCTestCase {
private func makeTempSocketPath() -> String {
"/tmp/cmux-socket-health-\(UUID().uuidString).sock"
}
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)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strcpy(pathBuf, ptr)
}
}
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
}
func testSocketListenerHealthRecognizesSocketPath() throws {
let path = makeTempSocketPath()
let fd = try bindUnixSocket(at: path)
defer {
Darwin.close(fd)
unlink(path)
}
let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path)
XCTAssertTrue(health.socketPathExists)
XCTAssertFalse(health.isHealthy)
}
func testSocketListenerHealthRejectsRegularFile() throws {
let path = makeTempSocketPath()
let url = URL(fileURLWithPath: path)
try "not-a-socket".write(to: url, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: url) }
let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: path)
XCTAssertFalse(health.socketPathExists)
XCTAssertFalse(health.isHealthy)
}
func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() {
let health = TerminalController.SocketListenerHealth(
isRunning: true,
acceptLoopAlive: true,
socketPathMatches: true,
socketPathExists: true
)
XCTAssertTrue(health.isHealthy)
XCTAssertEqual(health.failureSignals, [])
}
func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() {
let health = TerminalController.SocketListenerHealth(
isRunning: false,
acceptLoopAlive: false,
socketPathMatches: false,
socketPathExists: false
)
XCTAssertFalse(health.isHealthy)
XCTAssertEqual(
health.failureSignals,
["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"]
)
}
}