diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d0238cdbd..61d0099bc 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -260,6 +260,7 @@ B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; + D1A0B0C2A1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0C3A1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; BEEF50100000000000000003 /* WindowCloseConfirmCancelUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; @@ -297,6 +298,7 @@ 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; + D1A0B0C0A1B2C3D4E5F60719 /* SidebarMarkdownRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0C1A1B2C3D4E5F60719 /* SidebarMarkdownRendererTests.swift */; }; 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */; }; 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; }; B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */; }; @@ -645,6 +647,7 @@ B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = ""; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; + D1A0B0C3A1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspacesConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCloseConfirmCancelUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; @@ -683,6 +686,7 @@ BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = ""; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; + D1A0B0C1A1B2C3D4E5F60719 /* SidebarMarkdownRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMarkdownRendererTests.swift; sourceTree = ""; }; B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; }; D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerUnitTests.swift; sourceTree = ""; }; @@ -1133,6 +1137,7 @@ B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */, B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */, B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */, + D1A0B0C3A1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift */, BEEF50100000000000000004 /* WindowCloseConfirmCancelUITests.swift */, B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, @@ -1180,6 +1185,7 @@ BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */, BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, + D1A0B0C1A1B2C3D4E5F60719 /* SidebarMarkdownRendererTests.swift */, AGSV0006A1B2C3D4E5F60719 /* AgentSupervisionTests.swift */, AGOV0004A1B2C3D4E5F60719 /* AgentOverviewTests.swift */, B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */, @@ -1573,6 +1579,7 @@ B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */, B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */, B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */, + D1A0B0C2A1B2C3D4E5F60719 /* CloseWorkspacesConfirmDialogUITests.swift in Sources */, B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, BEEF50100000000000000003 /* WindowCloseConfirmCancelUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, @@ -1621,6 +1628,7 @@ 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */, CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */, + D1A0B0C0A1B2C3D4E5F60719 /* SidebarMarkdownRendererTests.swift in Sources */, AGSV0005A1B2C3D4E5F60719 /* AgentSupervisionTests.swift in Sources */, AGOV0003A1B2C3D4E5F60719 /* AgentOverviewTests.swift in Sources */, 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index baddd7081..b5c381afb 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -596,7 +596,14 @@ class TabManager: ObservableObject { /// Kept in sidebar hierarchy order (see `canonicalizeHierarchyOrderIfNeeded`). @Published var tabs: [Workspace] = [] { - didSet { canonicalizeHierarchyOrderIfNeeded() } + didSet { + canonicalizeHierarchyOrderIfNeeded() +#if DEBUG + // UI-test seam: lets XCUITests read the workspace count without the control socket. + // No-op unless PROGRAMA_UI_TEST_KEYEQUIV_PATH is set. + UITestRecorder.record(["workspaceCount": String(tabs.count)]) +#endif + } } @Published var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] diff --git a/programaUITests/CloseWorkspacesConfirmDialogUITests.swift b/programaUITests/CloseWorkspacesConfirmDialogUITests.swift index fe5052364..df0880c84 100644 --- a/programaUITests/CloseWorkspacesConfirmDialogUITests.swift +++ b/programaUITests/CloseWorkspacesConfirmDialogUITests.swift @@ -2,33 +2,34 @@ import XCTest import Foundation final class CloseWorkspacesConfirmDialogUITests: XCTestCase { - private var socketPath = "" + private var countPath = "" override func setUp() { super.setUp() continueAfterFailure = false - socketPath = "/tmp/programa-ui-test-close-workspaces-\(UUID().uuidString).sock" - try? FileManager.default.removeItem(atPath: socketPath) + countPath = "/tmp/programa-ui-test-close-workspaces-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: countPath) } func testCommandPaletteCloseOtherWorkspacesShowsSingleSummaryDialog() { let app = XCUIApplication() - app.launchEnvironment["PROGRAMA_SOCKET_PATH"] = socketPath + configureLaunch(app) app.launchEnvironment["PROGRAMA_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1" app.launch() XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: 12.0), "Expected app to launch for close-workspaces confirmation test. state=\(app.state.rawValue)" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)") + XCTAssertTrue(waitForWorkspaceCount(1, timeout: 12.0), "Expected initial workspace. count=\(workspaceCount())") - XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK") - XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK") + app.typeKey("n", modifierFlags: [.command]) + XCTAssertTrue(waitForWorkspaceCount(2, timeout: 5.0), "Expected 2 workspaces. count=\(workspaceCount())") + app.typeKey("n", modifierFlags: [.command]) XCTAssertTrue( waitForWorkspaceCount(3, timeout: 5.0), - "Expected 3 workspaces before running the close-other-workspaces command. list=\(socketCommand("list_workspaces") ?? "")" + "Expected 3 workspaces before running the close-other-workspaces command. count=\(workspaceCount())" ) - XCTAssertEqual(socketCommand("select_workspace 1"), "OK") + app.typeKey("2", modifierFlags: [.command]) app.typeKey("p", modifierFlags: [.command, .shift]) @@ -57,26 +58,31 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { ) XCTAssertTrue( waitForWorkspaceCount(3, timeout: 5.0), - "Expected all workspaces to remain after cancelling multi-close. list=\(socketCommand("list_workspaces") ?? "")" + "Expected all workspaces to remain after cancelling multi-close. count=\(workspaceCount())" ) } func testCmdShiftWUsesSidebarMultiSelectionSummaryDialog() { let app = XCUIApplication() - app.launchEnvironment["PROGRAMA_SOCKET_PATH"] = socketPath + configureLaunch(app) app.launchEnvironment["PROGRAMA_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1" - app.launchEnvironment["PROGRAMA_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES"] = "0,1" + // Applied by the app once the third workspace exists, so later Cmd+N presses cannot reset it. + app.launchEnvironment["PROGRAMA_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES"] = "1,2" app.launch() XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: 12.0), "Expected app to launch for close-workspaces shortcut test. state=\(app.state.rawValue)" ) - XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)") + XCTAssertTrue(waitForWorkspaceCount(1, timeout: 12.0), "Expected initial workspace. count=\(workspaceCount())") - XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK") + // Three workspaces so the two selected ones are not all of them; closing every + // workspace shows the "Close window?" alert instead. + app.typeKey("n", modifierFlags: [.command]) + XCTAssertTrue(waitForWorkspaceCount(2, timeout: 5.0), "Expected 2 workspaces. count=\(workspaceCount())") + app.typeKey("n", modifierFlags: [.command]) XCTAssertTrue( - waitForWorkspaceCount(2, timeout: 5.0), - "Expected 2 workspaces before running Cmd+Shift+W. list=\(socketCommand("list_workspaces") ?? "")" + waitForWorkspaceCount(3, timeout: 5.0), + "Expected 3 workspaces before running Cmd+Shift+W. count=\(workspaceCount())" ) app.typeKey("w", modifierFlags: [.command, .shift]) @@ -93,11 +99,16 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { "Expected aggregated close-workspaces alert to dismiss after clicking Cancel" ) XCTAssertTrue( - waitForWorkspaceCount(2, timeout: 5.0), - "Expected both workspaces to remain after cancelling Cmd+Shift+W multi-close. list=\(socketCommand("list_workspaces") ?? "")" + waitForWorkspaceCount(3, timeout: 5.0), + "Expected all workspaces to remain after cancelling Cmd+Shift+W multi-close. count=\(workspaceCount())" ) } + private func configureLaunch(_ app: XCUIApplication) { + app.launchEnvironment["PROGRAMA_UI_TEST_MODE"] = "1" + app.launchEnvironment["PROGRAMA_UI_TEST_KEYEQUIV_PATH"] = countPath + } + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { if app.wait(for: .runningForeground, timeout: timeout) { return true @@ -109,16 +120,6 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { return false } - private func waitForSocketPong(timeout: TimeInterval) -> Bool { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in - self.socketCommand("ping") == "PONG" - }, - object: NSObject() - ) - return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed - } - private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in @@ -129,48 +130,12 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } + /// Reads the workspace count the app records to `PROGRAMA_UI_TEST_KEYEQUIV_PATH`; -1 if unavailable. private func workspaceCount() -> Int { - guard let response = socketCommand("list_workspaces") else { return -1 } - if response == "No workspaces" { - return 0 - } - return response - .split(separator: "\n") - .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - .count - } - - private func socketCommand(_ cmd: String) -> String? { - let nc = "/usr/bin/nc" - guard FileManager.default.isExecutableFile(atPath: nc) else { return nil } - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: nc) - proc.arguments = ["-U", socketPath, "-w", "2"] - - let inPipe = Pipe() - let outPipe = Pipe() - let errPipe = Pipe() - proc.standardInput = inPipe - proc.standardOutput = outPipe - proc.standardError = errPipe - - do { - try proc.run() - } catch { - return nil - } - - if let data = (cmd + "\n").data(using: .utf8) { - inPipe.fileHandleForWriting.write(data) - } - inPipe.fileHandleForWriting.closeFile() - - proc.waitUntilExit() - - let outData = outPipe.fileHandleForReading.readDataToEndOfFile() - guard let outStr = String(data: outData, encoding: .utf8) else { return nil } - return outStr.trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = try? Data(contentsOf: URL(fileURLWithPath: countPath)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String], + let raw = object["workspaceCount"], let count = Int(raw) else { return -1 } + return count } private func isCloseWorkspacesAlertPresent(app: XCUIApplication) -> Bool { diff --git a/programaUITests/MenuKeyEquivalentRoutingUITests.swift b/programaUITests/MenuKeyEquivalentRoutingUITests.swift index 6e630517d..7f47b3680 100644 --- a/programaUITests/MenuKeyEquivalentRoutingUITests.swift +++ b/programaUITests/MenuKeyEquivalentRoutingUITests.swift @@ -20,7 +20,6 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { private var socketPath = "" private var diagnosticsPath = "" private var screenshotDir = "" - private var socketClient: ControlSocketClient? override func setUp() { super.setUp() @@ -715,139 +714,9 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { return (try? JSONSerialization.jsonObject(with: raw)) as? [String: String] } - // MARK: - Automation Socket Client (UI Tests) - - private func waitForSocketPong(timeout: TimeInterval) -> Bool { - waitForCondition(timeout: timeout) { - self.socketCommand("ping") == "PONG" - } - } - private func waitForVisualDone(timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { self.loadData()?["visualDone"] == "1" } } - - private func socketCommand(_ cmd: String) -> String? { - if socketClient == nil { - socketClient = ControlSocketClient(path: socketPath) - } - if let v = socketClient?.sendLine(cmd) { - return v - } - // Fallback: use `nc -U` (more tolerant of Darwin sockaddr_un quirks across OS versions). - return socketCommandViaNetcat(cmd) - } - - private func socketCommandViaNetcat(_ cmd: String) -> String? { - let nc = "/usr/bin/nc" - guard FileManager.default.isExecutableFile(atPath: nc) else { return nil } - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: nc) - proc.arguments = ["-U", socketPath, "-w", "2"] - - let inPipe = Pipe() - let outPipe = Pipe() - let errPipe = Pipe() - proc.standardInput = inPipe - proc.standardOutput = outPipe - proc.standardError = errPipe - - do { - try proc.run() - } catch { - return nil - } - - if let data = (cmd + "\n").data(using: .utf8) { - inPipe.fileHandleForWriting.write(data) - } - inPipe.fileHandleForWriting.closeFile() - - proc.waitUntilExit() - - let outData = outPipe.fileHandleForReading.readDataToEndOfFile() - guard let outStr = String(data: outData, encoding: .utf8) else { return nil } - if let first = outStr.split(separator: "\n", maxSplits: 1).first { - return String(first).trimmingCharacters(in: .whitespacesAndNewlines) - } - let trimmed = outStr.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private final class ControlSocketClient { - private let path: String - - init(path: String) { - self.path = path - } - - func sendLine(_ line: String) -> String? { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - defer { close(fd) } - - var addr = sockaddr_un() - // Zero-init is important because we compute a variable sockaddr length and - // the kernel may validate `sun_len` on some macOS versions. - 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) // includes null terminator - guard bytes.count <= maxLen else { return nil } - withUnsafeMutablePointer(to: &addr.sun_path) { p in - let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self) - memset(raw, 0, maxLen) - for i in 0...offset(of: \.sun_path) ?? 0 - let addrLen = socklen_t(pathOffset + bytes.count) -#if os(macOS) - // `sun_len` exists on Darwin/BSD. - addr.sun_len = UInt8(min(Int(addrLen), 255)) -#endif - - let ok = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in - connect(fd, sa, addrLen) - } - } - guard ok == 0 else { return nil } - - let payload = line + "\n" - let wrote: Bool = payload.withCString { cstr in - var remaining = strlen(cstr) - var p = UnsafeRawPointer(cstr) - while remaining > 0 { - let n = write(fd, p, remaining) - if n <= 0 { return false } - remaining -= n - p = p.advanced(by: n) - } - return true - } - guard wrote else { return nil } - - var buf = [UInt8](repeating: 0, count: 4096) - var accum = "" - while true { - let n = read(fd, &buf, buf.count) - if n <= 0 { break } - if let chunk = String(bytes: buf[0.. String? { var lastResponse: String? _ = waitForCondition(timeout: timeout) { - lastResponse = self.socketCommand("ping") + lastResponse = self.socketPingResponse() return lastResponse == "PONG" } - return lastResponse == "PONG" ? "PONG" : (socketCommand("ping") ?? lastResponse) + return lastResponse == "PONG" ? "PONG" : (socketPingResponse() ?? lastResponse) } private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { waitForCondition(timeout: timeout) { - self.socketCommand("is_terminal_focused \(surfaceId)") == "true" + self.socketResult("debug.terminal.is_focused", params: ["surface_id": surfaceId])?["focused"] as? Bool == true } } @@ -562,18 +562,13 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? { - guard let response = socketCommand("list_surfaces \(workspaceId)"), - !response.isEmpty, - !response.hasPrefix("ERROR"), - response != "No surfaces" else { + guard let result = socketResult("surface.list", params: ["workspace_id": workspaceId]), + let surfaces = result["surfaces"] as? [[String: Any]] else { return nil } - for line in response.split(separator: "\n", omittingEmptySubsequences: true) { - let parts = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2 else { continue } - let candidate = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) - if UUID(uuidString: candidate) != nil { + for surface in surfaces { + if let candidate = surface["id"] as? String, UUID(uuidString: candidate) != nil { return candidate } } @@ -999,10 +994,9 @@ final class MultiWindowNotificationsUITests: XCTestCase { socketPath = candidatePath defer { socketPath = originalPath } - guard let response = socketCommand("list_surfaces \(workspaceId)"), - !response.isEmpty, - !response.hasPrefix("ERROR"), - response != "No surfaces" else { + guard let result = socketResult("surface.list", params: ["workspace_id": workspaceId]), + let surfaces = result["surfaces"] as? [[String: Any]], + !surfaces.isEmpty else { return false } return true @@ -1032,10 +1026,45 @@ final class MultiWindowNotificationsUITests: XCTestCase { let originalPath = socketPath socketPath = path defer { socketPath = originalPath } - return socketCommand("ping") == "PONG" + return socketPingResponse() == "PONG" } - private func socketCommand(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? { + /// Returns "PONG" for a valid v2 `system.ping` response, otherwise the raw response (or nil). + private func socketPingResponse() -> String? { + guard let envelope = socketJSON(method: "system.ping") else { return nil } + if envelope["ok"] as? Bool == true, (envelope["result"] as? [String: Any])?["pong"] as? Bool == true { + return "PONG" + } + return "\(envelope)" + } + + /// Sends a v2 JSON-RPC request and returns the full response envelope. + private func socketJSON( + method: String, + params: [String: Any] = [:], + responseTimeout: TimeInterval = 2.0 + ) -> [String: Any]? { + let request: [String: Any] = ["id": UUID().uuidString, "method": method, "params": params] + guard JSONSerialization.isValidJSONObject(request), + let body = try? JSONSerialization.data(withJSONObject: request), + let line = String(data: body, encoding: .utf8), + let response = socketRequestLine(line, responseTimeout: responseTimeout), + let data = response.data(using: .utf8) else { + return nil + } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + /// Returns the `result` object of a successful v2 response, nil on transport failure or error. + private func socketResult(_ method: String, params: [String: Any] = [:]) -> [String: Any]? { + guard let envelope = socketJSON(method: method, params: params), + envelope["ok"] as? Bool == true else { + return nil + } + return envelope["result"] as? [String: Any] ?? [:] + } + + private func socketRequestLine(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? { if let response = ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(cmd) { return response } @@ -1200,10 +1229,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func readCurrentTerminalText() -> String? { - guard let response = socketCommand("read_terminal_text"), response.hasPrefix("OK ") else { + guard let result = socketResult("debug.terminal.read_text"), + let encoded = result["base64"] as? String else { return nil } - let encoded = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) guard let data = Data(base64Encoded: encoded) else { return nil } return String(data: data, encoding: .utf8) } diff --git a/programaUITests/SidebarHelpMenuUITests.swift b/programaUITests/SidebarHelpMenuUITests.swift index ca90bc9ef..cd46dd875 100644 --- a/programaUITests/SidebarHelpMenuUITests.swift +++ b/programaUITests/SidebarHelpMenuUITests.swift @@ -223,9 +223,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") - let mainWindowId = try XCTUnwrap( - socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines) - ) + let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") openCommandPaletteCommands(app: app) @@ -338,22 +336,35 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") - let mainWindowId = try XCTUnwrap(socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines)) - let secondaryWorkspaceId = try XCTUnwrap(okUUID(from: socketCommand("new_workspace"))) + let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") + let secondaryWorkspaceId = try XCTUnwrap(createWorkspaceId(), "Expected workspace.create to return a workspace ID") + // v2 workspace.create only selects the new workspace when focus mutations are allowed; select explicitly. + assertSocketOK("workspace.select", params: ["workspace_id": secondaryWorkspaceId]) let initialSurfaceId = try XCTUnwrap(waitForSurfaceIDs(minimumCount: 1, timeout: 5.0).first) - let hiddenSurfaceId = try XCTUnwrap(okUUID(from: socketCommand("new_surface --type=terminal"))) + let hiddenSurfaceId = try XCTUnwrap( + socketResult("surface.create", params: ["type": "terminal"])?["surface_id"] as? String, + "Expected surface.create to return a surface ID" + ) - XCTAssertEqual( - socketCommand("report_pwd /tmp/\(hiddenSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(hiddenSurfaceId)"), - "OK" + assertSocketOK( + "surface.report_pwd", + params: [ + "workspace_id": secondaryWorkspaceId, + "surface_id": hiddenSurfaceId, + "path": "/tmp/\(hiddenSurfaceToken)", + ] ) - XCTAssertEqual(socketCommand("focus_surface \(initialSurfaceId)"), "OK") - XCTAssertEqual( - socketCommand("report_pwd /tmp/\(visibleSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(initialSurfaceId)"), - "OK" + assertSocketOK("surface.focus", params: ["surface_id": initialSurfaceId]) + assertSocketOK( + "surface.report_pwd", + params: [ + "workspace_id": secondaryWorkspaceId, + "surface_id": initialSurfaceId, + "path": "/tmp/\(visibleSurfaceToken)", + ] ) - XCTAssertEqual(socketCommand("select_workspace 0"), "OK") - XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK") + assertFirstWorkspaceSelected() + assertSocketOK("window.focus", params: ["window_id": mainWindowId]) RunLoop.current.run(until: Date().addingTimeInterval(0.4)) @@ -378,7 +389,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { "Expected the all-surfaces search setting to be enabled" ) - XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK") + assertSocketOK("window.focus", params: ["window_id": mainWindowId]) openCommandPalette(app: app, query: hiddenSurfaceToken) let enabledSnapshot = try XCTUnwrap( @@ -482,9 +493,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") - let mainWindowId = try XCTUnwrap( - socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines) - ) + let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") focusSettingsWindow(app: app) let toggle = try requireMinimalModeToggle(app: app) @@ -498,7 +507,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) } - XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK") + assertSocketOK("window.focus", params: ["window_id": mainWindowId]) openCommandPaletteCommands(app: app) let searchField = app.textFields["CommandPaletteSearchField"] searchField.typeText("minimal") @@ -528,7 +537,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { "Expected running the command palette action to enable minimal mode" ) - XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK") + assertSocketOK("window.focus", params: ["window_id": mainWindowId]) openCommandPaletteCommands(app: app) let disableSearchField = app.textFields["CommandPaletteSearchField"] disableSearchField.typeText("minimal") @@ -572,9 +581,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") - let mainWindowId = try XCTUnwrap( - socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines) - ) + let mainWindowId = try XCTUnwrap(currentWindowId(), "Expected window.current to return a window id") try seedWorkspaceSwitcherCorpus(workspaceCount: 96) let searchField = app.textFields["CommandPaletteSearchField"] @@ -759,7 +766,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { private func waitForSocketPong(timeout: TimeInterval) -> Bool { sidebarHelpPollUntil(timeout: timeout) { - socketCommand("ping") == "PONG" + (socketResult("system.ping")?["pong"] as? Bool) == true } } @@ -773,21 +780,54 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { } private func surfaceIDs() -> [String] { - guard let response = socketCommand("list_surfaces"), !response.isEmpty, !response.hasPrefix("No surfaces") else { + guard let surfaces = socketResult("surface.list")?["surfaces"] as? [[String: Any]] else { return [] } - return response - .split(separator: "\n") - .compactMap { line in - guard let range = line.range(of: ": ") else { return nil } - return String(line[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) - } + return surfaces.compactMap { $0["id"] as? String } } - private func okUUID(from response: String?) -> String? { - guard let response, response.hasPrefix("OK ") else { return nil } - let value = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - return UUID(uuidString: value) != nil ? value : nil + private func currentWindowId() -> String? { + socketResult("window.current")?["window_id"] as? String + } + + private func createWorkspaceId() -> String? { + guard let id = socketResult("workspace.create")?["workspace_id"] as? String, + UUID(uuidString: id) != nil else { return nil } + return id + } + + private func assertSocketOK( + _ method: String, + params: [String: Any] = [:], + file: StaticString = #filePath, + line: UInt = #line + ) { + let envelope = socketJSON(method: method, params: params) + XCTAssertEqual( + envelope?["ok"] as? Bool, + true, + "Expected \(method) to succeed. response=\(envelope.map { "\($0)" } ?? "")", + file: file, + line: line + ) + } + + private func assertFirstWorkspaceSelected(file: StaticString = #filePath, line: UInt = #line) { + let firstWorkspaceId = (socketResult("workspace.list")?["workspaces"] as? [[String: Any]])? + .first?["id"] as? String + guard let firstWorkspaceId else { + XCTFail("Expected workspace.list to return a first workspace", file: file, line: line) + return + } + assertSocketOK("workspace.select", params: ["workspace_id": firstWorkspaceId], file: file, line: line) + } + + private func socketResult(_ method: String, params: [String: Any] = [:]) -> [String: Any]? { + guard let envelope = socketJSON(method: method, params: params), + envelope["ok"] as? Bool == true else { + return nil + } + return envelope["result"] as? [String: Any] ?? [:] } private func debugTypeText(_ text: String) throws { @@ -822,8 +862,8 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { for index in 1.. String { "\(noMatchWorkspaceQuery)-\(index)-" + String(repeating: "workspace-", count: 8) } - private func socketCommand(_ command: String) -> String? { - ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendLine(command) - } - private func commandPaletteResultRows(from snapshot: [String: Any]) -> [[String: Any]] { snapshot["results"] as? [[String: Any]] ?? [] }