1512 lines
66 KiB
Swift
1512 lines
66 KiB
Swift
import XCTest
|
|
import Foundation
|
|
|
|
final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|
private struct WorkspaceContext {
|
|
let workspaceId: String
|
|
let windowId: String
|
|
}
|
|
|
|
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 testCmdShiftEnterKeepsBrowserOmnibarHittableAcrossZoomRoundTripWhenWebViewFocused() {
|
|
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
|
|
}
|
|
|
|
guard let browserPanelId = setup["browserPanelId"] else {
|
|
XCTFail("Missing browserPanelId in goto_split setup data")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test")
|
|
|
|
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
|
let pill = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarPill").firstMatch
|
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field before zoom")
|
|
XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill before zoom")
|
|
|
|
// Reproduce the loaded-page state from the bug report before toggling zoom.
|
|
app.typeKey("l", modifierFlags: [.command])
|
|
XCTAssertTrue(waitForElementToBecomeHittable(pill, timeout: 6.0), "Expected browser omnibar pill before navigation")
|
|
pill.click()
|
|
app.typeKey("a", modifierFlags: [.command])
|
|
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
|
app.typeText(zoomRoundTripPageURL)
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
|
|
|
XCTAssertTrue(
|
|
waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0),
|
|
"Expected browser to finish navigating to the regression page before zoom. value=\(String(describing: omnibar.value))"
|
|
)
|
|
|
|
let browserPane = app.otherElements["BrowserPanelContent.\(browserPanelId)"].firstMatch
|
|
XCTAssertTrue(browserPane.waitForExistence(timeout: 6.0), "Expected browser pane content before zoom")
|
|
browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
|
|
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 8.0) { data in
|
|
data["splitZoomedAfterToggle"] == "true" &&
|
|
data["otherTerminalHostHiddenAfterToggle"] == "true" &&
|
|
data["otherTerminalVisibleFlagAfterToggle"] == "false"
|
|
},
|
|
"Expected Cmd+Shift+Enter zoom-in to hide the non-browser terminal portal. data=\(loadData() ?? [:])"
|
|
)
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 8.0) { data in
|
|
data["splitZoomedAfterToggle"] == "false" &&
|
|
data["otherTerminalHostHiddenAfterToggle"] == "false" &&
|
|
data["otherTerminalVisibleFlagAfterToggle"] == "true"
|
|
},
|
|
"Expected Cmd+Shift+Enter zoom-out to restore the non-browser terminal portal. data=\(loadData() ?? [:])"
|
|
)
|
|
|
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field after Cmd+Shift+Enter zoom round-trip")
|
|
XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill after Cmd+Shift+Enter zoom round-trip")
|
|
XCTAssertTrue(
|
|
waitForElementToBecomeHittable(pill, timeout: 6.0),
|
|
"Expected browser omnibar to stay hittable after Cmd+Shift+Enter zoom round-trip"
|
|
)
|
|
let page = app.webViews.firstMatch
|
|
XCTAssertTrue(page.waitForExistence(timeout: 6.0), "Expected browser web area after Cmd+Shift+Enter")
|
|
XCTAssertLessThanOrEqual(
|
|
pill.frame.maxY,
|
|
page.frame.minY + 12,
|
|
"Expected browser omnibar to remain above the web content after Cmd+Shift+Enter. pill=\(pill.frame) page=\(page.frame)"
|
|
)
|
|
|
|
pill.click()
|
|
app.typeKey("a", modifierFlags: [.command])
|
|
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
|
app.typeText("issue1144")
|
|
|
|
XCTAssertTrue(
|
|
waitForOmnibarToContain(omnibar, value: "issue1144", timeout: 4.0),
|
|
"Expected browser omnibar to stay editable after Cmd+Shift+Enter. value=\(String(describing: omnibar.value))"
|
|
)
|
|
}
|
|
|
|
func testCmdShiftEnterHidesBrowserPortalWhenTerminalPaneZooms() {
|
|
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: ["terminalPaneId", "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
|
|
}
|
|
|
|
guard let expectedTerminalPaneId = setup["terminalPaneId"] else {
|
|
XCTFail("Missing terminalPaneId in goto_split setup data")
|
|
return
|
|
}
|
|
|
|
app.typeKey("h", modifierFlags: [.command, .control])
|
|
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 5.0) { data in
|
|
data["focusedPaneId"] == expectedTerminalPaneId && data["focusedPanelKind"] == "terminal"
|
|
},
|
|
"Expected Cmd+Ctrl+H to focus the terminal pane before zoom. data=\(loadData() ?? [:])"
|
|
)
|
|
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 8.0) { data in
|
|
data["splitZoomedAfterToggle"] == "true" &&
|
|
data["browserContainerHiddenAfterToggle"] == "true" &&
|
|
data["browserVisibleFlagAfterToggle"] == "false"
|
|
},
|
|
"Expected Cmd+Shift+Enter zoom-in on the terminal pane to hide the browser portal. data=\(loadData() ?? [:])"
|
|
)
|
|
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 8.0) { data in
|
|
data["splitZoomedAfterToggle"] == "false" &&
|
|
data["browserContainerHiddenAfterToggle"] == "false" &&
|
|
data["browserVisibleFlagAfterToggle"] == "true"
|
|
},
|
|
"Expected Cmd+Shift+Enter zoom-out from the terminal pane to restore the browser portal. data=\(loadData() ?? [:])"
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func testCmdFFocusesBrowserFindFieldAfterCmdDCmdLNavigation() {
|
|
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
|
|
launchAndEnsureForeground(app)
|
|
|
|
let window = app.windows.firstMatch
|
|
// On some CI runners the app accepts key events before XCUI exposes the window tree.
|
|
_ = window.waitForExistence(timeout: 2.0)
|
|
|
|
app.typeKey("d", modifierFlags: [.command])
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
guard data["lastSplitDirection"] == "right" else { return false }
|
|
guard let paneCountAfterSplit = Int(data["paneCountAfterSplit"] ?? "") else { return false }
|
|
return paneCountAfterSplit >= 2
|
|
},
|
|
"Expected Cmd+D to create a split before opening the browser. data=\(String(describing: loadData()))"
|
|
)
|
|
|
|
app.typeKey("l", modifierFlags: [.command])
|
|
|
|
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+L")
|
|
|
|
app.typeKey("a", modifierFlags: [.command])
|
|
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
|
app.typeText("example.com")
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
|
|
|
XCTAssertTrue(
|
|
waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0),
|
|
"Expected browser navigation to example domain before opening find. value=\(String(describing: omnibar.value))"
|
|
)
|
|
|
|
app.typeKey("f", modifierFlags: [.command])
|
|
|
|
let findField = app.textFields["BrowserFindSearchTextField"].firstMatch
|
|
XCTAssertTrue(findField.waitForExistence(timeout: 6.0), "Expected browser find field after Cmd+F")
|
|
|
|
let omnibarValueBeforeFindTyping = (omnibar.value as? String) ?? ""
|
|
app.typeText("needle")
|
|
|
|
XCTAssertTrue(
|
|
waitForCondition(timeout: 4.0) {
|
|
((findField.value as? String) ?? "") == "needle"
|
|
},
|
|
"Expected Cmd+F to focus browser find after Cmd+D, Cmd+L, and navigation. " +
|
|
"findValue=\(String(describing: findField.value)) omnibarValue=\(String(describing: omnibar.value))"
|
|
)
|
|
let omnibarValueAfterFindTyping = (omnibar.value as? String) ?? ""
|
|
XCTAssertFalse(
|
|
omnibarValueAfterFindTyping.contains("needle"),
|
|
"Expected typing after Cmd+F to stay out of the omnibar. " +
|
|
"omnibarValueBefore=\(omnibarValueBeforeFindTyping) " +
|
|
"omnibarValueAfter=\(String(describing: omnibar.value)) " +
|
|
"findValue=\(String(describing: findField.value))"
|
|
)
|
|
}
|
|
|
|
func testBrowserFindFieldKeepsFocusAfterNewWorkspaceRoundTrip() {
|
|
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
|
|
launchAndEnsureForeground(app)
|
|
|
|
let window = app.windows.firstMatch
|
|
_ = window.waitForExistence(timeout: 2.0)
|
|
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
|
|
|
|
guard let originalWorkspace = currentWorkspaceContext() else {
|
|
XCTFail("Expected current workspace context before leaving the original workspace")
|
|
return
|
|
}
|
|
|
|
app.typeKey("d", modifierFlags: [.command])
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
guard data["lastSplitDirection"] == "right" else { return false }
|
|
guard let paneCountAfterSplit = Int(data["paneCountAfterSplit"] ?? "") else { return false }
|
|
return paneCountAfterSplit >= 2
|
|
},
|
|
"Expected Cmd+D to create a split before opening the browser. data=\(String(describing: loadData()))"
|
|
)
|
|
|
|
app.typeKey("l", modifierFlags: [.command])
|
|
|
|
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+L")
|
|
|
|
app.typeKey("a", modifierFlags: [.command])
|
|
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
|
app.typeText("example.com")
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
|
|
|
XCTAssertTrue(
|
|
waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0),
|
|
"Expected browser navigation to example domain before opening find. value=\(String(describing: omnibar.value))"
|
|
)
|
|
|
|
app.typeKey("f", modifierFlags: [.command])
|
|
|
|
let findField = app.textFields["BrowserFindSearchTextField"].firstMatch
|
|
XCTAssertTrue(findField.waitForExistence(timeout: 6.0), "Expected browser find field after Cmd+F")
|
|
|
|
app.typeText("seed")
|
|
XCTAssertTrue(
|
|
waitForCondition(timeout: 4.0) {
|
|
((findField.value as? String) ?? "") == "seed"
|
|
},
|
|
"Expected browser find field to capture initial typing. value=\(String(describing: findField.value))"
|
|
)
|
|
|
|
openCommandPaletteForNewWorkspace(app, windowId: originalWorkspace.windowId)
|
|
XCTAssertTrue(
|
|
selectWorkspace(originalWorkspace.workspaceId),
|
|
"Expected to return to the original workspace by identity"
|
|
)
|
|
|
|
let restoredFindField = app.textFields["BrowserFindSearchTextField"].firstMatch
|
|
XCTAssertTrue(restoredFindField.waitForExistence(timeout: 6.0), "Expected browser find field after returning to workspace 1")
|
|
XCTAssertTrue(
|
|
waitForCondition(timeout: 4.0) {
|
|
((restoredFindField.value as? String) ?? "") == "seed"
|
|
},
|
|
"Expected existing browser find query to persist after returning. value=\(String(describing: restoredFindField.value))"
|
|
)
|
|
|
|
app.typeText("x")
|
|
XCTAssertTrue(
|
|
waitForCondition(timeout: 4.0) {
|
|
((restoredFindField.value as? String) ?? "") == "seedx"
|
|
},
|
|
"Expected typing after returning from a new workspace to stay in the browser find field. " +
|
|
"findValue=\(String(describing: restoredFindField.value)) omnibarValue=\(String(describing: omnibar.value))"
|
|
)
|
|
}
|
|
|
|
func testWorkspaceRoundTripPreservesFocusedTerminalFindWhenBrowserFindIsAlsoOpen() {
|
|
runSplitFindWorkspaceRoundTripScenario(restoredOwner: .terminal)
|
|
}
|
|
|
|
func testWorkspaceRoundTripPreservesFocusedBrowserFindWhenTerminalFindIsAlsoOpen() {
|
|
runSplitFindWorkspaceRoundTripScenario(restoredOwner: .browser)
|
|
}
|
|
|
|
private enum FindFocusRoute {
|
|
case cmdOptionArrows
|
|
case cmdCtrlLetters
|
|
}
|
|
|
|
private enum SplitFindOwner {
|
|
case terminal
|
|
case browser
|
|
|
|
var focusedPanelKind: String {
|
|
switch self {
|
|
case .terminal:
|
|
return "terminal"
|
|
case .browser:
|
|
return "browser"
|
|
}
|
|
}
|
|
}
|
|
|
|
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 runSplitFindWorkspaceRoundTripScenario(restoredOwner: SplitFindOwner) {
|
|
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
|
|
launchAndEnsureForeground(app)
|
|
|
|
let window = app.windows.firstMatch
|
|
XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist")
|
|
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
|
|
|
|
guard let originalWorkspace = currentWorkspaceContext() else {
|
|
XCTFail("Expected current workspace context before leaving workspace 1")
|
|
return
|
|
}
|
|
|
|
app.typeKey("d", modifierFlags: [.command])
|
|
focusRightPaneForFindScenario(app, route: .cmdOptionArrows)
|
|
|
|
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: [])
|
|
app.typeText("example.com")
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
|
|
|
XCTAssertTrue(
|
|
waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0),
|
|
"Expected browser navigation to example domain before running workspace round trip. value=\(String(describing: omnibar.value))"
|
|
)
|
|
|
|
focusLeftPaneForFindScenario(app, route: .cmdOptionArrows)
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
data["focusedPanelKind"] == "terminal"
|
|
},
|
|
"Expected left terminal pane to be focused before opening terminal find. data=\(String(describing: loadData()))"
|
|
)
|
|
app.typeKey("f", modifierFlags: [.command])
|
|
app.typeText("la")
|
|
|
|
focusRightPaneForFindScenario(app, route: .cmdOptionArrows)
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
data["focusedPanelKind"] == "browser"
|
|
&& data["terminalFindNeedle"] == "la"
|
|
},
|
|
"Expected terminal find query to persist before opening browser find. data=\(String(describing: loadData()))"
|
|
)
|
|
app.typeKey("f", modifierFlags: [.command])
|
|
app.typeText("am")
|
|
|
|
switch restoredOwner {
|
|
case .terminal:
|
|
focusLeftPaneForFindScenario(app, route: .cmdOptionArrows)
|
|
case .browser:
|
|
break
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
data["focusedPanelKind"] == restoredOwner.focusedPanelKind
|
|
&& data["terminalFindNeedle"] == "la"
|
|
&& data["browserFindNeedle"] == "am"
|
|
},
|
|
"Expected the intended find owner before leaving workspace 1. data=\(String(describing: loadData()))"
|
|
)
|
|
|
|
openCommandPaletteForNewWorkspace(app, windowId: originalWorkspace.windowId)
|
|
XCTAssertTrue(
|
|
selectWorkspace(originalWorkspace.workspaceId),
|
|
"Expected to return to the original workspace by identity"
|
|
)
|
|
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
data["focusedPanelKind"] == restoredOwner.focusedPanelKind
|
|
&& data["terminalFindNeedle"] == "la"
|
|
&& data["browserFindNeedle"] == "am"
|
|
},
|
|
"Expected the previously focused find owner to be restored after the workspace round trip. data=\(String(describing: loadData()))"
|
|
)
|
|
|
|
switch restoredOwner {
|
|
case .terminal:
|
|
app.typeText("foo")
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
data["focusedPanelKind"] == "terminal"
|
|
&& data["terminalFindNeedle"] == "lafoo"
|
|
&& data["browserFindNeedle"] == "am"
|
|
},
|
|
"Expected typing after returning to stay in terminal find. data=\(String(describing: loadData()))"
|
|
)
|
|
case .browser:
|
|
app.typeText("do")
|
|
XCTAssertTrue(
|
|
waitForDataMatch(timeout: 6.0) { data in
|
|
data["focusedPanelKind"] == "browser"
|
|
&& data["terminalFindNeedle"] == "la"
|
|
&& data["browserFindNeedle"] == "amdo"
|
|
},
|
|
"Expected typing after returning to stay in browser find. data=\(String(describing: loadData()))"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func openCommandPaletteForNewWorkspace(_ app: XCUIApplication, windowId: String) {
|
|
app.typeKey("p", modifierFlags: [.command, .shift])
|
|
|
|
let paletteSearchField = app.textFields["CommandPaletteSearchField"].firstMatch
|
|
XCTAssertTrue(paletteSearchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
|
|
paletteSearchField.click()
|
|
paletteSearchField.typeText("New Workspace")
|
|
|
|
guard let snapshot = waitForCommandPaletteSnapshot(
|
|
windowId: windowId,
|
|
mode: "commands",
|
|
query: "New Workspace",
|
|
timeout: 5.0,
|
|
predicate: { snapshot in
|
|
guard let firstRow = self.commandPaletteResultRows(from: snapshot).first else { return false }
|
|
return (firstRow["command_id"] as? String) == "palette.newWorkspace"
|
|
}
|
|
) else {
|
|
XCTFail("Expected palette.newWorkspace to be the selected command palette result")
|
|
return
|
|
}
|
|
XCTAssertEqual(
|
|
commandPaletteResultRows(from: snapshot).first?["command_id"] as? String,
|
|
"palette.newWorkspace",
|
|
"Expected palette.newWorkspace to be selected before pressing Return"
|
|
)
|
|
|
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
|
|
|
XCTAssertTrue(
|
|
waitForNonExistence(paletteSearchField, timeout: 5.0),
|
|
"Expected command palette to dismiss after creating a workspace"
|
|
)
|
|
}
|
|
|
|
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 {
|
|
waitForCondition(timeout: timeout) {
|
|
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 {
|
|
waitForCondition(timeout: timeout) {
|
|
let value = (omnibar.value as? String) ?? ""
|
|
return value.contains(expectedSubstring)
|
|
}
|
|
}
|
|
|
|
private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
|
waitForCondition(timeout: timeout) {
|
|
element.exists && element.isHittable
|
|
}
|
|
}
|
|
|
|
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
|
|
waitForCondition(timeout: timeout) {
|
|
socketCommand("ping") == "PONG"
|
|
}
|
|
}
|
|
|
|
private func currentWorkspaceContext() -> WorkspaceContext? {
|
|
guard let envelope = socketJSON(method: "workspace.current", params: [:]),
|
|
let ok = envelope["ok"] as? Bool,
|
|
ok,
|
|
let result = envelope["result"] as? [String: Any],
|
|
let workspaceId = result["workspace_id"] as? String,
|
|
let windowId = result["window_id"] as? String else {
|
|
return nil
|
|
}
|
|
return WorkspaceContext(workspaceId: workspaceId, windowId: windowId)
|
|
}
|
|
|
|
private func selectWorkspace(_ workspaceId: String) -> Bool {
|
|
guard let envelope = socketJSON(
|
|
method: "workspace.select",
|
|
params: ["workspace_id": workspaceId]
|
|
),
|
|
let ok = envelope["ok"] as? Bool,
|
|
ok else {
|
|
return false
|
|
}
|
|
|
|
return waitForCondition(timeout: 5.0) {
|
|
self.currentWorkspaceContext()?.workspaceId == workspaceId
|
|
}
|
|
}
|
|
|
|
private func socketCommand(_ command: String) -> String? {
|
|
ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendLine(command)
|
|
}
|
|
|
|
private func socketJSON(method: String, params: [String: Any]) -> [String: Any]? {
|
|
let request: [String: Any] = [
|
|
"id": UUID().uuidString,
|
|
"method": method,
|
|
"params": params,
|
|
]
|
|
return ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendJSON(request)
|
|
}
|
|
|
|
private func commandPaletteResultRows(from snapshot: [String: Any]) -> [[String: Any]] {
|
|
snapshot["results"] as? [[String: Any]] ?? []
|
|
}
|
|
|
|
private func waitForCommandPaletteSnapshot(
|
|
windowId: String,
|
|
mode: String,
|
|
query: String,
|
|
timeout: TimeInterval,
|
|
predicate: (([String: Any]) -> Bool)? = nil
|
|
) -> [String: Any]? {
|
|
var latest: [String: Any]?
|
|
let matched = waitForCondition(timeout: timeout) {
|
|
guard let snapshot = self.commandPaletteSnapshot(windowId: windowId) else { return false }
|
|
latest = snapshot
|
|
guard (snapshot["visible"] as? Bool) == true else { return false }
|
|
guard (snapshot["mode"] as? String) == mode else { return false }
|
|
guard (snapshot["query"] as? String) == query else { return false }
|
|
return predicate?(snapshot) ?? true
|
|
}
|
|
return matched ? latest : nil
|
|
}
|
|
|
|
private func commandPaletteSnapshot(windowId: String) -> [String: Any]? {
|
|
let envelope = socketJSON(
|
|
method: "debug.command_palette.results",
|
|
params: [
|
|
"window_id": windowId,
|
|
"limit": 20,
|
|
]
|
|
)
|
|
guard let ok = envelope?["ok"] as? Bool, ok else { return nil }
|
|
return envelope?["result"] as? [String: Any]
|
|
}
|
|
|
|
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 var zoomRoundTripPageURL: String {
|
|
"data:text/html,%3Ctitle%3EIssue%201144%3C/title%3E%3Cbody%20style%3D%22margin:0;background:%231d1f24;color:white;font-family:system-ui;height:2200px%22%3E%3Cmain%20style%3D%22padding:32px%22%3E%3Ch1%3EIssue%201144%20Regression%20Page%3C/h1%3E%3Cp%3EZoom%20should%20not%20leave%20stale%20split%20chrome%20above%20the%20browser%20omnibar.%3C/p%3E%3C/main%3E%3C/body%3E"
|
|
}
|
|
|
|
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
|
|
// On headless CI runners (no GUI session), XCUIApplication.launch()
|
|
// blocks ~60s then fails with "Failed to activate application
|
|
// (current state: Running Background)". Mark this as an expected
|
|
// failure so the test can continue — keyboard and element APIs work
|
|
// via accessibility even when the app is in .runningBackground.
|
|
let options = XCTExpectedFailure.Options()
|
|
options.isStrict = false
|
|
XCTExpectFailure("App activation may fail on headless CI runners", options: options) {
|
|
app.launch()
|
|
}
|
|
|
|
if app.state == .runningForeground { return }
|
|
|
|
if app.state == .runningBackground {
|
|
// App launched but couldn't activate — continue in background.
|
|
// XCUIElement queries and keyboard input work through the
|
|
// accessibility framework regardless of activation state.
|
|
return
|
|
}
|
|
|
|
XCTFail("App failed to start. state=\(app.state.rawValue)")
|
|
}
|
|
|
|
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
|
|
waitForCondition(timeout: timeout) {
|
|
guard let data = self.loadData() else { return false }
|
|
return keys.allSatisfy { data[$0] != nil }
|
|
}
|
|
}
|
|
|
|
private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool {
|
|
waitForCondition(timeout: timeout) {
|
|
guard let data = self.loadData() else { return false }
|
|
return predicate(data)
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool {
|
|
let expectation = XCTNSPredicateExpectation(
|
|
predicate: NSPredicate { _, _ in predicate() },
|
|
object: nil
|
|
)
|
|
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
|
}
|
|
|
|
private final class ControlSocketClient {
|
|
private let path: String
|
|
private let responseTimeout: TimeInterval
|
|
|
|
init(path: String, responseTimeout: TimeInterval) {
|
|
self.path = path
|
|
self.responseTimeout = responseTimeout
|
|
}
|
|
|
|
func sendJSON(_ object: [String: Any]) -> [String: Any]? {
|
|
guard JSONSerialization.isValidJSONObject(object),
|
|
let data = try? JSONSerialization.data(withJSONObject: object),
|
|
let line = String(data: data, encoding: .utf8),
|
|
let response = sendLine(line),
|
|
let responseData = response.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
|
return nil
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func sendLine(_ line: String) -> String? {
|
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
guard fd >= 0 else { return nil }
|
|
defer { close(fd) }
|
|
|
|
#if os(macOS)
|
|
var noSigPipe: Int32 = 1
|
|
_ = withUnsafePointer(to: &noSigPipe) { ptr in
|
|
setsockopt(
|
|
fd,
|
|
SOL_SOCKET,
|
|
SO_NOSIGPIPE,
|
|
ptr,
|
|
socklen_t(MemoryLayout<Int32>.size)
|
|
)
|
|
}
|
|
#endif
|
|
|
|
var addr = sockaddr_un()
|
|
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
|
|
addr.sun_family = sa_family_t(AF_UNIX)
|
|
|
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
|
let bytes = Array(path.utf8CString)
|
|
guard bytes.count <= maxLen else { return nil }
|
|
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
|
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self)
|
|
memset(raw, 0, maxLen)
|
|
for index in 0..<bytes.count {
|
|
raw[index] = bytes[index]
|
|
}
|
|
}
|
|
|
|
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
|
|
let addrLen = socklen_t(pathOffset + bytes.count)
|
|
#if os(macOS)
|
|
addr.sun_len = UInt8(min(Int(addrLen), 255))
|
|
#endif
|
|
|
|
let connected = withUnsafePointer(to: &addr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
|
connect(fd, sa, addrLen)
|
|
}
|
|
}
|
|
guard connected == 0 else { return nil }
|
|
|
|
let payload = line + "\n"
|
|
let wrote: Bool = payload.withCString { cString in
|
|
var remaining = strlen(cString)
|
|
var pointer = UnsafeRawPointer(cString)
|
|
while remaining > 0 {
|
|
let written = write(fd, pointer, remaining)
|
|
if written <= 0 { return false }
|
|
remaining -= written
|
|
pointer = pointer.advanced(by: written)
|
|
}
|
|
return true
|
|
}
|
|
guard wrote else { return nil }
|
|
|
|
let deadline = Date().addingTimeInterval(responseTimeout)
|
|
var buffer = [UInt8](repeating: 0, count: 4096)
|
|
var accumulator = ""
|
|
while Date() < deadline {
|
|
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
|
let ready = poll(&pollDescriptor, 1, 100)
|
|
if ready < 0 {
|
|
return nil
|
|
}
|
|
if ready == 0 {
|
|
continue
|
|
}
|
|
let count = read(fd, &buffer, buffer.count)
|
|
if count <= 0 { break }
|
|
if let chunk = String(bytes: buffer[0..<count], encoding: .utf8) {
|
|
accumulator.append(chunk)
|
|
if let newline = accumulator.firstIndex(of: "\n") {
|
|
return String(accumulator[..<newline])
|
|
}
|
|
}
|
|
}
|
|
|
|
return accumulator.isEmpty ? nil : accumulator.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
}
|
|
}
|