* Add browser focus debug logging around omnibar/content handoff * Avoid release-only unused-value warnings in focus debug logs * Add omnibar focus writer trace logs * Fix omnibar tap focus race * Stabilize omnibar focus state transitions * Propagate event context into responder guard * Fix webview pointer hit testing in focus guard * Stop omnibar reacquire on pointer blur intent * Blur omnibar on webview click intent * Preserve pointer intent through webview focus handoff * Restore page input focus after omnibar escape * Fix omnibar escape focus handoff and restore retry * Track editable focus for omnibar restore and improve test diagnostics * Add omnibar focus tracker telemetry to failing UI test * Wait for page readiness before seeding focused input in UI test setup * Strengthen omnibar escape focus regression with post-click assertion * Use deterministic window offsets for post-escape web input click test * Always enforce webview responder on omnibar escape * Harden omnibar focus restore and address PR review feedback --------- Co-authored-by: tiffanysun1 <tiffanysun8@gmail.com>
872 lines
38 KiB
Swift
872 lines
38 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_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 testEscapeRestoresFocusedPageInputAfterCmdL() {
|
|
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"
|
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] = "1"
|
|
launchAndEnsureForeground(app)
|
|
|
|
XCTAssertTrue(
|
|
waitForData(
|
|
keys: [
|
|
"browserPanelId",
|
|
"webViewFocused",
|
|
"webInputFocusSeeded",
|
|
"webInputFocusElementId",
|
|
"webInputFocusSecondaryElementId",
|
|
"webInputFocusSecondaryClickOffsetX",
|
|
"webInputFocusSecondaryClickOffsetY"
|
|
],
|
|
timeout: 12.0
|
|
),
|
|
"Expected setup data including focused page input 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["webInputFocusSeeded"], "true", "Expected test page input to be focused before Cmd+L")
|
|
|
|
guard let expectedInputId = setup["webInputFocusElementId"], !expectedInputId.isEmpty else {
|
|
XCTFail("Missing webInputFocusElementId in setup data")
|
|
return
|
|
}
|
|
guard let expectedSecondaryInputId = setup["webInputFocusSecondaryElementId"], !expectedSecondaryInputId.isEmpty else {
|
|
XCTFail("Missing webInputFocusSecondaryElementId in setup data")
|
|
return
|
|
}
|
|
guard let secondaryClickOffsetXRaw = setup["webInputFocusSecondaryClickOffsetX"],
|
|
let secondaryClickOffsetYRaw = setup["webInputFocusSecondaryClickOffsetY"],
|
|
let secondaryClickOffsetX = Double(secondaryClickOffsetXRaw),
|
|
let secondaryClickOffsetY = Double(secondaryClickOffsetYRaw) else {
|
|
XCTFail(
|
|
"Missing or invalid secondary input click offsets in setup data. " +
|
|
"webInputFocusSecondaryClickOffsetX=\(setup["webInputFocusSecondaryClickOffsetX"] ?? "nil") " +
|
|
"webInputFocusSecondaryClickOffsetY=\(setup["webInputFocusSecondaryClickOffsetY"] ?? "nil")"
|
|
)
|
|
return
|
|
}
|
|
|
|
app.typeKey("l", modifierFlags: [.command])
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 5.0) { data in
|
|
data["webViewFocusedAfterAddressBarFocus"] == "false"
|
|
},
|
|
"Expected Cmd+L to focus omnibar"
|
|
)
|
|
|
|
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
|
if !waitForDataMatch(timeout: 2.0, predicate: { data in
|
|
data["webViewFocusedAfterAddressBarExit"] == "true" &&
|
|
data["addressBarExitActiveElementId"] == expectedInputId &&
|
|
data["addressBarExitActiveElementEditable"] == "true"
|
|
}) {
|
|
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
|
}
|
|
|
|
let restoredExpectedInput = waitForDataMatch(timeout: 6.0) { data in
|
|
data["webViewFocusedAfterAddressBarExit"] == "true" &&
|
|
data["addressBarExitActiveElementId"] == expectedInputId &&
|
|
data["addressBarExitActiveElementEditable"] == "true"
|
|
}
|
|
if !restoredExpectedInput {
|
|
let snapshot = loadData() ?? [:]
|
|
XCTFail(
|
|
"Expected Escape to restore focus to the previously focused page input. " +
|
|
"expectedInputId=\(expectedInputId) " +
|
|
"webViewFocusedAfterAddressBarExit=\(snapshot["webViewFocusedAfterAddressBarExit"] ?? "nil") " +
|
|
"addressBarExitActiveElementId=\(snapshot["addressBarExitActiveElementId"] ?? "nil") " +
|
|
"addressBarExitActiveElementTag=\(snapshot["addressBarExitActiveElementTag"] ?? "nil") " +
|
|
"addressBarExitActiveElementType=\(snapshot["addressBarExitActiveElementType"] ?? "nil") " +
|
|
"addressBarExitActiveElementEditable=\(snapshot["addressBarExitActiveElementEditable"] ?? "nil") " +
|
|
"addressBarExitTrackedFocusStateId=\(snapshot["addressBarExitTrackedFocusStateId"] ?? "nil") " +
|
|
"addressBarExitFocusTrackerInstalled=\(snapshot["addressBarExitFocusTrackerInstalled"] ?? "nil") " +
|
|
"addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " +
|
|
"addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " +
|
|
"addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil") " +
|
|
"webInputFocusElementId=\(snapshot["webInputFocusElementId"] ?? "nil") " +
|
|
"webInputFocusTrackerInstalled=\(snapshot["webInputFocusTrackerInstalled"] ?? "nil") " +
|
|
"webInputFocusTrackedStateId=\(snapshot["webInputFocusTrackedStateId"] ?? "nil")"
|
|
)
|
|
}
|
|
|
|
let window = app.windows.firstMatch
|
|
XCTAssertTrue(
|
|
window.waitForExistence(timeout: 6.0),
|
|
"Expected app window for post-escape click regression check"
|
|
)
|
|
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
|
|
window
|
|
.coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0))
|
|
.withOffset(CGVector(dx: secondaryClickOffsetX, dy: secondaryClickOffsetY))
|
|
.click()
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
|
|
|
|
app.typeKey("l", modifierFlags: [.command])
|
|
let clickMovedFocusToSecondary = waitForDataMatch(timeout: 6.0) { data in
|
|
data["webViewFocusedAfterAddressBarFocus"] == "false" &&
|
|
data["addressBarFocusActiveElementId"] == expectedSecondaryInputId &&
|
|
data["addressBarFocusActiveElementEditable"] == "true"
|
|
}
|
|
if !clickMovedFocusToSecondary {
|
|
let snapshot = loadData() ?? [:]
|
|
XCTFail(
|
|
"Expected post-escape click to focus secondary page input before Cmd+L. " +
|
|
"secondaryInputId=\(expectedSecondaryInputId) " +
|
|
"addressBarFocusActiveElementId=\(snapshot["addressBarFocusActiveElementId"] ?? "nil") " +
|
|
"addressBarFocusActiveElementTag=\(snapshot["addressBarFocusActiveElementTag"] ?? "nil") " +
|
|
"addressBarFocusActiveElementType=\(snapshot["addressBarFocusActiveElementType"] ?? "nil") " +
|
|
"addressBarFocusActiveElementEditable=\(snapshot["addressBarFocusActiveElementEditable"] ?? "nil") " +
|
|
"addressBarFocusTrackedFocusStateId=\(snapshot["addressBarFocusTrackedFocusStateId"] ?? "nil") " +
|
|
"addressBarFocusFocusTrackerInstalled=\(snapshot["addressBarFocusFocusTrackerInstalled"] ?? "nil")"
|
|
)
|
|
}
|
|
|
|
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
|
if !waitForDataMatch(timeout: 2.0, predicate: { data in
|
|
data["webViewFocusedAfterAddressBarExit"] == "true" &&
|
|
data["addressBarExitActiveElementId"] == expectedSecondaryInputId &&
|
|
data["addressBarExitActiveElementEditable"] == "true"
|
|
}) {
|
|
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
data["webViewFocusedAfterAddressBarExit"] == "true" &&
|
|
data["addressBarExitActiveElementId"] == expectedSecondaryInputId &&
|
|
data["addressBarExitActiveElementEditable"] == "true"
|
|
},
|
|
"Expected Escape to restore focus to the clicked secondary page input"
|
|
)
|
|
}
|
|
|
|
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 testClickingBrowserDismissesCommandPaletteAndKeepsBrowserFocus() {
|
|
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 so Cmd+R opens the rename overlay.
|
|
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 renameField = app.textFields["CommandPaletteRenameField"].firstMatch
|
|
app.typeKey("r", modifierFlags: [.command])
|
|
XCTAssertTrue(
|
|
renameField.waitForExistence(timeout: 5.0),
|
|
"Expected Cmd+R to open the rename command palette while terminal is focused"
|
|
)
|
|
|
|
let browserPane = app.otherElements["BrowserPanelContent.\(expectedBrowserPanelId)"].firstMatch
|
|
XCTAssertTrue(browserPane.waitForExistence(timeout: 5.0), "Expected browser pane content for click target")
|
|
browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
|
|
XCTAssertTrue(
|
|
waitForNonExistence(renameField, timeout: 5.0),
|
|
"Expected clicking the browser pane to dismiss the command palette"
|
|
)
|
|
|
|
// Cmd+L behavior is context-aware:
|
|
// - If terminal is still focused: opens a new browser in that pane.
|
|
// - If the original browser took focus: focuses that existing browser's omnibar.
|
|
app.typeKey("l", modifierFlags: [.command])
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 5.0) { data in
|
|
guard data["webViewFocusedAfterAddressBarFocus"] == "false" else { return false }
|
|
return data["webViewFocusedAfterAddressBarFocusPanelId"] == expectedBrowserPanelId
|
|
},
|
|
"Expected clicking browser content to dismiss the palette and keep focus on the existing browser pane"
|
|
)
|
|
}
|
|
|
|
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 waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
|
let predicate = NSPredicate(format: "exists == false")
|
|
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
|
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
|
}
|
|
|
|
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]
|
|
}
|
|
}
|