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 WindowCyclingActionSpy: NSObject { weak var firstWindow: NSWindow? weak var secondWindow: NSWindow? private(set) var invocationCount = 0 @objc func cycleWindow(_ sender: Any?) { invocationCount += 1 guard let firstWindow, let secondWindow else { return } if NSApp.keyWindow === firstWindow { secondWindow.makeKeyAndOrderFront(nil) } else { firstWindow.makeKeyAndOrderFront(nil) } } } 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 testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() { _ = 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 contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) window.contentView = contentView window.makeKeyAndOrderFront(nil) defer { AppDelegate.clearWindowFirstResponderGuardTesting() window.orderOut(nil) } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let slot = WindowBrowserSlotView(frame: host.bounds) slot.autoresizingMask = [.width, .height] host.addSubview(slot) let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration()) webView.autoresizingMask = [.width, .height] slot.addSubview(webView) let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height)) inspector.autoresizingMask = [.minXMargin, .height] slot.addSubview(inspector) webView.allowsFirstResponderAcquisition = false _ = window.makeFirstResponder(nil) XCTAssertFalse( window.makeFirstResponder(inspector), "Expected portal-hosted inspector focus to stay blocked without pointer click context" ) let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) let pointInWindow = inspector.convert(pointInInspector, to: nil) let pointerDownEvent = NSEvent.mouseEvent( with: .leftMouseDown, location: pointInWindow, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, 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(inspector), "Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target" ) } @MainActor func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() { _ = 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 contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) window.contentView = contentView let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260)) contentView.addSubview(anchor) let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) window.makeKeyAndOrderFront(nil) contentView.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) } guard let slot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected bound portal slot") return } let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height)) inspector.autoresizingMask = [.minXMargin, .height] slot.addSubview(inspector) webView.allowsFirstResponderAcquisition = false _ = window.makeFirstResponder(nil) XCTAssertFalse( window.makeFirstResponder(inspector), "Expected bound portal inspector focus to stay blocked without pointer click context" ) let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY) let pointInWindow = inspector.convert(pointInInspector, to: nil) XCTAssertTrue( BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView, "Expected portal registry to resolve the owning web view from a click inside inspector chrome" ) let pointerDownEvent = NSEvent.mouseEvent( with: .leftMouseDown, location: pointInWindow, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, 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(inspector), "Expected bound portal inspector click to bypass blocked policy through portal registry fallback" ) } @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)) } @MainActor func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() { _ = NSApplication.shared AppDelegate.installWindowResponderSwizzlesForTesting() let firstWindow = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), styleMask: [.titled, .closable], backing: .buffered, defer: false ) let secondWindow = NSWindow( contentRect: NSRect(x: 40, y: 40, width: 640, height: 420), styleMask: [.titled, .closable], backing: .buffered, defer: false ) let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame)) let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame)) firstWindow.contentView = firstContainer secondWindow.contentView = secondContainer let firstTerminal = GhosttyNSView(frame: firstContainer.bounds) firstTerminal.autoresizingMask = [.width, .height] firstContainer.addSubview(firstTerminal) let secondTerminal = GhosttyNSView(frame: secondContainer.bounds) secondTerminal.autoresizingMask = [.width, .height] secondContainer.addSubview(secondTerminal) let spy = WindowCyclingActionSpy() spy.firstWindow = firstWindow spy.secondWindow = secondWindow installMenu( target: spy, action: #selector(WindowCyclingActionSpy.cycleWindow(_:)), key: "`", modifiers: [.command] ) secondWindow.orderFront(nil) firstWindow.makeKeyAndOrderFront(nil) defer { secondWindow.orderOut(nil) firstWindow.orderOut(nil) } XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal)) guard let event = makeKeyDownEvent( key: "`", modifiers: [.command], keyCode: 50, windowNumber: firstWindow.windowNumber ) else { XCTFail("Failed to construct Cmd+` event") return } NSApp.sendEvent(event) RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action") } @MainActor func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() { _ = 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) let spy = ActionSpy() installMenu( target: spy, action: #selector(ActionSpy.didInvoke(_:)), key: "`", modifiers: [.command] ) window.makeKeyAndOrderFront(nil) defer { window.orderOut(nil) } XCTAssertTrue(window.makeFirstResponder(webView)) guard let event = makeKeyDownEvent( key: "`", modifiers: [.command], keyCode: 50, windowNumber: window.windowNumber ) else { XCTFail("Failed to construct Cmd+` event") return } XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event)) _ = webView.performKeyEquivalent(with: event) XCTAssertFalse( spy.invoked, "CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder" ) } private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { installMenu( target: spy, action: #selector(ActionSpy.didInvoke(_:)), key: key, modifiers: modifiers ) } private func installMenu( target: NSObject, action: Selector, 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: action, keyEquivalent: key) item.keyEquivalentModifierMask = modifiers item.target = target 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, windowNumber: Int = 0 ) -> NSEvent? { NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: modifiers, timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: windowNumber, 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) } func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() { _ = 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() ) windowA.makeKeyAndOrderFront(nil) _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) XCTAssertTrue(app.tabManager === managerA) let originalSelectedA = managerA.selectedTabId let originalSelectedB = managerB.selectedTabId let originalTabCountB = managerB.tabs.count let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false) XCTAssertNotNil(createdWorkspaceId) XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing") XCTAssertEqual(managerA.selectedTabId, originalSelectedA) XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab") XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1) XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId })) } } @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) } func testResetSidebarContextClearsBrowserPanelsIntoNewTabState() throws { let workspace = Workspace() let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) let contextPanelId = try XCTUnwrap(workspace.focusedPanelId) let browser = try XCTUnwrap( workspace.newBrowserSurface( inPane: paneId, url: URL(string: "https://example.com"), focus: false ) ) browser.restoreSessionNavigationHistory( backHistoryURLStrings: ["https://example.com/prev"], forwardHistoryURLStrings: ["https://example.com/next"], currentURLString: "https://example.com/current" ) browser.startFind() workspace.statusEntries["task"] = SidebarStatusEntry(key: "task", value: "Issue #1208") workspace.metadataBlocks["notes"] = SidebarMetadataBlock( key: "notes", markdown: "test", priority: 0, timestamp: Date() ) workspace.progress = SidebarProgressState(value: 0.5, label: "Loading") workspace.updatePanelGitBranch(panelId: contextPanelId, branch: "issue-1208", isDirty: false) workspace.updatePanelPullRequest( panelId: contextPanelId, number: 1208, label: "PR", url: try XCTUnwrap(URL(string: "https://example.com/pull/1208")), status: .open ) workspace.logEntries.append( SidebarLogEntry( message: "Issue #1208", level: .info, source: "test", timestamp: Date() ) ) workspace.surfaceListeningPorts[contextPanelId] = [3000] workspace.recomputeListeningPorts() XCTAssertTrue(browser.shouldRenderWebView) XCTAssertNotNil(browser.preferredURLStringForOmnibar()) XCTAssertTrue(browser.canGoBack) XCTAssertTrue(browser.canGoForward) XCTAssertNotNil(browser.searchState) XCTAssertFalse(workspace.statusEntries.isEmpty) XCTAssertFalse(workspace.logEntries.isEmpty) XCTAssertFalse(workspace.metadataBlocks.isEmpty) XCTAssertNotNil(workspace.progress) XCTAssertNotNil(workspace.gitBranch) XCTAssertNotNil(workspace.pullRequest) XCTAssertEqual(workspace.listeningPorts, [3000]) let priorWebView = browser.webView let priorInstanceID = browser.webViewInstanceID workspace.resetSidebarContext(reason: "test") XCTAssertTrue(workspace.statusEntries.isEmpty) XCTAssertTrue(workspace.logEntries.isEmpty) XCTAssertTrue(workspace.metadataBlocks.isEmpty) XCTAssertNil(workspace.progress) XCTAssertNil(workspace.gitBranch) XCTAssertTrue(workspace.panelGitBranches.isEmpty) XCTAssertNil(workspace.pullRequest) XCTAssertTrue(workspace.panelPullRequests.isEmpty) XCTAssertTrue(workspace.surfaceListeningPorts.isEmpty) XCTAssertTrue(workspace.listeningPorts.isEmpty) XCTAssertFalse(browser.shouldRenderWebView) XCTAssertNil(browser.preferredURLStringForOmnibar()) XCTAssertFalse(browser.canGoBack) XCTAssertFalse(browser.canGoForward) XCTAssertNil(browser.searchState) XCTAssertFalse(browser.webView === priorWebView) XCTAssertNotEqual(browser.webViewInstanceID, priorInstanceID) } } @MainActor final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { private final class WKInspectorProbeView: NSView { override var acceptsFirstResponder: Bool { true } } private final class FakeInspector: NSObject { enum HideBehavior { case unsupported case noEffect case hides } private(set) var attachCount = 0 private(set) var showCount = 0 private(set) var hideCount = 0 private(set) var closeCount = 0 private let hideBehavior: HideBehavior private var visible = false private var attached = false init(hideBehavior: HideBehavior = .unsupported) { self.hideBehavior = hideBehavior super.init() } override func responds(to aSelector: Selector!) -> Bool { guard NSStringFromSelector(aSelector) == "hide" else { return super.responds(to: aSelector) } return hideBehavior != .unsupported } @objc func isVisible() -> Bool { visible } @objc func isAttached() -> Bool { attached } @objc func attach() { attachCount += 1 attached = true show() } @objc func show() { showCount += 1 visible = true } @objc func hide() { hideCount += 1 guard hideBehavior == .hides else { return } visible = false } @objc func close() { closeCount += 1 visible = false attached = false } } override class func setUp() { super.setUp() installCmuxUnitTestInspectorOverride() } private func makePanelWithInspector( hideBehavior: FakeInspector.HideBehavior = .unsupported ) -> (BrowserPanel, FakeInspector) { let panel = BrowserPanel(workspaceId: UUID()) let inspector = FakeInspector(hideBehavior: hideBehavior) panel.webView.cmuxSetUnitTestInspector(inspector) return (panel, inspector) } private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? { if let host = root as? WebViewRepresentable.HostContainerView { return host } for subview in root.subviews { if let host = findHostContainerView(in: subview) { return host } } return nil } private func waitForDeveloperToolsTransitions() { RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? { if let slot = root as? WindowBrowserSlotView { return slot } for subview in root.subviews { if let slot = findWindowBrowserSlotView(in: subview) { return slot } } return nil } 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 testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() { let (panel, inspector) = makePanelWithInspector() XCTAssertTrue(panel.toggleDeveloperTools()) XCTAssertTrue(panel.toggleDeveloperTools()) XCTAssertTrue(panel.toggleDeveloperTools()) XCTAssertEqual(inspector.showCount, 1) XCTAssertEqual(inspector.closeCount, 0) waitForDeveloperToolsTransitions() XCTAssertTrue(panel.isDeveloperToolsVisible()) XCTAssertEqual(inspector.showCount, 1) XCTAssertEqual(inspector.closeCount, 0) } func testRapidToggleQueuesHideAfterOpenTransitionSettles() { let (panel, inspector) = makePanelWithInspector() XCTAssertTrue(panel.toggleDeveloperTools()) XCTAssertTrue(panel.toggleDeveloperTools()) XCTAssertEqual(inspector.showCount, 1) XCTAssertEqual(inspector.closeCount, 0) waitForDeveloperToolsTransitions() XCTAssertFalse(panel.isDeveloperToolsVisible()) XCTAssertEqual(inspector.showCount, 1) XCTAssertEqual(inspector.closeCount, 1) } func testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() { let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect) XCTAssertTrue(panel.showDeveloperTools()) XCTAssertTrue(panel.isDeveloperToolsVisible()) XCTAssertTrue(panel.toggleDeveloperTools()) XCTAssertEqual(inspector.hideCount, 1) XCTAssertEqual(inspector.closeCount, 1) XCTAssertFalse(panel.isDeveloperToolsVisible()) } func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { let (panel, _) = makePanelWithInspector() XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) XCTAssertTrue(panel.showDeveloperTools()) XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) XCTAssertTrue(panel.hideDeveloperTools()) XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) } func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() { let (panel, _) = makePanelWithInspector() let paneId = PaneID(id: UUID()) 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, paneId: paneId, shouldAttachWebView: true, useLocalInlineHosting: false, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, paneDropZone: nil, searchOverlay: nil, paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) XCTAssertNotNil(panel.webView.superview) window.orderOut(nil) } func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() { let (panel, _) = makePanelWithInspector() let paneId = PaneID(id: UUID()) 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, paneId: paneId, shouldAttachWebView: true, useLocalInlineHosting: false, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, paneDropZone: nil, searchOverlay: nil, paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView WebViewRepresentable.dismantleNSView(anchor, coordinator: coordinator) XCTAssertNotNil(panel.webView.superview) window.orderOut(nil) } func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() { let (panel, _) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height) host.addSubview(panel.webView) let inspectorContainer = NSView( frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height) ) let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) host.addSubview(inspectorContainer) XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) } func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() { let (panel, _) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240)) panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80) host.addSubview(panel.webView) let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80)) let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) host.addSubview(inspectorContainer) XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) } func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() { let (panel, _) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) let paneId = PaneID(id: UUID()) let representable = WebViewRepresentable( panel: panel, paneId: paneId, shouldAttachWebView: false, useLocalInlineHosting: true, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, paneDropZone: nil, searchOverlay: nil, paneTopChromeHeight: 0 ) 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 visibleHosting = NSHostingView(rootView: representable) visibleHosting.frame = contentView.bounds visibleHosting.autoresizingMask = [.width, .height] contentView.addSubview(visibleHosting) window.makeKeyAndOrderFront(nil) window.displayIfNeeded() contentView.layoutSubtreeIfNeeded() visibleHosting.layoutSubtreeIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) guard let visibleHost = findHostContainerView(in: visibleHosting) else { XCTFail("Expected visible local host") return } guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else { XCTFail("Expected visible local inline slot") return } let inspectorView = WKInspectorProbeView( frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72) ) inspectorView.autoresizingMask = [.width] visibleSlot.addSubview(inspectorView) panel.webView.frame = NSRect( x: 0, y: inspectorView.frame.maxY, width: visibleSlot.bounds.width, height: visibleSlot.bounds.height - inspectorView.frame.height ) visibleSlot.layoutSubtreeIfNeeded() let detachedRoot = NSView(frame: visibleHosting.frame) let offWindowHosting = NSHostingView(rootView: representable) offWindowHosting.frame = detachedRoot.bounds offWindowHosting.autoresizingMask = [.width, .height] detachedRoot.addSubview(offWindowHosting) detachedRoot.layoutSubtreeIfNeeded() offWindowHosting.layoutSubtreeIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host") XCTAssertTrue(visibleHost.window === window) XCTAssertTrue( panel.webView.superview === visibleSlot, "An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn" ) XCTAssertTrue( inspectorView.superview === visibleSlot, "An off-window replacement host should leave DevTools companion views in the visible local host" ) } func testVisibleReplacementLocalHostNormalizesBottomDockedInspectorFrames() { let (panel, _) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) let paneId = PaneID(id: UUID()) let representable = WebViewRepresentable( panel: panel, paneId: paneId, shouldAttachWebView: false, useLocalInlineHosting: true, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, paneDropZone: nil, searchOverlay: nil, paneTopChromeHeight: 0 ) 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 narrowHosting = NSHostingView(rootView: representable) narrowHosting.frame = NSRect(x: 180, y: 0, width: 180, height: 240) contentView.addSubview(narrowHosting) window.makeKeyAndOrderFront(nil) window.displayIfNeeded() contentView.layoutSubtreeIfNeeded() narrowHosting.layoutSubtreeIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) guard let initialSlot = panel.webView.superview as? WindowBrowserSlotView else { XCTFail("Expected initial local inline slot") return } let inspectorView = WKInspectorProbeView( frame: NSRect(x: 0, y: 0, width: initialSlot.bounds.width, height: 72) ) inspectorView.autoresizingMask = [.width] initialSlot.addSubview(inspectorView) panel.webView.frame = NSRect( x: 0, y: inspectorView.frame.maxY, width: initialSlot.bounds.width, height: initialSlot.bounds.height - inspectorView.frame.height ) initialSlot.layoutSubtreeIfNeeded() let replacementHosting = NSHostingView(rootView: representable) replacementHosting.frame = contentView.bounds replacementHosting.autoresizingMask = [.width, .height] contentView.addSubview(replacementHosting, positioned: .above, relativeTo: narrowHosting) contentView.layoutSubtreeIfNeeded() replacementHosting.layoutSubtreeIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) replacementHosting.rootView = representable contentView.layoutSubtreeIfNeeded() replacementHosting.layoutSubtreeIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) narrowHosting.removeFromSuperview() contentView.layoutSubtreeIfNeeded() replacementHosting.layoutSubtreeIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) guard let replacementHost = findHostContainerView(in: replacementHosting), let replacementSlot = findWindowBrowserSlotView(in: replacementHost) else { XCTFail("Expected replacement local inline host") return } XCTAssertTrue( panel.webView.superview === replacementSlot, "A visible replacement local host should take over the hosted page" ) XCTAssertTrue( inspectorView.superview === replacementSlot, "A visible replacement local host should move the DevTools companion views with the page" ) XCTAssertEqual(inspectorView.frame.minX, 0, accuracy: 0.5) XCTAssertEqual(inspectorView.frame.minY, 0, accuracy: 0.5) XCTAssertEqual(inspectorView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) XCTAssertEqual(inspectorView.frame.height, 72, accuracy: 0.5) XCTAssertEqual(panel.webView.frame.minX, 0, accuracy: 0.5) XCTAssertEqual(panel.webView.frame.minY, 72, accuracy: 0.5) XCTAssertEqual(panel.webView.frame.width, replacementSlot.bounds.width, accuracy: 0.5) XCTAssertEqual(panel.webView.frame.height, replacementSlot.bounds.height - 72, accuracy: 0.5) } } 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, layoutCharacterProvider: { _, _ in nil } ) ) } func testDoesNotFallbackToANSIWhenLayoutTranslationReturnsNonFCharacter() { XCTAssertFalse( shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: [.command, .control], chars: "", keyCode: 3, layoutCharacterProvider: { _, _ in "u" } ) ) } func testMatchesCommandControlFWhenCommandAwareLayoutTranslationProvidesF() { XCTAssertTrue( shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: [.command, .control], chars: "", keyCode: 3, layoutCharacterProvider: { _, modifierFlags in modifierFlags.contains(.command) ? "f" : "u" } ) ) } func testMatchesCommandControlFWhenCharsAreControlSequence() { XCTAssertTrue( shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: [.command, .control], chars: "\u{06}", keyCode: 3, layoutCharacterProvider: { _, _ in nil } ) ) } func testRejectsPhysicalFWhenCharacterRepresentsDifferentLayoutKey() { XCTAssertFalse( shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: [.command, .control], chars: "u", 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 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 ShortcutHintModifierPolicyTests: XCTestCase { func testShortcutHintRequiresEnabledCommandOnlyModifier() { withDefaultsSuite { defaults in defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .shift], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .shift], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .option], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control, .option], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command, .control], defaults: defaults)) } } func testCommandHintCanBeDisabledInSettings() { withDefaultsSuite { defaults in defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) } } func testCommandHintDefaultsToEnabledWhenSettingMissing() { withDefaultsSuite { defaults in defaults.removeObject(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) } } func testShortcutHintUsesIntentionalHoldDelay() { XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001) } func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() { XCTAssertTrue( ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: 42, keyWindowNumber: 42 ) ) XCTAssertFalse( ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: 7, keyWindowNumber: 42 ) ) XCTAssertFalse( ShortcutHintModifierPolicy.isCurrentWindow( hostWindowNumber: 42, hostWindowIsKey: false, eventWindowNumber: 42, keyWindowNumber: 42 ) ) } func testWindowScopedShortcutHintsUseKeyWindowWhenNoEventWindowIsAvailable() { withDefaultsSuite { defaults in defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) XCTAssertTrue( ShortcutHintModifierPolicy.shouldShowHints( for: [.command], hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: nil, keyWindowNumber: 42, defaults: defaults ) ) XCTAssertFalse( ShortcutHintModifierPolicy.shouldShowHints( for: [.command], hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: nil, keyWindowNumber: 7, defaults: defaults ) ) XCTAssertTrue( ShortcutHintModifierPolicy.shouldShowHints( for: [.command], hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: nil, keyWindowNumber: 42, defaults: defaults ) ) XCTAssertFalse( ShortcutHintModifierPolicy.shouldShowHints( for: [.control], hostWindowNumber: 42, hostWindowIsKey: true, eventWindowNumber: nil, keyWindowNumber: 42, defaults: defaults ) ) } } private func withDefaultsSuite(_ body: (UserDefaults) -> Void) { let suiteName = "ShortcutHintModifierPolicyTests-\(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)) } func testResetVisibilityDefaultsRestoresAlwaysShowAndCommandHoldFlags() { 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.set(true, forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) defaults.set(false, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) ShortcutHintDebugSettings.resetVisibilityDefaults(defaults: defaults) XCTAssertEqual( defaults.object(forKey: ShortcutHintDebugSettings.alwaysShowHintsKey) as? Bool, ShortcutHintDebugSettings.defaultAlwaysShowHints ) XCTAssertEqual( defaults.object(forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) as? Bool, ShortcutHintDebugSettings.defaultShowHintsOnCommandHold ) } } final class DevBuildBannerDebugSettingsTests: XCTestCase { func testShowSidebarBannerDefaultsToVisible() { let suiteName = "DevBuildBannerDebugSettingsTests.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: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) } func testShowSidebarBannerRespectsStoredValue() { let suiteName = "DevBuildBannerDebugSettingsTests.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: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) XCTAssertFalse(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) defaults.set(true, forKey: DevBuildBannerDebugSettings.sidebarBannerVisibleKey) XCTAssertTrue(DevBuildBannerDebugSettings.showSidebarBanner(defaults: defaults)) } } final class ShortcutHintLanePlannerTests: XCTestCase { func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() { let intervals: [ClosedRange] = [0...20, 28...40, 48...64] XCTAssertEqual(ShortcutHintLanePlanner.assignLanes(for: intervals, minSpacing: 4), [0, 0, 0]) } func testAssignLanesStacksOverlappingIntervalsIntoAdditionalLanes() { let intervals: [ClosedRange] = [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] = [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] = [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 WorkspaceNotificationReorderTests: XCTestCase { func testNotificationAutoReorderDoesNotMovePinnedWorkspace() { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() let notificationStore = TerminalNotificationStore.shared let originalTabManager = appDelegate.tabManager let originalNotificationStore = appDelegate.notificationStore let defaults = UserDefaults.standard let originalAutoReorderSetting = defaults.object(forKey: WorkspaceAutoReorderSettings.key) let originalAppFocusOverride = AppFocusState.overrideIsFocused notificationStore.replaceNotificationsForTesting([]) notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in } appDelegate.tabManager = manager appDelegate.notificationStore = notificationStore defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) AppFocusState.overrideIsFocused = false defer { notificationStore.replaceNotificationsForTesting([]) notificationStore.resetNotificationDeliveryHandlerForTesting() appDelegate.tabManager = originalTabManager appDelegate.notificationStore = originalNotificationStore AppFocusState.overrideIsFocused = originalAppFocusOverride if let originalAutoReorderSetting { defaults.set(originalAutoReorderSetting, forKey: WorkspaceAutoReorderSettings.key) } else { defaults.removeObject(forKey: WorkspaceAutoReorderSettings.key) } } let firstPinned = manager.tabs[0] manager.setPinned(firstPinned, pinned: true) let secondPinned = manager.addWorkspace() manager.setPinned(secondPinned, pinned: true) let unpinned = manager.addWorkspace() let expectedOrder = [firstPinned.id, secondPinned.id, unpinned.id] notificationStore.addNotification( tabId: secondPinned.id, surfaceId: nil, title: "Build finished", subtitle: "", body: "Pinned workspaces should stay put" ) XCTAssertEqual(manager.tabs.map(\.id), expectedOrder) } } @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 WorkspaceTerminalFocusRecoveryTests: XCTestCase { private func makeWindow() -> NSWindow { NSWindow( contentRect: NSRect(x: 0, y: 0, width: 360, height: 220), styleMask: [.titled, .closable], backing: .buffered, defer: false ) } 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 } private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { var stack: [NSView] = [hostedView] while let current = stack.popLast() { if let surfaceView = current as? GhosttyNSView { return surfaceView } stack.append(contentsOf: current.subviews) } return nil } func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() { let workspace = Workspace() guard let leftPanelId = workspace.focusedPanelId, let leftPanel = workspace.terminalPanel(for: leftPanelId), let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } XCTAssertEqual( workspace.focusedPanelId, rightPanel.id, "Expected the new split panel to be selected before simulating stale focus state" ) // Simulate the split-pane failure mode: Bonsplit already points at the right panel, // but the active terminal state is still stale on the left panel. leftPanel.surface.setFocus(true) leftPanel.hostedView.setActive(true) rightPanel.surface.setFocus(false) rightPanel.hostedView.setActive(false) workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder) XCTAssertFalse( leftPanel.hostedView.debugRenderStats().isActive, "Expected stale left-pane active state to be cleared" ) XCTAssertTrue( rightPanel.hostedView.debugRenderStats().isActive, "Expected terminal-first-responder recovery to reactivate the selected split pane" ) } func testTerminalClickRecoversSplitActiveStateWhenFocusCallbackIsSuppressed() { let workspace = Workspace() guard let leftPanelId = workspace.focusedPanelId, let leftPanel = workspace.terminalPanel(for: leftPanelId), let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { XCTFail("Expected split terminal panels") return } let window = makeWindow() defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220) rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220) contentView.addSubview(leftPanel.hostedView) contentView.addSubview(rightPanel.hostedView) leftPanel.hostedView.setVisibleInUI(true) rightPanel.hostedView.setVisibleInUI(true) leftPanel.hostedView.setFocusHandler { workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder) } rightPanel.hostedView.setFocusHandler { workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder) } window.makeKeyAndOrderFront(nil) window.displayIfNeeded() contentView.layoutSubtreeIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertEqual( workspace.focusedPanelId, rightPanel.id, "Expected the clicked split pane to already be selected before simulating stale focus state" ) // Simulate the ghost-terminal race: the right pane is selected in Bonsplit, but stale // active state remains on the left and the right pane's AppKit focus callback never fires // after split reparent/layout churn. leftPanel.surface.setFocus(true) leftPanel.hostedView.setActive(true) rightPanel.surface.setFocus(false) rightPanel.hostedView.setActive(false) rightPanel.hostedView.suppressReparentFocus() guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else { XCTFail("Expected right terminal surface view") return } let pointInWindow = rightSurfaceView.convert(NSPoint(x: 24, y: 24), to: nil) let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) rightSurfaceView.mouseDown(with: event) RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertFalse( leftPanel.hostedView.debugRenderStats().isActive, "Expected clicking the selected split pane to clear stale sibling active state even when AppKit focus callbacks are suppressed" ) XCTAssertTrue( rightPanel.hostedView.debugRenderStats().isActive, "Expected clicking the selected split pane to reactivate terminal input when focus callbacks are suppressed" ) XCTAssertTrue( rightPanel.hostedView.isSurfaceViewFirstResponder(), "Expected the clicked split pane to become first responder" ) } } @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? { 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, 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) } func testStopRemovesOrphanedConnectionTokenFiles() throws { let tokenFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: tokenFileURL) } try Data("token".utf8).write(to: tokenFileURL) XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path)) let controller = VSCodeServeWebController.makeForTesting { _, _ in XCTFail("Expected no launch") return nil } controller.trackConnectionTokenFileForTesting(tokenFileURL) controller.stop() XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path)) } } 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)) } } @MainActor final class TerminalNotificationDirectInteractionTests: XCTestCase { private func makeWindow() -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), styleMask: [.titled, .closable], backing: .buffered, defer: false ) window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) return window } 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 } private func makeKeyEvent(characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent { guard let event = NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, characters: characters, charactersIgnoringModifiers: characters, isARepeat: false, keyCode: keyCode ) else { fatalError("Failed to create key event") } return event } private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? { hostedView.subviews .compactMap { $0 as? NSScrollView } .first? .documentView? .subviews .first } func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() let store = TerminalNotificationStore.shared let window = makeWindow() let originalTabManager = appDelegate.tabManager let originalNotificationStore = appDelegate.notificationStore let originalAppFocusOverride = AppFocusState.overrideIsFocused store.replaceNotificationsForTesting([]) store.configureNotificationDeliveryHandlerForTesting { _, _ in } appDelegate.tabManager = manager appDelegate.notificationStore = store defer { store.replaceNotificationsForTesting([]) store.resetNotificationDeliveryHandlerForTesting() appDelegate.tabManager = originalTabManager appDelegate.notificationStore = originalNotificationStore AppFocusState.overrideIsFocused = originalAppFocusOverride window.orderOut(nil) } guard let workspace = manager.selectedWorkspace, let terminalPanel = workspace.focusedTerminalPanel else { XCTFail("Expected an initial focused terminal panel") return } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let hostedView = terminalPanel.hostedView hostedView.frame = contentView.bounds hostedView.autoresizingMask = [.width, .height] contentView.addSubview(hostedView) contentView.layoutSubtreeIfNeeded() hostedView.layoutSubtreeIfNeeded() guard let surfaceView = surfaceView(in: hostedView) else { XCTFail("Expected terminal surface view") return } GhosttySurfaceScrollView.resetFlashCounts() AppFocusState.overrideIsFocused = true XCTAssertTrue(window.makeFirstResponder(surfaceView)) store.addNotification( tabId: workspace.id, surfaceId: terminalPanel.id, title: "Unread", subtitle: "", body: "" ) XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) AppFocusState.overrideIsFocused = true let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil) let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) surfaceView.mouseDown(with: event) let drained = expectation(description: "flash drained") DispatchQueue.main.async { drained.fulfill() } wait(for: [drained], timeout: 1.0) XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) } func testTerminalKeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() let store = TerminalNotificationStore.shared let window = makeWindow() let originalTabManager = appDelegate.tabManager let originalNotificationStore = appDelegate.notificationStore let originalAppFocusOverride = AppFocusState.overrideIsFocused store.replaceNotificationsForTesting([]) store.configureNotificationDeliveryHandlerForTesting { _, _ in } appDelegate.tabManager = manager appDelegate.notificationStore = store defer { store.replaceNotificationsForTesting([]) store.resetNotificationDeliveryHandlerForTesting() appDelegate.tabManager = originalTabManager appDelegate.notificationStore = originalNotificationStore AppFocusState.overrideIsFocused = originalAppFocusOverride window.orderOut(nil) } guard let workspace = manager.selectedWorkspace, let terminalPanel = workspace.focusedTerminalPanel else { XCTFail("Expected an initial focused terminal panel") return } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let hostedView = terminalPanel.hostedView hostedView.frame = contentView.bounds hostedView.autoresizingMask = [.width, .height] contentView.addSubview(hostedView) contentView.layoutSubtreeIfNeeded() hostedView.layoutSubtreeIfNeeded() guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else { XCTFail("Expected terminal surface view") return } GhosttySurfaceScrollView.resetFlashCounts() AppFocusState.overrideIsFocused = true XCTAssertTrue(window.makeFirstResponder(surfaceView)) store.addNotification( tabId: workspace.id, surfaceId: terminalPanel.id, title: "Unread", subtitle: "", body: "" ) XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) let event = makeKeyEvent(characters: "", keyCode: 122, window: window) surfaceView.keyDown(with: event) let drained = expectation(description: "flash drained") DispatchQueue.main.async { drained.fulfill() } wait(for: [drained], timeout: 1.0) XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) } } 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 PrimaryPageProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil } } private final class WKInspectorProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil } } private final class EdgeTransparentWKInspectorProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { let localPoint = convert(point, from: superview) guard bounds.contains(localPoint) else { return nil } return localPoint.x <= 12 ? nil : self } } private final class TrailingEdgeTransparentWKInspectorProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { let localPoint = convert(point, from: superview) guard bounds.contains(localPoint) else { return nil } return localPoint.x >= bounds.maxX - 12 ? nil : self } } private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {} 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 } private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool { guard let hit else { return false } if hit === pageView || hit.isDescendant(of: pageView) { return false } if hit === inspectorView || hit.isDescendant(of: inspectorView) { return true } return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit)) } 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() guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] let child = CapturingView(frame: host.bounds) child.autoresizingMask = [.width, .height] host.addSubview(child) container.addSubview(host, positioned: .above, relativeTo: contentView) 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) } func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let appSplit = NSSplitView(frame: contentView.bounds) appSplit.autoresizingMask = [.width, .height] appSplit.isVertical = true appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))) appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height))) contentView.addSubview(appSplit) let inspectorSplit = NSSplitView(frame: host.bounds) inspectorSplit.autoresizingMask = [.width, .height] inspectorSplit.isVertical = true inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))) inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height))) host.addSubview(inspectorSplit) XCTAssertTrue( WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( appSplit, window: window, hostView: host ), "App layout splits should still trigger browser portal geometry sync" ) XCTAssertFalse( WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( inspectorSplit, window: window, hostView: host ), "Hosted DevTools/internal splits should not trigger browser portal geometry sync" ) } func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() { XCTAssertTrue( WindowBrowserHostView.shouldPassThroughToDragTargets( pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], eventType: .cursorUpdate ) ) XCTAssertTrue( WindowBrowserHostView.shouldPassThroughToDragTargets( pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], eventType: .mouseEntered ) ) } func testDragHoverEventsPassThroughForSidebarReorderWithoutMouseButtonState() { XCTAssertTrue( WindowBrowserHostView.shouldPassThroughToDragTargets( pasteboardTypes: [DragOverlayRoutingPolicy.sidebarTabReorderType], eventType: .cursorUpdate ) ) } func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() { XCTAssertFalse( WindowBrowserHostView.shouldPassThroughToDragTargets( pasteboardTypes: [.fileURL], eventType: .cursorUpdate ) ) } func testHostViewKeepsHostedInspectorDividerInteractive() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } // Underlying app layout split that should still be pass-through. let appSplit = NSSplitView(frame: contentView.bounds) appSplit.autoresizingMask = [.width, .height] appSplit.isVertical = true appSplit.dividerStyle = .thin let appSplitDelegate = BonsplitMockSplitDelegate() appSplit.delegate = appSplitDelegate let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height)) let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height)) appSplit.addSubview(leading) appSplit.addSubview(trailing) contentView.addSubview(appSplit) appSplit.adjustSubviews() let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) // WebKit inspector uses an internal split (page + console). Divider drags // here must stay in hosted content, not pass through to appSplit behind it. let inspectorSplit = NSSplitView(frame: host.bounds) inspectorSplit.autoresizingMask = [.width, .height] inspectorSplit.isVertical = false inspectorSplit.dividerStyle = .thin let inspectorDelegate = BonsplitMockSplitDelegate() inspectorSplit.delegate = inspectorDelegate let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160)) let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99)) inspectorSplit.addSubview(pageView) inspectorSplit.addSubview(consoleView) host.addSubview(inspectorSplit) inspectorSplit.setPosition(160, ofDividerAt: 0) inspectorSplit.adjustSubviews() contentView.layoutSubtreeIfNeeded() let appDividerPointInSplit = NSPoint( x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5), y: appSplit.bounds.midY ) let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil) let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil) XCTAssertNil( host.hitTest(appDividerPointInHost), "Underlying app split divider should still pass through with a hosted inspector split present" ) let dividerPointInInspector = NSPoint( x: inspectorSplit.bounds.midX, y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5) ) let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil) let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) let hit = host.hitTest(dividerPointInHost) XCTAssertNotNil( hit, "Inspector divider should receive hit-testing in hosted content, not pass through" ) XCTAssertFalse(hit === host) if let hit { XCTAssertTrue( hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), "Expected hit to remain inside inspector split subtree" ) } } func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) slot.autoresizingMask = [.minXMargin, .height] host.addSubview(slot) let inspectorSplit = NSSplitView(frame: slot.bounds) inspectorSplit.autoresizingMask = [.width, .height] inspectorSplit.isVertical = true inspectorSplit.dividerStyle = .thin let inspectorDelegate = BonsplitMockSplitDelegate() inspectorSplit.delegate = inspectorDelegate let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height)) let inspectorView = CapturingView( frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height) ) inspectorSplit.addSubview(pageView) inspectorSplit.addSubview(inspectorView) slot.addSubview(inspectorSplit) inspectorSplit.setPosition(1, ofDividerAt: 0) inspectorSplit.adjustSubviews() contentView.layoutSubtreeIfNeeded() let dividerPointInSplit = NSPoint( x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5), y: inspectorSplit.bounds.midY ) let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil) let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5) XCTAssertTrue( abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide, "Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone" ) let hit = host.hitTest(dividerPointInHost) XCTAssertNotNil( hit, "Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge" ) XCTAssertFalse(hit === host) if let hit { XCTAssertTrue( hit === inspectorSplit || hit.isDescendant(of: inspectorSplit), "Expected hit to remain inside hosted inspector split subtree at the slot edge" ) } } func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) slot.autoresizingMask = [.minXMargin, .height] host.addSubview(slot) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) let inspectorView = WKInspectorProbeView( frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) ) slot.addSubview(pageView) slot.addSubview(inspectorView) contentView.layoutSubtreeIfNeeded() let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY) let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) let dividerHit = host.hitTest(dividerPointInHost) XCTAssertTrue( isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), "Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))" ) let interiorHit = host.hitTest(bodyPointInHost) XCTAssertTrue( isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), "Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" ) } func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) slot.autoresizingMask = [.minXMargin, .height] host.addSubview(slot) let wrapper = NSView(frame: slot.bounds) wrapper.autoresizingMask = [.width, .height] slot.addSubview(wrapper) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)) let inspectorContainer = NSView( frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height) ) let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) wrapper.addSubview(pageView) wrapper.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY) let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY) let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil) let bodyPointInHost = host.convert(bodyPointInWindow, from: nil) let dividerHit = host.hitTest(dividerPointInHost) XCTAssertTrue( isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), "Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))" ) let interiorHit = host.hitTest(bodyPointInHost) XCTAssertTrue( isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView), "Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))" ) } func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) slot.autoresizingMask = [.minXMargin, .height] host.addSubview(slot) let wrapper = NSView(frame: slot.bounds) wrapper.autoresizingMask = [.width, .height] slot.addSubview(wrapper) let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height) let originalInspectorFrame = NSRect( x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height ) let pageView = PrimaryPageProbeView(frame: originalPageFrame) let inspectorContainer = NSView(frame: originalInspectorFrame) let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) wrapper.addSubview(pageView) wrapper.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY) let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) host.mouseDown(with: down) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) let draggedPageWidth = pageView.frame.width let draggedInspectorMinX = inspectorContainer.frame.minX XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) pageView.frame = originalPageFrame inspectorContainer.frame = originalInspectorFrame slot.needsLayout = true slot.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) } func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) slot.autoresizingMask = [.minXMargin, .height] host.addSubview(slot) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)) let inspectorView = EdgeTransparentWKInspectorProbeView( frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) ) slot.addSubview(pageView) slot.addSubview(inspectorView) contentView.layoutSubtreeIfNeeded() let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) let dividerHit = host.hitTest(dividerPointInHost) XCTAssertTrue( dividerHit === host, "Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))" ) let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) host.mouseDown(with: down) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) XCTAssertGreaterThan(pageView.frame.width, 92) XCTAssertGreaterThan(inspectorView.frame.minX, 92) } func testHostViewFallsBackToManualHostedInspectorDragForLeftDockedInspector() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) slot.autoresizingMask = [.minXMargin, .height] host.addSubview(slot) let inspectorView = TrailingEdgeTransparentWKInspectorProbeView( frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height) ) let pageView = PrimaryPageProbeView( frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height) ) slot.addSubview(inspectorView) slot.addSubview(pageView) contentView.layoutSubtreeIfNeeded() let dividerPointInSlot = NSPoint(x: inspectorView.frame.maxX - 2, y: slot.bounds.midY) let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) XCTAssertTrue( host.hitTest(dividerPointInHost) === host, "Host should take the manual fallback path for a left-docked divider when the native edge is not hittable" ) let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) host.mouseDown(with: down) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) XCTAssertGreaterThan(inspectorView.frame.width, 92) XCTAssertGreaterThan(pageView.frame.minX, 92) } func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } guard let container = contentView.superview else { XCTFail("Expected content container") return } let hostFrame = container.convert(contentView.bounds, from: contentView) let host = WindowBrowserHostView(frame: hostFrame) host.autoresizingMask = [.width, .height] container.addSubview(host, positioned: .above, relativeTo: contentView) let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height)) slot.autoresizingMask = [.minXMargin, .height] host.addSubview(slot) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height)) let inspectorView = WKInspectorProbeView(frame: slot.bounds) slot.addSubview(pageView) slot.addSubview(inspectorView) contentView.layoutSubtreeIfNeeded() let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY) let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil) let dividerPointInHost = host.convert(dividerPointInWindow, from: nil) XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide) let dividerHit = host.hitTest(dividerPointInHost) XCTAssertTrue( isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView), "Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))" ) } } @MainActor final class BrowserPanelHostContainerViewTests: XCTestCase { private final class PrimaryPageProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil } } private final class TrackingInspectorFrontendWebView: WKWebView { private(set) var evaluatedJavaScript: [String] = [] @MainActor override func evaluateJavaScript( _ javaScriptString: String, completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil ) { evaluatedJavaScript.append(javaScriptString) completionHandler?(nil, nil) } } private final class WKInspectorProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil } } private final class EdgeTransparentWKInspectorProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { let localPoint = convert(point, from: superview) guard bounds.contains(localPoint) else { return nil } return localPoint.x <= 12 ? nil : self } } private final class TrailingEdgeTransparentWKInspectorProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { let localPoint = convert(point, from: superview) guard bounds.contains(localPoint) else { return nil } return localPoint.x >= bounds.maxX - 12 ? nil : self } } 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 testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let webViewRoot = NSView(frame: host.bounds) webViewRoot.autoresizingMask = [.width, .height] host.addSubview(webViewRoot) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) let inspectorContainer = NSView( frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) ) let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) webViewRoot.addSubview(pageView) webViewRoot.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY) let interiorHit = host.hitTest(bodyPointInHost) XCTAssertTrue( host.hitTest(dividerPointInHost) === host, "Browser panel host should claim the right-docked divider edge for the manual resize path" ) XCTAssertTrue( interiorHit == nil || interiorHit !== host, "Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))" ) } func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let webViewRoot = NSView(frame: host.bounds) webViewRoot.autoresizingMask = [.width, .height] host.addSubview(webViewRoot) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height)) let inspectorContainer = NSView(frame: webViewRoot.bounds) let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) webViewRoot.addSubview(pageView) webViewRoot.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) XCTAssertTrue( host.hitTest(dividerPointInHost) === host, "Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap" ) let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) host.mouseDown(with: down) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) XCTAssertGreaterThan(pageView.frame.width, 0) XCTAssertGreaterThan(inspectorContainer.frame.minX, 0) } func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let webViewRoot = NSView(frame: host.bounds) webViewRoot.autoresizingMask = [.width, .height] host.addSubview(webViewRoot) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40)) let inspectorContainer = EdgeTransparentWKInspectorProbeView( frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40) ) webViewRoot.addSubview(pageView) webViewRoot.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() XCTAssertTrue( host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, "The custom DevTools divider should remain draggable at the top edge of the browser pane" ) XCTAssertTrue( host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, "The custom DevTools divider should remain draggable at the bottom edge of the browser pane" ) } func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let webViewRoot = NSView(frame: host.bounds) webViewRoot.autoresizingMask = [.width, .height] host.addSubview(webViewRoot) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) let inspectorContainer = EdgeTransparentWKInspectorProbeView( frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) ) webViewRoot.addSubview(pageView) webViewRoot.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) XCTAssertTrue( host.hitTest(dividerPointInHost) === host, "Browser panel host should only take the manual fallback path when the divider edge is not natively hittable" ) let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) host.mouseDown(with: down) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) XCTAssertGreaterThan(pageView.frame.width, 92) XCTAssertGreaterThan(inspectorContainer.frame.minX, 92) } func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let webViewRoot = NSView(frame: host.bounds) webViewRoot.autoresizingMask = [.width, .height] host.addSubview(webViewRoot) let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) let inspectorContainer = EdgeTransparentWKInspectorProbeView( frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) ) webViewRoot.addSubview(pageView) webViewRoot.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) XCTAssertGreaterThanOrEqual( inspectorContainer.frame.width, 120, "Shrinking the DevTools pane should clamp to a recoverable minimum width" ) XCTAssertTrue( host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, "After clamping, the DevTools divider should still be draggable near the top edge" ) XCTAssertTrue( host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, "After clamping, the DevTools divider should still be draggable near the bottom edge" ) } func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let slotView = host.ensureLocalInlineSlotView() let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180)) let inspectorView = WKWebView( frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) ) slotView.addSubview(pageView) slotView.addSubview(inspectorView) host.pinHostedWebView(pageView, in: slotView) host.setHostedInspectorFrontendWebView(inspectorView) contentView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() XCTAssertTrue( host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path" ) XCTAssertTrue( pageView.superview === inspectorView.superview && pageView.superview !== slotView, "Promotion should move both hosted inspector siblings into the managed side-dock container" ) XCTAssertEqual( pageView.frame.height, host.bounds.height, accuracy: 0.5, "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider" ) XCTAssertEqual( inspectorView.frame.height, host.bounds.height, accuracy: 0.5, "Promotion should normalize the inspector height to the host height" ) } func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let slotView = host.ensureLocalInlineSlotView() let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) let inspectorView = WKWebView( frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) ) slotView.addSubview(pageView) slotView.addSubview(inspectorView) host.pinHostedWebView(pageView, in: slotView) host.setHostedInspectorFrontendWebView(inspectorView) contentView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() XCTAssertTrue( host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), "The managed side-dock path should be active before drag assertions run" ) let initialPageWidth = pageView.frame.width let initialInspectorWidth = inspectorView.frame.width let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) XCTAssertGreaterThan( inspectorView.frame.width, initialInspectorWidth, "Right-docked DevTools should expand when the divider is dragged left" ) XCTAssertLessThan( pageView.frame.width, initialPageWidth, "Expanding right-docked DevTools should shrink the page width" ) } func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let slotView = host.ensureLocalInlineSlotView() let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height)) let inspectorView = WKWebView( frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height) ) slotView.addSubview(pageView) slotView.addSubview(inspectorView) host.pinHostedWebView(pageView, in: slotView) host.setHostedInspectorFrontendWebView(inspectorView) contentView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil) host.setFrameSize(NSSize(width: 210, height: host.frame.height)) contentView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() XCTAssertGreaterThanOrEqual( inspectorView.frame.width, 120, "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging" ) XCTAssertEqual( inspectorView.frame.height, host.bounds.height, accuracy: 0.5, "Automatic shrink should keep the inspector vertically normalized to the host height" ) } func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let slotView = host.ensureLocalInlineSlotView() let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)) let inspectorView = TrackingInspectorFrontendWebView( frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height) ) slotView.addSubview(pageView) slotView.addSubview(inspectorView) host.pinHostedWebView(pageView, in: slotView) host.setHostedInspectorFrontendWebView(inspectorView) contentView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) host.setFrameSize(NSSize(width: 210, height: host.frame.height)) contentView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() XCTAssertTrue( inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }), "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout" ) XCTAssertTrue( inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }), "Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls" ) } func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let slotView = host.ensureLocalInlineSlotView() let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) let inspectorView = WKWebView( frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) ) slotView.addSubview(pageView) slotView.addSubview(inspectorView) host.pinHostedWebView(pageView, in: slotView) host.setHostedInspectorFrontendWebView(inspectorView) contentView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) guard let managedContainer = pageView.superview else { XCTFail("Expected managed side-dock container") return } let draggedPageFrame = pageView.frame let draggedInspectorFrame = inspectorView.frame managedContainer.setFrameSize( NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24) ) XCTAssertEqual( pageView.frame.origin.x, draggedPageFrame.origin.x, accuracy: 0.5, "Managed side-dock container should not autoresize the page back to a stale divider position" ) XCTAssertEqual( pageView.frame.width, draggedPageFrame.width, accuracy: 0.5, "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout" ) XCTAssertEqual( inspectorView.frame.origin.x, draggedInspectorFrame.origin.x, accuracy: 0.5, "Managed side-dock container should preserve the dragged inspector origin" ) XCTAssertEqual( inspectorView.frame.width, draggedInspectorFrame.width, accuracy: 0.5, "Managed side-dock container should preserve the dragged inspector width" ) } func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let webViewRoot = NSView(frame: host.bounds) webViewRoot.autoresizingMask = [.width, .height] host.addSubview(webViewRoot) let inspectorContainer = TrailingEdgeTransparentWKInspectorProbeView( frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) ) let pageView = PrimaryPageProbeView( frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) ) webViewRoot.addSubview(inspectorContainer) webViewRoot.addSubview(pageView) contentView.layoutSubtreeIfNeeded() let dividerPointInHost = NSPoint(x: inspectorContainer.frame.maxX - 2, y: host.bounds.midY) let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) XCTAssertTrue( host.hitTest(dividerPointInHost) === host, "Browser panel host should take the manual fallback path for a left-docked divider when the native edge is not hittable" ) let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) host.mouseDown(with: down) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) XCTAssertGreaterThan(inspectorContainer.frame.width, 92) XCTAssertGreaterThan(pageView.frame.minX, 92) } func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let host = WebViewRepresentable.HostContainerView( frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height) ) host.autoresizingMask = [.minXMargin, .height] contentView.addSubview(host) let webViewRoot = NSView(frame: host.bounds) webViewRoot.autoresizingMask = [.width, .height] host.addSubview(webViewRoot) let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height) let originalInspectorFrame = NSRect( x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height ) let pageView = PrimaryPageProbeView(frame: originalPageFrame) let inspectorContainer = NSView(frame: originalInspectorFrame) let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) webViewRoot.addSubview(pageView) webViewRoot.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window) host.mouseDown(with: down) let drag = makeMouseEvent( type: .leftMouseDragged, location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y), window: window ) host.mouseDragged(with: drag) host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) let draggedPageWidth = pageView.frame.width let draggedInspectorMinX = inspectorContainer.frame.minX XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width) XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX) pageView.frame = originalPageFrame inspectorContainer.frame = originalInspectorFrame host.needsLayout = true host.layoutSubtreeIfNeeded() XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5) XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5) } func testWindowBrowserSlotPinsHostedWebViewWithAutoresizingForAttachedInspector() { let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 180)) let webView = WKWebView(frame: .zero) slot.addSubview(webView) slot.pinHostedWebView(webView) slot.frame = NSRect(x: 0, y: 0, width: 300, height: 220) slot.layoutSubtreeIfNeeded() XCTAssertTrue(webView.translatesAutoresizingMaskIntoConstraints) XCTAssertEqual(webView.autoresizingMask, [.width, .height]) XCTAssertEqual(webView.frame, slot.bounds) } func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() { let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180)) let webView = WKWebView(frame: .zero) slot.addSubview(webView) slot.pinHostedWebView(webView) XCTAssertEqual(webView.frame, slot.bounds) let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180)) webView.removeFromSuperview() externalHost.addSubview(webView) webView.frame = externalHost.bounds webView.translatesAutoresizingMaskIntoConstraints = true webView.autoresizingMask = [.width, .height] slot.addSubview(webView) slot.pinHostedWebView(webView) slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180) slot.layoutSubtreeIfNeeded() XCTAssertEqual( webView.frame, slot.bounds, "Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host" ) } } @MainActor final class CmuxWebViewDragRoutingTests: XCTestCase { func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { XCTAssertTrue( CmuxWebView.shouldRejectInternalPaneDrag([ DragOverlayRoutingPolicy.bonsplitTabTransferType, NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), ]) ) } func testAllowsRegularExternalFileDrops() { XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL])) } } #if compiler(>=6.2) @available(macOS 26.0, *) private struct DragConfigurationOperationsSnapshot: Equatable { let allowCopy: Bool let allowMove: Bool let allowDelete: Bool let allowAlias: Bool } @available(macOS 26.0, *) private enum DragConfigurationSnapshotError: Error { case missingBoolField(primary: String, fallback: String?) } @available(macOS 26.0, *) private func dragConfigurationOperationsSnapshot(from operations: T) throws -> DragConfigurationOperationsSnapshot { let mirror = Mirror(reflecting: operations) func readBool(_ primary: String, fallback: String? = nil) throws -> Bool { if let value = mirror.descendant(primary) as? Bool { return value } if let fallback, let value = mirror.descendant(fallback) as? Bool { return value } throw DragConfigurationSnapshotError.missingBoolField(primary: primary, fallback: fallback) } return try DragConfigurationOperationsSnapshot( allowCopy: readBool("allowCopy", fallback: "_allowCopy"), allowMove: readBool("allowMove", fallback: "_allowMove"), allowDelete: readBool("allowDelete", fallback: "_allowDelete"), allowAlias: readBool("allowAlias", fallback: "_allowAlias") ) } @MainActor final class InternalTabDragConfigurationTests: XCTestCase { func testDisablesExternalOperationsForInternalTabDrags() throws { guard #available(macOS 26.0, *) else { throw XCTSkip("Requires macOS 26 drag configuration APIs") } let configuration = InternalTabDragConfigurationProvider.value let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp) let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp) XCTAssertEqual( withinApp, DragConfigurationOperationsSnapshot( allowCopy: false, allowMove: true, allowDelete: false, allowAlias: false ) ) XCTAssertEqual( outsideApp, DragConfigurationOperationsSnapshot( allowCopy: false, allowMove: false, allowDelete: false, allowAlias: false ) ) } } #endif @MainActor final class BrowserPaneDropRoutingTests: XCTestCase { func testVerticalZonesFollowAppKitCoordinates() { let size = CGSize(width: 240, height: 180) XCTAssertEqual( BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size), .top ) XCTAssertEqual( BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size), .bottom ) } func testTopChromeHeightPushesTopSplitThresholdIntoWebView() { let size = CGSize(width: 240, height: 180) XCTAssertEqual( BrowserPaneDropRouting.zone( for: CGPoint(x: size.width * 0.5, y: 110), in: size, topChromeHeight: 36 ), .center ) XCTAssertEqual( BrowserPaneDropRouting.zone( for: CGPoint(x: size.width * 0.5, y: 150), in: size, topChromeHeight: 36 ), .top ) } func testHitTestingCapturesOnlyForRelevantDragEvents() { XCTAssertTrue( BrowserPaneDropTargetView.shouldCaptureHitTesting( pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], eventType: .cursorUpdate ) ) XCTAssertFalse( BrowserPaneDropTargetView.shouldCaptureHitTesting( pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], eventType: .leftMouseDown ) ) XCTAssertFalse( BrowserPaneDropTargetView.shouldCaptureHitTesting( pasteboardTypes: [.fileURL], eventType: .cursorUpdate ) ) } func testCenterDropOnSamePaneIsNoOp() { let paneId = PaneID(id: UUID()) let target = BrowserPaneDropContext( workspaceId: UUID(), panelId: UUID(), paneId: paneId ) let transfer = BrowserPaneDragTransfer( tabId: UUID(), sourcePaneId: paneId.id, sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) ) XCTAssertEqual( BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center), .noOp ) } func testRightEdgeDropBuildsSplitMoveAction() { let paneId = PaneID(id: UUID()) let target = BrowserPaneDropContext( workspaceId: UUID(), panelId: UUID(), paneId: paneId ) let tabId = UUID() let transfer = BrowserPaneDragTransfer( tabId: tabId, sourcePaneId: UUID(), sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) ) XCTAssertEqual( BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right), .move( tabId: tabId, targetWorkspaceId: target.workspaceId, targetPane: paneId, splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) ) ) } func testDecodeTransferPayloadReadsTabAndSourcePane() { let tabId = UUID() let sourcePaneId = UUID() let payload = try! JSONSerialization.data( withJSONObject: [ "tab": ["id": tabId.uuidString], "sourcePaneId": sourcePaneId.uuidString, "sourceProcessId": ProcessInfo.processInfo.processIdentifier, ] ) let transfer = BrowserPaneDragTransfer.decode(from: payload) XCTAssertEqual(transfer?.tabId, tabId) XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId) XCTAssertTrue(transfer?.isFromCurrentProcess == true) } } @MainActor final class WindowBrowserSlotViewTests: XCTestCase { private final class CapturingView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil } } private func advanceAnimations() { RunLoop.current.run(until: Date().addingTimeInterval(0.25)) } func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() { let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) let slot = WindowBrowserSlotView(frame: container.bounds) container.addSubview(slot) let child = CapturingView(frame: slot.bounds) child.autoresizingMask = [.width, .height] slot.addSubview(child) slot.setDropZoneOverlay(zone: .right) container.layoutSubtreeIfNeeded() guard let overlay = container.subviews.first(where: { $0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") }) else { XCTFail("Expected browser slot drop-zone overlay") return } XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view") XCTAssertFalse(overlay.isHidden) XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5) XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.width, 96, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.height, 92, accuracy: 0.5) XCTAssertNil(overlay.hitTest(NSPoint(x: 120, y: 50)), "Overlay should never intercept pointer hits") XCTAssertTrue(slot.hitTest(NSPoint(x: 120, y: 50)) === child) slot.setDropZoneOverlay(zone: nil) advanceAnimations() XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay") } func testTopDropZoneOverlayUsesFullBrowserContentHeight() { let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) let slot = WindowBrowserSlotView(frame: container.bounds) container.addSubview(slot) slot.setPaneTopChromeHeight(20) slot.setDropZoneOverlay(zone: .top) container.layoutSubtreeIfNeeded() guard let overlay = container.subviews.first(where: { String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") }) else { XCTFail("Expected browser slot drop-zone overlay") return } XCTAssertFalse(overlay.isHidden) XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5) XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5) XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY) XCTAssertEqual(slot.layer?.masksToBounds, true) slot.setDropZoneOverlay(zone: nil) advanceAnimations() XCTAssertEqual(slot.layer?.masksToBounds, true) } } @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" ) } @MainActor func testKeyboardCopyModeIndicatorMountsAndUnmounts() { let surface = TerminalSurface( tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, workingDirectory: nil ) let hostedView = surface.hostedView XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) hostedView.syncKeyStateIndicator(text: "vim") XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator()) hostedView.syncKeyStateIndicator(text: nil) XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator()) } @MainActor func testDropHoverOverlayAttachesToParentContainerInsteadOfHostedTerminalView() { let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 120)) let surfaceView = GhosttyNSView(frame: .zero) let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) hostedView.frame = container.bounds container.addSubview(hostedView) hostedView.setDropZoneOverlay(zone: .right) container.layoutSubtreeIfNeeded() let state = hostedView.debugDropZoneOverlayState() XCTAssertFalse(state.isHidden) XCTAssertFalse( state.isAttachedToHostedView, "Drop-hover overlay should be mounted outside the hosted terminal view" ) XCTAssertTrue( state.isAttachedToParentContainer, "Drop-hover overlay should be mounted in the parent container so it cannot perturb terminal layout" ) XCTAssertEqual(state.frame.origin.x, 120, accuracy: 0.5) XCTAssertEqual(state.frame.origin.y, 4, accuracy: 0.5) XCTAssertEqual(state.frame.size.width, 116, accuracy: 0.5) XCTAssertEqual(state.frame.size.height, 112, accuracy: 0.5) hostedView.setDropZoneOverlay(zone: nil) RunLoop.current.run(until: Date().addingTimeInterval(0.25)) XCTAssertTrue(hostedView.debugDropZoneOverlayState().isHidden) } 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 testTerminalPortalHostStaysBelowBrowserPortalHostWhenBothAreInstalled() { 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 browserPortal = WindowBrowserPortal(window: window) let terminalPortal = WindowTerminalPortal(window: window) _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) guard let contentView = window.contentView, let container = contentView.superview else { XCTFail("Expected content container") return } func assertHostOrder(_ message: String) { guard let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }), let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }) else { XCTFail("Expected both portal hosts in same container") return } XCTAssertLessThan( terminalHostIndex, browserHostIndex, message ) } assertHostOrder("Terminal portal host should start below browser portal host") let anchor = NSView(frame: NSRect(x: 24, y: 24, width: 220, height: 150)) contentView.addSubview(anchor) let hosted = GhosttySurfaceScrollView( surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) ) terminalPortal.bind(hostedView: hosted, to: anchor, visibleInUI: true) terminalPortal.synchronizeHostedViewForAnchor(anchor) assertHostOrder("Terminal portal bind/sync should not rise above the browser portal host") } 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") } func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) window.orderOut(nil) } realizeWindowLayout(window) guard let contentView = window.contentView else { XCTFail("Expected content view") return } let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160)) contentView.addSubview(shiftedContainer) let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56)) shiftedContainer.addSubview(anchor) let surface = TerminalSurface( tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: nil, workingDirectory: nil ) let hosted = surface.hostedView TerminalWindowPortalRegistry.bind( hostedView: hosted, to: anchor, visibleInUI: true, expectedSurfaceId: surface.id, expectedGeneration: surface.portalBindingGeneration() ) TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) let originalWindowPoint = anchor.convert(anchorCenter, to: nil) XCTAssertNotNil( TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), "Initial hit-testing should resolve the portal-hosted terminal at its original window position" ) shiftedContainer.frame.origin.x += 96 contentView.layoutSubtreeIfNeeded() window.displayIfNeeded() let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil) XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5) XCTAssertNil( TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs" ) XCTAssertNotNil( TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), "Before the external geometry sync, hit-testing should still point at the stale portal location" ) TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) XCTAssertNil( TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), "The stale portal position should be cleared after the scheduled external geometry sync" ) XCTAssertNotNil( TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" ) } } @MainActor final class BrowserWindowPortalLifecycleTests: XCTestCase { private final class TrackingPortalWebView: WKWebView { private(set) var displayIfNeededCount = 0 private(set) var reattachRenderingStateCount = 0 override func displayIfNeeded() { displayIfNeededCount += 1 super.displayIfNeeded() } @objc(_enterInWindow) func cmuxUnitTestEnterInWindow() { reattachRenderingStateCount += 1 } @objc(_endDeferringViewInWindowChangesSync) func cmuxUnitTestEndDeferringViewInWindowChangesSync() { reattachRenderingStateCount += 1 } } private final class WKInspectorProbeView: NSView {} private func realizeWindowLayout(_ window: NSWindow) { window.makeKeyAndOrderFront(nil) window.displayIfNeeded() window.contentView?.layoutSubtreeIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) window.contentView?.layoutSubtreeIfNeeded() } private func advanceAnimations() { RunLoop.current.run(until: Date().addingTimeInterval(0.25)) } private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? { let candidates = slot.subviews + (slot.superview?.subviews ?? []) return candidates.first(where: { $0 !== slot && $0 !== webView && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") }) } 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 testBrowserPortalHostStaysAboveTerminalPortalHostDuringPortalChurn() { 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 browserPortal = WindowBrowserPortal(window: window) let terminalPortal = WindowTerminalPortal(window: window) _ = browserPortal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) _ = terminalPortal.viewAtWindowPoint(NSPoint(x: 1, y: 1)) guard let contentView = window.contentView, let container = contentView.superview else { XCTFail("Expected content container") return } func assertHostOrder(_ message: String) { guard let browserHostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), let terminalHostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }) else { XCTFail("Expected both portal hosts in same container") return } XCTAssertGreaterThan( browserHostIndex, terminalHostIndex, message ) } assertHostOrder("Browser portal host should start above terminal portal host") let terminalAnchor = NSView(frame: NSRect(x: 20, y: 20, width: 200, height: 140)) contentView.addSubview(terminalAnchor) let terminalHostedView = GhosttySurfaceScrollView( surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) ) terminalPortal.bind(hostedView: terminalHostedView, to: terminalAnchor, visibleInUI: true) terminalPortal.synchronizeHostedViewForAnchor(terminalAnchor) assertHostOrder("Terminal portal sync should not rise above the browser portal host") let browserAnchor = NSView(frame: NSRect(x: 240, y: 20, width: 220, height: 140)) contentView.addSubview(browserAnchor) let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) browserPortal.bind(webView: webView, to: browserAnchor, visibleInUI: true) browserPortal.synchronizeWebViewForAnchor(browserAnchor) assertHostOrder("Browser portal sync should keep browser panes above portal-hosted terminals") } 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 testPortalClipsAnchorFrameThroughAncestorBounds() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 480, 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 clipView = NSView(frame: NSRect(x: 60, y: 40, width: 150, height: 120)) contentView.addSubview(clipView) // Simulate SwiftUI/AppKit reporting an anchor wider than the actual visible pane. let anchor = NSView(frame: NSRect(x: -30, y: 0, width: 220, height: 120)) clipView.addSubview(anchor) let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) portal.bind(webView: webView, to: anchor, visibleInUI: true) contentView.layoutSubtreeIfNeeded() clipView.layoutSubtreeIfNeeded() portal.synchronizeWebViewForAnchor(anchor) guard let slot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected browser slot") return } XCTAssertFalse(slot.isHidden, "Ancestor clipping should keep the browser visible in the real pane") XCTAssertEqual(slot.frame.origin.x, 60, accuracy: 0.5) XCTAssertEqual(slot.frame.origin.y, 40, accuracy: 0.5) XCTAssertEqual(slot.frame.size.width, 150, accuracy: 0.5) XCTAssertEqual(slot.frame.size.height, 120, 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 testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() { let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160)) let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration()) let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160)) let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) slot.addSubview(webView) slot.addSubview(inspectorContainer) webView.translatesAutoresizingMaskIntoConstraints = false webView.autoresizingMask = [] slot.pinHostedWebView(webView) XCTAssertEqual( webView.frame.maxX, inspectorContainer.frame.minX, accuracy: 0.5, "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split" ) XCTAssertLessThan( webView.frame.width, slot.bounds.width, "The page frame should stay narrower than the full slot while a side-docked inspector is present" ) } func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, 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: 260, height: 180)) 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 } let initialInspectorWidth: CGFloat = 110 let inspectorContainer = NSView( frame: NSRect( x: slot.bounds.width - initialInspectorWidth, y: 0, width: initialInspectorWidth, height: slot.bounds.height ) ) inspectorContainer.autoresizingMask = [.minXMargin, .height] let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) slot.addSubview(inspectorContainer) webView.frame = NSRect( x: 0, y: 0, width: slot.bounds.width - initialInspectorWidth, height: slot.bounds.height ) webView.autoresizingMask = [.width, .height] slot.layoutSubtreeIfNeeded() anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) contentView.layoutSubtreeIfNeeded() portal.synchronizeWebViewForAnchor(anchor) XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible") XCTAssertEqual( webView.frame.maxX, inspectorContainer.frame.minX, accuracy: 0.5, "Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector" ) XCTAssertLessThan( webView.frame.width, slot.bounds.width, "Side-docked inspector should still own part of the slot after pane resize" ) } func testPortalAnchorResizeDoesNotForceHostedWebViewPresentationRefresh() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, 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 = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) portal.bind(webView: webView, to: anchor, visibleInUI: true) contentView.layoutSubtreeIfNeeded() portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() guard let slot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected browser slot") return } let initialDisplayCount = webView.displayIfNeededCount let initialReattachCount = webView.reattachRenderingStateCount anchor.frame = NSRect(x: 52, y: 30, width: 248, height: 178) contentView.layoutSubtreeIfNeeded() portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() XCTAssertFalse(slot.isHidden, "Anchor resize should keep the portal-hosted browser visible") XCTAssertEqual(slot.frame.origin.x, 52, accuracy: 0.5) XCTAssertEqual(slot.frame.origin.y, 30, accuracy: 0.5) XCTAssertEqual(slot.frame.size.width, 248, accuracy: 0.5) XCTAssertEqual(slot.frame.size.height, 178, accuracy: 0.5) XCTAssertGreaterThan( webView.displayIfNeededCount, initialDisplayCount, "Pure anchor geometry updates should still repaint the hosted browser" ) XCTAssertEqual( webView.reattachRenderingStateCount, initialReattachCount, "Pure anchor geometry updates should not trigger the WebKit reattach path" ) } func testExternalSplitResizeDoesNotForceHostedWebViewPresentationRefresh() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 640, height: 360), 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 splitView = NSSplitView(frame: contentView.bounds) splitView.autoresizingMask = [.width, .height] splitView.isVertical = true let leadingPane = NSView( frame: NSRect(x: 0, y: 0, width: 220, height: contentView.bounds.height) ) leadingPane.autoresizingMask = [.height] let trailingPane = NSView( frame: NSRect( x: 221, y: 0, width: contentView.bounds.width - 221, height: contentView.bounds.height ) ) trailingPane.autoresizingMask = [.width, .height] splitView.addSubview(leadingPane) splitView.addSubview(trailingPane) contentView.addSubview(splitView) splitView.adjustSubviews() let anchor = NSView(frame: trailingPane.bounds.insetBy(dx: 12, dy: 12)) anchor.autoresizingMask = [.width, .height] trailingPane.addSubview(anchor) let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) portal.bind(webView: webView, to: anchor, visibleInUI: true) contentView.layoutSubtreeIfNeeded() portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() guard let slot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected browser slot") return } let initialDisplayCount = webView.displayIfNeededCount let initialReattachCount = webView.reattachRenderingStateCount let initialWidth = slot.frame.width splitView.setPosition(280, ofDividerAt: 0) contentView.layoutSubtreeIfNeeded() NotificationCenter.default.post(name: NSSplitView.didResizeSubviewsNotification, object: splitView) advanceAnimations() XCTAssertFalse(slot.isHidden, "App split resize should keep the browser slot visible") XCTAssertLessThan( slot.frame.width, initialWidth, "Moving the app split divider should shrink the hosted browser slot" ) XCTAssertGreaterThan( webView.displayIfNeededCount, initialDisplayCount, "External split resize should still repaint the hosted browser" ) XCTAssertEqual( webView.reattachRenderingStateCount, initialReattachCount, "External split resize should not trigger the WebKit reattach path" ) } func testPortalSyncRepairsBottomDockedInspectorOverflowedPageFrame() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, 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: 260, height: 180)) 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 } let inspectorHeight: CGFloat = 84 let inspectorContainer = NSView( frame: NSRect(x: 0, y: 0, width: slot.bounds.width, height: inspectorHeight) ) inspectorContainer.autoresizingMask = [.width] let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) slot.addSubview(inspectorContainer) webView.frame = NSRect( x: 0, y: inspectorHeight, width: slot.bounds.width, height: slot.bounds.height ) webView.autoresizingMask = [.width, .height] slot.layoutSubtreeIfNeeded() portal.synchronizeWebViewForAnchor(anchor) XCTAssertFalse(slot.isHidden, "Portal sync should keep the hosted browser visible") XCTAssertEqual( webView.frame.minY, inspectorHeight, accuracy: 0.5, "Portal sync should keep the page viewport below a bottom-docked inspector instead of shifting the page upward" ) XCTAssertEqual( webView.frame.height, slot.bounds.height - inspectorHeight, accuracy: 0.5, "Portal sync should shrink the page viewport to the space above a bottom-docked inspector" ) XCTAssertEqual( webView.frame.maxY, slot.bounds.maxY, accuracy: 0.5, "The repaired page viewport should stay flush with the top edge of the slot" ) } func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), 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 slot = WindowBrowserSlotView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) contentView.addSubview(slot) let inspectorContainer = NSView(frame: slot.bounds) inspectorContainer.autoresizingMask = [.width, .height] let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) inspectorView.autoresizingMask = [.width, .height] inspectorContainer.addSubview(inspectorView) slot.addSubview(inspectorContainer) contentView.layoutSubtreeIfNeeded() XCTAssertTrue( window.makeFirstResponder(inspectorView), "Precondition failed: inspector probe should become first responder" ) XCTAssertTrue(window.firstResponder === inspectorView) slot.isHidden = true XCTAssertFalse( window.firstResponder === inspectorView, "Hiding a browser slot should yield any owned inspector responder before it goes off-screen" ) if let firstResponderView = window.firstResponder as? NSView { XCTAssertFalse( firstResponderView === slot || firstResponderView.isDescendant(of: slot), "Hiding a browser slot should not leave first responder inside the hidden slot" ) } } func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, 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: 260, height: 180)) contentView.addSubview(anchor) let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) portal.bind(webView: webView, to: anchor, visibleInUI: true) contentView.layoutSubtreeIfNeeded() portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected browser slot") return } portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden") let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame) contentView.addSubview(localInlineSlot) let inspectorView = WKInspectorProbeView( frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72) ) inspectorView.autoresizingMask = [.width] localInlineSlot.addSubview(inspectorView) localInlineSlot.addSubview(webView) webView.frame = NSRect( x: 0, y: inspectorView.frame.maxY, width: localInlineSlot.bounds.width, height: localInlineSlot.bounds.height - inspectorView.frame.height ) localInlineSlot.layoutSubtreeIfNeeded() anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180) localInlineSlot.frame = anchor.frame contentView.layoutSubtreeIfNeeded() localInlineSlot.layoutSubtreeIfNeeded() portal.synchronizeWebViewForAnchor(anchor) XCTAssertTrue( webView.superview === localInlineSlot, "Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize" ) XCTAssertTrue( inspectorView.superview === localInlineSlot, "Hidden portal sync should leave local DevTools companion views in the local inline host" ) XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting") } 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 testPortalDropZoneOverlayPersistsAcrossVisibilityChanges() { 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 overlay = dropZoneOverlay(in: slot, excluding: webView) else { XCTFail("Expected browser slot overlay") return } XCTAssertTrue(overlay.isHidden, "Overlay should start hidden without an active drop zone") portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right) slot.layoutSubtreeIfNeeded() XCTAssertFalse(overlay.isHidden) XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view") XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5) XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5) portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() XCTAssertTrue(overlay.isHidden, "Invisible browser entries should hide the overlay") portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) portal.synchronizeWebViewForAnchor(anchor) XCTAssertFalse(overlay.isHidden, "Restoring visibility should restore the active drop-zone overlay") } func testPortalRevealRefreshesHostedWebViewWithoutFrameDelta() { 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 = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) portal.bind(webView: webView, to: anchor, visibleInUI: true) portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() let initialDisplayCount = webView.displayIfNeededCount let initialReattachCount = webView.reattachRenderingStateCount portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() let hiddenDisplayCount = webView.displayIfNeededCount let hiddenReattachCount = webView.reattachRenderingStateCount portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: true, zPriority: 0) portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() XCTAssertGreaterThanOrEqual(hiddenDisplayCount, initialDisplayCount) XCTAssertEqual( hiddenReattachCount, initialReattachCount, "Hiding a portal-hosted browser should not itself trigger the WebKit reattach path" ) XCTAssertGreaterThan( webView.displayIfNeededCount, hiddenDisplayCount, "Revealing an existing portal-hosted browser should refresh WebKit presentation immediately" ) XCTAssertGreaterThan( webView.reattachRenderingStateCount, hiddenReattachCount, "Revealing an existing portal-hosted browser should trigger the WebKit reattach path" ) } func testVisiblePortalEntryHidesWithoutDetachingDuringTransientAnchorRemovalUntilRebind() { 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 anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) let anchor1 = NSView(frame: anchorFrame) contentView.addSubview(anchor1) let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) portal.bind(webView: webView, to: anchor1, visibleInUI: true) portal.synchronizeWebViewForAnchor(anchor1) advanceAnimations() guard let slot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected browser slot") return } anchor1.removeFromSuperview() portal.synchronizeWebViewForAnchor(anchor1) advanceAnimations() XCTAssertTrue(webView.superview === slot, "Visible browser entries should not detach during transient anchor removal") XCTAssertTrue( slot.isHidden, "Transient anchor churn should hide the stale browser slot instead of rendering in the wrong pane" ) XCTAssertEqual(portal.debugEntryCount(), 1) let displayCountBeforeRebind = webView.displayIfNeededCount let anchor2 = NSView(frame: anchorFrame) contentView.addSubview(anchor2) portal.bind(webView: webView, to: anchor2, visibleInUI: true) portal.synchronizeWebViewForAnchor(anchor2) advanceAnimations() XCTAssertTrue(webView.superview === slot, "Rebinding after transient anchor removal should reuse the existing portal slot") XCTAssertFalse(slot.isHidden) XCTAssertEqual(portal.debugEntryCount(), 1) XCTAssertGreaterThan( webView.displayIfNeededCount, displayCountBeforeRebind, "Anchor rebinds should refresh hosted browser presentation even when geometry is unchanged" ) } func testVisiblePortalEntryStaysVisibleDuringOffWindowAnchorReparentUntilRebind() { 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 anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) let anchor = NSView(frame: anchorFrame) contentView.addSubview(anchor) let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) portal.bind(webView: webView, to: anchor, visibleInUI: true) portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() guard let slot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected browser slot") return } let offWindowContainer = NSView(frame: anchorFrame) anchor.removeFromSuperview() offWindowContainer.addSubview(anchor) portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() XCTAssertTrue( webView.superview === slot, "Off-window anchor reparent should preserve the hosted browser slot during drag churn" ) XCTAssertFalse( slot.isHidden, "Off-window anchor reparent should keep the visible browser portal alive until the anchor returns" ) XCTAssertEqual(portal.debugEntryCount(), 1) contentView.addSubview(anchor) portal.synchronizeWebViewForAnchor(anchor) advanceAnimations() XCTAssertTrue(webView.superview === slot, "Rebinding after off-window reparent should reuse the existing portal slot") XCTAssertFalse(slot.isHidden) XCTAssertEqual(portal.debugEntryCount(), 1) } 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) } func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() { 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) BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) advanceAnimations() guard let slot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected browser slot") return } XCTAssertFalse(slot.isHidden) BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest") advanceAnimations() XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment") XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot") } func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() { 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 anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) let oldAnchor = NSView(frame: anchorFrame) contentView.addSubview(oldAnchor) let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) portal.bind(webView: webView, to: oldAnchor, visibleInUI: true) portal.synchronizeWebViewForAnchor(oldAnchor) advanceAnimations() guard let slot = webView.superview as? WindowBrowserSlotView else { XCTFail("Expected browser slot") return } portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) portal.synchronizeWebViewForAnchor(oldAnchor) advanceAnimations() XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount") oldAnchor.removeFromSuperview() portal.synchronizeWebViewForAnchor(oldAnchor) advanceAnimations() XCTAssertTrue( webView.superview === slot, "Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted" ) XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound") XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive") let displayCountBeforeRebind = webView.displayIfNeededCount let newAnchor = NSView(frame: anchorFrame) contentView.addSubview(newAnchor) portal.bind(webView: webView, to: newAnchor, visibleInUI: true) portal.synchronizeWebViewForAnchor(newAnchor) advanceAnimations() XCTAssertTrue( webView.superview === slot, "Selecting the workspace again should reuse the existing hidden browser portal slot" ) XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot") XCTAssertEqual(portal.debugEntryCount(), 1) XCTAssertGreaterThan( webView.displayIfNeededCount, displayCountBeforeRebind, "Workspace rebind should refresh the preserved browser without recreating its portal slot" ) } } @MainActor final class FileDropOverlayViewTests: 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 testOverlayResolvesPortalHostedBrowserWebViewForFileDrops() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), styleMask: [.titled, .closable], backing: .buffered, defer: false ) defer { NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) window.orderOut(nil) } realizeWindowLayout(window) guard let contentView = window.contentView, let container = contentView.superview else { XCTFail("Expected content container") return } let anchor = NSView(frame: NSRect(x: 40, y: 36, width: 220, height: 150)) contentView.addSubview(anchor) let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) let overlay = FileDropOverlayView(frame: container.bounds) overlay.autoresizingMask = [.width, .height] container.addSubview(overlay, positioned: .above, relativeTo: nil) let point = anchor.convert( NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY), to: nil ) XCTAssertTrue( overlay.webViewUnderPoint(point) === webView, "File-drop overlay should resolve portal-hosted browser panes so Finder uploads still reach WKWebView" ) } } @MainActor final class MarkdownPanelPointerObserverViewTests: XCTestCase { private func makeWindow() -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), styleMask: [.titled, .closable], backing: .buffered, defer: false ) window.makeKeyAndOrderFront(nil) window.displayIfNeeded() window.contentView?.layoutSubtreeIfNeeded() return window } private func makeMouseEvent( type: NSEvent.EventType, location: NSPoint, window: NSWindow, eventNumber: Int = 1 ) -> NSEvent { guard let event = NSEvent.mouseEvent( with: type, location: location, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: eventNumber, clickCount: 1, pressure: 1.0 ) else { fatalError("Expected to create mouse event") } return event } func testObserverTriggersFocusForVisibleLeftClickInsideBounds() { let window = makeWindow() defer { window.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) overlay.autoresizingMask = [.width, .height] let focusExpectation = expectation(description: "observer forwards focus callback") var pointerDownCount = 0 overlay.onPointerDown = { pointerDownCount += 1 focusExpectation.fulfill() } contentView.addSubview(overlay) _ = overlay.handleEventIfNeeded( makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window) ) wait(for: [focusExpectation], timeout: 1.0) XCTAssertEqual(pointerDownCount, 1) } func testObserverIgnoresOutsideOrForeignWindowClicks() { let window = makeWindow() defer { window.orderOut(nil) } let otherWindow = makeWindow() defer { otherWindow.orderOut(nil) } guard let contentView = window.contentView else { XCTFail("Expected content view") return } let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) overlay.autoresizingMask = [.width, .height] let noFocusExpectation = expectation(description: "observer ignores invalid clicks") noFocusExpectation.isInverted = true var pointerDownCount = 0 overlay.onPointerDown = { pointerDownCount += 1 noFocusExpectation.fulfill() } contentView.addSubview(overlay) _ = overlay.handleEventIfNeeded( makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window) ) _ = overlay.handleEventIfNeeded( makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2) ) _ = overlay.handleEventIfNeeded( makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3) ) wait(for: [noFocusExpectation], timeout: 0.1) XCTAssertEqual(pointerDownCount, 0) } func testObserverDoesNotParticipateInHitTesting() { let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30))) } } 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 "".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 testReacquiresFocusWhenOmnibarStillWantsFocusAndNextResponderIsNotAnotherTextField() { XCTAssertTrue( browserOmnibarShouldReacquireFocusAfterEndEditing( desiredOmnibarFocus: true, nextResponderIsOtherTextField: false ) ) } func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { XCTAssertFalse( browserOmnibarShouldReacquireFocusAfterEndEditing( desiredOmnibarFocus: true, nextResponderIsOtherTextField: true ) ) } func testDoesNotReacquireFocusWhenOmnibarNoLongerWantsFocus() { XCTAssertFalse( browserOmnibarShouldReacquireFocusAfterEndEditing( desiredOmnibarFocus: 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.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 } @MainActor 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) } @MainActor 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, socketProbePerformed: true, socketConnectable: true, socketConnectErrno: nil ) XCTAssertTrue(health.isHealthy) XCTAssertTrue(health.failureSignals.isEmpty) XCTAssertTrue(health.socketProbePerformed) XCTAssertEqual(health.socketConnectable, true) XCTAssertNil(health.socketConnectErrno) } func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { let health = TerminalController.SocketListenerHealth( isRunning: false, acceptLoopAlive: false, socketPathMatches: false, socketPathExists: false, socketProbePerformed: false, socketConnectable: nil, socketConnectErrno: nil ) XCTAssertFalse(health.isHealthy) XCTAssertFalse(health.socketProbePerformed) XCTAssertNil(health.socketConnectable) XCTAssertNil(health.socketConnectErrno) XCTAssertEqual( health.failureSignals, ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] ) } }