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?