import XCTest import Foundation final class BrowserPaneNavigationKeybindUITests: XCTestCase { private var dataPath = "" private var socketPath = "" override func setUp() { super.setUp() continueAfterFailure = false dataPath = "/tmp/cmux-ui-test-goto-split-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: dataPath) socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock" try? FileManager.default.removeItem(atPath: socketPath) } func testCmdCtrlHMovesLeftWhenWebViewFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") guard let expectedTerminalPaneId = setup["terminalPaneId"] else { XCTFail("Missing terminalPaneId in goto_split setup data") return } // Trigger pane navigation via the actual key event path (while WebKit is first responder). app.typeKey("h", modifierFlags: [.command, .control]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId }, "Expected Cmd+Ctrl+H to move focus to left pane (terminal)" ) } func testCmdCtrlHMovesLeftWhenWebViewFocusedUsingGhosttyConfigKeybind() { // Write a test Ghostty config in the preferred macOS location so GhosttyKit loads it at app startup. let fileManager = FileManager.default guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { XCTFail("Missing Application Support directory") return } let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) let configURL = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) do { try fileManager.createDirectory(at: ghosttyDir, withIntermediateDirectories: true) } catch { XCTFail("Failed to create Ghostty app support dir: \(error)") return } let originalConfigData = try? Data(contentsOf: configURL) addTeardownBlock { if let originalConfigData { try? originalConfigData.write(to: configURL, options: .atomic) } else { try? fileManager.removeItem(at: configURL) } } let home = fileManager.homeDirectoryForCurrentUser let configContents = """ # cmux ui test working-directory = \(home.path) keybind = cmd+ctrl+h=goto_split:left """ do { try configContents.write(to: configURL, atomically: true, encoding: .utf8) } catch { XCTFail("Failed to write Ghostty config: \(error)") return } let app = XCUIApplication() app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_USE_GHOSTTY_CONFIG"] = "1" launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused", "ghosttyGotoSplitLeftShortcut"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") XCTAssertEqual(setup["ghosttyGotoSplitLeftShortcut"], "⌃⌘H", "Expected Ghostty config trigger to be Cmd+Ctrl+H") guard let expectedTerminalPaneId = setup["terminalPaneId"] else { XCTFail("Missing terminalPaneId in goto_split setup data") return } // Trigger pane navigation via the actual key event path (while WebKit is first responder). app.typeKey("h", modifierFlags: [.command, .control]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId }, "Expected Cmd+Ctrl+H to move focus to left pane (terminal) via Ghostty config trigger" ) } func testEscapeLeavesOmnibarAndFocusesWebView() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") // Cmd+L focuses the omnibar (so WebKit is no longer first responder). app.typeKey("l", modifierFlags: [.command]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["webViewFocusedAfterAddressBarFocus"] == "false" }, "Expected Cmd+L to focus omnibar (WebKit not first responder)" ) // Escape should leave the omnibar and focus WebKit again. // Send Escape twice: the first may only clear suggestions/editing state // (Chrome-like two-stage escape), the second triggers blur to WebView. app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) if !waitForDataMatch(timeout: 2.0, predicate: { $0["webViewFocusedAfterAddressBarExit"] == "true" }) { app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) } XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["webViewFocusedAfterAddressBarExit"] == "true" }, "Expected Escape to return focus to WebKit" ) } func testCmdLOpensBrowserWhenTerminalFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } guard let originalBrowserPanelId = setup["browserPanelId"] else { XCTFail("Missing browserPanelId in goto_split setup data") return } guard let expectedTerminalPaneId = setup["terminalPaneId"] else { XCTFail("Missing terminalPaneId in goto_split setup data") return } // Move focus to the terminal pane first. app.typeKey("h", modifierFlags: [.command, .control]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId }, "Expected Cmd+Ctrl+H to move focus to left pane (terminal)" ) // Cmd+L should open a browser in the focused pane, then focus omnibar. app.typeKey("l", modifierFlags: [.command]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in guard data["webViewFocusedAfterAddressBarFocus"] == "false" else { return false } guard let focusedAddressPanelId = data["webViewFocusedAfterAddressBarFocusPanelId"] else { return false } return focusedAddressPanelId != originalBrowserPanelId }, "Expected Cmd+L on terminal focus to open a new browser and focus omnibar" ) } func testClickingOmnibarFocusesBrowserPane() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } guard let expectedBrowserPanelId = setup["browserPanelId"] else { XCTFail("Missing browserPanelId in goto_split setup data") return } guard let expectedTerminalPaneId = setup["terminalPaneId"] else { XCTFail("Missing terminalPaneId in goto_split setup data") return } // Move focus away from browser to terminal first. app.typeKey("h", modifierFlags: [.command, .control]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId }, "Expected Cmd+Ctrl+H to move focus to left pane (terminal)" ) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field") omnibar.click() // Cmd+L behavior is context-aware: // - If terminal is focused: opens a new browser and focuses that new omnibar. // - If browser is focused: focuses current browser omnibar. // After clicking the omnibar, Cmd+L should stay on the existing browser panel. app.typeKey("l", modifierFlags: [.command]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in guard data["webViewFocusedAfterAddressBarFocus"] == "false" else { return false } return data["webViewFocusedAfterAddressBarFocusPanelId"] == expectedBrowserPanelId }, "Expected omnibar click to focus browser panel so Cmd+L stays on that browser" ) } func testCmdDSplitsRightWhenWebViewFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") let initialPaneCount = Int(setup["initialPaneCount"] ?? "") ?? 0 XCTAssertGreaterThanOrEqual(initialPaneCount, 2, "Expected at least two panes before split. data=\(setup)") app.typeKey("d", modifierFlags: [.command]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in guard data["lastSplitDirection"] == "right" else { return false } guard let paneCountAfter = Int(data["paneCountAfterSplit"] ?? "") else { return false } return paneCountAfter == initialPaneCount + 1 }, "Expected Cmd+D to split right while WKWebView is first responder" ) } func testCmdShiftDSplitsDownWhenWebViewFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") let initialPaneCount = Int(setup["initialPaneCount"] ?? "") ?? 0 XCTAssertGreaterThanOrEqual(initialPaneCount, 2, "Expected at least two panes before split. data=\(setup)") app.typeKey("d", modifierFlags: [.command, .shift]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in guard data["lastSplitDirection"] == "down" else { return false } guard let paneCountAfter = Int(data["paneCountAfterSplit"] ?? "") else { return false } return paneCountAfter == initialPaneCount + 1 }, "Expected Cmd+Shift+D to split down while WKWebView is first responder" ) } func testCmdDSplitsRightWhenOmnibarFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } let initialPaneCount = Int(setup["initialPaneCount"] ?? "") ?? 0 XCTAssertGreaterThanOrEqual(initialPaneCount, 2, "Expected at least two panes before split. data=\(setup)") // Focus browser omnibar (WebKit no longer first responder). app.typeKey("l", modifierFlags: [.command]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["webViewFocusedAfterAddressBarFocus"] == "false" }, "Expected Cmd+L to focus omnibar before split" ) app.typeKey("d", modifierFlags: [.command]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in guard data["lastSplitDirection"] == "right" else { return false } guard let paneCountAfter = Int(data["paneCountAfterSplit"] ?? "") else { return false } return paneCountAfter == initialPaneCount + 1 }, "Expected Cmd+D to split right while omnibar is first responder" ) } func testCmdShiftDSplitsDownWhenOmnibarFocused() { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath launchAndEnsureForeground(app) XCTAssertTrue( waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0), "Expected goto_split setup data to be written" ) guard let setup = loadData() else { XCTFail("Missing goto_split setup data") return } let initialPaneCount = Int(setup["initialPaneCount"] ?? "") ?? 0 XCTAssertGreaterThanOrEqual(initialPaneCount, 2, "Expected at least two panes before split. data=\(setup)") // Focus browser omnibar (WebKit no longer first responder). app.typeKey("l", modifierFlags: [.command]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in data["webViewFocusedAfterAddressBarFocus"] == "false" }, "Expected Cmd+L to focus omnibar before split" ) app.typeKey("d", modifierFlags: [.command, .shift]) XCTAssertTrue( waitForDataMatch(timeout: 5.0) { data in guard data["lastSplitDirection"] == "down" else { return false } guard let paneCountAfter = Int(data["paneCountAfterSplit"] ?? "") else { return false } return paneCountAfter == initialPaneCount + 1 }, "Expected Cmd+Shift+D to split down while omnibar is first responder" ) } private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { app.launch() XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: timeout), "Expected app to launch in foreground. state=\(app.state.rawValue)" ) } private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { if app.wait(for: .runningForeground, timeout: timeout) { return true } if app.state == .runningBackground { app.activate() return app.wait(for: .runningForeground, timeout: 6.0) } return false } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { return true } return false } private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let data = loadData(), predicate(data) { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } if let data = loadData(), predicate(data) { return true } return false } private func loadData() -> [String: String]? { guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else { return nil } return (try? JSONSerialization.jsonObject(with: data)) as? [String: String] } }