* Set cmux TestAction to Debug for UI tests * Broaden XCTest detection for debug launch gate * Fix AutomationSocketUITests launch hang in CI * Stabilize CI Swift package resolution for test jobs * Stabilize Xcode Cloud UI test focus and socket handling * Add Xcode Cloud pre-xcodebuild submodule bootstrap * Harden Xcode Cloud bonsplit bootstrap fallback
474 lines
19 KiB
Swift
474 lines
19 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
|
|
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]
|
|
}
|
|
}
|