diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 07c167e6..05e62cee 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -406,6 +406,7 @@ final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { final class CommandPaletteFocusStealerClassificationTests: XCTestCase { private final class NonViewTextDelegate: NSObject, NSTextViewDelegate {} + private final class UnrelatedViewTextDelegate: NSView, NSTextViewDelegate {} func testTreatsGhosttySurfaceViewAsFocusStealer() { let surfaceView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) @@ -446,6 +447,21 @@ final class CommandPaletteFocusStealerClassificationTests: XCTestCase { "NSTextView responders should still be blocked via the NSView hierarchy walk when the delegate is not a view" ) } + + func testTreatsTextViewInsideTerminalHostedViewAsFocusStealerWhenDelegateViewIsUnrelated() { + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + let delegateView = UnrelatedViewTextDelegate(frame: .zero) + textView.delegate = delegateView + hostedView.addSubview(textView) + + XCTAssertTrue( + isCommandPaletteFocusStealingTerminalOrBrowserResponder(textView), + "NSTextView responders should still be blocked via the NSView hierarchy walk when the delegate view is unrelated" + ) + } } diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index 873f6b16..ec18756d 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -2,6 +2,11 @@ import XCTest import Foundation final class BrowserPaneNavigationKeybindUITests: XCTestCase { + private struct WorkspaceContext { + let workspaceId: String + let windowId: String + } + private var dataPath = "" private var socketPath = "" @@ -862,6 +867,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { 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( @@ -901,24 +912,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { "Expected browser find field to capture initial typing. value=\(String(describing: findField.value))" ) - 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") - - let firstResultRow = app.descendants(matching: .any).matching(identifier: "CommandPaletteResultRow.0").firstMatch - XCTAssertTrue(firstResultRow.waitForExistence(timeout: 5.0), "Expected command palette results for New Workspace") - app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - + openCommandPaletteForNewWorkspace(app, windowId: originalWorkspace.windowId) XCTAssertTrue( - waitForNonExistence(paletteSearchField, timeout: 5.0), - "Expected command palette to dismiss after creating a workspace" + selectWorkspace(originalWorkspace.workspaceId), + "Expected to return to the original workspace by identity" ) - app.typeKey("1", modifierFlags: [.command]) - let restoredFindField = app.textFields["BrowserFindSearchTextField"].firstMatch XCTAssertTrue(restoredFindField.waitForExistence(timeout: 6.0), "Expected browser find field after returning to workspace 1") XCTAssertTrue( @@ -1083,6 +1082,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { 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) @@ -1138,8 +1143,11 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { "Expected the intended find owner before leaving workspace 1. data=\(String(describing: loadData()))" ) - openCommandPaletteForNewWorkspace(app) - app.typeKey("1", modifierFlags: [.command]) + 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 @@ -1174,7 +1182,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } } - private func openCommandPaletteForNewWorkspace(_ app: XCUIApplication) { + private func openCommandPaletteForNewWorkspace(_ app: XCUIApplication, windowId: String) { app.typeKey("p", modifierFlags: [.command, .shift]) let paletteSearchField = app.textFields["CommandPaletteSearchField"].firstMatch @@ -1182,8 +1190,25 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { paletteSearchField.click() paletteSearchField.typeText("New Workspace") - let firstResultRow = app.descendants(matching: .any).matching(identifier: "CommandPaletteResultRow.0").firstMatch - XCTAssertTrue(firstResultRow.waitForExistence(timeout: 5.0), "Expected command palette results for 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( @@ -1230,6 +1255,87 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } } + 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" } @@ -1296,4 +1402,111 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { ) 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.size) + ) + } +#endif + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.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...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..