Add command palette follow-up regressions
This commit is contained in:
parent
58dc932248
commit
9295a7f676
2 changed files with 249 additions and 20 deletions
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue