From 4351c3cf185cf42893c5b8cac4e81864a6163212 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:20:23 -0800 Subject: [PATCH 01/22] Fix notify CLI focus-steal regression and add UI test --- Sources/AppDelegate.swift | 18 ++ .../MultiWindowNotificationsUITests.swift | 195 ++++++++++++++++++ 2 files changed, 213 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 809b432c..9df55688 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1683,6 +1683,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. @@ -1693,6 +1695,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() @@ -7621,6 +7629,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( @@ -7631,6 +7643,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/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index f698b9af..89948f66 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -190,6 +190,72 @@ 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_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( + waitForData(keys: ["tabId2"], timeout: 15.0), + "Expected multi-window notification setup data" + ) + + guard let tabId2 = loadData()?["tabId2"], !tabId2.isEmpty else { + XCTFail("Missing setup workspace id") + return + } + + XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) + + guard let resolvedPath = resolveSocketPath(timeout: 8.0) else { + throw XCTSkip("Control socket unavailable in this test environment. requested=\(socketPath)") + } + socketPath = resolvedPath + let pingResponse = waitForSocketPong(timeout: 8.0) + guard pingResponse == "PONG" else { + throw XCTSkip("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "")") + } + + guard let surfaceId = firstSurfaceId(forWorkspaceId: tabId2) else { + XCTFail("Expected at least one surface in workspace \(tabId2)") + return + } + + let finder = XCUIApplication(bundleIdentifier: "com.apple.finder") + finder.activate() + XCTAssertTrue( + waitForAppToLeaveForeground(app, timeout: 8.0), + "Expected cmux to move to background before sending notify command. state=\(app.state.rawValue)" + ) + + let title = "focus-regression-\(UUID().uuidString.prefix(8))" + let notifyResult = runCmuxNotify( + socketPath: socketPath, + workspaceId: tabId2, + surfaceId: surfaceId, + title: title + ) + XCTAssertEqual(notifyResult.terminationStatus, 0, "Expected `cmux notify` to succeed. stderr=\(notifyResult.stderr)") + XCTAssertTrue(notifyResult.stdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyResult.stdout)") + + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + XCTAssertFalse( + app.state == .runningForeground, + "Expected cmux to remain in background after `cmux notify`. state=\(app.state.rawValue)" + ) + } + private func clickNotificationPopoverRowAndWaitForFocusChange( button: XCUIElement, app: XCUIApplication, @@ -287,6 +353,135 @@ final class MultiWindowNotificationsUITests: XCTestCase { return socketCommand("ping") ?? lastResponse } + 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 runCmuxNotify( + socketPath: String, + workspaceId: String, + surfaceId: String, + title: String + ) -> (terminationStatus: Int32, stdout: String, stderr: String) { + let process = Process() + let cliPath = resolveCmuxCLIPath() + var args = [ + "--socket", + socketPath, + "notify", + "--workspace", + workspaceId, + "--surface", + surfaceId, + "--title", + title, + "--subtitle", + "ui-test", + "--body", + "focus-regression" + ] + if let cliPath { + process.executableURL = URL(fileURLWithPath: cliPath) + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + args.insert("cmux", at: 0) + } + process.arguments = args + + 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 notify: \(error.localizedDescription) (cliPath=\(cliPath ?? "env:cmux"))" + ) + } + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stdout = String(data: stdoutData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stderr = String(data: stderrData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return (process.terminationStatus, stdout, stderr) + } + + private func resolveCmuxCLIPath() -> String? { + let fileManager = FileManager.default + let env = ProcessInfo.processInfo.environment + var candidates: [String] = [] + + 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 { + candidates.append("\(builtProductsDir)/cmux DEV.app/Contents/Resources/bin/cmux") + candidates.append("\(builtProductsDir)/cmux.app/Contents/Resources/bin/cmux") + candidates.append("\(builtProductsDir)/cmux") + } + + if let hostPath = env["TEST_HOST"], !hostPath.isEmpty { + let hostURL = URL(fileURLWithPath: hostPath) + let productsDir = hostURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux") + candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux") + candidates.append("\(productsDir)/cmux") + } + + 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") + candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux") + + for path in candidates { + if fileManager.isExecutableFile(atPath: path) { + return URL(fileURLWithPath: path).resolvingSymlinksInPath().path + } + } + + return nil + } + private func resolveSocketPath(timeout: TimeInterval) -> String? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { From efdfd76484ab433be6045d3a0cd06c173b7371ba Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:39:46 -0800 Subject: [PATCH 02/22] Harden notify UI test socket resolution --- .../MultiWindowNotificationsUITests.swift | 97 ++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 89948f66..69dcf00c 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -218,13 +218,15 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) - guard let resolvedPath = resolveSocketPath(timeout: 8.0) else { - throw XCTSkip("Control socket unavailable in this test environment. requested=\(socketPath)") + guard let resolvedPath = resolveSocketPath(timeout: 8.0, requiredWorkspaceId: tabId2) else { + XCTFail("Control socket unavailable in this test environment. requested=\(socketPath)") + return } socketPath = resolvedPath let pingResponse = waitForSocketPong(timeout: 8.0) guard pingResponse == "PONG" else { - throw XCTSkip("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "")") + XCTFail("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "")") + return } guard let surfaceId = firstSurfaceId(forWorkspaceId: tabId2) else { @@ -482,33 +484,106 @@ final class MultiWindowNotificationsUITests: XCTestCase { return nil } - private func resolveSocketPath(timeout: TimeInterval) -> String? { + 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 expectedSocketCandidates() { + for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate) { + if socketRespondsToPing(at: candidate), + socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + 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 expectedSocketCandidates() { + for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate) { + if socketRespondsToPing(at: candidate), + socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + 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() -> [String] { + 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 { From c8487e1457dc1b81a7f39b7d8b9fda700af78164 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:47:36 -0800 Subject: [PATCH 03/22] Stabilize notify focus regression socket detection --- cmuxUITests/MultiWindowNotificationsUITests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 69dcf00c..4567bb63 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -218,12 +218,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) - guard let resolvedPath = resolveSocketPath(timeout: 8.0, requiredWorkspaceId: tabId2) else { + guard let resolvedPath = resolveSocketPath(timeout: 20.0, requiredWorkspaceId: tabId2) else { XCTFail("Control socket unavailable in this test environment. requested=\(socketPath)") return } socketPath = resolvedPath - let pingResponse = waitForSocketPong(timeout: 8.0) + let pingResponse = waitForSocketPong(timeout: 20.0) guard pingResponse == "PONG" else { XCTFail("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "")") return From 69cfce9596a27683e9fd79ec644c35246db9a47d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:14:52 -0800 Subject: [PATCH 04/22] Stabilize notify focus UI test socket and surface waits --- .../MultiWindowNotificationsUITests.swift | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 4567bb63..4f875312 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -229,8 +229,8 @@ final class MultiWindowNotificationsUITests: XCTestCase { return } - guard let surfaceId = firstSurfaceId(forWorkspaceId: tabId2) else { - XCTFail("Expected at least one surface in workspace \(tabId2)") + guard let surfaceId = waitForSurfaceId(forWorkspaceId: tabId2, timeout: 12.0) else { + XCTFail("Expected at least one surface in workspace \(tabId2). socket=\(socketPath)") return } @@ -385,6 +385,17 @@ final class MultiWindowNotificationsUITests: XCTestCase { return nil } + 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 runCmuxNotify( socketPath: String, workspaceId: String, @@ -498,8 +509,9 @@ final class MultiWindowNotificationsUITests: XCTestCase { while Date() < deadline { for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate), - socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + // 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 } } @@ -514,8 +526,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate), - socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + if socketRespondsToPing(at: candidate) { return candidate } } From ee899042ca9c1615e41d81da3ded77684bce6812 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:35:57 -0800 Subject: [PATCH 05/22] Publish socket health into notify focus UI test --- Sources/AppDelegate.swift | 50 +++++++++++++++++++ .../MultiWindowNotificationsUITests.swift | 49 ++++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9df55688..0eb83629 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5725,10 +5725,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "expectedLatestWindowId": window1.windowId.uuidString, "expectedLatestTabId": tabId1.uuidString, ], at: path) + self.publishMultiWindowNotificationSocketStateIfNeeded(at: path) } } } + 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", + "socketIsRunning": "0", + "socketAcceptLoopAlive": "0", + "socketPathMatches": "0", + "socketPathExists": "0", + "socketFailureSignals": "socket_disabled", + ], at: path) + return + } + + writeMultiWindowNotificationTestData([ + "socketExpectedPath": config.path, + "socketMode": config.mode.rawValue, + "socketReady": "pending", + ], at: path) + + restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + + let deadline = Date().addingTimeInterval(12.0) + func publish() { + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) + let isTimedOut = Date() >= deadline + writeMultiWindowNotificationTestData([ + "socketExpectedPath": config.path, + "socketMode": config.mode.rawValue, + "socketReady": health.isHealthy ? "1" : (isTimedOut ? "0" : "pending"), + "socketIsRunning": health.isRunning ? "1" : "0", + "socketAcceptLoopAlive": health.acceptLoopAlive ? "1" : "0", + "socketPathMatches": health.socketPathMatches ? "1" : "0", + "socketPathExists": health.socketPathExists ? "1" : "0", + "socketFailureSignals": health.failureSignals.joined(separator: ","), + ], at: path) + guard !health.isHealthy, !isTimedOut 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 { diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 4f875312..e5c4e4c7 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -207,19 +207,44 @@ final class MultiWindowNotificationsUITests: XCTestCase { "Expected app to launch for notify focus regression test. state=\(app.state.rawValue)" ) XCTAssertTrue( - waitForData(keys: ["tabId2"], timeout: 15.0), - "Expected multi-window notification setup data" + waitForDataMatch(timeout: 20.0) { data in + let tabId2 = data["tabId2"] ?? "" + let socketReady = data["socketReady"] ?? "" + return !tabId2.isEmpty && !socketReady.isEmpty && socketReady != "pending" + }, + "Expected multi-window notification setup data and socket readiness" ) - guard let tabId2 = loadData()?["tabId2"], !tabId2.isEmpty else { + 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) " + + "mode=\(setup["socketMode"] ?? "") running=\(setup["socketIsRunning"] ?? "") " + + "acceptLoopAlive=\(setup["socketAcceptLoopAlive"] ?? "") pathMatches=\(setup["socketPathMatches"] ?? "") " + + "pathExists=\(setup["socketPathExists"] ?? "") signals=\(setup["socketFailureSignals"] ?? "")" + ) + return + } XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) - guard let resolvedPath = resolveSocketPath(timeout: 20.0, requiredWorkspaceId: tabId2) else { - XCTFail("Control socket unavailable in this test environment. requested=\(socketPath)") + guard let resolvedPath = resolveSocketPath(timeout: 5.0, requiredWorkspaceId: tabId2) else { + XCTFail( + "Control socket unavailable in this test environment. requested=\(socketPath) " + + "mode=\(setup["socketMode"] ?? "") running=\(setup["socketIsRunning"] ?? "") " + + "acceptLoopAlive=\(setup["socketAcceptLoopAlive"] ?? "") pathMatches=\(setup["socketPathMatches"] ?? "") " + + "pathExists=\(setup["socketPathExists"] ?? "") signals=\(setup["socketFailureSignals"] ?? "")" + ) return } socketPath = resolvedPath @@ -342,6 +367,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? From 9c9670ea7110a3b323a0f32e32cab356617d3873 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:44:48 -0800 Subject: [PATCH 06/22] Use socket ping before notify UI test fallback --- .../MultiWindowNotificationsUITests.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index e5c4e4c7..b2383065 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -238,21 +238,22 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) - guard let resolvedPath = resolveSocketPath(timeout: 5.0, requiredWorkspaceId: tabId2) else { + let pingResponse = waitForSocketPong(timeout: 20.0) + if pingResponse != "PONG", + let resolvedPath = resolveSocketPath(timeout: 5.0, requiredWorkspaceId: tabId2) { + socketPath = resolvedPath + } + + let confirmedPingResponse = pingResponse == "PONG" ? pingResponse : waitForSocketPong(timeout: 5.0) + guard confirmedPingResponse == "PONG" else { XCTFail( - "Control socket unavailable in this test environment. requested=\(socketPath) " + + "Control socket did not respond in time. path=\(socketPath) response=\(confirmedPingResponse ?? "") " + "mode=\(setup["socketMode"] ?? "") running=\(setup["socketIsRunning"] ?? "") " + "acceptLoopAlive=\(setup["socketAcceptLoopAlive"] ?? "") pathMatches=\(setup["socketPathMatches"] ?? "") " + "pathExists=\(setup["socketPathExists"] ?? "") signals=\(setup["socketFailureSignals"] ?? "")" ) return } - socketPath = resolvedPath - let pingResponse = waitForSocketPong(timeout: 20.0) - guard pingResponse == "PONG" else { - XCTFail("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "")") - return - } guard let surfaceId = waitForSurfaceId(forWorkspaceId: tabId2, timeout: 12.0) else { XCTFail("Expected at least one surface in workspace \(tabId2). socket=\(socketPath)") From 5fa357063252a04dff77ce0ee2407369d1d03c0d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:10:00 -0800 Subject: [PATCH 07/22] Harden notify focus regression socket checks --- Sources/AppDelegate.swift | 52 +++-- Sources/TerminalController.swift | 94 +++++++++ .../MultiWindowNotificationsUITests.swift | 178 +++++++++++++++--- 3 files changed, 281 insertions(+), 43 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0eb83629..f74e65db 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5739,6 +5739,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketExpectedPath": env["CMUX_SOCKET_PATH"] ?? "", "socketMode": "off", "socketReady": "0", + "socketPingResponse": "", "socketIsRunning": "0", "socketAcceptLoopAlive": "0", "socketPathMatches": "0", @@ -5752,27 +5753,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketExpectedPath": config.path, "socketMode": config.mode.rawValue, "socketReady": "pending", + "socketPingResponse": "", ], at: path) restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") - let deadline = Date().addingTimeInterval(12.0) + let deadline = Date().addingTimeInterval(20.0) func publish() { let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) let isTimedOut = Date() >= deadline - writeMultiWindowNotificationTestData([ - "socketExpectedPath": config.path, - "socketMode": config.mode.rawValue, - "socketReady": health.isHealthy ? "1" : (isTimedOut ? "0" : "pending"), - "socketIsRunning": health.isRunning ? "1" : "0", - "socketAcceptLoopAlive": health.acceptLoopAlive ? "1" : "0", - "socketPathMatches": health.socketPathMatches ? "1" : "0", - "socketPathExists": health.socketPathExists ? "1" : "0", - "socketFailureSignals": health.failureSignals.joined(separator: ","), - ], at: path) - guard !health.isHealthy, !isTimedOut else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - publish() + 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 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + publish() + } + } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 56d7205b..b536d0e7 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -662,6 +662,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..") " + - "mode=\(setup["socketMode"] ?? "") running=\(setup["socketIsRunning"] ?? "") " + - "acceptLoopAlive=\(setup["socketAcceptLoopAlive"] ?? "") pathMatches=\(setup["socketPathMatches"] ?? "") " + - "pathExists=\(setup["socketPathExists"] ?? "") signals=\(setup["socketFailureSignals"] ?? "")" + "Control socket did not respond in time. path=\(socketPath) response=\(confirmedPingResult.stdout ?? "") " + + "stderr=\(confirmedPingResult.stderr ?? "") " + + socketDiagnostics(from: failureSetup) ) return } - guard let surfaceId = waitForSurfaceId(forWorkspaceId: tabId2, timeout: 12.0) else { - XCTFail("Expected at least one surface in workspace \(tabId2). socket=\(socketPath)") + guard let surfaceId = waitForSurfaceIdViaCLI(forWorkspaceId: tabId2, timeout: 12.0) + ?? waitForSurfaceId(forWorkspaceId: tabId2, timeout: 3.0) else { + let failureSetup = loadData() ?? setup + XCTFail( + "Expected at least one surface in workspace \(tabId2). socket=\(socketPath) " + + socketDiagnostics(from: failureSetup) + ) return } @@ -395,6 +398,40 @@ final class MultiWindowNotificationsUITests: XCTestCase { return socketCommand("ping") ?? lastResponse } + 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) + } + 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 + return (stdout ?? lastStdout, stderr ?? lastStderr) + } + private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { @@ -436,29 +473,101 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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 nil + } + let result = runCmuxCommand( + socketPath: socketPath, + arguments: [ + "list-pane-surfaces", + "--workspace", + workspaceId, + "--pane", + paneId, + "--id-format", + "uuids" + ], + responseTimeoutSeconds: 3.0 + ) + guard result.terminationStatus == 0 else { 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 { 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 + ) + } + + private func runCmuxCommand( + socketPath: String, + arguments: [String], + responseTimeoutSeconds: Double = 3.0 ) -> (terminationStatus: Int32, stdout: String, stderr: String) { let process = Process() let cliPath = resolveCmuxCLIPath() - var args = [ - "--socket", - socketPath, - "notify", - "--workspace", - workspaceId, - "--surface", - surfaceId, - "--title", - title, - "--subtitle", - "ui-test", - "--body", - "focus-regression" - ] + var args = ["--socket", socketPath] + args.append(contentsOf: arguments) if let cliPath { process.executableURL = URL(fileURLWithPath: cliPath) } else { @@ -466,6 +575,9 @@ final class MultiWindowNotificationsUITests: XCTestCase { args.insert("cmux", at: 0) } process.arguments = args + var environment = ProcessInfo.processInfo.environment + environment["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = String(responseTimeoutSeconds) + process.environment = environment let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -479,7 +591,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { return ( terminationStatus: -1, stdout: "", - stderr: "Failed to run cmux notify: \(error.localizedDescription) (cliPath=\(cliPath ?? "env:cmux"))" + stderr: "Failed to run cmux command: \(error.localizedDescription) (cliPath=\(cliPath ?? "env:cmux"))" ) } @@ -492,6 +604,14 @@ final class MultiWindowNotificationsUITests: XCTestCase { return (process.terminationStatus, stdout, stderr) } + 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 resolveCmuxCLIPath() -> String? { let fileManager = FileManager.default let env = ProcessInfo.processInfo.environment From 9853235cb7ff2c5fb6f67c5216bac820bb898c24 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:22:05 -0800 Subject: [PATCH 08/22] Resolve notify UI test CLI path on CI --- .../MultiWindowNotificationsUITests.swift | 63 ++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 79d49fea..be7daba1 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -616,6 +616,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { let fileManager = FileManager.default let env = ProcessInfo.processInfo.environment var candidates: [String] = [] + var productDirectories: [String] = [] for key in ["CMUX_UI_TEST_CLI_PATH", "CMUXTERM_CLI"] { if let value = env[key], !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -624,9 +625,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty { - candidates.append("\(builtProductsDir)/cmux DEV.app/Contents/Resources/bin/cmux") - candidates.append("\(builtProductsDir)/cmux.app/Contents/Resources/bin/cmux") - candidates.append("\(builtProductsDir)/cmux") + productDirectories.append(builtProductsDir) } if let hostPath = env["TEST_HOST"], !hostPath.isEmpty { @@ -637,16 +636,19 @@ final class MultiWindowNotificationsUITests: XCTestCase { .deletingLastPathComponent() .deletingLastPathComponent() .path - candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux") - candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux") - candidates.append("\(productsDir)/cmux") + productDirectories.append(productsDir) + } + + productDirectories.append(contentsOf: inferredBuildProductsDirectories()) + for productsDir in uniquePaths(productDirectories) { + appendCLIPathCandidates(fromProductsDirectory: productsDir, 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") candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux") - for path in candidates { + for path in uniquePaths(candidates) { if fileManager.isExecutableFile(atPath: path) { return URL(fileURLWithPath: path).resolvingSymlinksInPath().path } @@ -655,6 +657,53 @@ final class MultiWindowNotificationsUITests: XCTestCase { return nil } + 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, to candidates: inout [String]) { + candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux") + candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux") + 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) + } + } + + 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] From 827df22fee3b71e754a247a1cf303d16ee5ecdea Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:41:26 -0800 Subject: [PATCH 09/22] Prefer standalone CLI in notify UI test --- .../MultiWindowNotificationsUITests.swift | 109 ++++++++++++------ 1 file changed, 72 insertions(+), 37 deletions(-) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index be7daba1..10343dc1 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -564,44 +564,38 @@ final class MultiWindowNotificationsUITests: XCTestCase { arguments: [String], responseTimeoutSeconds: Double = 3.0 ) -> (terminationStatus: Int32, stdout: String, stderr: String) { - let process = Process() - let cliPath = resolveCmuxCLIPath() var args = ["--socket", socketPath] args.append(contentsOf: arguments) - if let cliPath { - process.executableURL = URL(fileURLWithPath: cliPath) - } else { - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - args.insert("cmux", at: 0) - } - process.arguments = args var environment = ProcessInfo.processInfo.environment environment["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = String(responseTimeoutSeconds) - 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=\(cliPath ?? "env:cmux"))" + var lastPermissionFailure: (terminationStatus: Int32, stdout: String, stderr: String)? + for cliPath in resolveCmuxCLIPaths() { + 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 } - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stdout = String(data: stdoutData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let stderr = String(data: stderrData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return (process.terminationStatus, stdout, stderr) + 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 func socketDiagnostics(from data: [String: String]) -> String { @@ -612,7 +606,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { "signals=\(data["socketFailureSignals"] ?? "")" } - private func resolveCmuxCLIPath() -> String? { + private func resolveCmuxCLIPaths() -> [String] { let fileManager = FileManager.default let env = ProcessInfo.processInfo.environment var candidates: [String] = [] @@ -648,13 +642,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux.app/Contents/Resources/bin/cmux") candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux") + var resolvedPaths: [String] = [] for path in uniquePaths(candidates) { - if fileManager.isExecutableFile(atPath: path) { - return URL(fileURLWithPath: path).resolvingSymlinksInPath().path - } + guard fileManager.isExecutableFile(atPath: path) else { continue } + resolvedPaths.append(URL(fileURLWithPath: path).resolvingSymlinksInPath().path) } - - return nil + return uniquePaths(resolvedPaths) } private func inferredBuildProductsDirectories() -> [String] { @@ -676,14 +669,20 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func appendCLIPathCandidates(fromProductsDirectory productsDir: String, to candidates: inout [String]) { + candidates.append("\(productsDir)/cmux") candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux") candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux") - candidates.append("\(productsDir)/cmux") guard let entries = try? FileManager.default.contentsOfDirectory(atPath: productsDir) else { return } + for entry in entries.sorted() where entry == "cmux" { + let cliPath = URL(fileURLWithPath: productsDir) + .appendingPathComponent(entry) + .path + candidates.append(cliPath) + } for entry in entries.sorted() where entry.hasSuffix(".app") { let cliPath = URL(fileURLWithPath: productsDir) .appendingPathComponent(entry) @@ -693,6 +692,42 @@ final class MultiWindowNotificationsUITests: XCTestCase { } } + 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 uniquePaths(_ paths: [String]) -> [String] { var unique: [String] = [] var seen = Set() From 3e10c3f790492699f71be898ac4a209c1d0da55d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:55:05 -0800 Subject: [PATCH 10/22] Use raw socket fallback in notify UI test --- .../MultiWindowNotificationsUITests.swift | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 10343dc1..966d060f 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -236,25 +236,23 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) - let pingResult = waitForCmuxPing(timeout: 20.0) - if pingResult.stdout != "PONG", + let pingResponse = waitForSocketPong(timeout: 20.0) + if pingResponse != "PONG", let resolvedPath = resolveSocketPath(timeout: 5.0, requiredWorkspaceId: tabId2) { socketPath = resolvedPath } - let confirmedPingResult = pingResult.stdout == "PONG" ? pingResult : waitForCmuxPing(timeout: 5.0) - guard confirmedPingResult.stdout == "PONG" else { + let confirmedPingResponse = pingResponse == "PONG" ? pingResponse : waitForSocketPong(timeout: 5.0) + guard confirmedPingResponse == "PONG" else { let failureSetup = loadData() ?? setup XCTFail( - "Control socket did not respond in time. path=\(socketPath) response=\(confirmedPingResult.stdout ?? "") " + - "stderr=\(confirmedPingResult.stderr ?? "") " + + "Control socket did not respond in time. path=\(socketPath) response=\(confirmedPingResponse ?? "") " + socketDiagnostics(from: failureSetup) ) return } - guard let surfaceId = waitForSurfaceIdViaCLI(forWorkspaceId: tabId2, timeout: 12.0) - ?? waitForSurfaceId(forWorkspaceId: tabId2, timeout: 3.0) else { + guard let surfaceId = waitForSurfaceId(forWorkspaceId: tabId2, timeout: 12.0) else { let failureSetup = loadData() ?? setup XCTFail( "Expected at least one surface in workspace \(tabId2). socket=\(socketPath) " + @@ -419,6 +417,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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)) } @@ -429,6 +431,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { ) 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) } @@ -486,7 +492,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? { guard let paneId = firstPaneIdViaCLI(forWorkspaceId: workspaceId) else { - return nil + return firstSurfaceId(forWorkspaceId: workspaceId) } let result = runCmuxCommand( socketPath: socketPath, @@ -501,7 +507,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { ], responseTimeoutSeconds: 3.0 ) - guard result.terminationStatus == 0 else { return nil } + guard result.terminationStatus == 0 else { + if isSocketPermissionFailure(result.stderr) { + return firstSurfaceId(forWorkspaceId: workspaceId) + } + return nil + } return firstHandle(in: result.stdout) } @@ -517,7 +528,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { ], responseTimeoutSeconds: 3.0 ) - guard result.terminationStatus == 0 else { return nil } + guard result.terminationStatus == 0 else { + if isSocketPermissionFailure(result.stderr) { + return nil + } + return nil + } return firstHandle(in: result.stdout) } @@ -540,7 +556,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { surfaceId: String, title: String ) -> (terminationStatus: Int32, stdout: String, stderr: String) { - runCmuxCommand( + let result = runCmuxCommand( socketPath: socketPath, arguments: [ "notify", @@ -557,6 +573,19 @@ final class MultiWindowNotificationsUITests: XCTestCase { ], responseTimeoutSeconds: 4.0 ) + if result.terminationStatus == 0 || !isSocketPermissionFailure(result.stderr) { + return result + } + + let response = socketCommand("notify_target \(workspaceId) \(surfaceId) \(title)|ui-test|focus-regression") ?? "" + let succeeded = response == "OK" + return ( + terminationStatus: succeeded ? 0 : 1, + stdout: response, + stderr: succeeded + ? "\(result.stderr) raw-socket-fallback" + : "\(result.stderr) raw-socket-fallback-response=\(response)" + ) } private func runCmuxCommand( @@ -728,6 +757,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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() From 2b99af19bcefb5210e65fcb1c2ef60e331efab49 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:18:01 -0800 Subject: [PATCH 11/22] Harden multi-window notify regression test --- Sources/AppDelegate.swift | 69 +++++--- .../MultiWindowNotificationsUITests.swift | 155 +++++++++++------- 2 files changed, 137 insertions(+), 87 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f74e65db..21569100 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5683,6 +5683,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + func waitForSurfaceId( + on tabManager: TabManager, + tabId: UUID, + _ completion: @escaping (UUID) -> Void + ) { + if let surfaceId = tabManager.focusedPanelId(for: tabId) { + completion(surfaceId) + return + } + guard Date() < deadline else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + waitForSurfaceId(on: tabManager, tabId: tabId, completion) + } + } + waitForContexts(minCount: 1) { [weak self] in guard let self else { return } guard let window1 = self.mainWindowContexts.values.first else { return } @@ -5696,36 +5711,40 @@ 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: 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.publishMultiWindowNotificationSocketStateIfNeeded(at: path) + self.writeMultiWindowNotificationTestData([ + "window1Id": window1.windowId.uuidString, + "window2Id": window2.windowId.uuidString, + "window2InitialSidebarSelection": "notifications", + "tabId1": tabId1.uuidString, + "tabId2": tabId2.uuidString, + "surfaceId2": surfaceId2.uuidString, + "notifId1": notif1?.id.uuidString ?? "", + "notifId2": notif2?.id.uuidString ?? "", + "expectedLatestWindowId": window1.windowId.uuidString, + "expectedLatestTabId": tabId1.uuidString, + ], at: path) + self.publishMultiWindowNotificationSocketStateIfNeeded(at: path) + } } } } diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 966d060f..f42aba29 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -209,8 +209,9 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTAssertTrue( waitForDataMatch(timeout: 20.0) { data in let tabId2 = data["tabId2"] ?? "" + let surfaceId2 = data["surfaceId2"] ?? "" let socketReady = data["socketReady"] ?? "" - return !tabId2.isEmpty && !socketReady.isEmpty && socketReady != "pending" + return !tabId2.isEmpty && !surfaceId2.isEmpty && !socketReady.isEmpty && socketReady != "pending" }, "Expected multi-window notification setup data and socket readiness" ) @@ -233,34 +234,20 @@ final class MultiWindowNotificationsUITests: XCTestCase { ) 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 + } XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0)) - let pingResponse = waitForSocketPong(timeout: 20.0) - if pingResponse != "PONG", - let resolvedPath = resolveSocketPath(timeout: 5.0, requiredWorkspaceId: tabId2) { - socketPath = resolvedPath - } - - let confirmedPingResponse = pingResponse == "PONG" ? pingResponse : waitForSocketPong(timeout: 5.0) - guard confirmedPingResponse == "PONG" else { - let failureSetup = loadData() ?? setup - XCTFail( - "Control socket did not respond in time. path=\(socketPath) response=\(confirmedPingResponse ?? "") " + - socketDiagnostics(from: failureSetup) - ) - return - } - - guard let surfaceId = waitForSurfaceId(forWorkspaceId: tabId2, timeout: 12.0) else { - let failureSetup = loadData() ?? setup - XCTFail( - "Expected at least one surface in workspace \(tabId2). socket=\(socketPath) " + - socketDiagnostics(from: failureSetup) - ) - return - } - let finder = XCUIApplication(bundleIdentifier: "com.apple.finder") finder.activate() XCTAssertTrue( @@ -275,14 +262,26 @@ final class MultiWindowNotificationsUITests: XCTestCase { surfaceId: surfaceId, title: title ) - XCTAssertEqual(notifyResult.terminationStatus, 0, "Expected `cmux notify` to succeed. stderr=\(notifyResult.stderr)") - XCTAssertTrue(notifyResult.stdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyResult.stdout)") - RunLoop.current.run(until: Date().addingTimeInterval(0.5)) XCTAssertFalse( app.state == .runningForeground, - "Expected cmux to remain in background after `cmux notify`. state=\(app.state.rawValue)" + "Expected cmux to remain in background after bundled `cmux notify`. state=\(app.state.rawValue) stderr=\(notifyResult.stderr)" ) + + guard notifyResult.terminationStatus == 0 else { + let rawFallbackResponse: String? + if isSocketPermissionFailure(notifyResult.stderr) { + rawFallbackResponse = socketCommand("notify_target \(tabId2) \(surfaceId) \(title)|ui-test|focus-regression") + } else { + rawFallbackResponse = nil + } + XCTFail( + "Expected bundled `cmux notify` to succeed. stderr=\(notifyResult.stderr) " + + "rawFallback=\(rawFallbackResponse ?? "")" + ) + return + } + XCTAssertTrue(notifyResult.stdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyResult.stdout)") } private func clickNotificationPopoverRowAndWaitForFocusChange( @@ -556,7 +555,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { surfaceId: String, title: String ) -> (terminationStatus: Int32, stdout: String, stderr: String) { - let result = runCmuxCommand( + runCmuxCommand( socketPath: socketPath, arguments: [ "notify", @@ -571,35 +570,33 @@ final class MultiWindowNotificationsUITests: XCTestCase { "--body", "focus-regression" ], - responseTimeoutSeconds: 4.0 - ) - if result.terminationStatus == 0 || !isSocketPermissionFailure(result.stderr) { - return result - } - - let response = socketCommand("notify_target \(workspaceId) \(surfaceId) \(title)|ui-test|focus-regression") ?? "" - let succeeded = response == "OK" - return ( - terminationStatus: succeeded ? 0 : 1, - stdout: response, - stderr: succeeded - ? "\(result.stderr) raw-socket-fallback" - : "\(result.stderr) raw-socket-fallback-response=\(response)" + responseTimeoutSeconds: 4.0, + cliStrategy: .bundledOnly ) } private func runCmuxCommand( socketPath: String, arguments: [String], - responseTimeoutSeconds: Double = 3.0 + 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 resolveCmuxCLIPaths() { + for cliPath in cliPaths { let result = executeCmuxCommand( executablePath: cliPath, arguments: args, @@ -615,6 +612,14 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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", @@ -627,6 +632,11 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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"] ?? "") " + @@ -635,15 +645,17 @@ final class MultiWindowNotificationsUITests: XCTestCase { "signals=\(data["socketFailureSignals"] ?? "")" } - private func resolveCmuxCLIPaths() -> [String] { + private func resolveCmuxCLIPaths(strategy: CmuxCLIStrategy) -> [String] { let fileManager = FileManager.default let env = ProcessInfo.processInfo.environment var candidates: [String] = [] var productDirectories: [String] = [] - for key in ["CMUX_UI_TEST_CLI_PATH", "CMUXTERM_CLI"] { - if let value = env[key], !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - candidates.append(value) + 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) + } } } @@ -664,12 +676,14 @@ final class MultiWindowNotificationsUITests: XCTestCase { productDirectories.append(contentsOf: inferredBuildProductsDirectories()) for productsDir in uniquePaths(productDirectories) { - appendCLIPathCandidates(fromProductsDirectory: productsDir, to: &candidates) + 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") - candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux") + if strategy == .any { + candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux") + } var resolvedPaths: [String] = [] for path in uniquePaths(candidates) { @@ -697,21 +711,21 @@ final class MultiWindowNotificationsUITests: XCTestCase { } } - private func appendCLIPathCandidates(fromProductsDirectory productsDir: String, to candidates: inout [String]) { - candidates.append("\(productsDir)/cmux") + 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 == "cmux" { - let cliPath = URL(fileURLWithPath: productsDir) - .appendingPathComponent(entry) - .path - candidates.append(cliPath) - } for entry in entries.sorted() where entry.hasSuffix(".app") { let cliPath = URL(fileURLWithPath: productsDir) .appendingPathComponent(entry) @@ -719,6 +733,14 @@ final class MultiWindowNotificationsUITests: XCTestCase { .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( @@ -991,9 +1013,18 @@ final class MultiWindowNotificationsUITests: XCTestCase { } guard wrote else { return nil } + let deadline = Date().addingTimeInterval(2.0) 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.. Date: Thu, 5 Mar 2026 19:26:08 -0800 Subject: [PATCH 12/22] Reset surface wait timeout in notify UI setup --- Sources/AppDelegate.swift | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 21569100..645c4e4e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5670,14 +5670,14 @@ 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) } @@ -5686,16 +5686,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func waitForSurfaceId( on tabManager: TabManager, tabId: UUID, + timeout: TimeInterval = 8.0, _ completion: @escaping (UUID) -> Void ) { - if let surfaceId = tabManager.focusedPanelId(for: tabId) { - completion(surfaceId) - return - } - guard Date() < deadline else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - waitForSurfaceId(on: tabManager, tabId: tabId, completion) + let deadline = Date().addingTimeInterval(timeout) + + func poll() { + if let surfaceId = tabManager.focusedPanelId(for: tabId) { + completion(surfaceId) + return + } + guard Date() < deadline else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + poll() + } } + + poll() } waitForContexts(minCount: 1) { [weak self] in From 6c163b1eb004fe3b7fefee759598fee37c8b4f9a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:04:52 -0800 Subject: [PATCH 13/22] Run notify regression from in-app shell --- Sources/AppDelegate.swift | 3 + .../MultiWindowNotificationsUITests.swift | 120 +++++++++++++++--- 2 files changed, 104 insertions(+), 19 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 645c4e4e..c3b56efe 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5750,6 +5750,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "expectedLatestWindowId": window1.windowId.uuidString, "expectedLatestTabId": tabId1.uuidString, ], at: path) + // Leave the initial window's terminal focused so UI tests can type shell + // commands while still keeping the second window configured for notifications. + window1.window?.makeKeyAndOrderFront(nil) self.publishMultiWindowNotificationSocketStateIfNeeded(at: path) } } diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index f42aba29..245d423e 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -248,40 +248,83 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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 + defer { + try? FileManager.default.removeItem(atPath: commandStatusPath) + try? FileManager.default.removeItem(atPath: commandStdoutPath) + try? FileManager.default.removeItem(atPath: commandStderrPath) + } + + guard let bundledCLIPath = resolveCmuxCLIPaths(strategy: .bundledOnly).first else { + XCTFail("Failed to locate bundled cmux CLI for notify regression test") + return + } + + XCTAssertTrue(app.windows.element(boundBy: 0).waitForExistence(timeout: 4.0), "Expected at least one window before typing notify command") + app.windows.element(boundBy: 0) + .coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + .click() + + let notifyCommand = [ + "rm -f \(shellSingleQuote(commandStatusPath)) \(shellSingleQuote(commandStdoutPath)) \(shellSingleQuote(commandStderrPath));", + "(sleep 1;", + "\(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))) >/dev/null 2>&1 &" + ].joined(separator: " ") + app.typeText(notifyCommand + "\n") + let finder = XCUIApplication(bundleIdentifier: "com.apple.finder") finder.activate() XCTAssertTrue( waitForAppToLeaveForeground(app, timeout: 8.0), - "Expected cmux to move to background before sending notify command. state=\(app.state.rawValue)" + "Expected cmux to move to background before delayed notify command runs. state=\(app.state.rawValue)" ) - let title = "focus-regression-\(UUID().uuidString.prefix(8))" - let notifyResult = runCmuxNotify( - socketPath: socketPath, - workspaceId: tabId2, - surfaceId: surfaceId, - title: title + 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=\(notifyResult.stderr)" + "Expected cmux to remain in background after bundled `cmux notify`. state=\(app.state.rawValue) stderr=\(notifyStderr)" ) - - guard notifyResult.terminationStatus == 0 else { - let rawFallbackResponse: String? - if isSocketPermissionFailure(notifyResult.stderr) { - rawFallbackResponse = socketCommand("notify_target \(tabId2) \(surfaceId) \(title)|ui-test|focus-regression") - } else { - rawFallbackResponse = nil - } + guard notifyExitStatus == "0" else { XCTFail( - "Expected bundled `cmux notify` to succeed. stderr=\(notifyResult.stderr) " + - "rawFallback=\(rawFallbackResponse ?? "")" + "Expected bundled `cmux notify` launched from the in-app shell to succeed. " + + "status=\(notifyExitStatus) stdout=\(notifyStdout) stderr=\(notifyStderr)" ) return } - XCTAssertTrue(notifyResult.stdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyResult.stdout)") + XCTAssertTrue(notifyStdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyStdout) stderr=\(notifyStderr)") } private func clickNotificationPopoverRowAndWaitForFocusChange( @@ -437,6 +480,37 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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 { @@ -946,6 +1020,14 @@ 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 From e6e5a57dd6060e55ea090351c113376b2e53b290 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:18:45 -0800 Subject: [PATCH 14/22] Send notify regression input via workspace socket --- Sources/AppDelegate.swift | 5 +- Sources/TerminalController.swift | 47 +++++++++++++++++++ .../MultiWindowNotificationsUITests.swift | 14 +++--- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c3b56efe..082b9486 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5750,9 +5750,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "expectedLatestWindowId": window1.windowId.uuidString, "expectedLatestTabId": tabId1.uuidString, ], at: path) - // Leave the initial window's terminal focused so UI tests can type shell - // commands while still keeping the second window configured for notifications. - window1.window?.makeKeyAndOrderFront(nil) self.publishMultiWindowNotificationSocketStateIfNeeded(at: path) } } @@ -5821,7 +5818,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketPathExists": health.socketPathExists ? "1" : "0", "socketFailureSignals": failureSignals, ], at: dataPath) - guard !isTimedOut else { return } + guard !isTimedOut, !isReady else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { publish() } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b536d0e7..ca04b758 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1247,6 +1247,9 @@ class TerminalController { case "send_key": return sendKey(args) + case "send_workspace": + return sendInputToWorkspace(args) + case "send_surface": return sendInputToSurface(args) @@ -9267,6 +9270,7 @@ class TerminalController { Input commands: send - Send text to current terminal send_key - Send special key (ctrl-c, ctrl-d, enter, tab, escape) + send_workspace - Send text to a workspace's focused terminal send_surface - Send text to a specific terminal send_key_surface - Send special key to a specific terminal read_screen [id|idx] [--scrollback] [--lines N] - Read terminal text (plain text) @@ -11594,6 +11598,49 @@ 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 }), + let terminalPanel = tab.focusedTerminalPanel else { + error = "ERROR: No focused terminal in workspace" + return + } + + let unescaped = text + .replacingOccurrences(of: "\\n", with: "\r") + .replacingOccurrences(of: "\\r", with: "\r") + .replacingOccurrences(of: "\\t", with: "\t") + + if let surface = terminalPanel.surface.surface { + 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 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 245d423e..5753173f 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -224,6 +224,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTFail("Missing setup workspace id") return } + guard let tabId1 = setup["tabId1"], !tabId1.isEmpty else { + XCTFail("Missing source workspace id") + return + } if let expectedSocketPath = setup["socketExpectedPath"], !expectedSocketPath.isEmpty { socketPath = expectedSocketPath } @@ -270,11 +274,6 @@ final class MultiWindowNotificationsUITests: XCTestCase { return } - XCTAssertTrue(app.windows.element(boundBy: 0).waitForExistence(timeout: 4.0), "Expected at least one window before typing notify command") - app.windows.element(boundBy: 0) - .coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) - .click() - let notifyCommand = [ "rm -f \(shellSingleQuote(commandStatusPath)) \(shellSingleQuote(commandStdoutPath)) \(shellSingleQuote(commandStderrPath));", "(sleep 1;", @@ -290,7 +289,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { "2>\(shellSingleQuote(commandStderrPath));", "printf '%s' $? >\(shellSingleQuote(commandStatusPath))) >/dev/null 2>&1 &" ].joined(separator: " ") - app.typeText(notifyCommand + "\n") + guard socketCommand("send_workspace \(tabId1) \(notifyCommand)\\n") == "OK" else { + XCTFail("Failed to inject delayed bundled `cmux notify` command into source workspace \(tabId1)") + return + } let finder = XCUIApplication(bundleIdentifier: "com.apple.finder") finder.activate() From acd8dbff69e960c82085b989b2f4be4bed9402d1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:24:49 -0800 Subject: [PATCH 15/22] Keep send_workspace test-only --- Sources/TerminalController.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ca04b758..0269522e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1247,9 +1247,6 @@ class TerminalController { case "send_key": return sendKey(args) - case "send_workspace": - return sendInputToWorkspace(args) - case "send_surface": return sendInputToSurface(args) @@ -1360,6 +1357,9 @@ class TerminalController { #if DEBUG + case "send_workspace": + return sendInputToWorkspace(args) + case "set_shortcut": return setShortcut(args) @@ -9270,7 +9270,6 @@ class TerminalController { Input commands: send - Send text to current terminal send_key - Send special key (ctrl-c, ctrl-d, enter, tab, escape) - send_workspace - Send text to a workspace's focused terminal send_surface - Send text to a specific terminal send_key_surface - Send special key to a specific terminal read_screen [id|idx] [--scrollback] [--lines N] - Read terminal text (plain text) @@ -9346,6 +9345,7 @@ class TerminalController { sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only) terminal_drop_overlay_probe [deferred|direct] - Trigger focused terminal drop-overlay show path and report animation counts (test-only) activate_app - Bring app + main window to front (test-only) + send_workspace - Send text to a workspace's focused 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) From 2023d8eb57418fd94db30b99c19058eaaabe0e6c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:34:51 -0800 Subject: [PATCH 16/22] Fallback panel lookup for notify UI setup --- Sources/AppDelegate.swift | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 082b9486..093f2a6c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5691,8 +5691,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { let deadline = Date().addingTimeInterval(timeout) - func poll() { + 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 } From 335aaaececdfc668f621fb323783ca9c99fc7a54 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:24:25 -0800 Subject: [PATCH 17/22] Fix send_workspace routing in inactive windows --- Sources/TerminalController.swift | 50 +++++++++++++++++-- .../MultiWindowNotificationsUITests.swift | 8 ++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0269522e..323cd891 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -9345,7 +9345,7 @@ class TerminalController { sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only) terminal_drop_overlay_probe [deferred|direct] - Trigger focused terminal drop-overlay show path and report animation counts (test-only) activate_app - Bring app + main window to front (test-only) - send_workspace - Send text to a workspace's focused terminal (test-only) + send_workspace - 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) @@ -11617,9 +11617,13 @@ class TerminalController { error = "ERROR: Workspace not found" return } - guard let tab = targetManager.tabs.first(where: { $0.id == workspaceId }), - let terminalPanel = tab.focusedTerminalPanel else { - error = "ERROR: No focused terminal in workspace" + 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 } @@ -11641,6 +11645,44 @@ class TerminalController { 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 5753173f..2ad740ce 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -289,8 +289,12 @@ final class MultiWindowNotificationsUITests: XCTestCase { "2>\(shellSingleQuote(commandStderrPath));", "printf '%s' $? >\(shellSingleQuote(commandStatusPath))) >/dev/null 2>&1 &" ].joined(separator: " ") - guard socketCommand("send_workspace \(tabId1) \(notifyCommand)\\n") == "OK" else { - XCTFail("Failed to inject delayed bundled `cmux notify` command into source workspace \(tabId1)") + let sendWorkspaceResponse = socketCommand("send_workspace \(tabId1) \(notifyCommand)\\n") + guard sendWorkspaceResponse == "OK" else { + XCTFail( + "Failed to inject delayed bundled `cmux notify` command into source workspace \(tabId1). " + + "response=\(sendWorkspaceResponse ?? "")" + ) return } From 7bee9ddc1336a3781bc215bdaeb4296fdfc4958b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:43:20 -0800 Subject: [PATCH 18/22] Increase notify regression socket timeout --- .../MultiWindowNotificationsUITests.swift | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 2ad740ce..c1c00575 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -289,7 +289,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { "2>\(shellSingleQuote(commandStderrPath));", "printf '%s' $? >\(shellSingleQuote(commandStatusPath))) >/dev/null 2>&1 &" ].joined(separator: " ") - let sendWorkspaceResponse = socketCommand("send_workspace \(tabId1) \(notifyCommand)\\n") + let sendWorkspaceResponse = socketCommand( + "send_workspace \(tabId1) \(notifyCommand)\\n", + responseTimeout: 8.0 + ) guard sendWorkspaceResponse == "OK" else { XCTFail( "Failed to inject delayed bundled `cmux notify` command into source workspace \(tabId1). " + @@ -985,20 +988,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() @@ -1036,9 +1040,11 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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? { @@ -1101,7 +1107,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } guard wrote else { return nil } - let deadline = Date().addingTimeInterval(2.0) + let deadline = Date().addingTimeInterval(responseTimeout) var buf = [UInt8](repeating: 0, count: 4096) var accum = "" while Date() < deadline { From 21bb31dcfb76c90e16c987052d1102aa5ebaebaa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:58:38 -0800 Subject: [PATCH 19/22] Avoid blocking notify regression socket replies --- Sources/TerminalController.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 323cd891..7059092f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11632,11 +11632,17 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + // 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 } From 5f82a1ae2a9802e6c917b46001a53dde691ecd44 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:18:14 -0800 Subject: [PATCH 20/22] Run notify regression through a real terminal --- Sources/AppDelegate.swift | 6 +- .../MultiWindowNotificationsUITests.swift | 67 +++++++++++++------ 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 093f2a6c..6b4acc59 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5741,7 +5741,9 @@ 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 } - waitForSurfaceId(on: window2.tabManager, tabId: tabId2) { [weak self] surfaceId2 in + 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 } @@ -5767,6 +5769,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "window2InitialSidebarSelection": "notifications", "tabId1": tabId1.uuidString, "tabId2": tabId2.uuidString, + "surfaceId1": surfaceId1.uuidString, "surfaceId2": surfaceId2.uuidString, "notifId1": notif1?.id.uuidString ?? "", "notifId2": notif2?.id.uuidString ?? "", @@ -5775,6 +5778,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ], at: path) self.publishMultiWindowNotificationSocketStateIfNeeded(at: path) } + } } } } diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index c1c00575..43696454 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -228,6 +228,14 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTFail("Missing source workspace id") return } + guard let window1Id = setup["window1Id"], !window1Id.isEmpty else { + XCTFail("Missing source window id") + return + } + guard let sourceSurfaceId = setup["surfaceId1"], !sourceSurfaceId.isEmpty else { + XCTFail("Missing source surface id") + return + } if let expectedSocketPath = setup["socketExpectedPath"], !expectedSocketPath.isEmpty { socketPath = expectedSocketPath } @@ -263,10 +271,14 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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 { @@ -274,32 +286,32 @@ final class MultiWindowNotificationsUITests: XCTestCase { return } - let notifyCommand = [ - "rm -f \(shellSingleQuote(commandStatusPath)) \(shellSingleQuote(commandStdoutPath)) \(shellSingleQuote(commandStderrPath));", - "(sleep 1;", - "\(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))) >/dev/null 2>&1 &" - ].joined(separator: " ") - let sendWorkspaceResponse = socketCommand( - "send_workspace \(tabId1) \(notifyCommand)\\n", - responseTimeout: 8.0 - ) - guard sendWorkspaceResponse == "OK" else { + 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 inject delayed bundled `cmux notify` command into source workspace \(tabId1). " + - "response=\(sendWorkspaceResponse ?? "")" + "Failed to write delayed bundled `cmux notify` script. " + + "path=\(commandScriptPath) error=\(error)" ) return } + XCTAssertEqual(socketCommand("focus_window \(window1Id)"), "OK", "Expected source window to be focusable") + XCTAssertEqual(socketCommand("select_workspace \(tabId1)"), "OK", "Expected source workspace to be selectable") + XCTAssertEqual(socketCommand("focus_surface \(sourceSurfaceId)"), "OK", "Expected source terminal to be focusable") + XCTAssertTrue( + waitForTerminalFocus(surfaceId: sourceSurfaceId, timeout: 4.0), + "Expected source terminal surface to own first responder before typing" + ) + + app.typeText("sh \(commandScriptPath)") + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) let finder = XCUIApplication(bundleIdentifier: "com.apple.finder") finder.activate() @@ -447,6 +459,17 @@ final class MultiWindowNotificationsUITests: XCTestCase { return socketCommand("ping") ?? lastResponse } + private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if socketCommand("is_terminal_focused \(surfaceId)") == "true" { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return socketCommand("is_terminal_focused \(surfaceId)") == "true" + } + private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) { let deadline = Date().addingTimeInterval(timeout) var lastStdout: String? From ae4781ef6646fd17aea832d8515b4db8d4ae17e8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:39:26 -0800 Subject: [PATCH 21/22] Prepare notify regression source terminal in app setup --- Sources/AppDelegate.swift | 72 +++++++++++++++++++ .../MultiWindowNotificationsUITests.swift | 37 +++++----- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6b4acc59..1b979eac 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5776,6 +5776,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "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) } } @@ -5783,6 +5790,71 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + 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 } diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 43696454..632ad44d 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -199,6 +199,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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() @@ -211,9 +212,15 @@ final class MultiWindowNotificationsUITests: XCTestCase { let tabId2 = data["tabId2"] ?? "" let surfaceId2 = data["surfaceId2"] ?? "" let socketReady = data["socketReady"] ?? "" - return !tabId2.isEmpty && !surfaceId2.isEmpty && !socketReady.isEmpty && socketReady != "pending" + let sourceTerminalReady = data["sourceTerminalReady"] ?? "" + return !tabId2.isEmpty && + !surfaceId2.isEmpty && + !socketReady.isEmpty && + socketReady != "pending" && + !sourceTerminalReady.isEmpty && + sourceTerminalReady != "pending" }, - "Expected multi-window notification setup data and socket readiness" + "Expected multi-window notification setup data, socket readiness, and source terminal focus" ) guard let setup = loadData() else { @@ -224,18 +231,6 @@ final class MultiWindowNotificationsUITests: XCTestCase { XCTFail("Missing setup workspace id") return } - guard let tabId1 = setup["tabId1"], !tabId1.isEmpty else { - XCTFail("Missing source workspace id") - return - } - guard let window1Id = setup["window1Id"], !window1Id.isEmpty else { - XCTFail("Missing source window id") - return - } - guard let sourceSurfaceId = setup["surfaceId1"], !sourceSurfaceId.isEmpty else { - XCTFail("Missing source surface id") - return - } if let expectedSocketPath = setup["socketExpectedPath"], !expectedSocketPath.isEmpty { socketPath = expectedSocketPath } @@ -257,6 +252,13 @@ final class MultiWindowNotificationsUITests: XCTestCase { 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)) @@ -302,13 +304,6 @@ final class MultiWindowNotificationsUITests: XCTestCase { ) return } - XCTAssertEqual(socketCommand("focus_window \(window1Id)"), "OK", "Expected source window to be focusable") - XCTAssertEqual(socketCommand("select_workspace \(tabId1)"), "OK", "Expected source workspace to be selectable") - XCTAssertEqual(socketCommand("focus_surface \(sourceSurfaceId)"), "OK", "Expected source terminal to be focusable") - XCTAssertTrue( - waitForTerminalFocus(surfaceId: sourceSurfaceId, timeout: 4.0), - "Expected source terminal surface to own first responder before typing" - ) app.typeText("sh \(commandScriptPath)") app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) From 6993c8ceeffdd0ec2ec2042ecc0f85ed8642d3b1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:53:37 -0800 Subject: [PATCH 22/22] Allow notify_target across windows --- Sources/TerminalController.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7059092f..af7faec9 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -10583,7 +10583,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 }