diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index a0c72b11..12e827b1 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -15,11 +15,11 @@ jobs: matrix: include: - os: warp-macos-15-arm64-6x - timeout: 20 + timeout: 30 smoke: true skip_zig: false - os: warp-macos-26-arm64-6x - timeout: 20 + timeout: 30 smoke: false skip_zig: true # zig 0.15.2 MachO linker can't resolve libSystem on macOS 26 runs-on: ${{ matrix.os }} @@ -133,8 +133,9 @@ jobs: } set +e - OUTPUT=$(run_unit_tests) - EXIT_CODE=$? + run_unit_tests | tee /tmp/test-output.txt + EXIT_CODE=${PIPESTATUS[0]} + OUTPUT=$(cat /tmp/test-output.txt) set -e # SwiftPM binary artifact resolution can occasionally fail on ephemeral @@ -145,12 +146,12 @@ jobs: mkdir -p ~/Library/Caches/org.swift.swiftpm rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* set +e - OUTPUT=$(run_unit_tests) - EXIT_CODE=$? + run_unit_tests | tee /tmp/test-output.txt + EXIT_CODE=${PIPESTATUS[0]} + OUTPUT=$(cat /tmp/test-output.txt) set -e fi - echo "$OUTPUT" if [ "$EXIT_CODE" -ne 0 ]; then SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) if echo "$SUMMARY" | grep -q "(0 unexpected)"; then diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 1571043e..0a4da569 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -86,7 +86,6 @@ D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; @@ -103,7 +102,22 @@ DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; - /* End PBXBuildFile section */ + E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; + 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; + 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; + 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */; }; + 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; + 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; }; + CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; + 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */; }; + 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; }; + B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */; }; + DCC935C5F55C1DCB33E25521 /* WorkspacePullRequestSidebarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */; }; + 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */; }; + CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; + 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; + 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; }; + /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ A5001020 /* Embed Frameworks */ = { @@ -236,7 +250,6 @@ D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; @@ -253,6 +266,21 @@ DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; + 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; + 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; + 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; + 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = ""; }; + BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; + 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; }; + BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; + B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; }; + D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; + 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = ""; }; + 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = ""; }; + 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; }; + EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; + 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; + 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -475,7 +503,6 @@ F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { isa = PBXGroup; children = ( - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, @@ -489,6 +516,21 @@ FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, + 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, + 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, + 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, + 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */, + BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, + 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */, + BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, + B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, + D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, + 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */, + 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */, + EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, + 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, + 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -719,7 +761,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift new file mode 100644 index 00000000..487c680c --- /dev/null +++ b/cmuxTests/BrowserConfigTests.swift @@ -0,0 +1,3108 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +var cmuxUnitTestInspectorAssociationKey: UInt8 = 0 +var cmuxUnitTestInspectorOverrideInstalled = false + +extension CmuxWebView { + @objc func cmuxUnitTestInspector() -> NSObject? { + objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject + } +} + +extension WKWebView { + func cmuxSetUnitTestInspector(_ inspector: NSObject?) { + objc_setAssociatedObject( + self, + &cmuxUnitTestInspectorAssociationKey, + inspector, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } +} + +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 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 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 testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() { + let defaults = makeIsolatedDefaults() + defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key) + + XCTAssertEqual( + BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), + BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing + ) + } + + func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() { + let defaults = makeIsolatedDefaults() + defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key) + + XCTAssertEqual( + BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), + BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing + ) + } + + func testBrowserProfilePopoverPaddingDefaultsWhenUnset() { + let defaults = makeIsolatedDefaults() + defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) + + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + ) + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultVerticalPadding + ) + } + + func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() { + let defaults = makeIsolatedDefaults() + defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) + defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) + + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultHorizontalPadding + ) + XCTAssertEqual( + BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), + BrowserProfilePopoverDebugSettings.defaultVerticalPadding + ) + } + + 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 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) + } +} + + +@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) + } +} + + +@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 + ) + ) + } +} + + +final class BrowserPopupDecisionTests: XCTestCase { + func testLinkActivatedPlainLeftClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationPlainLeftClickCreatesPopup() { + XCTAssertTrue( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedCmdClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } +} + + +final class BrowserNilTargetFallbackDecisionTests: XCTestCase { + func testOtherNavigationDoesNotFallbackToNewTab() { + XCTAssertFalse( + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: .other + ) + ) + } + + func testLinkActivatedNavigationFallsBackToNewTab() { + XCTAssertTrue( + browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: .linkActivated + ) + ) + } +} + + +final class BrowserPopupContentRectTests: XCTestCase { + func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() { + let rect = browserPopupContentRect( + requestedWidth: 400, + requestedHeight: 300, + requestedX: 150, + requestedTopY: 120, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01) + XCTAssertEqual(rect.width, 400, accuracy: 0.01) + XCTAssertEqual(rect.height, 300, accuracy: 0.01) + } + + func testExplicitCoordinatesClampToVisibleFrame() { + let rect = browserPopupContentRect( + requestedWidth: 1400, + requestedHeight: 1200, + requestedX: 900, + requestedTopY: -25, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01) + XCTAssertEqual(rect.width, 1000, accuracy: 0.01) + XCTAssertEqual(rect.height, 800, accuracy: 0.01) + } + + func testMissingCoordinatesCentersPopup() { + let rect = browserPopupContentRect( + requestedWidth: 300, + requestedHeight: 200, + requestedX: nil, + requestedTopY: nil, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01) + XCTAssertEqual(rect.width, 300, accuracy: 0.01) + XCTAssertEqual(rect.height, 200, accuracy: 0.01) + } +} + + +@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 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 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 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/" })) + } +} + + +@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") + ) +} + + +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 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 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 + ) + ) + } +} diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift new file mode 100644 index 00000000..104cecbf --- /dev/null +++ b/cmuxTests/BrowserPanelTests.swift @@ -0,0 +1,2935 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +func drainMainQueue() { + let expectation = XCTestExpectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + XCTWaiter().wait(for: [expectation], timeout: 1.0) +} + +@MainActor +func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) +} + +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) + } +} + + +@MainActor +final class BrowserPanelProfileIsolationTests: XCTestCase { + func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { + let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched") + let defaultStore = BrowserHistoryStore.shared + let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) + defaultStore.clearHistory() + alternateStore.clearHistory() + defer { + defaultStore.clearHistory() + alternateStore.clearHistory() + } + + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: BrowserProfileStore.shared.builtInDefaultProfileID + ) + let staleWebView = panel.webView + let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) + let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) + staleWebView.loadHTMLString( + "Stalestale", + baseURL: staleURL + ) + + XCTAssertTrue( + panel.switchToProfile(alternateProfile.id), + "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" + ) + defaultStore.clearHistory() + alternateStore.clearHistory() + + staleDelegate.webView?(staleWebView, didFinish: nil) + drainMainQueue() + + XCTAssertTrue( + defaultStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" + ) + XCTAssertTrue( + alternateStore.entries.isEmpty, + "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" + ) + } +} + + +@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) + } +} + + +@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 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 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" + ) + } +} diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift deleted file mode 100644 index 9f343720..00000000 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ /dev/null @@ -1,15907 +0,0 @@ -import XCTest -import AppKit -import SwiftUI -import UniformTypeIdentifiers -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 let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut" - -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 -} - -private func drainMainQueue() { - let expectation = XCTestExpectation(description: "drain main queue") - DispatchQueue.main.async { - expectation.fulfill() - } - XCTWaiter().wait(for: [expectation], timeout: 1.0) -} - -@MainActor -private func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition { - try XCTUnwrap( - BrowserProfileStore.shared.createProfile( - named: "\(prefix)-\(UUID().uuidString)" - ) - ) -} - -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 GhosttyPasteboardHelperTests: XCTestCase { - func testHTMLOnlyPasteboardExtractsPlainText() { - let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString("

Hello world

