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_UI_TEST_FOCUS_SHORTCUTS"] = "1" 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_UI_TEST_FOCUS_SHORTCUTS"] = "1" 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") XCTAssertFalse((setup["ghosttyGotoSplitLeftShortcut"] ?? "").isEmpty, "Expected Ghostty trigger metadata to be present") 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 app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" 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 app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" 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 app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" 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" ) } func testCmdOptionPaneSwitchPreservesFindFieldFocus() { runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: false) } func testCmdCtrlPaneSwitchPreservesFindFieldFocus() { runFindFocusPersistenceScenario(route: .cmdCtrlLetters, useAutofocusRacePage: false) } func testCmdOptionPaneSwitchPreservesFindFieldFocusDuringPageAutofocusRace() { runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: true) } private enum FindFocusRoute { case cmdOptionArrows case cmdCtrlLetters } private func runFindFocusPersistenceScenario(route: FindFocusRoute, useAutofocusRacePage: Bool) { let app = XCUIApplication() app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1" app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath if route == .cmdCtrlLetters { app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1" } launchAndEnsureForeground(app) let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist") // Repro setup: split, open browser split, navigate to example.com. app.typeKey("d", modifierFlags: [.command]) focusRightPaneForFindScenario(app, route: route) app.typeKey("l", modifierFlags: [.command, .shift]) let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+Shift+L") app.typeKey("a", modifierFlags: [.command]) app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) if useAutofocusRacePage { app.typeText(autofocusRacePageURL) } else { app.typeText("example.com") } app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) if useAutofocusRacePage { XCTAssertTrue( waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0), "Expected browser navigation to data URL before running find flow. value=\(String(describing: omnibar.value))" ) } else { XCTAssertTrue( waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0), "Expected browser navigation to example domain before running find flow. value=\(String(describing: omnibar.value))" ) } // Left terminal: Cmd+F then type "la". focusLeftPaneForFindScenario(app, route: route) XCTAssertTrue( waitForDataMatch(timeout: 6.0) { data in data["focusedPanelKind"] == "terminal" }, "Expected left terminal pane to be focused before terminal find. data=\(String(describing: loadData()))" ) app.typeKey("f", modifierFlags: [.command]) app.typeText("la") // Right browser: Cmd+F then type "am". focusRightPaneForFindScenario(app, route: route) XCTAssertTrue( waitForDataMatch(timeout: 6.0) { data in data["lastMoveDirection"] == "right" && data["focusedPanelKind"] == "browser" && data["terminalFindNeedle"] == "la" }, "Expected terminal find query to persist as 'la' after focusing browser pane. data=\(String(describing: loadData()))" ) app.typeKey("f", modifierFlags: [.command]) app.typeText("am") if useAutofocusRacePage { XCTAssertTrue( waitForOmnibarToContain(omnibar, value: "#focused", timeout: 5.0), "Expected autofocus race page to signal focus handoff via URL hash. value=\(String(describing: omnibar.value))" ) } // Left terminal: typing should keep going into terminal find field. focusLeftPaneForFindScenario(app, route: route) XCTAssertTrue( waitForDataMatch(timeout: 6.0) { data in data["lastMoveDirection"] == "left" && data["focusedPanelKind"] == "terminal" && data["browserFindNeedle"] == "am" }, "Expected browser find query to persist as 'am' after returning left. data=\(String(describing: loadData()))" ) app.typeText("foo") // Right browser: typing should keep going into browser find field. focusRightPaneForFindScenario(app, route: route) XCTAssertTrue( waitForDataMatch(timeout: 6.0) { data in data["lastMoveDirection"] == "right" && data["focusedPanelKind"] == "browser" && data["terminalFindNeedle"] == "lafoo" }, "Expected terminal find query to stay focused and become 'lafoo'. data=\(String(describing: loadData()))" ) app.typeText("do") // Move left once more so the recorder captures browser find state after typing. focusLeftPaneForFindScenario(app, route: route) XCTAssertTrue( waitForDataMatch(timeout: 6.0) { data in data["lastMoveDirection"] == "left" && data["focusedPanelKind"] == "terminal" && data["browserFindNeedle"] == "amdo" }, "Expected browser find query to stay focused and become 'amdo'. data=\(String(describing: loadData()))" ) } private func focusLeftPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { switch route { case .cmdOptionArrows: app.typeKey(XCUIKeyboardKey.leftArrow.rawValue, modifierFlags: [.command, .option]) case .cmdCtrlLetters: app.typeKey("h", modifierFlags: [.command, .control]) } } private func focusRightPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) { switch route { case .cmdOptionArrows: app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: [.command, .option]) case .cmdCtrlLetters: app.typeKey("l", modifierFlags: [.command, .control]) } } private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { let value = (omnibar.value as? String) ?? "" if value.contains("example.com") || value.contains("example.org") { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } let value = (omnibar.value as? String) ?? "" return value.contains("example.com") || value.contains("example.org") } private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { let value = (omnibar.value as? String) ?? "" if value.contains(expectedSubstring) { return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } let value = (omnibar.value as? String) ?? "" return value.contains(expectedSubstring) } private var autofocusRacePageURL: String { "data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E" } 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] } }