Harden multi-window notify regression test

This commit is contained in:
Lawrence Chen 2026-03-05 19:18:01 -08:00
parent 3e10c3f790
commit 2b99af19bc
2 changed files with 137 additions and 87 deletions

View file

@ -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)
}
}
}
}

View file

@ -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 ?? "<nil>") " +
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 ?? "<nil>")"
)
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 } ?? "<nil>"
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..<n], encoding: .utf8) {