", forType: .html) - - XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world") - XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) - } - - func testImageHTMLClipboardFallsBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString("", forType: .html) - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.red.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - let tiffData = try XCTUnwrap(image.tiffRepresentation) - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) - let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) - pasteboard.setData(pngData, forType: .png) - - XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) - - let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) - defer { try? FileManager.default.removeItem(atPath: imagePath) } - - XCTAssertTrue(imagePath.hasSuffix(".png")) - XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) - } - - func testImageHTMLClipboardWithVisibleTextPrefersText() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString("

Hello

", forType: .html) - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.blue.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - let tiffData = try XCTUnwrap(image.tiffRepresentation) - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) - let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) - pasteboard.setData(pngData, forType: .png) - - XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") - XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) - } - - func testJPEGClipboardFallsBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)")) - pasteboard.clearContents() - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.green.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - - let tiffData = try XCTUnwrap(image.tiffRepresentation) - let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) - let jpegData = try XCTUnwrap( - bitmap.representation( - using: .jpeg, - properties: [.compressionFactor: 1.0] - ) - ) - pasteboard.setData( - jpegData, - forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier) - ) - - let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) - defer { try? FileManager.default.removeItem(atPath: imagePath) } - - XCTAssertTrue(imagePath.hasSuffix(".jpeg")) - XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) - } - - func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)")) - pasteboard.clearContents() - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.orange.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - - let attachment = NSTextAttachment() - attachment.image = image - let attributed = NSAttributedString(attachment: attachment) - let data = try attributed.data( - from: NSRange(location: 0, length: attributed.length), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] - ) - pasteboard.setData(data, forType: .rtfd) - - XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) - - let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) - defer { try? FileManager.default.removeItem(atPath: imagePath) } - - XCTAssertTrue(imagePath.hasSuffix(".tiff")) - XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) - } - - func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)")) - pasteboard.clearContents() - - let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8)) - wrapper.preferredFilename = "note.txt" - - let attachment = NSTextAttachment(fileWrapper: wrapper) - let attributed = NSAttributedString(attachment: attachment) - let data = try attributed.data( - from: NSRange(location: 0, length: attributed.length), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] - ) - pasteboard.setData(data, forType: .rtfd) - - XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) - XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) - } - - func testRTFDClipboardWithVisibleTextPrefersText() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)")) - pasteboard.clearContents() - - let image = NSImage(size: NSSize(width: 1, height: 1)) - image.lockFocus() - NSColor.purple.setFill() - NSRect(x: 0, y: 0, width: 1, height: 1).fill() - image.unlockFocus() - - let attachment = NSTextAttachment() - attachment.image = image - - let attributed = NSMutableAttributedString(string: "Hello ") - attributed.append(NSAttributedString(attachment: attachment)) - let data = try attributed.data( - from: NSRange(location: 0, length: attributed.length), - documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] - ) - pasteboard.setData(data, forType: .rtfd) - - XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") - XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) - } -} - -@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 })) - } - - func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws { - _ = 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() - ) - - window.makeKeyAndOrderFront(nil) - _ = app.synchronizeActiveMainWindowContext(preferredWindow: window) - - let defaults = UserDefaults.standard - let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey) - defaults.set(true, forKey: WelcomeSettings.shownKey) - defer { - if let previousWelcomeShown { - defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey) - } else { - defaults.removeObject(forKey: WelcomeSettings.shownKey) - } - } - - let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true) - try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: rootDirectory) } - - let existingWorkspaceIds = Set(manager.tabs.map(\.id)) - - app.application( - NSApplication.shared, - open: [URL(fileURLWithPath: droppedDirectory.path)] - ) - - let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) } - XCTAssertNotNil(createdWorkspace) - XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path) - } -} - -@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 testBrowserToolbarAccessorySpacingDefaultsToTwoWhenUnset() { - let defaults = makeIsolatedDefaults() - defaults.removeObject(forKey: BrowserToolbarAccessorySpacingDebugSettings.key) - - XCTAssertEqual( - BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), - BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing - ) - } - - func testBrowserToolbarAccessorySpacingFallsBackToDefaultForUnsupportedValue() { - let defaults = makeIsolatedDefaults() - defaults.set(99, forKey: BrowserToolbarAccessorySpacingDebugSettings.key) - - XCTAssertEqual( - BrowserToolbarAccessorySpacingDebugSettings.current(defaults: defaults), - BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing - ) - } - - func testBrowserProfilePopoverPaddingDefaultsWhenUnset() { - let defaults = makeIsolatedDefaults() - defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) - defaults.removeObject(forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) - - XCTAssertEqual( - BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), - BrowserProfilePopoverDebugSettings.defaultHorizontalPadding - ) - XCTAssertEqual( - BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), - BrowserProfilePopoverDebugSettings.defaultVerticalPadding - ) - } - - func testBrowserProfilePopoverPaddingFallsBackForUnsupportedValues() { - let defaults = makeIsolatedDefaults() - defaults.set(-3, forKey: BrowserProfilePopoverDebugSettings.horizontalPaddingKey) - defaults.set(999, forKey: BrowserProfilePopoverDebugSettings.verticalPaddingKey) - - XCTAssertEqual( - BrowserProfilePopoverDebugSettings.currentHorizontalPadding(defaults: defaults), - BrowserProfilePopoverDebugSettings.defaultHorizontalPadding - ) - XCTAssertEqual( - BrowserProfilePopoverDebugSettings.currentVerticalPadding(defaults: defaults), - BrowserProfilePopoverDebugSettings.defaultVerticalPadding - ) - } - - 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 - ) - ) - } -} - -final class BrowserPopupDecisionTests: XCTestCase { - func testLinkActivatedPlainLeftClickDoesNotCreatePopup() { - XCTAssertFalse( - browserNavigationShouldCreatePopup( - navigationType: .linkActivated, - modifierFlags: [], - buttonNumber: 0 - ) - ) - } - - func testOtherNavigationPlainLeftClickCreatesPopup() { - XCTAssertTrue( - browserNavigationShouldCreatePopup( - navigationType: .other, - modifierFlags: [], - buttonNumber: 0 - ) - ) - } - - func testOtherNavigationMiddleClickDoesNotCreatePopup() { - XCTAssertFalse( - browserNavigationShouldCreatePopup( - navigationType: .other, - modifierFlags: [], - buttonNumber: 2 - ) - ) - } - - func testLinkActivatedCmdClickDoesNotCreatePopup() { - XCTAssertFalse( - browserNavigationShouldCreatePopup( - navigationType: .linkActivated, - modifierFlags: [.command], - buttonNumber: 0 - ) - ) - } -} - -final class BrowserNilTargetFallbackDecisionTests: XCTestCase { - func testOtherNavigationDoesNotFallbackToNewTab() { - XCTAssertFalse( - browserNavigationShouldFallbackNilTargetToNewTab( - navigationType: .other - ) - ) - } - - func testLinkActivatedNavigationFallsBackToNewTab() { - XCTAssertTrue( - browserNavigationShouldFallbackNilTargetToNewTab( - navigationType: .linkActivated - ) - ) - } -} - -final class BrowserPopupContentRectTests: XCTestCase { - func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() { - let rect = browserPopupContentRect( - requestedWidth: 400, - requestedHeight: 300, - requestedX: 150, - requestedTopY: 120, - visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) - ) - - XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01) - XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01) - XCTAssertEqual(rect.width, 400, accuracy: 0.01) - XCTAssertEqual(rect.height, 300, accuracy: 0.01) - } - - func testExplicitCoordinatesClampToVisibleFrame() { - let rect = browserPopupContentRect( - requestedWidth: 1400, - requestedHeight: 1200, - requestedX: 900, - requestedTopY: -25, - visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) - ) - - XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01) - XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01) - XCTAssertEqual(rect.width, 1000, accuracy: 0.01) - XCTAssertEqual(rect.height, 800, accuracy: 0.01) - } - - func testMissingCoordinatesCentersPopup() { - let rect = browserPopupContentRect( - requestedWidth: 300, - requestedHeight: 200, - requestedX: nil, - requestedTopY: nil, - visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) - ) - - XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01) - XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01) - XCTAssertEqual(rect.width, 300, accuracy: 0.01) - XCTAssertEqual(rect.height, 200, accuracy: 0.01) - } -} - -@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 LastSurfaceCloseShortcutSettingsTests: XCTestCase { - func testDefaultClosesWorkspace() { - let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) - } - - func testStoredTrueClosesWorkspace() { - let suiteName = "LastSurfaceCloseShortcutSettingsTests.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: LastSurfaceCloseShortcutSettings.key) - XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) - } - - func testStoredFalseKeepsWorkspaceOpen() { - let suiteName = "LastSurfaceCloseShortcutSettingsTests.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: LastSurfaceCloseShortcutSettings.key) - XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(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 SidebarWorkspaceDetailSettingsTests: XCTestCase { - func testDefaultPreferencesWhenUnset() { - let suiteName = "SidebarWorkspaceDetailSettingsTests.Default.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertFalse(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)) - XCTAssertTrue(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults)) - XCTAssertTrue( - SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( - showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults), - hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults) - ) - ) - } - - func testStoredPreferencesOverrideDefaults() { - let suiteName = "SidebarWorkspaceDetailSettingsTests.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(true, forKey: SidebarWorkspaceDetailSettings.hideAllDetailsKey) - defaults.set(false, forKey: SidebarWorkspaceDetailSettings.showNotificationMessageKey) - - XCTAssertTrue(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)) - XCTAssertFalse(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults)) - XCTAssertFalse( - SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( - showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults), - hideAllDetails: false - ) - ) - XCTAssertFalse( - SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( - showNotificationMessage: true, - hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults) - ) - ) - } -} - -final class SidebarWorkspaceAuxiliaryDetailVisibilityTests: XCTestCase { - func testResolvedVisibilityPreservesPerRowTogglesWhenDetailsAreShown() { - XCTAssertEqual( - SidebarWorkspaceAuxiliaryDetailVisibility.resolved( - showMetadata: true, - showLog: false, - showProgress: true, - showBranchDirectory: false, - showPullRequests: true, - showPorts: false, - hideAllDetails: false - ), - SidebarWorkspaceAuxiliaryDetailVisibility( - showsMetadata: true, - showsLog: false, - showsProgress: true, - showsBranchDirectory: false, - showsPullRequests: true, - showsPorts: false - ) - ) - } - - func testResolvedVisibilityHidesAllAuxiliaryRowsWhenDetailsAreHidden() { - XCTAssertEqual( - SidebarWorkspaceAuxiliaryDetailVisibility.resolved( - showMetadata: true, - showLog: true, - showProgress: true, - showBranchDirectory: true, - showPullRequests: true, - showPorts: true, - hideAllDetails: true - ), - .hidden - ) - } -} - -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 UpdateSettingsTests: XCTestCase { - func testApplyEnablesAutomaticChecksAndDailySchedule() { - let defaults = makeDefaults() - UpdateSettings.apply(to: defaults) - - XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) - XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) - XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) - XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey)) - XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey)) - } - - func testApplyRepairsLegacyDisabledAutomaticChecksOnce() { - let defaults = makeDefaults() - defaults.set(false, forKey: UpdateSettings.automaticChecksKey) - defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey) - defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey) - - UpdateSettings.apply(to: defaults) - - XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) - XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) - XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) - - defaults.set(false, forKey: UpdateSettings.automaticChecksKey) - UpdateSettings.apply(to: defaults) - - XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) - } - - private func makeDefaults() -> UserDefaults { - let suiteName = "UpdateSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - fatalError("Failed to create isolated UserDefaults suite") - } - defaults.removePersistentDomain(forName: suiteName) - return defaults - } -} - -final class SidebarRemoteErrorCopySupportTests: XCTestCase { - func testMenuLabelIsNilWhenThereAreNoErrors() { - XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) - XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) - } - - func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { - let entries = [ - SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox:22", - detail: "failed to start reverse relay" - ) - ] - - XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: entries), - "SSH error (devbox:22): failed to start reverse relay" - ) - } - - func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { - let entries = [ - SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox-a:22", - detail: "connection timed out" - ), - SidebarRemoteErrorCopyEntry( - workspaceTitle: "beta", - target: "devbox-b:22", - detail: "permission denied" - ), - ] - - XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: entries), - """ - 1. alpha (devbox-a:22): connection timed out - 2. beta (devbox-b:22): permission denied - """ - ) - } - - func testClipboardTextSingleEntryUsesStructuredEntryFields() { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox:22", - detail: "failed to bootstrap daemon" - ) - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: [entry]), - "SSH error (devbox:22): failed to bootstrap daemon" - ) - } -} - -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 - func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() { - let manager = TabManager() - let firstPinned = manager.tabs[0] - manager.setPinned(firstPinned, pinned: true) - let secondPinned = manager.addWorkspace() - manager.setPinned(secondPinned, pinned: true) - let unpinned = manager.addWorkspace() - - XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0)) - XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id]) - } - - @MainActor - func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() { - let manager = TabManager() - let firstPinned = manager.tabs[0] - manager.setPinned(firstPinned, pinned: true) - let secondPinned = manager.addWorkspace() - manager.setPinned(secondPinned, pinned: true) - let unpinned = manager.addWorkspace() - - XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999)) - XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id]) - } -} - -@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 WorkspaceSplitWorkingDirectoryTests: XCTestCase { - func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { - let workspace = Workspace() - guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { - XCTFail("Expected focused pane in new workspace") - return - } - - let staleCurrentDirectory = workspace.currentDirectory - let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)" - guard let sourcePanel = workspace.newTerminalSurface( - inPane: sourcePaneId, - focus: false, - workingDirectory: requestedDirectory - ) else { - XCTFail("Expected source terminal panel to be created") - return - } - - XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) - XCTAssertNil( - workspace.panelDirectories[sourcePanel.id], - "Expected requested cwd to exist before shell integration reports a live cwd" - ) - XCTAssertEqual( - workspace.currentDirectory, - staleCurrentDirectory, - "Expected focused workspace cwd to remain stale before panel directory updates" - ) - - guard let splitPanel = workspace.newTerminalSplit( - from: sourcePanel.id, - orientation: .horizontal, - focus: false - ) else { - XCTFail("Expected split terminal panel to be created") - return - } - - XCTAssertEqual( - splitPanel.requestedWorkingDirectory, - requestedDirectory, - "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet" - ) - } -} - -@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 TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase { - func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() { - let manager = TabManager() - let second = manager.addWorkspace() - let third = manager.addWorkspace() - manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") - manager.setCustomTitle(tabId: second.id, title: "Beta") - manager.setCustomTitle(tabId: third.id, title: "Gamma") - - var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] - manager.confirmCloseHandler = { title, message, acceptCmdD in - prompts.append((title, message, acceptCmdD)) - return true - } - - manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) - - let expectedMessage = String( - format: String( - localized: "dialog.closeWorkspaces.message", - defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" - ), - locale: .current, - Int64(2), - "• Alpha\n• Beta" - ) - XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close") - XCTAssertEqual( - prompts.first?.title, - String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") - ) - XCTAssertEqual(prompts.first?.message, expectedMessage) - XCTAssertEqual(prompts.first?.acceptCmdD, false) - XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"]) - } - - func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() { - let manager = TabManager() - let second = manager.addWorkspace() - manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") - manager.setCustomTitle(tabId: second.id, title: "Beta") - - var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] - manager.confirmCloseHandler = { title, message, acceptCmdD in - prompts.append((title, message, acceptCmdD)) - return false - } - - manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) - - let expectedMessage = String( - format: String( - localized: "dialog.closeWorkspacesWindow.message", - defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" - ), - locale: .current, - Int64(2), - "• Alpha\n• Beta" - ) - XCTAssertEqual(prompts.count, 1) - XCTAssertEqual( - prompts.first?.title, - String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") - ) - XCTAssertEqual(prompts.first?.message, expectedMessage) - XCTAssertEqual(prompts.first?.acceptCmdD, true) - XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"]) - } - - func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() { - let manager = TabManager() - let second = manager.addWorkspace() - let third = manager.addWorkspace() - manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") - manager.setCustomTitle(tabId: second.id, title: "Beta") - manager.setCustomTitle(tabId: third.id, title: "Gamma") - manager.selectWorkspace(second) - manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id]) - - var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] - manager.confirmCloseHandler = { title, message, acceptCmdD in - prompts.append((title, message, acceptCmdD)) - return false - } - - manager.closeCurrentWorkspaceWithConfirmation() - - let expectedMessage = String( - format: String( - localized: "dialog.closeWorkspaces.message", - defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" - ), - locale: .current, - Int64(2), - "• Alpha\n• Beta" - ) - XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog") - XCTAssertEqual( - prompts.first?.title, - String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") - ) - XCTAssertEqual(prompts.first?.message, expectedMessage) - XCTAssertEqual(prompts.first?.acceptCmdD, false) - XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"]) - } -} - -@MainActor -final class TabManagerCloseCurrentPanelTests: XCTestCase { - func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected selected workspace and focused terminal panel") - return - } - - terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true) - workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) - - var promptCount = 0 - manager.confirmCloseHandler = { _, _, _ in - promptCount += 1 - return false - } - - manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state") - XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close") - XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel") - } - - func testRuntimeClosePromptsWhenShellReportsRunningCommand() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { - XCTFail("Expected selected workspace and focused terminal panel") - return - } - - terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false) - workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) - - var promptCount = 0 - manager.confirmCloseHandler = { _, _, _ in - promptCount += 1 - return false - } - - manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId) - - XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation") - XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open") - } - - func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() { - let manager = TabManager() - let firstWorkspace = manager.tabs[0] - let secondWorkspace = manager.addWorkspace() - manager.selectWorkspace(secondWorkspace) - - guard let secondPanelId = secondWorkspace.focusedPanelId else { - XCTFail("Expected focused panel in selected workspace") - return - } - - XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) - XCTAssertEqual(secondWorkspace.panels.count, 1) - - manager.closeCurrentPanelWithConfirmation() - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) - XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) - XCTAssertNil(secondWorkspace.panels[secondPanelId]) - XCTAssertTrue(secondWorkspace.panels.isEmpty) - } - - func testCloseCurrentPanelKeepsWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() { - let defaults = UserDefaults.standard - let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) - defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) - defer { - if let originalSetting { - defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) - } else { - defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) - } - } - - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace and focused panel") - return - } - - let initialWorkspaceId = workspace.id - - manager.closeCurrentPanelWithConfirmation() - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.selectedTabId, initialWorkspaceId) - XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId) - XCTAssertNil(workspace.panels[initialPanelId]) - XCTAssertEqual(workspace.panels.count, 1) - XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) - } - - func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() { - let manager = TabManager() - let firstWorkspace = manager.tabs[0] - let secondWorkspace = manager.addWorkspace() - manager.selectWorkspace(secondWorkspace) - - guard let secondPanelId = secondWorkspace.focusedPanelId else { - XCTFail("Expected focused panel in selected workspace") - return - } - - XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) - XCTAssertEqual(secondWorkspace.panels.count, 1) - - guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { - XCTFail("Expected bonsplit surface ID for focused panel") - return - } - - secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId) - XCTAssertFalse(secondWorkspace.closePanel(secondPanelId)) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) - XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) - XCTAssertNil(secondWorkspace.panels[secondPanelId]) - XCTAssertTrue(secondWorkspace.panels.isEmpty) - } - - func testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpenPreferenceIsEnabled() { - let defaults = UserDefaults.standard - let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) - defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) - defer { - if let originalSetting { - defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) - } else { - defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) - } - } - - let manager = TabManager() - let firstWorkspace = manager.tabs[0] - let secondWorkspace = manager.addWorkspace() - manager.selectWorkspace(secondWorkspace) - - guard let secondPanelId = secondWorkspace.focusedPanelId else { - XCTFail("Expected focused panel in selected workspace") - return - } - - guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { - XCTFail("Expected bonsplit surface ID for focused panel") - return - } - - secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId) - XCTAssertFalse(secondWorkspace.closePanel(secondPanelId)) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) - XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) - XCTAssertNil(secondWorkspace.panels[secondPanelId]) - XCTAssertTrue(secondWorkspace.panels.isEmpty) - } - - func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace and focused panel") - return - } - - let initialWorkspaceId = workspace.id - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(workspace.panels.count, 1) - - XCTAssertTrue(workspace.closePanel(initialPanelId)) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.selectedTabId, initialWorkspaceId) - XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId) - XCTAssertNil(workspace.panels[initialPanelId]) - XCTAssertEqual(workspace.panels.count, 1) - XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) - } - - func testCloseCurrentPanelIgnoresStaleSurfaceId() { - let manager = TabManager() - let firstWorkspace = manager.tabs[0] - let secondWorkspace = manager.addWorkspace() - - manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID()) - - XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id]) - } - - func testCloseCurrentPanelClearsNotificationsForClosedSurface() { - let appDelegate = AppDelegate.shared ?? AppDelegate() - let manager = TabManager() - let store = TerminalNotificationStore.shared - - let originalTabManager = appDelegate.tabManager - let originalNotificationStore = appDelegate.notificationStore - store.replaceNotificationsForTesting([]) - store.configureNotificationDeliveryHandlerForTesting { _, _ in } - appDelegate.tabManager = manager - appDelegate.notificationStore = store - - defer { - store.replaceNotificationsForTesting([]) - store.resetNotificationDeliveryHandlerForTesting() - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore - } - - guard let workspace = manager.selectedWorkspace, - let initialPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace and focused panel") - return - } - - store.addNotification( - tabId: workspace.id, - surfaceId: initialPanelId, - title: "Unread", - subtitle: "", - body: "" - ) - XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) - - manager.closeCurrentPanelWithConfirmation() - drainMainQueue() - drainMainQueue() - - XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) - } -} - -@MainActor -final class TabManagerNotificationFocusTests: XCTestCase { - func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let leftPanelId = workspace.focusedPanelId, - let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { - XCTFail("Expected split setup to succeed") - return - } - - workspace.focusPanel(leftPanelId) - XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable") - XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed") - - XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) - drainMainQueue() - drainMainQueue() - - XCTAssertFalse( - workspace.bonsplitController.isSplitZoomed, - "Expected notification focus to exit split zoom so the target pane becomes visible" - ) - XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused") - } - - func testFocusTabFromNotificationReturnsFalseForMissingPanel() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace else { - XCTFail("Expected selected workspace") - return - } - - XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID())) - } -} - -@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 WorkspaceBrowserProfileSelectionTests: XCTestCase { - private final class RejectingCreateTabDelegate: BonsplitDelegate { - func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { - false - } - } - - private final class RejectingSplitPaneDelegate: BonsplitDelegate { - func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { - false - } - } - - func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws { - let workspace = Workspace() - let profileA = try makeTemporaryBrowserProfile(named: "Alpha") - let profileB = try makeTemporaryBrowserProfile(named: "Beta") - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) - let browserA = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - focus: true, - preferredProfileID: profileA.id - ) - ) - _ = try XCTUnwrap( - workspace.newBrowserSplit( - from: browserA.id, - orientation: .horizontal, - preferredProfileID: profileB.id, - focus: true - ) - ) - - XCTAssertEqual( - workspace.preferredBrowserProfileID, - profileB.id, - "Expected workspace preference to drift to the most recently created browser profile" - ) - - let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id)) - workspace.bonsplitController.focusPane(paneId) - workspace.bonsplitController.selectTab(leftSurfaceId) - - let created = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - focus: false - ) - ) - - XCTAssertEqual( - created.profileID, - profileA.id, - "Expected new browser creation to inherit the selected browser profile from the target pane" - ) - } - - func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws { - let workspace = Workspace() - let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") - let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") - - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) - _ = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - focus: false, - preferredProfileID: preferredProfile.id - ) - ) - XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) - - let rejectingDelegate = RejectingCreateTabDelegate() - workspace.bonsplitController.delegate = rejectingDelegate - let created = workspace.newBrowserSurface( - inPane: paneId, - focus: false, - preferredProfileID: unexpectedProfile.id - ) - - XCTAssertNil(created) - XCTAssertEqual( - workspace.preferredBrowserProfileID, - preferredProfile.id, - "Expected a failed browser creation to leave the workspace preferred profile unchanged" - ) - } - - func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { - let workspace = Workspace() - let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") - let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") - - let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) - let browser = try XCTUnwrap( - workspace.newBrowserSurface( - inPane: paneId, - focus: true, - preferredProfileID: preferredProfile.id - ) - ) - XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) - - let rejectingDelegate = RejectingSplitPaneDelegate() - workspace.bonsplitController.delegate = rejectingDelegate - let created = workspace.newBrowserSplit( - from: browser.id, - orientation: .horizontal, - preferredProfileID: unexpectedProfile.id, - focus: false - ) - - XCTAssertNil(created) - XCTAssertEqual( - workspace.preferredBrowserProfileID, - preferredProfile.id, - "Expected a failed browser split to leave the workspace preferred profile unchanged" - ) - } -} - -@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 BrowserPanelProfileIsolationTests: XCTestCase { - func testStaleDidFinishDoesNotRecordVisitIntoSwitchedProfileHistory() throws { - let alternateProfile = try makeTemporaryBrowserProfile(named: "Switched") - let defaultStore = BrowserHistoryStore.shared - let alternateStore = BrowserProfileStore.shared.historyStore(for: alternateProfile.id) - defaultStore.clearHistory() - alternateStore.clearHistory() - defer { - defaultStore.clearHistory() - alternateStore.clearHistory() - } - - let panel = BrowserPanel( - workspaceId: UUID(), - profileID: BrowserProfileStore.shared.builtInDefaultProfileID - ) - let staleWebView = panel.webView - let staleDelegate = try XCTUnwrap(staleWebView.navigationDelegate) - let staleURL = try XCTUnwrap(URL(string: "https://example.com/stale-finish")) - staleWebView.loadHTMLString( - "Stalestale", - baseURL: staleURL - ) - - XCTAssertTrue( - panel.switchToProfile(alternateProfile.id), - "Expected profile switch to succeed, current=\(panel.profileID) requested=\(alternateProfile.id) exists=\(BrowserProfileStore.shared.profileDefinition(id: alternateProfile.id) != nil)" - ) - defaultStore.clearHistory() - alternateStore.clearHistory() - - staleDelegate.webView?(staleWebView, didFinish: nil) - drainMainQueue() - - XCTAssertTrue( - defaultStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the old profile history store, found \(defaultStore.entries.map { $0.url })" - ) - XCTAssertTrue( - alternateStore.entries.isEmpty, - "Expected stale completion callbacks to avoid writing into the newly selected profile history store, found \(alternateStore.entries.map { $0.url })" - ) - } -} - -@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 testNewTerminalSurfaceWithFocusFalsePreservesFocusedPanel() { - let workspace = Workspace() - guard let originalFocusedPanelId = workspace.focusedPanelId, - let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { - XCTFail("Expected initial focused panel and pane") - return - } - - guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else { - XCTFail("Expected terminal surface to be created") - return - } - - drainMainQueue() - drainMainQueue() - drainMainQueue() - - XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) - XCTAssertEqual( - workspace.focusedPanelId, - originalFocusedPanelId, - "Expected non-focus terminal surface creation to preserve the existing focused panel" - ) - XCTAssertEqual( - workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, - workspace.surfaceIdFromPanelId(originalFocusedPanelId), - "Expected selected tab to stay on the original focused panel" - ) - } - - func testNewBrowserSurfaceWithFocusFalsePreservesFocusedPanel() { - let workspace = Workspace() - guard let originalFocusedPanelId = workspace.focusedPanelId, - let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { - XCTFail("Expected initial focused panel and pane") - return - } - - guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else { - XCTFail("Expected browser surface to be created") - return - } - - drainMainQueue() - drainMainQueue() - drainMainQueue() - - XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) - XCTAssertEqual( - workspace.focusedPanelId, - originalFocusedPanelId, - "Expected non-focus browser surface creation to preserve the existing focused panel" - ) - XCTAssertEqual( - workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, - workspace.surfaceIdFromPanelId(originalFocusedPanelId), - "Expected selected tab to stay on the original focused panel" - ) - } - - 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]) - } - - @MainActor - func testSidebarPullRequestsTrackFocusedPanelOnly() { - let workspace = Workspace() - guard let firstPanelId = workspace.focusedPanelId, - let paneId = workspace.paneId(forPanelId: firstPanelId), - let secondPanel = workspace.newTerminalSurface(inPane: paneId, focus: false) else { - XCTFail("Expected focused panel and a second panel") - return - } - - workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false) - workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/sidebar-pr", isDirty: false) - workspace.updatePanelPullRequest( - panelId: secondPanel.id, - number: 1629, - label: "PR", - url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, - status: .open - ) - - XCTAssertNil(workspace.pullRequest) - XCTAssertTrue( - workspace.sidebarPullRequestsInDisplayOrder().isEmpty, - "Expected background panel PRs to stay hidden while the focused panel has no PR" - ) - - workspace.focusPanel(secondPanel.id) - - XCTAssertEqual( - workspace.sidebarPullRequestsInDisplayOrder().map(\.number), - [1629] - ) - } - - 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 testOrderedUniquePullRequestsPrefersEntryWithChecksWhenStatusesMatch() { - 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/cmux/pull/42", - status: .open, - checks: .pass - ) - ], - fallbackPullRequest: nil - ) - - XCTAssertEqual(pullRequests.count, 1) - XCTAssertEqual(pullRequests.first?.checks, .pass) - } - - @MainActor - func testUpdatePanelPullRequestPreservesExistingChecksWhenUpdateOmitsThem() { - let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) - guard let panelId = workspace.focusedPanelId else { - XCTFail("Expected focused panel for new workspace") - return - } - - workspace.updatePanelPullRequest( - panelId: panelId, - number: 42, - label: "PR", - url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!, - status: .open, - checks: .pass - ) - workspace.updatePanelPullRequest( - panelId: panelId, - number: 42, - label: "PR", - url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!, - status: .open - ) - - XCTAssertEqual(workspace.panelPullRequests[panelId]?.checks, .pass) - XCTAssertEqual(workspace.pullRequest?.checks, .pass) - } - - 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]) - } - - @MainActor - func testUpdatePanelGitBranchClearsFocusedPullRequestWhenBranchChanges() { - let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) - guard let panelId = workspace.focusedPanelId else { - XCTFail("Expected focused panel for new workspace") - return - } - - workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false) - workspace.updatePanelPullRequest( - panelId: panelId, - number: 1629, - label: "PR", - url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, - status: .open - ) - - workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) - - XCTAssertNil(workspace.pullRequest) - XCTAssertNil(workspace.panelPullRequests[panelId]) - XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty) - } - - @MainActor - func testSidebarPullRequestsHideBranchMismatches() { - let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) - guard let panelId = workspace.focusedPanelId else { - XCTFail("Expected focused panel for new workspace") - return - } - - workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) - workspace.updatePanelPullRequest( - panelId: panelId, - number: 1629, - label: "PR", - url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, - status: .open, - branch: "feature/sidebar-pr" - ) - - XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty) - } - - private func pullRequestState( - number: Int, - label: String, - url: String, - status: SidebarPullRequestStatus, - branch: String? = nil, - checks: SidebarPullRequestChecksStatus? = nil - ) -> SidebarPullRequestState { - SidebarPullRequestState( - number: number, - label: label, - url: URL(string: url)!, - status: status, - branch: branch, - checks: checks - ) - } -} - -@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, - pinnedTabIds: [] - ) - ) - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: third, - targetTabId: nil, - tabIds: tabIds, - pinnedTabIds: [] - ) - ) - } - - func testNoIndicatorWhenOnlyOneTabExists() { - let only = UUID() - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: only, - targetTabId: nil, - tabIds: [only], - pinnedTabIds: [] - ) - ) - XCTAssertNil( - SidebarDropPlanner.indicator( - draggedTabId: only, - targetTabId: only, - tabIds: [only], - pinnedTabIds: [] - ) - ) - } - - 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, - pinnedTabIds: [] - ) - 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, - pinnedTabIds: [] - ) - 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, - pinnedTabIds: [] - ) - ) - } - - 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, - pinnedTabIds: [], - 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, - pinnedTabIds: [], - pointerY: 38, - targetHeight: 40 - ) - XCTAssertEqual(indicator?.tabId, third) - XCTAssertEqual(indicator?.edge, .top) - XCTAssertEqual( - SidebarDropPlanner.targetIndex( - draggedTabId: first, - targetTabId: second, - indicator: indicator, - tabIds: tabIds, - pinnedTabIds: [] - ), - 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, - pinnedTabIds: [], - pointerY: 38, - targetHeight: 40 - ) - let fromTopOfSecond = SidebarDropPlanner.indicator( - draggedTabId: third, - targetTabId: second, - tabIds: tabIds, - pinnedTabIds: [], - 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, - pinnedTabIds: [], - pointerY: 38, - targetHeight: 40 - ) - ) - } - - func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { - let pinnedA = UUID() - let pinnedB = UUID() - let unpinnedA = UUID() - let unpinnedB = UUID() - let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] - let pinnedIds: Set = [pinnedA, pinnedB] - - let indicator = SidebarDropPlanner.indicator( - draggedTabId: unpinnedB, - targetTabId: pinnedA, - tabIds: tabIds, - pinnedTabIds: pinnedIds, - pointerY: 2, - targetHeight: 40 - ) - - XCTAssertEqual(indicator?.tabId, unpinnedA) - XCTAssertEqual(indicator?.edge, .top) - } - - func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { - let pinnedA = UUID() - let pinnedB = UUID() - let unpinnedA = UUID() - let unpinnedB = UUID() - let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] - let pinnedIds: Set = [pinnedA, pinnedB] - - let targetIndex = SidebarDropPlanner.targetIndex( - draggedTabId: unpinnedB, - targetTabId: pinnedA, - indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top), - tabIds: tabIds, - pinnedTabIds: pinnedIds - ) - - XCTAssertEqual(targetIndex, 2) - } - -} - -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", - applicationPathsByName: [String: String] = [:] - ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { - TerminalDirectoryOpenTarget.DetectionEnvironment( - homeDirectoryPath: homeDirectoryPath, - fileExistsAtPath: { existingPaths.contains($0) }, - isExecutableFileAtPath: { existingPaths.contains($0) }, - applicationPathForName: { applicationPathsByName[$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 testVSCodeInlineRequiresCodeTunnelExecutable() { - let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"]) - XCTAssertTrue(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env)) - XCTAssertFalse(TerminalDirectoryOpenTarget.vscodeInline.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 testAvailableTargetsFallbackToApplicationLookupForVSCodeAliasOutsideApplications() { - let vscodePath = "/Volumes/Tools/Code.app" - let env = environment( - existingPaths: [ - vscodePath, - "\(vscodePath)/Contents/Resources/app/bin/code-tunnel", - ], - applicationPathsByName: [ - "Code": vscodePath, - ] - ) - - let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) - XCTAssertTrue(availableTargets.contains(.vscode)) - XCTAssertTrue(availableTargets.contains(.vscodeInline)) - } - - func testTowerDetectedViaApplicationLookupOutsideApplications() { - let towerPath = "/Volumes/Setapp/Tower.app" - let env = environment( - existingPaths: [towerPath], - applicationPathsByName: [ - "Tower": towerPath, - ] - ) - - 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 testNotificationPaneFlashPreferenceDefaultsToEnabled() { - let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) - - defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey) - XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) - - defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey) - XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) - } - - func testMenuBarExtraPreferenceDefaultsToVisible() { - let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) - - defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey) - XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) - - defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey) - XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(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 - ) - ) - } -} - -@MainActor -final class InternalTabDragBundleDeclarationTests: XCTestCase { - private func exportedTypeIdentifiers(bundle: Bundle) -> Set { - let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? [] - return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String }) - } - - func testAppBundleExportsInternalDragTypes() { - let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self)) - - XCTAssertTrue( - exported.contains("com.splittabbar.tabtransfer"), - "Expected app bundle to export bonsplit tab-transfer type, got \(exported)" - ) - XCTAssertTrue( - exported.contains("com.cmux.sidebar-tab-reorder"), - "Expected app bundle to export sidebar tab-reorder type, got \(exported)" - ) - } -} -#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 - } - - private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool { - if firstResponder === textField { - return true - } - if let editor = firstResponder as? NSTextView, - editor.isFieldEditor, - editor.delegate as? NSTextField === textField { - return true - } - return false - } - - 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) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertTrue(hostedView.debugHasSearchOverlay()) - - hostedView.setSearchOverlay(searchState: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertFalse(hostedView.debugHasSearchOverlay()) - } - - func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() { - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = surface.hostedView - - hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example")) - hostedView.setSearchOverlay(searchState: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - XCTAssertFalse( - hostedView.debugHasSearchOverlay(), - "A stale deferred mount must not resurrect the find overlay after it closes" - ) - } - - func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() { - 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 { 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) - - 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 - } - - XCTAssertTrue( - firstResponderOwnsTextField(window.firstResponder, textField: searchField), - "Deferred search overlay attach should still move focus into the find field" - ) - } - - func testStartOrFocusTerminalSearchReusesExistingSearchState() { - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let existingSearchState = TerminalSurface.SearchState(needle: "existing") - surface.searchState = existingSearchState - - var focusNotificationCount = 0 - XCTAssertTrue( - startOrFocusTerminalSearch(surface) { _ in - focusNotificationCount += 1 - } - ) - - XCTAssertTrue(surface.searchState === existingSearchState) - XCTAssertEqual( - focusNotificationCount, - 1, - "Re-triggering terminal Find should refocus the existing overlay without recreating state" - ) - } - - 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")) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - 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")) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - 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" - ) - } - - func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() { - 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) - } - - let surface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) - contentView.addSubview(shiftedContainer) - let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) - shiftedContainer.addSubview(anchor) - 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) - let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) - XCTAssertNotNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), - "Initial hit-testing should resolve the portal-hosted terminal at its original window position" - ) - - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() - DispatchQueue.main.async { - shiftedContainer.frame.origin.x += 72 - contentView.layoutSubtreeIfNeeded() - window.displayIfNeeded() - } - - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) - XCTAssertGreaterThan( - shiftedAnchorFrameInWindow.minX, - originalAnchorFrameInWindow.minX + 1, - "The queued layout shift should move the anchor to the right" - ) - XCTAssertGreaterThan( - shiftedAnchorFrameInWindow.maxX, - originalAnchorFrameInWindow.maxX + 1, - "The shifted anchor should expose a new trailing region outside the stale portal frame" - ) - let retiredStaleWindowPoint = NSPoint( - x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, - y: shiftedAnchorFrameInWindow.midY - ) - let shiftedWindowPoint = NSPoint( - x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, - y: shiftedAnchorFrameInWindow.midY - ) - XCTAssertNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), - "The queued external sync should wait until the later layout shift settles, clearing the stale portal location" - ) - XCTAssertNotNil( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), - "The delayed external sync should move the portal-hosted terminal to the queued layout shift 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 { - func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() { - XCTAssertEqual( - TerminalController.fallbackSocketPathAfterBindFailure( - requestedPath: SocketControlSettings.stableDefaultSocketPath, - stage: "bind", - errnoCode: EACCES, - currentUserID: 501 - ), - SocketControlSettings.userScopedStableSocketPath(currentUserID: 501) - ) - } - - func testNonStableSocketBindFailureDoesNotFallback() { - XCTAssertNil( - TerminalController.fallbackSocketPathAfterBindFailure( - requestedPath: "/tmp/cmux-debug.sock", - stage: "bind", - errnoCode: EACCES, - currentUserID: 501 - ) - ) - } - - 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 - } - - private func acceptSingleClient( - on listenerFD: Int32, - handler: @escaping (_ clientFD: Int32) -> Void - ) -> XCTestExpectation { - let handled = expectation(description: "socket client handled") - DispatchQueue.global(qos: .userInitiated).async { - var clientAddr = sockaddr_un() - var clientAddrLen = socklen_t(MemoryLayout.size) - let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) - } - } - guard clientFD >= 0 else { - handled.fulfill() - return - } - defer { - Darwin.close(clientFD) - handled.fulfill() - } - handler(clientFD) - } - return handled - } - - @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 testProbeSocketCommandReturnsFirstLineResponse() throws { - let path = makeTempSocketPath() - let listenerFD = try bindUnixSocket(at: path) - defer { - Darwin.close(listenerFD) - unlink(path) - } - - let handled = acceptSingleClient(on: listenerFD) { clientFD in - var buffer = [UInt8](repeating: 0, count: 256) - _ = read(clientFD, &buffer, buffer.count) - let response = "PONG\nextra\n" - _ = response.withCString { ptr in - write(clientFD, ptr, strlen(ptr)) - } - } - - let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5) - - XCTAssertEqual(response, "PONG") - wait(for: [handled], timeout: 1.0) - } - - func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws { - let path = makeTempSocketPath() - let listenerFD = try bindUnixSocket(at: path) - defer { - Darwin.close(listenerFD) - unlink(path) - } - - let releaseServer = DispatchSemaphore(value: 0) - let handled = acceptSingleClient(on: listenerFD) { clientFD in - var buffer = [UInt8](repeating: 0, count: 256) - _ = read(clientFD, &buffer, buffer.count) - _ = releaseServer.wait(timeout: .now() + 1.0) - } - - let startedAt = Date() - let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2) - let elapsed = Date().timeIntervalSince(startedAt) - releaseServer.signal() - - XCTAssertNil(response) - XCTAssertGreaterThanOrEqual(elapsed, 0.18) - XCTAssertLessThan(elapsed, 0.8) - wait(for: [handled], timeout: 1.0) - } - - func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() { - let health = TerminalController.SocketListenerHealth( - isRunning: true, - acceptLoopAlive: true, - socketPathMatches: true, - socketPathExists: true - ) - XCTAssertTrue(health.isHealthy) - XCTAssertTrue(health.failureSignals.isEmpty) - } - - func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { - let health = TerminalController.SocketListenerHealth( - isRunning: false, - acceptLoopAlive: false, - socketPathMatches: false, - socketPathExists: false - ) - XCTAssertFalse(health.isHealthy) - XCTAssertEqual( - health.failureSignals, - ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] - ) - } -} diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift new file mode 100644 index 00000000..c519e3ab --- /dev/null +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -0,0 +1,832 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@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 testNotificationPaneFlashPreferenceDefaultsToEnabled() { + let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) + + defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey) + XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) + + defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey) + XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults)) + } + + func testMenuBarExtraPreferenceDefaultsToVisible() { + let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) + + defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey) + XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) + + defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey) + XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults)) + } + + func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + + defaults.set("Ping", forKey: NotificationSoundSettings.key) + XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set("none", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationCustomFileURLExpandsTildePath() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let rawPath = "~/Library/Sounds/my-custom.wav" + defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey) + let expectedPath = (rawPath as NSString).expandingTildeInPath + XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath) + } + + func testNotificationCustomFileSelectionMustBeExplicit() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey) + + defaults.set("none", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + + defaults.set("Ping", forKey: NotificationSoundSettings.key) + XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) + } + + func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let fileManager = FileManager.default + let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + do { + try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + } catch { + XCTFail("Failed to create sounds directory: \(error)") + return + } + + let sourceURL = soundsDirectory.appendingPathComponent( + "cmux-custom-notification-sound.source-\(UUID().uuidString).wav", + isDirectory: false + ) + defer { + try? fileManager.removeItem(at: sourceURL) + } + + do { + try Data("test".utf8).write(to: sourceURL, options: .atomic) + } catch { + XCTFail("Failed to write source custom sound file: \(error)") + return + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + _ = NotificationSoundSettings.sound(defaults: defaults) + + guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else { + XCTFail("Expected staged custom sound name") + return + } + let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) + defer { + try? fileManager.removeItem(at: stagedURL) + } + + XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) + XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-")) + XCTAssertTrue(stagedName.hasSuffix(".wav")) + } + + func testNotificationCustomUnsupportedExtensionsStageAsCaf() { + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"), + "caf" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"), + "caf" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"), + "wav" + ) + XCTAssertEqual( + NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"), + "aiff" + ) + + let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3") + let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3") + let stagedA = NotificationSoundSettings.stagedCustomSoundFileName( + forSourceURL: sourceA, + destinationExtension: "caf" + ) + let stagedB = NotificationSoundSettings.stagedCustomSoundFileName( + forSourceURL: sourceB, + destinationExtension: "caf" + ) + XCTAssertNotEqual(stagedA, stagedB) + XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-")) + XCTAssertTrue(stagedA.hasSuffix(".caf")) + } + + func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let fileManager = FileManager.default + let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + do { + try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) + } catch { + XCTFail("Failed to create sounds directory: \(error)") + return + } + + let sourceURL = soundsDirectory.appendingPathComponent( + "cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav", + isDirectory: false + ) + do { + try Data("test".utf8).write(to: sourceURL, options: .atomic) + } catch { + XCTFail("Failed to write source custom sound file: \(error)") + return + } + defer { + try? fileManager.removeItem(at: sourceURL) + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path) + let stagedName: String + switch prepareResult { + case .success(let name): + stagedName = name + case .failure(let issue): + XCTFail("Expected custom sound preparation success, got \(issue)") + return + } + + let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) + let metadataURL = stagedURL.appendingPathExtension("source-metadata") + defer { + try? fileManager.removeItem(at: stagedURL) + try? fileManager.removeItem(at: metadataURL) + } + + XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path)) + } + + func testNotificationCustomSoundReturnsNilWhenPreparationFails() { + let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + let invalidSourceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false) + defer { + try? FileManager.default.removeItem(at: invalidSourceURL) + let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Sounds", isDirectory: true) + .appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false) + try? FileManager.default.removeItem(at: stagedURL) + } + + do { + try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic) + } catch { + XCTFail("Failed to write invalid custom sound source: \(error)") + return + } + + defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) + defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) + + XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) + } + + func testNotificationCustomPreparationReportsMissingFile() { + let missingPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false) + .path + + let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath) + switch result { + case .success: + XCTFail("Expected missing file failure") + case .failure(let issue): + guard case .missingFile = issue else { + XCTFail("Expected missingFile issue, got \(issue)") + return + } + } + } + + func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() { + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized) + XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional) + } + + func testNotificationAuthorizationStateDeliveryCapability() { + XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery) + XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery) + XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery) + XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery) + } + + func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() { + XCTAssertTrue( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .notDetermined, + isAppActive: false + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .notDetermined, + isAppActive: true + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest( + status: .authorized, + isAppActive: false + ) + ) + } + + func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() { + XCTAssertTrue( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: false, + hasRequestedAutomaticAuthorization: true + ) + ) + XCTAssertTrue( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: true, + hasRequestedAutomaticAuthorization: false + ) + ) + XCTAssertFalse( + TerminalNotificationStore.shouldRequestAuthorization( + isAutomaticRequest: true, + hasRequestedAutomaticAuthorization: true + ) + ) + } + + func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() { + let store = TerminalNotificationStore.shared + let alertSpy = NotificationSettingsAlertSpy() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + + var openedURL: URL? + store.configureNotificationSettingsPromptHooksForTesting( + windowProvider: { window }, + alertFactory: { alertSpy }, + scheduler: { _, block in block() }, + urlOpener: { openedURL = $0 } + ) + + store.promptToEnableNotificationsForTesting() + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + XCTAssertEqual( + openedURL?.absoluteString, + "x-apple.systempreferences:com.apple.preference.notifications" + ) + } + + func testNotificationSettingsPromptRetriesUntilWindowExists() { + let store = TerminalNotificationStore.shared + let alertSpy = NotificationSettingsAlertSpy() + alertSpy.nextResponse = .alertSecondButtonReturn + + var queuedRetryBlocks: [() -> Void] = [] + var promptWindow: NSWindow? + store.configureNotificationSettingsPromptHooksForTesting( + windowProvider: { promptWindow }, + alertFactory: { alertSpy }, + scheduler: { _, block in queuedRetryBlocks.append(block) }, + urlOpener: { _ in XCTFail("Should not open settings for Not Now response") } + ) + + store.promptToEnableNotificationsForTesting() + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + XCTAssertEqual(queuedRetryBlocks.count, 1) + + promptWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + queuedRetryBlocks.removeFirst()() + + XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1) + XCTAssertEqual(alertSpy.runModalCallCount, 0) + } + + func testNotificationIndexesTrackUnreadCountsByTabAndSurface() { + let tabA = UUID() + let tabB = UUID() + let surfaceA = UUID() + let surfaceB = UUID() + let notificationAUnread = TerminalNotification( + id: UUID(), + tabId: tabA, + surfaceId: surfaceA, + title: "A unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + let notificationARead = TerminalNotification( + id: UUID(), + tabId: tabA, + surfaceId: surfaceB, + title: "A read", + subtitle: "", + body: "", + createdAt: Date(), + isRead: true + ) + let notificationBUnread = TerminalNotification( + id: UUID(), + tabId: tabB, + surfaceId: nil, + title: "B unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([ + notificationAUnread, + notificationARead, + notificationBUnread + ]) + + XCTAssertEqual(store.unreadCount, 2) + XCTAssertEqual(store.unreadCount(forTabId: tabA), 1) + XCTAssertEqual(store.unreadCount(forTabId: tabB), 1) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA)) + XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB)) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil)) + XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id) + XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id) + } + + func testNotificationIndexesUpdateAfterReadAndClearMutations() { + let tab = UUID() + let surfaceUnread = UUID() + let surfaceRead = UUID() + let unreadNotification = TerminalNotification( + id: UUID(), + tabId: tab, + surfaceId: surfaceUnread, + title: "Unread", + subtitle: "", + body: "", + createdAt: Date(), + isRead: false + ) + let readNotification = TerminalNotification( + id: UUID(), + tabId: tab, + surfaceId: surfaceRead, + title: "Read", + subtitle: "", + body: "", + createdAt: Date(), + isRead: true + ) + + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([unreadNotification, readNotification]) + XCTAssertEqual(store.unreadCount(forTabId: tab), 1) + XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread)) + + store.markRead(forTabId: tab, surfaceId: surfaceUnread) + XCTAssertEqual(store.unreadCount(forTabId: tab), 0) + XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread)) + XCTAssertEqual(store.latestNotification(forTabId: tab)?.id, unreadNotification.id) + + store.clearNotifications(forTabId: tab) + XCTAssertEqual(store.unreadCount(forTabId: tab), 0) + XCTAssertNil(store.latestNotification(forTabId: tab)) + } +} + + +final class MenuBarBadgeLabelFormatterTests: XCTestCase { + func testBadgeLabelFormatting() { + XCTAssertNil(MenuBarBadgeLabelFormatter.badgeText(for: 0)) + XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 1), "1") + XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 9), "9") + XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 10), "9+") + XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 47), "9+") + } +} + + +final class NotificationMenuSnapshotBuilderTests: XCTestCase { + func testSnapshotCountsUnreadAndLimitsRecentItems() { + let notifications = (0..<8).map { index in + TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "N\(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: index.isMultiple(of: 2) + ) + } + + let snapshot = NotificationMenuSnapshotBuilder.make( + notifications: notifications, + maxInlineNotificationItems: 3 + ) + + XCTAssertEqual(snapshot.unreadCount, 4) + XCTAssertTrue(snapshot.hasNotifications) + XCTAssertTrue(snapshot.hasUnreadNotifications) + XCTAssertEqual(snapshot.recentNotifications.count, 3) + XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id)) + } + + func testStateHintTitleHandlesSingularPluralAndZero() { + XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications") + XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification") + XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 2), "2 unread notifications") + } +} + + +final class MenuBarBuildHintFormatterTests: XCTestCase { + func testReleaseBuildShowsNoHint() { + XCTAssertNil(MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: false)) + } + + func testDebugBuildWithTagShowsTag() { + XCTAssertEqual( + MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: true), + "Build Tag: menubar-extra" + ) + } + + func testDebugBuildWithoutTagShowsUntagged() { + XCTAssertEqual( + MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV", isDebugBuild: true), + "Build: DEV (untagged)" + ) + } +} + + +final class MenuBarNotificationLineFormatterTests: XCTestCase { + func testPlainTitleContainsUnreadDotBodyAndTab() { + let notification = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "Build finished", + subtitle: "", + body: "All checks passed", + createdAt: Date(timeIntervalSince1970: 0), + isRead: false + ) + + let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: "workspace-1") + XCTAssertTrue(line.hasPrefix("● Build finished")) + XCTAssertTrue(line.contains("All checks passed")) + XCTAssertTrue(line.contains("workspace-1")) + } + + func testPlainTitleFallsBackToSubtitleWhenBodyEmpty() { + let notification = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "Deploy", + subtitle: "staging", + body: "", + createdAt: Date(timeIntervalSince1970: 0), + isRead: true + ) + + let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: nil) + XCTAssertTrue(line.hasPrefix(" Deploy")) + XCTAssertTrue(line.contains("staging")) + } + + func testMenuTitleWrapsAndTruncatesToThreeLines() { + let notification = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "Extremely long notification title for wrapping behavior validation", + subtitle: "", + body: Array(repeating: "this body should wrap and eventually truncate", count: 8).joined(separator: " "), + createdAt: Date(timeIntervalSince1970: 0), + isRead: false + ) + + let title = MenuBarNotificationLineFormatter.menuTitle( + notification: notification, + tabTitle: "workspace-with-a-very-long-name", + maxWidth: 120, + maxLines: 3 + ) + + XCTAssertLessThanOrEqual(title.components(separatedBy: "\n").count, 3) + XCTAssertTrue(title.hasSuffix("…")) + } + + func testMenuTitlePreservesShortTextWithoutEllipsis() { + let notification = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: nil, + title: "Done", + subtitle: "", + body: "All checks passed", + createdAt: Date(timeIntervalSince1970: 0), + isRead: false + ) + + let title = MenuBarNotificationLineFormatter.menuTitle( + notification: notification, + tabTitle: "w1", + maxWidth: 320, + maxLines: 3 + ) + + XCTAssertFalse(title.hasSuffix("…")) + } +} + + +final class MenuBarIconDebugSettingsTests: XCTestCase { + func testDisplayedUnreadCountUsesPreviewOverrideWhenEnabled() { + let suiteName = "MenuBarIconDebugSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: MenuBarIconDebugSettings.previewEnabledKey) + defaults.set(7, forKey: MenuBarIconDebugSettings.previewCountKey) + + XCTAssertEqual(MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: 2, defaults: defaults), 7) + } + + func testBadgeRenderConfigClampsInvalidValues() { + let suiteName = "MenuBarIconDebugSettingsTests.Clamp.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(-100, forKey: MenuBarIconDebugSettings.badgeRectXKey) + defaults.set(200, forKey: MenuBarIconDebugSettings.badgeRectYKey) + defaults.set(-100, forKey: MenuBarIconDebugSettings.singleDigitFontSizeKey) + defaults.set(100, forKey: MenuBarIconDebugSettings.multiDigitXAdjustKey) + + let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults) + XCTAssertEqual(config.badgeRect.origin.x, 0, accuracy: 0.001) + XCTAssertEqual(config.badgeRect.origin.y, 20, accuracy: 0.001) + XCTAssertEqual(config.singleDigitFontSize, 6, accuracy: 0.001) + XCTAssertEqual(config.multiDigitXAdjust, 4, accuracy: 0.001) + } + + func testBadgeRenderConfigUsesLegacySingleDigitXAdjustWhenNewKeyMissing() { + let suiteName = "MenuBarIconDebugSettingsTests.LegacyX.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(2.5, forKey: MenuBarIconDebugSettings.legacySingleDigitXAdjustKey) + + let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults) + XCTAssertEqual(config.singleDigitXAdjust, 2.5, accuracy: 0.001) + } +} + +@MainActor + + +final class MenuBarIconRendererTests: XCTestCase { + func testImageWidthDoesNotShiftWhenBadgeAppears() { + let noBadge = MenuBarIconRenderer.makeImage(unreadCount: 0) + let withBadge = MenuBarIconRenderer.makeImage(unreadCount: 2) + + XCTAssertEqual(noBadge.size.width, 18, accuracy: 0.001) + XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001) + } +} diff --git a/cmuxTests/OmnibarAndToolsTests.swift b/cmuxTests/OmnibarAndToolsTests.swift new file mode 100644 index 00000000..6b61a766 --- /dev/null +++ b/cmuxTests/OmnibarAndToolsTests.swift @@ -0,0 +1,857 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +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 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 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") + } +} diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift new file mode 100644 index 00000000..cc5b0c01 --- /dev/null +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -0,0 +1,965 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +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 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 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 LastSurfaceCloseShortcutSettingsTests: XCTestCase { + func testDefaultClosesWorkspace() { + let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) + } + + func testStoredTrueClosesWorkspace() { + let suiteName = "LastSurfaceCloseShortcutSettingsTests.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: LastSurfaceCloseShortcutSettings.key) + XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) + } + + func testStoredFalseKeepsWorkspaceOpen() { + let suiteName = "LastSurfaceCloseShortcutSettingsTests.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: LastSurfaceCloseShortcutSettings.key) + XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults)) + } +} + + +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 UpdateSettingsTests: XCTestCase { + func testApplyEnablesAutomaticChecksAndDailySchedule() { + let defaults = makeDefaults() + UpdateSettings.apply(to: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey)) + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey)) + } + + func testApplyRepairsLegacyDisabledAutomaticChecksOnce() { + let defaults = makeDefaults() + defaults.set(false, forKey: UpdateSettings.automaticChecksKey) + defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey) + defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey) + + UpdateSettings.apply(to: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) + + defaults.set(false, forKey: UpdateSettings.automaticChecksKey) + UpdateSettings.apply(to: defaults) + + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + } + + private func makeDefaults() -> UserDefaults { + let suiteName = "UpdateSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + + +@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 + ) + ) + } +} diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift new file mode 100644 index 00000000..e9301bb6 --- /dev/null +++ b/cmuxTests/SidebarOrderingTests.swift @@ -0,0 +1,941 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +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 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 SidebarRemoteErrorCopySupportTests: XCTestCase { + func testMenuLabelIsNilWhenThereAreNoErrors() { + XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) + XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) + } + + func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to start reverse relay" + ) + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + "SSH error (devbox:22): failed to start reverse relay" + ) + } + + func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox-a:22", + detail: "connection timed out" + ), + SidebarRemoteErrorCopyEntry( + workspaceTitle: "beta", + target: "devbox-b:22", + detail: "permission denied" + ), + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + """ + 1. alpha (devbox-a:22): connection timed out + 2. beta (devbox-b:22): permission denied + """ + ) + } + + func testClipboardTextSingleEntryUsesStructuredEntryFields() { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to bootstrap daemon" + ) + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: [entry]), + "SSH error (devbox:22): failed to bootstrap daemon" + ) + } +} + + +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 testOrderedUniquePullRequestsPrefersEntryWithChecksWhenStatusesMatch() { + 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/cmux/pull/42", + status: .open, + checks: .pass + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual(pullRequests.count, 1) + XCTAssertEqual(pullRequests.first?.checks, .pass) + } + + @MainActor + func testUpdatePanelPullRequestPreservesExistingChecksWhenUpdateOmitsThem() { + let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel for new workspace") + return + } + + workspace.updatePanelPullRequest( + panelId: panelId, + number: 42, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!, + status: .open, + checks: .pass + ) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 42, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!, + status: .open + ) + + XCTAssertEqual(workspace.panelPullRequests[panelId]?.checks, .pass) + XCTAssertEqual(workspace.pullRequest?.checks, .pass) + } + + 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]) + } + + @MainActor + func testUpdatePanelGitBranchClearsFocusedPullRequestWhenBranchChanges() { + let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel for new workspace") + return + } + + workspace.updatePanelGitBranch(panelId: panelId, branch: "feature/sidebar-pr", isDirty: false) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 1629, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, + status: .open + ) + + workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) + + XCTAssertNil(workspace.pullRequest) + XCTAssertNil(workspace.panelPullRequests[panelId]) + XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty) + } + + @MainActor + func testSidebarPullRequestsHideBranchMismatches() { + let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) + guard let panelId = workspace.focusedPanelId else { + XCTFail("Expected focused panel for new workspace") + return + } + + workspace.updatePanelGitBranch(panelId: panelId, branch: "main", isDirty: false) + workspace.updatePanelPullRequest( + panelId: panelId, + number: 1629, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, + status: .open, + branch: "feature/sidebar-pr" + ) + + XCTAssertTrue(workspace.sidebarPullRequestsInDisplayOrder().isEmpty) + } + + private func pullRequestState( + number: Int, + label: String, + url: String, + status: SidebarPullRequestStatus, + branch: String? = nil, + checks: SidebarPullRequestChecksStatus? = nil + ) -> SidebarPullRequestState { + SidebarPullRequestState( + number: number, + label: label, + url: URL(string: url)!, + status: status, + branch: branch, + checks: checks + ) + } +} + + +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, + pinnedTabIds: [] + ) + ) + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: third, + targetTabId: nil, + tabIds: tabIds, + pinnedTabIds: [] + ) + ) + } + + func testNoIndicatorWhenOnlyOneTabExists() { + let only = UUID() + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: only, + targetTabId: nil, + tabIds: [only], + pinnedTabIds: [] + ) + ) + XCTAssertNil( + SidebarDropPlanner.indicator( + draggedTabId: only, + targetTabId: only, + tabIds: [only], + pinnedTabIds: [] + ) + ) + } + + 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, + pinnedTabIds: [] + ) + 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, + pinnedTabIds: [] + ) + 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, + pinnedTabIds: [] + ) + ) + } + + 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, + pinnedTabIds: [], + 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, + pinnedTabIds: [], + pointerY: 38, + targetHeight: 40 + ) + XCTAssertEqual(indicator?.tabId, third) + XCTAssertEqual(indicator?.edge, .top) + XCTAssertEqual( + SidebarDropPlanner.targetIndex( + draggedTabId: first, + targetTabId: second, + indicator: indicator, + tabIds: tabIds, + pinnedTabIds: [] + ), + 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, + pinnedTabIds: [], + pointerY: 38, + targetHeight: 40 + ) + let fromTopOfSecond = SidebarDropPlanner.indicator( + draggedTabId: third, + targetTabId: second, + tabIds: tabIds, + pinnedTabIds: [], + 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, + pinnedTabIds: [], + pointerY: 38, + targetHeight: 40 + ) + ) + } + + func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { + let pinnedA = UUID() + let pinnedB = UUID() + let unpinnedA = UUID() + let unpinnedB = UUID() + let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] + let pinnedIds: Set = [pinnedA, pinnedB] + + let indicator = SidebarDropPlanner.indicator( + draggedTabId: unpinnedB, + targetTabId: pinnedA, + tabIds: tabIds, + pinnedTabIds: pinnedIds, + pointerY: 2, + targetHeight: 40 + ) + + XCTAssertEqual(indicator?.tabId, unpinnedA) + XCTAssertEqual(indicator?.edge, .top) + } + + func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() { + let pinnedA = UUID() + let pinnedB = UUID() + let unpinnedA = UUID() + let unpinnedB = UUID() + let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB] + let pinnedIds: Set = [pinnedA, pinnedB] + + let targetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: unpinnedB, + targetTabId: pinnedA, + indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top), + tabIds: tabIds, + pinnedTabIds: pinnedIds + ) + + XCTAssertEqual(targetIndex, 2) + } + +} + + +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 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" + ) + } +} diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift new file mode 100644 index 00000000..d6942378 --- /dev/null +++ b/cmuxTests/TabManagerUnitTests.swift @@ -0,0 +1,976 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +let lastSurfaceCloseShortcutDefaultsKey = "closeWorkspaceOnLastSurfaceShortcut" + +func drainMainQueue() { + let expectation = XCTestExpectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + XCTWaiter().wait(for: [expectation], timeout: 1.0) +} + +@MainActor +final class TabManagerChildExitCloseTests: XCTestCase { + func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + let third = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + guard let secondPanelId = second.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) + + XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id]) + XCTAssertEqual( + manager.selectedTabId, + third.id, + "Expected selection to stay at the same index after deleting the selected workspace" + ) + } + + func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + guard let secondPanelId = second.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) + + XCTAssertEqual(manager.tabs.map(\.id), [first.id]) + XCTAssertEqual( + manager.selectedTabId, + first.id, + "Expected previous workspace to be selected after closing the last-index workspace" + ) + } + + func testChildExitOnNonLastPanelClosesOnlyPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panel to be created") + return + } + + let panelCountBefore = workspace.panels.count + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.tabs.first?.id, workspace.id) + XCTAssertEqual(workspace.panels.count, panelCountBefore - 1) + XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain") + } +} + + +@MainActor +final class 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 TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase { + func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() { + let manager = TabManager() + let second = manager.addWorkspace() + let third = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + manager.setCustomTitle(tabId: third.id, title: "Gamma") + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return true + } + + manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspaces.message", + defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close") + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, false) + XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"]) + } + + func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() { + let manager = TabManager() + let second = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return false + } + + manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true) + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspacesWindow.message", + defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1) + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, true) + XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"]) + } + + func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() { + let manager = TabManager() + let second = manager.addWorkspace() + let third = manager.addWorkspace() + manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha") + manager.setCustomTitle(tabId: second.id, title: "Beta") + manager.setCustomTitle(tabId: third.id, title: "Gamma") + manager.selectWorkspace(second) + manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id]) + + var prompts: [(title: String, message: String, acceptCmdD: Bool)] = [] + manager.confirmCloseHandler = { title, message, acceptCmdD in + prompts.append((title, message, acceptCmdD)) + return false + } + + manager.closeCurrentWorkspaceWithConfirmation() + + let expectedMessage = String( + format: String( + localized: "dialog.closeWorkspaces.message", + defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" + ), + locale: .current, + Int64(2), + "• Alpha\n• Beta" + ) + XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog") + XCTAssertEqual( + prompts.first?.title, + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") + ) + XCTAssertEqual(prompts.first?.message, expectedMessage) + XCTAssertEqual(prompts.first?.acceptCmdD, false) + XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"]) + } +} + + +@MainActor +final class TabManagerCloseCurrentPanelTests: XCTestCase { + func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected selected workspace and focused terminal panel") + return + } + + terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true) + workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle) + + var promptCount = 0 + manager.confirmCloseHandler = { _, _, _ in + promptCount += 1 + return false + } + + manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state") + XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close") + XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel") + } + + func testRuntimeClosePromptsWhenShellReportsRunningCommand() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: panelId) else { + XCTFail("Expected selected workspace and focused terminal panel") + return + } + + terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false) + workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning) + + var promptCount = 0 + manager.confirmCloseHandler = { _, _, _ in + promptCount += 1 + return false + } + + manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId) + + XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation") + XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open") + } + + func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() { + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + manager.selectWorkspace(secondWorkspace) + + guard let secondPanelId = secondWorkspace.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + XCTAssertEqual(secondWorkspace.panels.count, 1) + + manager.closeCurrentPanelWithConfirmation() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) + XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) + XCTAssertNil(secondWorkspace.panels[secondPanelId]) + XCTAssertTrue(secondWorkspace.panels.isEmpty) + } + + func testCloseCurrentPanelKeepsWorkspaceOpenWhenKeepWorkspaceOpenPreferenceIsEnabled() { + let defaults = UserDefaults.standard + let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) + defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) + defer { + if let originalSetting { + defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) + } else { + defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) + } + } + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace and focused panel") + return + } + + let initialWorkspaceId = workspace.id + + manager.closeCurrentPanelWithConfirmation() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, initialWorkspaceId) + XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId) + XCTAssertNil(workspace.panels[initialPanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) + } + + func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() { + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + manager.selectWorkspace(secondWorkspace) + + guard let secondPanelId = secondWorkspace.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + XCTAssertEqual(secondWorkspace.panels.count, 1) + + guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { + XCTFail("Expected bonsplit surface ID for focused panel") + return + } + + secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId) + XCTAssertFalse(secondWorkspace.closePanel(secondPanelId)) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) + XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) + XCTAssertNil(secondWorkspace.panels[secondPanelId]) + XCTAssertTrue(secondWorkspace.panels.isEmpty) + } + + func testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpenPreferenceIsEnabled() { + let defaults = UserDefaults.standard + let originalSetting = defaults.object(forKey: lastSurfaceCloseShortcutDefaultsKey) + defaults.set(false, forKey: lastSurfaceCloseShortcutDefaultsKey) + defer { + if let originalSetting { + defaults.set(originalSetting, forKey: lastSurfaceCloseShortcutDefaultsKey) + } else { + defaults.removeObject(forKey: lastSurfaceCloseShortcutDefaultsKey) + } + } + + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + manager.selectWorkspace(secondWorkspace) + + guard let secondPanelId = secondWorkspace.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else { + XCTFail("Expected bonsplit surface ID for focused panel") + return + } + + secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId) + XCTAssertFalse(secondWorkspace.closePanel(secondPanelId)) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id]) + XCTAssertEqual(manager.selectedTabId, firstWorkspace.id) + XCTAssertNil(secondWorkspace.panels[secondPanelId]) + XCTAssertTrue(secondWorkspace.panels.isEmpty) + } + + func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace and focused panel") + return + } + + let initialWorkspaceId = workspace.id + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(workspace.panels.count, 1) + + XCTAssertTrue(workspace.closePanel(initialPanelId)) + drainMainQueue() + drainMainQueue() + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.selectedTabId, initialWorkspaceId) + XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId) + XCTAssertNil(workspace.panels[initialPanelId]) + XCTAssertEqual(workspace.panels.count, 1) + XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId) + } + + func testCloseCurrentPanelIgnoresStaleSurfaceId() { + let manager = TabManager() + let firstWorkspace = manager.tabs[0] + let secondWorkspace = manager.addWorkspace() + + manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID()) + + XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id]) + } + + func testCloseCurrentPanelClearsNotificationsForClosedSurface() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace and focused panel") + return + } + + store.addNotification( + tabId: workspace.id, + surfaceId: initialPanelId, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) + + manager.closeCurrentPanelWithConfirmation() + drainMainQueue() + drainMainQueue() + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId)) + } +} + + +@MainActor +final class TabManagerNotificationFocusTests: XCTestCase { + func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftPanelId) + XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable") + XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed") + + XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) + drainMainQueue() + drainMainQueue() + + XCTAssertFalse( + workspace.bonsplitController.isSplitZoomed, + "Expected notification focus to exit split zoom so the target pane becomes visible" + ) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused") + } + + func testFocusTabFromNotificationReturnsFalseForMissingPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace else { + XCTFail("Expected selected workspace") + return + } + + XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID())) + } +} + + +@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 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) + } +} diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift new file mode 100644 index 00000000..272d8b5a --- /dev/null +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -0,0 +1,2578 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class GhosttyPasteboardHelperTests: XCTestCase { + func testHTMLOnlyPasteboardExtractsPlainText() { + let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("

