diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 02558566..9b2f218e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1694,6 +1694,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent PostHogAnalytics.shared.startIfNeeded() } + let forceDuplicateLaunchObserver = env["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" + // UI tests frequently time out waiting for the main window if we do heavyweight // LaunchServices registration / single-instance enforcement synchronously at startup. // Skip these during XCTest (the app-under-test) so the window can appear quickly. @@ -1704,6 +1706,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.enforceSingleInstance() self.observeDuplicateLaunches() } + } else if forceDuplicateLaunchObserver { + // Some UI regressions specifically exercise launch-observer behavior while still + // running under XCTest. Allow an explicit opt-in for those cases only. + DispatchQueue.main.async { [weak self] in + self?.observeDuplicateLaunches() + } } NSWindow.allowsAutomaticWindowTabbing = false disableNativeTabbingShortcut() @@ -5752,19 +5760,64 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent try? FileManager.default.removeItem(atPath: path) - let deadline = Date().addingTimeInterval(8.0) + let contextDeadline = Date().addingTimeInterval(8.0) func waitForContexts(minCount: Int, _ completion: @escaping () -> Void) { if mainWindowContexts.count >= minCount, mainWindowContexts.values.allSatisfy({ $0.window != nil }) { completion() return } - guard Date() < deadline else { return } + guard Date() < contextDeadline else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { waitForContexts(minCount: minCount, completion) } } + func waitForSurfaceId( + on tabManager: TabManager, + tabId: UUID, + timeout: TimeInterval = 8.0, + _ completion: @escaping (UUID) -> Void + ) { + let deadline = Date().addingTimeInterval(timeout) + + func resolvedSurfaceId() -> UUID? { + if let surfaceId = tabManager.focusedPanelId(for: tabId) { + return surfaceId + } + + guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil + } + + if let terminalPanelId = workspace.focusedTerminalPanel?.id { + return terminalPanelId + } + + if let terminalPanelId = workspace.terminalPanelForConfigInheritance()?.id { + return terminalPanelId + } + + return workspace.panels.values + .compactMap { ($0 as? TerminalPanel)?.id } + .sorted(by: { $0.uuidString < $1.uuidString }) + .first + } + + func poll() { + if let surfaceId = resolvedSurfaceId() { + completion(surfaceId) + return + } + guard Date() < deadline else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + poll() + } + } + + poll() + } + waitForContexts(minCount: 1) { [weak self] in guard let self else { return } guard let window1 = self.mainWindowContexts.values.first else { return } @@ -5778,39 +5831,193 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let contexts = Array(self.mainWindowContexts.values) guard let window2 = contexts.first(where: { $0.windowId != window1.windowId }) else { return } guard let tabId2 = window2.tabManager.selectedTabId ?? window2.tabManager.tabs.first?.id else { return } - guard let store = self.notificationStore else { return } + waitForSurfaceId(on: window1.tabManager, tabId: tabId1) { [weak self] surfaceId1 in + guard let self else { return } + waitForSurfaceId(on: window2.tabManager, tabId: tabId2) { [weak self] surfaceId2 in + guard let self else { return } + guard let store = self.notificationStore else { return } - // Ensure the target window is currently showing the Notifications overlay, - // so opening a notification must switch it back to the terminal UI. - window2.sidebarSelectionState.selection = .notifications + // Ensure the target window is currently showing the Notifications overlay, + // so opening a notification must switch it back to the terminal UI. + window2.sidebarSelectionState.selection = .notifications - // Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused. - let prevOverride = AppFocusState.overrideIsFocused - AppFocusState.overrideIsFocused = false - store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "") - AppFocusState.overrideIsFocused = prevOverride + // Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused. + let prevOverride = AppFocusState.overrideIsFocused + AppFocusState.overrideIsFocused = false + store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "") + AppFocusState.overrideIsFocused = prevOverride - // Insert after W2 so it becomes "latest unread" (first in list). - store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "") + // Insert after W2 so it becomes "latest unread" (first in list). + store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "") - let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" }) - let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" }) + let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" }) + let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" }) - self.writeMultiWindowNotificationTestData([ - "window1Id": window1.windowId.uuidString, - "window2Id": window2.windowId.uuidString, - "window2InitialSidebarSelection": "notifications", - "tabId1": tabId1.uuidString, - "tabId2": tabId2.uuidString, - "notifId1": notif1?.id.uuidString ?? "", - "notifId2": notif2?.id.uuidString ?? "", - "expectedLatestWindowId": window1.windowId.uuidString, - "expectedLatestTabId": tabId1.uuidString, - ], at: path) + self.writeMultiWindowNotificationTestData([ + "window1Id": window1.windowId.uuidString, + "window2Id": window2.windowId.uuidString, + "window2InitialSidebarSelection": "notifications", + "tabId1": tabId1.uuidString, + "tabId2": tabId2.uuidString, + "surfaceId1": surfaceId1.uuidString, + "surfaceId2": surfaceId2.uuidString, + "notifId1": notif1?.id.uuidString ?? "", + "notifId2": notif2?.id.uuidString ?? "", + "expectedLatestWindowId": window1.windowId.uuidString, + "expectedLatestTabId": tabId1.uuidString, + ], at: path) + self.prepareMultiWindowNotificationSourceTerminalIfNeeded( + at: path, + windowId: window1.windowId, + tabManager: window1.tabManager, + tabId: tabId1, + surfaceId: surfaceId1 + ) + self.publishMultiWindowNotificationSocketStateIfNeeded(at: path) + } + } } } } + private func prepareMultiWindowNotificationSourceTerminalIfNeeded( + at path: String, + windowId: UUID, + tabManager: TabManager, + tabId: UUID, + surfaceId: UUID + ) { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_NOTIFY_SOURCE_TERMINAL_READY"] == "1" else { return } + + writeMultiWindowNotificationTestData([ + "sourceTerminalReady": "pending", + "sourceTerminalFocusFailure": "", + ], at: path) + + let deadline = Date().addingTimeInterval(8.0) + + func publish(ready: Bool, failure: String = "") { + writeMultiWindowNotificationTestData([ + "sourceTerminalReady": ready ? "1" : "0", + "sourceTerminalFocusFailure": failure, + ], at: path) + } + + func poll() { + guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + publish(ready: false, failure: "workspace_missing") + return + } + guard let terminalPanel = workspace.terminalPanel(for: surfaceId) else { + publish(ready: false, failure: "terminal_missing") + return + } + + let isWindowFrontmost = { + guard let window = self.mainWindow(for: windowId) else { return false } + return NSApp.keyWindow === window || NSApp.mainWindow === window + }() + if isWindowFrontmost && terminalPanel.hostedView.isSurfaceViewFirstResponder() { + publish(ready: true) + return + } + + guard Date() < deadline else { + publish( + ready: false, + failure: isWindowFrontmost ? "terminal_not_first_responder" : "window_not_frontmost" + ) + return + } + + _ = self.focusMainWindow(windowId: windowId) + if let tab = tabManager.tabs.first(where: { $0.id == tabId }) { + tabManager.selectTab(tab) + tabManager.focusSurface(tabId: tabId, surfaceId: surfaceId) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + poll() + } + } + + poll() + } + + private func publishMultiWindowNotificationSocketStateIfNeeded(at path: String) { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return } + + guard let config = socketListenerConfigurationIfEnabled() else { + writeMultiWindowNotificationTestData([ + "socketExpectedPath": env["CMUX_SOCKET_PATH"] ?? "", + "socketMode": "off", + "socketReady": "0", + "socketPingResponse": "", + "socketIsRunning": "0", + "socketAcceptLoopAlive": "0", + "socketPathMatches": "0", + "socketPathExists": "0", + "socketFailureSignals": "socket_disabled", + ], at: path) + return + } + + writeMultiWindowNotificationTestData([ + "socketExpectedPath": config.path, + "socketMode": config.mode.rawValue, + "socketReady": "pending", + "socketPingResponse": "", + ], at: path) + + restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + + let deadline = Date().addingTimeInterval(20.0) + func publish() { + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) + let isTimedOut = Date() >= deadline + let socketPath = config.path + let socketMode = config.mode.rawValue + let dataPath = path + + DispatchQueue.global(qos: .utility).async { [weak self] in + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + let failureSignals = { + var signals = health.failureSignals + if health.isHealthy && pingResponse != "PONG" { + signals.append("ping_timeout") + } + return signals.joined(separator: ",") + }() + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.writeMultiWindowNotificationTestData([ + "socketExpectedPath": socketPath, + "socketMode": socketMode, + "socketReady": isReady ? "1" : (isTimedOut ? "0" : "pending"), + "socketPingResponse": pingResponse ?? "", + "socketIsRunning": health.isRunning ? "1" : "0", + "socketAcceptLoopAlive": health.acceptLoopAlive ? "1" : "0", + "socketPathMatches": health.socketPathMatches ? "1" : "0", + "socketPathExists": health.socketPathExists ? "1" : "0", + "socketFailureSignals": failureSignals, + ], at: dataPath) + guard !isTimedOut, !isReady else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + publish() + } + } + } + } + + publish() + } + private func writeMultiWindowNotificationTestData(_ updates: [String: String], at path: String) { var payload = loadMultiWindowNotificationTestData(at: path) for (key, value) in updates { @@ -7836,6 +8043,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func observeDuplicateLaunches() { guard let bundleId = Bundle.main.bundleIdentifier else { return } + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() let currentPid = ProcessInfo.processInfo.processIdentifier workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( @@ -7846,6 +8057,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard self != nil else { return } guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } + if let executableURL = app.executableURL? + .standardizedFileURL + .resolvingSymlinksInPath(), + executableURL == embeddedCLIURL { + return + } app.terminate() if !app.isTerminated { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 5774a76f..f2b72af4 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -775,6 +775,100 @@ class TerminalController { ) } + nonisolated static func probeSocketCommand( + _ command: String, + at socketPath: String, + timeout: TimeInterval + ) -> 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 pathBytes = Array(socketPath.utf8CString) + guard pathBytes.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 + pathBytes.count) +#if os(macOS) + addr.sun_len = UInt8(min(Int(addrLen), 255)) +#endif + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { return nil } + + let payload = command + "\n" + let wroteAll = 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 wroteAll else { return nil } + + let deadline = Date().addingTimeInterval(timeout) + var buffer = [UInt8](repeating: 0, count: 4096) + var response = "" + + 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.. - Send text to a workspace's selected terminal (test-only) is_terminal_focused - Return true/false if terminal surface is first responder (test-only) read_terminal_text [id|idx] - Read visible terminal text (base64, test-only) render_stats [id|idx] - Read terminal render stats (draw counters, test-only) @@ -10758,7 +10856,13 @@ class TerminalController { var result = "OK" DispatchQueue.main.sync { - guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else { + let tab: Tab? + if let tabId = UUID(uuidString: tabArg) { + tab = tabForSidebarMutation(id: tabId) + } else { + tab = resolveTab(from: tabArg, tabManager: tabManager) + } + guard let tab else { result = "ERROR: Tab not found" return } @@ -11773,6 +11877,97 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } + private func sendInputToWorkspace(_ args: String) -> String { + guard let tabManager else { return "ERROR: TabManager not available" } + let parts = args.split(separator: " ", maxSplits: 1).map(String.init) + guard parts.count == 2 else { return "ERROR: Usage: send_workspace " } + + let workspaceArg = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let text = parts[1] + guard let workspaceId = UUID(uuidString: workspaceArg) else { + return "ERROR: Invalid workspace ID" + } + + var success = false + var error: String? + DispatchQueue.main.sync { + guard let targetManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) + ?? (tabManager.tabs.contains(where: { $0.id == workspaceId }) ? tabManager : nil) else { + error = "ERROR: Workspace not found" + return + } + guard let tab = targetManager.tabs.first(where: { $0.id == workspaceId }) else { + error = "ERROR: Workspace not found" + return + } + + guard let terminalPanel = sendableWorkspaceTerminalPanel(in: tab) else { + error = "ERROR: No selected terminal in workspace" + return + } + + let unescaped = text + .replacingOccurrences(of: "\\n", with: "\r") + .replacingOccurrences(of: "\\r", with: "\r") + .replacingOccurrences(of: "\\t", with: "\t") + + // This DEBUG-only command is used by UI tests to enqueue shell work in an + // existing workspace. Return once the input is queued on main so a long + // payload does not hold the control-socket response open in CI. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if let surface = terminalPanel.surface.surface { + self.sendSocketText(unescaped, surface: surface) + } else { + terminalPanel.sendText(unescaped) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + success = true + } + + if let error { return error } + return success ? "OK" : "ERROR: Failed to send input" + } + + private func sendableWorkspaceTerminalPanel(in workspace: Workspace) -> TerminalPanel? { + func selectedTerminalPanel(in paneId: PaneID) -> TerminalPanel? { + guard let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId), + let panelId = workspace.panelIdFromSurfaceId(selectedTab.id), + let terminalPanel = workspace.panels[panelId] as? TerminalPanel else { + return nil + } + return terminalPanel + } + + func isSelectedTerminalPanel(_ terminalPanel: TerminalPanel) -> Bool { + guard let surfaceId = workspace.surfaceIdFromPanelId(terminalPanel.id) else { + return false + } + return workspace.bonsplitController.allPaneIds.contains { paneId in + workspace.bonsplitController.selectedTab(inPane: paneId)?.id == surfaceId + } + } + + if let focusedPane = workspace.bonsplitController.focusedPaneId, + let terminalPanel = selectedTerminalPanel(in: focusedPane) { + return terminalPanel + } + + if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance(), + isSelectedTerminalPanel(rememberedTerminal) { + return rememberedTerminal + } + + for paneId in workspace.bonsplitController.allPaneIds { + if let terminalPanel = selectedTerminalPanel(in: paneId) { + return terminalPanel + } + } + + return nil + } + private func sendInputToSurface(_ args: String) -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index f698b9af..632ad44d 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -190,6 +190,159 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertFalse(after.contains(marker), "Expected typing to be blocked while empty notifications popover is open") } + func testNotifyCLIDoesNotStealFocusAcrossWindows() throws { + let app = XCUIApplication() + app.launchArguments += ["-socketControlMode", "allowAll"] + app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_NOTIFY_SOURCE_TERMINAL_READY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] = "1" + app.launchEnvironment["CMUX_TAG"] = launchTag + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for notify focus regression test. state=\(app.state.rawValue)" + ) + XCTAssertTrue( + waitForDataMatch(timeout: 20.0) { data in + let tabId2 = data["tabId2"] ?? "" + let surfaceId2 = data["surfaceId2"] ?? "" + let socketReady = data["socketReady"] ?? "" + let sourceTerminalReady = data["sourceTerminalReady"] ?? "" + return !tabId2.isEmpty && + !surfaceId2.isEmpty && + !socketReady.isEmpty && + socketReady != "pending" && + !sourceTerminalReady.isEmpty && + sourceTerminalReady != "pending" + }, + "Expected multi-window notification setup data, socket readiness, and source terminal focus" + ) + + guard let setup = loadData() else { + XCTFail("Missing setup data") + return + } + guard let tabId2 = setup["tabId2"], !tabId2.isEmpty else { + XCTFail("Missing setup workspace id") + return + } + if let expectedSocketPath = setup["socketExpectedPath"], !expectedSocketPath.isEmpty { + socketPath = expectedSocketPath + } + if setup["socketReady"] != "1" { + XCTFail( + "Control socket unavailable in this test environment. expected=\(socketPath) " + + socketDiagnostics(from: setup) + ) + return + } + guard setup["socketPingResponse"] == "PONG" else { + XCTFail( + "Control socket ping sanity check failed. path=\(socketPath) " + + socketDiagnostics(from: setup) + ) + return + } + guard let surfaceId = setup["surfaceId2"], !surfaceId.isEmpty else { + XCTFail("Missing target surface id for workspace \(tabId2)") + return + } + guard setup["sourceTerminalReady"] == "1" else { + XCTFail( + "Expected source terminal to be focused before typing. " + + "failure=\(setup["sourceTerminalFocusFailure"] ?? "")" + ) + return + } + + XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) + + let title = "focus-regression-\(UUID().uuidString.prefix(8))" + let commandResultStem = UUID().uuidString + let commandStatusPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).status") + .path + let commandStdoutPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).stdout") + .path + let commandStderrPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).stderr") + .path + let commandScriptPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).sh") + .path + defer { + try? FileManager.default.removeItem(atPath: commandStatusPath) + try? FileManager.default.removeItem(atPath: commandStdoutPath) + try? FileManager.default.removeItem(atPath: commandStderrPath) + try? FileManager.default.removeItem(atPath: commandScriptPath) + } + + guard let bundledCLIPath = resolveCmuxCLIPaths(strategy: .bundledOnly).first else { + XCTFail("Failed to locate bundled cmux CLI for notify regression test") + return + } + + let notifyScript = [ + "#!/bin/sh", + "sleep 1", + "rm -f \(shellSingleQuote(commandStatusPath)) \(shellSingleQuote(commandStdoutPath)) \(shellSingleQuote(commandStderrPath))", + "\(shellSingleQuote(bundledCLIPath)) --socket \(shellSingleQuote(socketPath)) notify --workspace \(shellSingleQuote(tabId2)) --surface \(shellSingleQuote(surfaceId)) --title \(shellSingleQuote(title)) --subtitle \(shellSingleQuote("ui-test")) --body \(shellSingleQuote("focus-regression")) >\(shellSingleQuote(commandStdoutPath)) 2>\(shellSingleQuote(commandStderrPath))", + "printf '%s' $? >\(shellSingleQuote(commandStatusPath))" + ].joined(separator: "\n") + do { + try notifyScript.write(toFile: commandScriptPath, atomically: true, encoding: .utf8) + } catch { + XCTFail( + "Failed to write delayed bundled `cmux notify` script. " + + "path=\(commandScriptPath) error=\(error)" + ) + return + } + + app.typeText("sh \(commandScriptPath)") + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + let finder = XCUIApplication(bundleIdentifier: "com.apple.finder") + finder.activate() + XCTAssertTrue( + waitForAppToLeaveForeground(app, timeout: 8.0), + "Expected cmux to move to background before delayed notify command runs. state=\(app.state.rawValue)" + ) + + XCTAssertTrue( + waitForCommandCompletionWhileBackgrounded( + statusPath: commandStatusPath, + app: app, + timeout: 15.0 + ), + "Expected delayed bundled `cmux notify` command to finish without foregrounding cmux. state=\(app.state.rawValue)" + ) + + let notifyExitStatus = readTrimmedFile(atPath: commandStatusPath) ?? "" + let notifyStdout = readTrimmedFile(atPath: commandStdoutPath) ?? "" + let notifyStderr = readTrimmedFile(atPath: commandStderrPath) ?? "" + + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + XCTAssertFalse( + app.state == .runningForeground, + "Expected cmux to remain in background after bundled `cmux notify`. state=\(app.state.rawValue) stderr=\(notifyStderr)" + ) + guard notifyExitStatus == "0" else { + XCTFail( + "Expected bundled `cmux notify` launched from the in-app shell to succeed. " + + "status=\(notifyExitStatus) stdout=\(notifyStdout) stderr=\(notifyStderr)" + ) + return + } + XCTAssertTrue(notifyStdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyStdout) stderr=\(notifyStderr)") + } + private func clickNotificationPopoverRowAndWaitForFocusChange( button: XCUIElement, app: XCUIApplication, @@ -274,6 +427,20 @@ final class MultiWindowNotificationsUITests: XCTestCase { return false } + private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let data = loadData(), predicate(data) { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + if let data = loadData(), predicate(data) { + return true + } + return false + } + private func waitForSocketPong(timeout: TimeInterval) -> String? { let deadline = Date().addingTimeInterval(timeout) var lastResponse: String? @@ -287,33 +454,549 @@ final class MultiWindowNotificationsUITests: XCTestCase { return socketCommand("ping") ?? lastResponse } - private func resolveSocketPath(timeout: TimeInterval) -> String? { + private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - for candidate in expectedSocketCandidates() { - guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate) { - return candidate - } + if socketCommand("is_terminal_focused \(surfaceId)") == "true" { + return true } RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } - for candidate in expectedSocketCandidates() { - guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate) { + return socketCommand("is_terminal_focused \(surfaceId)") == "true" + } + + private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) { + let deadline = Date().addingTimeInterval(timeout) + var lastStdout: String? + var lastStderr: String? + while Date() < deadline { + let result = runCmuxCommand( + socketPath: socketPath, + arguments: ["ping"], + responseTimeoutSeconds: 2.0 + ) + let stdout = result.stdout.isEmpty ? nil : result.stdout + let stderr = result.stderr.isEmpty ? nil : result.stderr + if let stdout { + lastStdout = stdout + } + if let stderr { + lastStderr = stderr + } + if result.terminationStatus == 0, stdout == "PONG" { + return ("PONG", stderr) + } + if isSocketPermissionFailure(stderr), + waitForSocketPong(timeout: 0.5) == "PONG" { + return ("PONG", stderr) + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + let result = runCmuxCommand( + socketPath: socketPath, + arguments: ["ping"], + responseTimeoutSeconds: 2.0 + ) + let stdout = result.stdout.isEmpty ? nil : result.stdout + let stderr = result.stderr.isEmpty ? nil : result.stderr + if isSocketPermissionFailure(stderr), + waitForSocketPong(timeout: 0.5) == "PONG" { + return ("PONG", stderr) + } + return (stdout ?? lastStdout, stderr ?? lastStderr) + } + + private func waitForCommandCompletionWhileBackgrounded( + statusPath: String, + app: XCUIApplication, + timeout: TimeInterval + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + var sawCompletion = false + while Date() < deadline { + if app.state == .runningForeground { + return false + } + if FileManager.default.fileExists(atPath: statusPath) { + sawCompletion = true + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else { + return false + } + + let postCompletionDeadline = Date().addingTimeInterval(0.75) + while Date() < postCompletionDeadline { + if app.state == .runningForeground { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return app.state != .runningForeground + } + + private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if app.state != .runningForeground { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return app.state != .runningForeground + } + + private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? { + guard let response = socketCommand("list_surfaces \(workspaceId)"), + !response.isEmpty, + !response.hasPrefix("ERROR"), + response != "No surfaces" 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 { return candidate } } return nil } - private func expectedSocketCandidates() -> [String] { + private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) { + return surfaceId + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return firstSurfaceId(forWorkspaceId: workspaceId) + } + + private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) { + return surfaceId + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + } + + private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? { + guard let paneId = firstPaneIdViaCLI(forWorkspaceId: workspaceId) else { + return firstSurfaceId(forWorkspaceId: workspaceId) + } + let result = runCmuxCommand( + socketPath: socketPath, + arguments: [ + "list-pane-surfaces", + "--workspace", + workspaceId, + "--pane", + paneId, + "--id-format", + "uuids" + ], + responseTimeoutSeconds: 3.0 + ) + guard result.terminationStatus == 0 else { + if isSocketPermissionFailure(result.stderr) { + return firstSurfaceId(forWorkspaceId: workspaceId) + } + return nil + } + return firstHandle(in: result.stdout) + } + + private func firstPaneIdViaCLI(forWorkspaceId workspaceId: String) -> String? { + let result = runCmuxCommand( + socketPath: socketPath, + arguments: [ + "list-panes", + "--workspace", + workspaceId, + "--id-format", + "uuids" + ], + responseTimeoutSeconds: 3.0 + ) + guard result.terminationStatus == 0 else { + if isSocketPermissionFailure(result.stderr) { + return nil + } + return nil + } + return firstHandle(in: result.stdout) + } + + private func firstHandle(in output: String) -> String? { + for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) { + var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty, !line.hasPrefix("No ") else { continue } + if line.hasPrefix("* ") || line.hasPrefix(" ") { + line = String(line.dropFirst(2)) + } + guard let token = line.split(whereSeparator: \.isWhitespace).first else { continue } + return String(token) + } + return nil + } + + private func runCmuxNotify( + socketPath: String, + workspaceId: String, + surfaceId: String, + title: String + ) -> (terminationStatus: Int32, stdout: String, stderr: String) { + runCmuxCommand( + socketPath: socketPath, + arguments: [ + "notify", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--title", + title, + "--subtitle", + "ui-test", + "--body", + "focus-regression" + ], + responseTimeoutSeconds: 4.0, + cliStrategy: .bundledOnly + ) + } + + private func runCmuxCommand( + socketPath: String, + arguments: [String], + responseTimeoutSeconds: Double = 3.0, + cliStrategy: CmuxCLIStrategy = .any + ) -> (terminationStatus: Int32, stdout: String, stderr: String) { + var args = ["--socket", socketPath] + args.append(contentsOf: arguments) + var environment = ProcessInfo.processInfo.environment + environment["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = String(responseTimeoutSeconds) + + let cliPaths = resolveCmuxCLIPaths(strategy: cliStrategy) + if cliPaths.isEmpty, cliStrategy == .bundledOnly { + return ( + terminationStatus: -1, + stdout: "", + stderr: "Failed to locate bundled cmux CLI" + ) + } + + var lastPermissionFailure: (terminationStatus: Int32, stdout: String, stderr: String)? + for cliPath in cliPaths { + let result = executeCmuxCommand( + executablePath: cliPath, + arguments: args, + environment: environment + ) + if result.terminationStatus == 0 { + return result + } + if result.stderr.localizedCaseInsensitiveContains("operation not permitted") { + lastPermissionFailure = result + continue + } + return result + } + + if cliStrategy == .bundledOnly { + return lastPermissionFailure ?? ( + terminationStatus: -1, + stdout: "", + stderr: "Bundled cmux CLI command failed without an executable path" + ) + } + + let fallbackArgs = ["cmux"] + args + let fallbackResult = executeCmuxCommand( + executablePath: "/usr/bin/env", + arguments: fallbackArgs, + environment: environment + ) + if fallbackResult.terminationStatus == 0 || lastPermissionFailure == nil { + return fallbackResult + } + return lastPermissionFailure ?? fallbackResult + } + + private enum CmuxCLIStrategy: Equatable { + case any + case bundledOnly + } + + private func socketDiagnostics(from data: [String: String]) -> String { + let pingResponse = data["socketPingResponse"].flatMap { $0.isEmpty ? nil : $0 } ?? "" + return "mode=\(data["socketMode"] ?? "") running=\(data["socketIsRunning"] ?? "") " + + "acceptLoopAlive=\(data["socketAcceptLoopAlive"] ?? "") pathMatches=\(data["socketPathMatches"] ?? "") " + + "pathExists=\(data["socketPathExists"] ?? "") ping=\(pingResponse) " + + "signals=\(data["socketFailureSignals"] ?? "")" + } + + private func resolveCmuxCLIPaths(strategy: CmuxCLIStrategy) -> [String] { + let fileManager = FileManager.default + let env = ProcessInfo.processInfo.environment + var candidates: [String] = [] + var productDirectories: [String] = [] + + if strategy == .any { + for key in ["CMUX_UI_TEST_CLI_PATH", "CMUXTERM_CLI"] { + if let value = env[key], !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(value) + } + } + } + + if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty { + productDirectories.append(builtProductsDir) + } + + if let hostPath = env["TEST_HOST"], !hostPath.isEmpty { + let hostURL = URL(fileURLWithPath: hostPath) + let productsDir = hostURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + productDirectories.append(productsDir) + } + + productDirectories.append(contentsOf: inferredBuildProductsDirectories()) + for productsDir in uniquePaths(productDirectories) { + appendCLIPathCandidates(fromProductsDirectory: productsDir, strategy: strategy, to: &candidates) + } + + candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux DEV.app/Contents/Resources/bin/cmux") + candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux.app/Contents/Resources/bin/cmux") + if strategy == .any { + candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux") + } + + var resolvedPaths: [String] = [] + for path in uniquePaths(candidates) { + guard fileManager.isExecutableFile(atPath: path) else { continue } + resolvedPaths.append(URL(fileURLWithPath: path).resolvingSymlinksInPath().path) + } + return uniquePaths(resolvedPaths) + } + + private func inferredBuildProductsDirectories() -> [String] { + let bundleURLs = [ + Bundle.main.bundleURL, + Bundle(for: Self.self).bundleURL, + ] + + return bundleURLs.compactMap { bundleURL in + let standardizedPath = bundleURL.standardizedFileURL.path + let components = standardizedPath.split(separator: "/") + guard let productsIndex = components.firstIndex(of: "Products"), + productsIndex + 1 < components.count else { + return nil + } + let prefixComponents = components.prefix(productsIndex + 2) + return "/" + prefixComponents.joined(separator: "/") + } + } + + private func appendCLIPathCandidates( + fromProductsDirectory productsDir: String, + strategy: CmuxCLIStrategy, + to candidates: inout [String] + ) { + candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux") + candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux") + if strategy == .any { + candidates.append("\(productsDir)/cmux") + } + + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: productsDir) else { + return + } + + for entry in entries.sorted() where entry.hasSuffix(".app") { + let cliPath = URL(fileURLWithPath: productsDir) + .appendingPathComponent(entry) + .appendingPathComponent("Contents/Resources/bin/cmux") + .path + candidates.append(cliPath) + } + if strategy == .any { + for entry in entries.sorted() where entry == "cmux" { + let cliPath = URL(fileURLWithPath: productsDir) + .appendingPathComponent(entry) + .path + candidates.append(cliPath) + } + } + } + + private func executeCmuxCommand( + executablePath: String, + arguments: [String], + environment: [String: String] + ) -> (terminationStatus: Int32, stdout: String, stderr: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + return ( + terminationStatus: -1, + stdout: "", + stderr: "Failed to run cmux command: \(error.localizedDescription) (cliPath=\(executablePath))" + ) + } + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stdout = String(data: stdoutData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let rawStderr = String(data: stderrData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stderr = rawStderr.isEmpty ? "" : "\(rawStderr) (cliPath=\(executablePath))" + return (process.terminationStatus, stdout, stderr) + } + + private func isSocketPermissionFailure(_ stderr: String?) -> Bool { + guard let stderr, !stderr.isEmpty else { return false } + return stderr.localizedCaseInsensitiveContains("failed to connect to socket") && + stderr.localizedCaseInsensitiveContains("operation not permitted") + } + + private func uniquePaths(_ paths: [String]) -> [String] { + var unique: [String] = [] + var seen = Set() + for path in paths { + if seen.insert(path).inserted { + unique.append(path) + } + } + return unique + } + + private func resolveSocketPath(timeout: TimeInterval, requiredWorkspaceId: String? = nil) -> String? { + let primaryCandidates = expectedSocketCandidates(includeGlobalFallback: false) + let fallbackCandidates: [String] + if let requiredWorkspaceId, !requiredWorkspaceId.isEmpty { + fallbackCandidates = expectedSocketCandidates(includeGlobalFallback: true) + .filter { !primaryCandidates.contains($0) } + } else { + fallbackCandidates = [] + } + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + for candidate in primaryCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + // Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds, + // prefer it even before workspace contents are fully initialized. + if socketRespondsToPing(at: candidate) { + return candidate + } + } + for candidate in fallbackCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if socketRespondsToPing(at: candidate), + socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + return candidate + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + for candidate in primaryCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if socketRespondsToPing(at: candidate) { + return candidate + } + } + for candidate in fallbackCandidates { + guard FileManager.default.fileExists(atPath: candidate) else { continue } + if socketRespondsToPing(at: candidate), + socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + return candidate + } + } + return nil + } + + private func expectedSocketCandidates(includeGlobalFallback: Bool) -> [String] { var candidates = [socketPath] let taggedDebugSocket = "/tmp/cmux-debug-\(launchTag).sock" - if taggedDebugSocket != socketPath { + if !taggedDebugSocket.isEmpty { candidates.append(taggedDebugSocket) } - return candidates + if includeGlobalFallback { + candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12)) + candidates.append("/tmp/cmux-debug.sock") + candidates.append("/tmp/cmux.sock") + } + + var unique: [String] = [] + var seen = Set() + for candidate in candidates { + if seen.insert(candidate).inserted { + unique.append(candidate) + } + } + return unique + } + + private func socketMatchesRequiredWorkspace(_ candidatePath: String, workspaceId: String?) -> Bool { + guard let workspaceId, !workspaceId.isEmpty else { return true } + let originalPath = socketPath + socketPath = candidatePath + defer { socketPath = originalPath } + + guard let response = socketCommand("list_surfaces \(workspaceId)"), + !response.isEmpty, + !response.hasPrefix("ERROR"), + response != "No surfaces" else { + return false + } + return true + } + + private func discoverTmpSocketCandidates(limit: Int) -> [String] { + let tmpPath = "/tmp" + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else { + return [] + } + + let matches = entries.filter { $0.hasPrefix("cmux") && $0.hasSuffix(".sock") } + let sorted = matches.compactMap { entry -> (path: String, mtime: Date)? in + let fullPath = (tmpPath as NSString).appendingPathComponent(entry) + guard let attrs = try? FileManager.default.attributesOfItem(atPath: fullPath) else { + return nil + } + let mtime = (attrs[.modificationDate] as? Date) ?? .distantPast + return (fullPath, mtime) + } + .sorted { $0.mtime > $1.mtime } + + return Array(sorted.prefix(limit)).map(\.path) } private func socketRespondsToPing(at path: String) -> Bool { @@ -323,20 +1006,21 @@ final class MultiWindowNotificationsUITests: XCTestCase { return socketCommand("ping") == "PONG" } - private func socketCommand(_ cmd: String) -> String? { - if let response = ControlSocketClient(path: socketPath).sendLine(cmd) { + private func socketCommand(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? { + if let response = ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(cmd) { return response } - return socketCommandViaNetcat(cmd) + return socketCommandViaNetcat(cmd, responseTimeout: responseTimeout) } - private func socketCommandViaNetcat(_ cmd: String) -> String? { + private func socketCommandViaNetcat(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? { let nc = "/usr/bin/nc" guard FileManager.default.isExecutableFile(atPath: nc) else { return nil } let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/sh") - let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w 2 2>/dev/null" + let timeoutSeconds = max(1, Int(ceil(responseTimeout))) + let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w \(timeoutSeconds) 2>/dev/null" proc.arguments = ["-lc", script] let outPipe = Pipe() @@ -364,11 +1048,21 @@ final class MultiWindowNotificationsUITests: XCTestCase { return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } + private func readTrimmedFile(atPath path: String) -> String? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + private final class ControlSocketClient { private let path: String + private let responseTimeout: TimeInterval - init(path: String) { + init(path: String, responseTimeout: TimeInterval = 2.0) { self.path = path + self.responseTimeout = responseTimeout } func sendLine(_ line: String) -> String? { @@ -431,9 +1125,18 @@ final class MultiWindowNotificationsUITests: XCTestCase { } guard wrote else { return nil } + let deadline = Date().addingTimeInterval(responseTimeout) var buf = [UInt8](repeating: 0, count: 4096) var accum = "" - while true { + 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 n = read(fd, &buf, buf.count) if n <= 0 { break } if let chunk = String(bytes: buf[0..