cmux/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
Lawrence Chen e680f1de55
Add DEBUG logging for browser omnibar/content focus handoff (#910)
* 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>
2026-03-07 03:05:13 -08:00

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]
}
}