cmux/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
2026-02-18 21:19:56 -08:00

464 lines
18 KiB
Swift

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
app.launch()
app.activate()
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"
app.launch()
app.activate()
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
app.launch()
app.activate()
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.launch()
app.activate()
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.launch()
app.activate()
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
app.launch()
app.activate()
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
app.launch()
app.activate()
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
app.launch()
app.activate()
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
app.launch()
app.activate()
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 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]
}
}