Hello world

", forType: .html) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + + func testImageHTMLClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("", forType: .html) + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.red.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + pasteboard.setData(pngData, forType: .png) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".png")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testImageHTMLClipboardWithVisibleTextPrefersText() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("

Hello

", forType: .html) + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.blue.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + pasteboard.setData(pngData, forType: .png) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + + func testJPEGClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.green.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let jpegData = try XCTUnwrap( + bitmap.representation( + using: .jpeg, + properties: [.compressionFactor: 1.0] + ) + ) + pasteboard.setData( + jpegData, + forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier) + ) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".jpeg")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.orange.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let attachment = NSTextAttachment() + attachment.image = image + let attributed = NSAttributedString(attachment: attachment) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".tiff")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)")) + pasteboard.clearContents() + + let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8)) + wrapper.preferredFilename = "note.txt" + + let attachment = NSTextAttachment(fileWrapper: wrapper) + let attributed = NSAttributedString(attachment: attachment) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + + func testRTFDClipboardWithVisibleTextPrefersText() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.purple.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let attachment = NSTextAttachment() + attachment.image = image + + let attributed = NSMutableAttributedString(string: "Hello ") + attributed.append(NSAttributedString(attachment: attachment)) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } +} + + +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 + ) + } +} + + +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) + } +} + + +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 TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { + private func environment( + existingPaths: Set, + homeDirectoryPath: String = "/Users/tester", + applicationPathsByName: [String: String] = [:] + ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { + TerminalDirectoryOpenTarget.DetectionEnvironment( + homeDirectoryPath: homeDirectoryPath, + fileExistsAtPath: { existingPaths.contains($0) }, + isExecutableFileAtPath: { existingPaths.contains($0) }, + applicationPathForName: { applicationPathsByName[$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 testVSCodeInlineRequiresCodeTunnelExecutable() { + let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env)) + XCTAssertFalse(TerminalDirectoryOpenTarget.vscodeInline.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 testAvailableTargetsFallbackToApplicationLookupForVSCodeAliasOutsideApplications() { + let vscodePath = "/Volumes/Tools/Code.app" + let env = environment( + existingPaths: [ + vscodePath, + "\(vscodePath)/Contents/Resources/app/bin/code-tunnel", + ], + applicationPathsByName: [ + "Code": vscodePath, + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.vscodeInline)) + } + + func testTowerDetectedViaApplicationLookupOutsideApplications() { + let towerPath = "/Volumes/Setapp/Tower.app" + let env = environment( + existingPaths: [towerPath], + applicationPathsByName: [ + "Tower": towerPath, + ] + ) + + 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" })) + } +} + + +@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) + } +} + + +@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 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 + } + + private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool { + if firstResponder === textField { + return true + } + if let editor = firstResponder as? NSTextView, + editor.isFieldEditor, + editor.delegate as? NSTextField === textField { + return true + } + return false + } + + 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) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue(hostedView.debugHasSearchOverlay()) + + hostedView.setSearchOverlay(searchState: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertFalse(hostedView.debugHasSearchOverlay()) + } + + func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hostedView = surface.hostedView + + hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example")) + hostedView.setSearchOverlay(searchState: nil) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.debugHasSearchOverlay(), + "A stale deferred mount must not resurrect the find overlay after it closes" + ) + } + + func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() { + 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 { 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) + + 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 + } + + XCTAssertTrue( + firstResponderOwnsTextField(window.firstResponder, textField: searchField), + "Deferred search overlay attach should still move focus into the find field" + ) + } + + func testStartOrFocusTerminalSearchReusesExistingSearchState() { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let existingSearchState = TerminalSurface.SearchState(needle: "existing") + surface.searchState = existingSearchState + + var focusNotificationCount = 0 + XCTAssertTrue( + startOrFocusTerminalSearch(surface) { _ in + focusNotificationCount += 1 + } + ) + + XCTAssertTrue(surface.searchState === existingSearchState) + XCTAssertEqual( + focusNotificationCount, + 1, + "Re-triggering terminal Find should refocus the existing overlay without recreating state" + ) + } + + 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")) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + 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")) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + 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" + ) + } + + func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() { + 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) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + shiftedContainer.addSubview(anchor) + 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) + let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + DispatchQueue.main.async { + shiftedContainer.frame.origin.x += 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.minX, + originalAnchorFrameInWindow.minX + 1, + "The queued layout shift should move the anchor to the right" + ) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.maxX, + originalAnchorFrameInWindow.maxX + 1, + "The shifted anchor should expose a new trailing region outside the stale portal frame" + ) + let retiredStaleWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + let shiftedWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), + "The queued external sync should wait until the later layout shift settles, clearing the stale portal location" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "The delayed external sync should move the portal-hosted terminal to the queued layout shift position" + ) + } +} + + +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 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 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 { + func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() { + XCTAssertEqual( + TerminalController.fallbackSocketPathAfterBindFailure( + requestedPath: SocketControlSettings.stableDefaultSocketPath, + stage: "bind", + errnoCode: EACCES, + currentUserID: 501 + ), + SocketControlSettings.userScopedStableSocketPath(currentUserID: 501) + ) + } + + func testNonStableSocketBindFailureDoesNotFallback() { + XCTAssertNil( + TerminalController.fallbackSocketPathAfterBindFailure( + requestedPath: "/tmp/cmux-debug.sock", + stage: "bind", + errnoCode: EACCES, + currentUserID: 501 + ) + ) + } + + 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 + } + + private func acceptSingleClient( + on listenerFD: Int32, + handler: @escaping (_ clientFD: Int32) -> Void + ) -> XCTestExpectation { + let handled = expectation(description: "socket client handled") + DispatchQueue.global(qos: .userInitiated).async { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + handled.fulfill() + return + } + defer { + Darwin.close(clientFD) + handled.fulfill() + } + handler(clientFD) + } + return handled + } + + @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 testProbeSocketCommandReturnsFirstLineResponse() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + let response = "PONG\nextra\n" + _ = response.withCString { ptr in + write(clientFD, ptr, strlen(ptr)) + } + } + + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5) + + XCTAssertEqual(response, "PONG") + wait(for: [handled], timeout: 1.0) + } + + func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let releaseServer = DispatchSemaphore(value: 0) + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + _ = releaseServer.wait(timeout: .now() + 1.0) + } + + let startedAt = Date() + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2) + let elapsed = Date().timeIntervalSince(startedAt) + releaseServer.signal() + + XCTAssertNil(response) + XCTAssertGreaterThanOrEqual(elapsed, 0.18) + XCTAssertLessThan(elapsed, 0.8) + wait(for: [handled], timeout: 1.0) + } + + func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() { + let health = TerminalController.SocketListenerHealth( + isRunning: true, + acceptLoopAlive: true, + socketPathMatches: true, + socketPathExists: true + ) + XCTAssertTrue(health.isHealthy) + XCTAssertTrue(health.failureSignals.isEmpty) + } + + func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { + let health = TerminalController.SocketListenerHealth( + isRunning: false, + acceptLoopAlive: false, + socketPathMatches: false, + socketPathExists: false + ) + XCTAssertFalse(health.isHealthy) + XCTAssertEqual( + health.failureSignals, + ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] + ) + } +} diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift new file mode 100644 index 00000000..e618949f --- /dev/null +++ b/cmuxTests/WindowAndDragTests.swift @@ -0,0 +1,1082 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@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 })) + } + + func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws { + _ = 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() + ) + + window.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: window) + + let defaults = UserDefaults.standard + let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey) + defaults.set(true, forKey: WelcomeSettings.shownKey) + defer { + if let previousWelcomeShown { + defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey) + } else { + defaults.removeObject(forKey: WelcomeSettings.shownKey) + } + } + + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true) + try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let existingWorkspaceIds = Set(manager.tabs.map(\.id)) + + app.application( + NSApplication.shared, + open: [URL(fileURLWithPath: droppedDirectory.path)] + ) + + let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) } + XCTAssertNotNil(createdWorkspace) + XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path) + } +} + + +@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 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 + ) + ) + } +} + + +@MainActor +final class InternalTabDragBundleDeclarationTests: XCTestCase { + private func exportedTypeIdentifiers(bundle: Bundle) -> Set { + let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? [] + return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String }) + } + + func testAppBundleExportsInternalDragTypes() { + let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self)) + + XCTAssertTrue( + exported.contains("com.splittabbar.tabtransfer"), + "Expected app bundle to export bonsplit tab-transfer type, got \(exported)" + ) + XCTAssertTrue( + exported.contains("com.cmux.sidebar-tab-reorder"), + "Expected app bundle to export sidebar tab-reorder type, got \(exported)" + ) + } +} +#endif + + +@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 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 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))) + } +} diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift new file mode 100644 index 00000000..5bd83682 --- /dev/null +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -0,0 +1,1899 @@ +import XCTest +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit +import ObjectiveC.runtime +import Bonsplit +import UserNotifications + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileDefinition { + try XCTUnwrap( + BrowserProfileStore.shared.createProfile( + named: "\(prefix)-\(UUID().uuidString)" + ) + ) +} + +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 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 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 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 SidebarWorkspaceDetailSettingsTests: XCTestCase { + func testDefaultPreferencesWhenUnset() { + let suiteName = "SidebarWorkspaceDetailSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertFalse(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)) + XCTAssertTrue(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults)) + XCTAssertTrue( + SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( + showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults), + hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults) + ) + ) + } + + func testStoredPreferencesOverrideDefaults() { + let suiteName = "SidebarWorkspaceDetailSettingsTests.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(true, forKey: SidebarWorkspaceDetailSettings.hideAllDetailsKey) + defaults.set(false, forKey: SidebarWorkspaceDetailSettings.showNotificationMessageKey) + + XCTAssertTrue(SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults)) + XCTAssertFalse(SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults)) + XCTAssertFalse( + SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( + showNotificationMessage: SidebarWorkspaceDetailSettings.showsNotificationMessage(defaults: defaults), + hideAllDetails: false + ) + ) + XCTAssertFalse( + SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( + showNotificationMessage: true, + hideAllDetails: SidebarWorkspaceDetailSettings.hidesAllDetails(defaults: defaults) + ) + ) + } +} + + +final class SidebarWorkspaceAuxiliaryDetailVisibilityTests: XCTestCase { + func testResolvedVisibilityPreservesPerRowTogglesWhenDetailsAreShown() { + XCTAssertEqual( + SidebarWorkspaceAuxiliaryDetailVisibility.resolved( + showMetadata: true, + showLog: false, + showProgress: true, + showBranchDirectory: false, + showPullRequests: true, + showPorts: false, + hideAllDetails: false + ), + SidebarWorkspaceAuxiliaryDetailVisibility( + showsMetadata: true, + showsLog: false, + showsProgress: true, + showsBranchDirectory: false, + showsPullRequests: true, + showsPorts: false + ) + ) + } + + func testResolvedVisibilityHidesAllAuxiliaryRowsWhenDetailsAreHidden() { + XCTAssertEqual( + SidebarWorkspaceAuxiliaryDetailVisibility.resolved( + showMetadata: true, + showLog: true, + showProgress: true, + showBranchDirectory: true, + showPullRequests: true, + showPorts: true, + hideAllDetails: true + ), + .hidden + ) + } +} + + +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 + func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0)) + XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id]) + } + + @MainActor + func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + + XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999)) + XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id]) + } +} + + +@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 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 WorkspaceSplitWorkingDirectoryTests: XCTestCase { + func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() { + let workspace = Workspace() + guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else { + XCTFail("Expected focused pane in new workspace") + return + } + + let staleCurrentDirectory = workspace.currentDirectory + let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)" + guard let sourcePanel = workspace.newTerminalSurface( + inPane: sourcePaneId, + focus: false, + workingDirectory: requestedDirectory + ) else { + XCTFail("Expected source terminal panel to be created") + return + } + + XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory) + XCTAssertNil( + workspace.panelDirectories[sourcePanel.id], + "Expected requested cwd to exist before shell integration reports a live cwd" + ) + XCTAssertEqual( + workspace.currentDirectory, + staleCurrentDirectory, + "Expected focused workspace cwd to remain stale before panel directory updates" + ) + + guard let splitPanel = workspace.newTerminalSplit( + from: sourcePanel.id, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected split terminal panel to be created") + return + } + + XCTAssertEqual( + splitPanel.requestedWorkingDirectory, + requestedDirectory, + "Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet" + ) + } +} + + +@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 WorkspaceBrowserProfileSelectionTests: XCTestCase { + private final class RejectingCreateTabDelegate: BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldCreateTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { + false + } + } + + private final class RejectingSplitPaneDelegate: BonsplitDelegate { + func splitTabBar(_ controller: BonsplitController, shouldSplitPane pane: PaneID, orientation: SplitOrientation) -> Bool { + false + } + } + + func testNewBrowserSurfacePrefersSelectedBrowserProfileInTargetPane() throws { + let workspace = Workspace() + let profileA = try makeTemporaryBrowserProfile(named: "Alpha") + let profileB = try makeTemporaryBrowserProfile(named: "Beta") + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browserA = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: profileA.id + ) + ) + _ = try XCTUnwrap( + workspace.newBrowserSplit( + from: browserA.id, + orientation: .horizontal, + preferredProfileID: profileB.id, + focus: true + ) + ) + + XCTAssertEqual( + workspace.preferredBrowserProfileID, + profileB.id, + "Expected workspace preference to drift to the most recently created browser profile" + ) + + let leftSurfaceId = try XCTUnwrap(workspace.surfaceIdFromPanelId(browserA.id)) + workspace.bonsplitController.focusPane(paneId) + workspace.bonsplitController.selectTab(leftSurfaceId) + + let created = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: false + ) + ) + + XCTAssertEqual( + created.profileID, + profileA.id, + "Expected new browser creation to inherit the selected browser profile from the target pane" + ) + } + + func testNewBrowserSurfaceFailureDoesNotMutatePreferredProfile() throws { + let workspace = Workspace() + let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") + let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") + + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + _ = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: false, + preferredProfileID: preferredProfile.id + ) + ) + XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) + + let rejectingDelegate = RejectingCreateTabDelegate() + workspace.bonsplitController.delegate = rejectingDelegate + let created = workspace.newBrowserSurface( + inPane: paneId, + focus: false, + preferredProfileID: unexpectedProfile.id + ) + + XCTAssertNil(created) + XCTAssertEqual( + workspace.preferredBrowserProfileID, + preferredProfile.id, + "Expected a failed browser creation to leave the workspace preferred profile unchanged" + ) + } + + func testNewBrowserSplitFailureDoesNotMutatePreferredProfile() throws { + let workspace = Workspace() + let preferredProfile = try makeTemporaryBrowserProfile(named: "Preferred") + let unexpectedProfile = try makeTemporaryBrowserProfile(named: "Unexpected") + + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let browser = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + preferredProfileID: preferredProfile.id + ) + ) + XCTAssertEqual(workspace.preferredBrowserProfileID, preferredProfile.id) + + let rejectingDelegate = RejectingSplitPaneDelegate() + workspace.bonsplitController.delegate = rejectingDelegate + let created = workspace.newBrowserSplit( + from: browser.id, + orientation: .horizontal, + preferredProfileID: unexpectedProfile.id, + focus: false + ) + + XCTAssertNil(created) + XCTAssertEqual( + workspace.preferredBrowserProfileID, + preferredProfile.id, + "Expected a failed browser split to leave the workspace preferred profile unchanged" + ) + } +} + + +@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 testNewTerminalSurfaceWithFocusFalsePreservesFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId, + let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected initial focused panel and pane") + return + } + + guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else { + XCTFail("Expected terminal surface to be created") + return + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal surface creation to preserve the existing focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to stay on the original focused panel" + ) + } + + func testNewBrowserSurfaceWithFocusFalsePreservesFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId, + let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected initial focused panel and pane") + return + } + + guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else { + XCTFail("Expected browser surface to be created") + return + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser surface creation to preserve the existing focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to stay on the original focused panel" + ) + } + + 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]) + } + + @MainActor + func testSidebarPullRequestsTrackFocusedPanelOnly() { + let workspace = Workspace() + guard let firstPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: firstPanelId), + let secondPanel = workspace.newTerminalSurface(inPane: paneId, focus: false) else { + XCTFail("Expected focused panel and a second panel") + return + } + + workspace.updatePanelGitBranch(panelId: firstPanelId, branch: "main", isDirty: false) + workspace.updatePanelGitBranch(panelId: secondPanel.id, branch: "feature/sidebar-pr", isDirty: false) + workspace.updatePanelPullRequest( + panelId: secondPanel.id, + number: 1629, + label: "PR", + url: URL(string: "https://github.com/manaflow-ai/cmux/pull/1629")!, + status: .open + ) + + XCTAssertNil(workspace.pullRequest) + XCTAssertTrue( + workspace.sidebarPullRequestsInDisplayOrder().isEmpty, + "Expected background panel PRs to stay hidden while the focused panel has no PR" + ) + + workspace.focusPanel(secondPanel.id) + + XCTAssertEqual( + workspace.sidebarPullRequestsInDisplayOrder().map(\.number), + [1629] + ) + } + + 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 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 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