Fix notification unread persistence when workspaces regain focus (#971)
* Fix notification unread persistence on focus * Address review feedback on notification unread fix
This commit is contained in:
parent
a08ad56244
commit
5f43a3fc32
8 changed files with 316 additions and 140 deletions
|
|
@ -1837,7 +1837,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
|
||||
tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false)
|
||||
}
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
|
|
@ -8123,16 +8122,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
#endif
|
||||
|
||||
if let notificationId, let store = notificationStore {
|
||||
markReadIfFocused(
|
||||
notificationId: notificationId,
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
tabManager: context.tabManager,
|
||||
notificationStore: store
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
recordMultiWindowNotificationFocusIfNeeded(
|
||||
windowId: context.windowId,
|
||||
|
|
@ -8186,15 +8175,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
#endif
|
||||
|
||||
if let notificationId, let store = notificationStore {
|
||||
markReadIfFocused(
|
||||
notificationId: notificationId,
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
tabManager: tabManager,
|
||||
notificationStore: store
|
||||
)
|
||||
}
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
||||
writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"])
|
||||
|
|
@ -8254,22 +8234,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
}
|
||||
|
||||
private func markReadIfFocused(
|
||||
notificationId: UUID,
|
||||
tabId: UUID,
|
||||
surfaceId: UUID?,
|
||||
tabManager: TabManager,
|
||||
notificationStore: TerminalNotificationStore
|
||||
) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
guard tabManager.selectedTabId == tabId else { return }
|
||||
if let surfaceId {
|
||||
guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return }
|
||||
}
|
||||
notificationStore.markRead(id: notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func recordMultiWindowNotificationOpenFailureIfNeeded(
|
||||
tabId: UUID,
|
||||
|
|
|
|||
|
|
@ -599,7 +599,7 @@ class TabManager: ObservableObject {
|
|||
self.focusSelectedTabPanel(previousTabId: previousTabId)
|
||||
self.updateWindowTitleForSelectedTab()
|
||||
if let selectedTabId = self.selectedTabId {
|
||||
self.markFocusedPanelReadIfActive(tabId: selectedTabId)
|
||||
self.flashFocusedPanelIfUnreadAndActive(tabId: selectedTabId)
|
||||
}
|
||||
#if DEBUG
|
||||
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
||||
|
|
@ -671,7 +671,7 @@ class TabManager: ObservableObject {
|
|||
guard let self else { return }
|
||||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
|
||||
guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return }
|
||||
markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId)
|
||||
flashPanelIfUnreadAndActive(tabId: tabId, panelId: surfaceId)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -1596,16 +1596,16 @@ class TabManager: ObservableObject {
|
|||
selectedTabId != pendingTabId
|
||||
}
|
||||
|
||||
private func markFocusedPanelReadIfActive(tabId: UUID) {
|
||||
private func flashFocusedPanelIfUnreadAndActive(tabId: UUID) {
|
||||
let shouldSuppressFlash = suppressFocusFlash
|
||||
suppressFocusFlash = false
|
||||
guard !shouldSuppressFlash else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
guard let panelId = focusedPanelId(for: tabId) else { return }
|
||||
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
|
||||
flashPanelIfUnreadAndActive(tabId: tabId, panelId: panelId)
|
||||
}
|
||||
|
||||
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
|
||||
private func flashPanelIfUnreadAndActive(tabId: UUID, panelId: UUID) {
|
||||
guard selectedTabId == tabId else { return }
|
||||
guard !suppressFocusFlash else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
|
|
@ -1614,7 +1614,6 @@ class TabManager: ObservableObject {
|
|||
if let tab = tabs.first(where: { $0.id == tabId }) {
|
||||
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
|
||||
}
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
|
||||
}
|
||||
|
||||
private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
|
||||
|
|
@ -1740,7 +1739,6 @@ class TabManager: ObservableObject {
|
|||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
||||
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetPanelId) else { return }
|
||||
tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true)
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -833,16 +833,9 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId
|
||||
let isFocusedPanel = isActiveTab && isFocusedSurface
|
||||
let isAppFocused = AppFocusState.isAppFocused()
|
||||
if isAppFocused && isFocusedPanel {
|
||||
if !idsToClear.isEmpty {
|
||||
notifications = updated
|
||||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
return
|
||||
}
|
||||
let suppressNativeDelivery = isAppFocused && isFocusedPanel
|
||||
|
||||
if WorkspaceAutoReorderSettings.isEnabled() {
|
||||
if WorkspaceAutoReorderSettings.isEnabled() && !suppressNativeDelivery {
|
||||
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
||||
}
|
||||
|
||||
|
|
@ -862,7 +855,11 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
scheduleUserNotification(notification)
|
||||
if suppressNativeDelivery {
|
||||
Self.runNotificationCustomCommand(notification)
|
||||
} else {
|
||||
scheduleUserNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(id: UUID) {
|
||||
|
|
@ -993,10 +990,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
guard let self, authorized else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||
?? "cmux"
|
||||
content.title = notification.title.isEmpty ? appName : notification.title
|
||||
content.title = Self.notificationDisplayTitle(notification)
|
||||
content.subtitle = notification.subtitle
|
||||
content.body = notification.body
|
||||
content.sound = NotificationSoundSettings.sound()
|
||||
|
|
@ -1019,16 +1013,27 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
if let error {
|
||||
NSLog("Failed to schedule notification: \(error)")
|
||||
} else {
|
||||
NotificationSoundSettings.runCustomCommand(
|
||||
title: content.title,
|
||||
subtitle: content.subtitle,
|
||||
body: content.body
|
||||
)
|
||||
Self.runNotificationCustomCommand(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func notificationDisplayTitle(_ notification: TerminalNotification) -> String {
|
||||
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||
?? "cmux"
|
||||
return notification.title.isEmpty ? appName : notification.title
|
||||
}
|
||||
|
||||
nonisolated private static func runNotificationCustomCommand(_ notification: TerminalNotification) {
|
||||
NotificationSoundSettings.runCustomCommand(
|
||||
title: notificationDisplayTitle(notification),
|
||||
subtitle: notification.subtitle,
|
||||
body: notification.body
|
||||
)
|
||||
}
|
||||
|
||||
private func ensureAuthorization(
|
||||
origin: AuthorizationRequestOrigin,
|
||||
_ completion: @escaping (Bool) -> Void
|
||||
|
|
|
|||
|
|
@ -6756,6 +6756,8 @@ final class NotificationDockBadgeTests: XCTestCase {
|
|||
}
|
||||
|
||||
override func tearDown() {
|
||||
AppFocusState.overrideIsFocused = nil
|
||||
AppDelegate.shared = nil
|
||||
TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting()
|
||||
TerminalNotificationStore.shared.replaceNotificationsForTesting([])
|
||||
super.tearDown()
|
||||
|
|
@ -7259,6 +7261,155 @@ final class NotificationDockBadgeTests: XCTestCase {
|
|||
XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id)
|
||||
}
|
||||
|
||||
func testFocusedTabNotificationIsStoredWhenNativeDeliveryIsSuppressed() {
|
||||
let store = TerminalNotificationStore.shared
|
||||
store.replaceNotificationsForTesting([])
|
||||
|
||||
let appDelegate = AppDelegate()
|
||||
let tabManager = TabManager()
|
||||
appDelegate.tabManager = tabManager
|
||||
AppDelegate.shared = appDelegate
|
||||
AppFocusState.overrideIsFocused = true
|
||||
|
||||
guard let tabId = tabManager.selectedTabId else {
|
||||
XCTFail("Expected selected tab for notification test")
|
||||
return
|
||||
}
|
||||
|
||||
store.addNotification(
|
||||
tabId: tabId,
|
||||
surfaceId: nil,
|
||||
title: "Needs input",
|
||||
subtitle: "",
|
||||
body: "agent requires user action"
|
||||
)
|
||||
|
||||
XCTAssertEqual(store.unreadCount(forTabId: tabId), 1)
|
||||
guard let latest = store.latestNotification(forTabId: tabId) else {
|
||||
XCTFail("Expected notification to be stored for focused tab")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(latest.tabId, tabId)
|
||||
XCTAssertEqual(latest.title, "Needs input")
|
||||
XCTAssertEqual(latest.body, "agent requires user action")
|
||||
XCTAssertFalse(latest.isRead)
|
||||
}
|
||||
|
||||
func testApplicationDidBecomeActiveDoesNotMarkFocusedNotificationRead() {
|
||||
let store = TerminalNotificationStore.shared
|
||||
let appDelegate = AppDelegate()
|
||||
let tabManager = TabManager()
|
||||
appDelegate.tabManager = tabManager
|
||||
appDelegate.notificationStore = store
|
||||
AppDelegate.shared = appDelegate
|
||||
AppFocusState.overrideIsFocused = true
|
||||
|
||||
guard let tabId = tabManager.selectedTabId,
|
||||
let surfaceId = tabManager.focusedSurfaceId(for: tabId) else {
|
||||
XCTFail("Expected selected tab and focused surface for activation test")
|
||||
return
|
||||
}
|
||||
|
||||
let notification = TerminalNotification(
|
||||
id: UUID(),
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: "Unread",
|
||||
subtitle: "",
|
||||
body: "should persist across app activation",
|
||||
createdAt: Date(),
|
||||
isRead: false
|
||||
)
|
||||
store.replaceNotificationsForTesting([notification])
|
||||
|
||||
appDelegate.applicationDidBecomeActive(
|
||||
Notification(name: NSApplication.didBecomeActiveNotification)
|
||||
)
|
||||
|
||||
XCTAssertTrue(store.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId))
|
||||
XCTAssertFalse(store.notifications[0].isRead)
|
||||
}
|
||||
|
||||
func testSelectingWorkspaceDoesNotMarkFocusedNotificationRead() {
|
||||
let store = TerminalNotificationStore.shared
|
||||
let appDelegate = AppDelegate()
|
||||
let tabManager = TabManager()
|
||||
appDelegate.tabManager = tabManager
|
||||
appDelegate.notificationStore = store
|
||||
AppDelegate.shared = appDelegate
|
||||
AppFocusState.overrideIsFocused = true
|
||||
|
||||
guard let originalTabId = tabManager.selectedTabId,
|
||||
let originalSurfaceId = tabManager.focusedSurfaceId(for: originalTabId) else {
|
||||
XCTFail("Expected selected tab and focused surface for workspace selection test")
|
||||
return
|
||||
}
|
||||
guard let originalWorkspace = tabManager.tabs.first(where: { $0.id == originalTabId }) else {
|
||||
XCTFail("Expected original workspace for workspace selection test")
|
||||
return
|
||||
}
|
||||
|
||||
let notification = TerminalNotification(
|
||||
id: UUID(),
|
||||
tabId: originalTabId,
|
||||
surfaceId: originalSurfaceId,
|
||||
title: "Unread",
|
||||
subtitle: "",
|
||||
body: "should persist across workspace selection",
|
||||
createdAt: Date(),
|
||||
isRead: false
|
||||
)
|
||||
store.replaceNotificationsForTesting([notification])
|
||||
|
||||
_ = tabManager.addWorkspace(select: true)
|
||||
tabManager.selectWorkspace(originalWorkspace)
|
||||
|
||||
let drained = expectation(description: "workspace selection side effects drained")
|
||||
DispatchQueue.main.async { drained.fulfill() }
|
||||
wait(for: [drained], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(tabManager.selectedTabId, originalTabId)
|
||||
XCTAssertTrue(store.hasUnreadNotification(forTabId: originalTabId, surfaceId: originalSurfaceId))
|
||||
XCTAssertFalse(store.notifications[0].isRead)
|
||||
}
|
||||
|
||||
func testNotificationFocusNavigationDoesNotMarkNotificationRead() {
|
||||
let store = TerminalNotificationStore.shared
|
||||
let appDelegate = AppDelegate()
|
||||
let tabManager = TabManager()
|
||||
appDelegate.tabManager = tabManager
|
||||
appDelegate.notificationStore = store
|
||||
AppDelegate.shared = appDelegate
|
||||
AppFocusState.overrideIsFocused = true
|
||||
|
||||
guard let tabId = tabManager.selectedTabId,
|
||||
let surfaceId = tabManager.focusedSurfaceId(for: tabId) else {
|
||||
XCTFail("Expected selected tab and focused surface for notification focus test")
|
||||
return
|
||||
}
|
||||
|
||||
let notification = TerminalNotification(
|
||||
id: UUID(),
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
title: "Unread",
|
||||
subtitle: "",
|
||||
body: "should persist after notification focus",
|
||||
createdAt: Date(),
|
||||
isRead: false
|
||||
)
|
||||
store.replaceNotificationsForTesting([notification])
|
||||
|
||||
tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId)
|
||||
|
||||
let drained = expectation(description: "notification focus drained")
|
||||
DispatchQueue.main.async { drained.fulfill() }
|
||||
wait(for: [drained], timeout: 1.0)
|
||||
|
||||
XCTAssertTrue(store.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId))
|
||||
XCTAssertFalse(store.notifications[0].isRead)
|
||||
}
|
||||
|
||||
func testNotificationIndexesUpdateAfterReadAndClearMutations() {
|
||||
let tab = UUID()
|
||||
let surfaceUnread = UUID()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E: focusing a panel clears its notification and triggers a flash.
|
||||
E2E: focusing a panel preserves its notification and triggers a flash.
|
||||
|
||||
Note: This uses the socket focus command (no assistive access needed).
|
||||
"""
|
||||
|
|
@ -74,8 +74,12 @@ def main() -> int:
|
|||
client.send("x")
|
||||
time.sleep(0.2)
|
||||
|
||||
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification did not become read after focus")
|
||||
if wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification became read after focus")
|
||||
return 1
|
||||
items = client.list_notifications()
|
||||
if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items):
|
||||
print("FAIL: Notification did not remain present and unread after focus")
|
||||
return 1
|
||||
|
||||
final_flash = client.flash_count(term_b)
|
||||
|
|
@ -93,7 +97,7 @@ def main() -> int:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: Focus clears notification and flashes panel")
|
||||
print("PASS: Focus preserves notification and flashes panel")
|
||||
return 0
|
||||
except (cmuxError, RuntimeError) as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
|
|
|
|||
|
|
@ -58,6 +58,15 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout:
|
|||
return last
|
||||
|
||||
|
||||
def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if client.current_workspace() == expected:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return client.current_workspace() == expected
|
||||
|
||||
|
||||
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
|
||||
surfaces = client.list_surfaces()
|
||||
if len(surfaces) < 2:
|
||||
|
|
@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
|
|||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Panel Focus")
|
||||
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Panel Focus")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.reset_flash_counts()
|
||||
|
|
@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
|||
|
||||
client.set_app_focus(False)
|
||||
client.notify_surface(other[0], "focusread")
|
||||
time.sleep(0.1)
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if target is None or target["is_read"]:
|
||||
result.failure("Expected unread notification for target surface before focus")
|
||||
return result
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.focus_surface(other[0])
|
||||
time.sleep(0.1)
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure("Expected flash on panel focus")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
target = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if target is None:
|
||||
result.failure("Expected notification for target surface")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on focus")
|
||||
elif target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on focus")
|
||||
else:
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure("Expected flash on panel focus dismissal")
|
||||
else:
|
||||
result.success("Notification marked read on focus")
|
||||
result.success("Notification persisted across panel focus")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On App Active")
|
||||
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On App Active")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
client.notify("activate")
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
if not items or items[0]["is_read"]:
|
||||
result.failure("Expected unread notification before activation")
|
||||
return result
|
||||
|
||||
client.simulate_app_active()
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
if not items:
|
||||
result.failure("Expected notification to remain after activation")
|
||||
elif not items[0]["is_read"]:
|
||||
result.failure("Expected notification to be marked read on app active")
|
||||
elif items[0]["is_read"]:
|
||||
result.failure("Expected notification to remain unread on app active")
|
||||
else:
|
||||
result.success("Notification marked read on app active")
|
||||
result.success("Notification persisted across app activation")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Tab Switch")
|
||||
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Tab Switch")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
tab1 = client.current_workspace()
|
||||
client.notify("tabswitch")
|
||||
time.sleep(0.1)
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["workspace_id"] == tab1), None)
|
||||
if target is None or target["is_read"]:
|
||||
result.failure("Expected unread notification for original tab before switching")
|
||||
return result
|
||||
|
||||
tab2 = client.new_workspace()
|
||||
time.sleep(0.1)
|
||||
if not wait_for_current_workspace(client, tab2):
|
||||
result.failure("Expected new workspace to become selected")
|
||||
return result
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.select_workspace(tab1)
|
||||
time.sleep(0.1)
|
||||
if not wait_for_current_workspace(client, tab1):
|
||||
result.failure("Expected original workspace to become selected again")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["workspace_id"] == tab1), None)
|
||||
if target is None:
|
||||
result.failure("Expected notification for original tab")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on tab switch")
|
||||
elif target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on tab switch")
|
||||
else:
|
||||
result.success("Notification marked read on tab switch")
|
||||
result.success("Notification persisted across tab switch")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -371,11 +387,20 @@ def test_focus_on_notification_click(client: cmux) -> TestResult:
|
|||
result.failure("Expected notification surface to be focused")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
notification = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if notification is None:
|
||||
result.failure("Expected notification to remain listed after notification click")
|
||||
return result
|
||||
if notification["is_read"]:
|
||||
result.failure("Expected notification click to preserve unread state")
|
||||
return result
|
||||
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure(f"Expected flash count >= 1, got {count}")
|
||||
else:
|
||||
result.success("Notification click focuses and flashes panel")
|
||||
result.success("Notification click focuses, flashes, and preserves unread state")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -455,9 +480,9 @@ def run_tests() -> int:
|
|||
results.append(test_kitty_notification_simple(client))
|
||||
results.append(test_kitty_notification_chunked(client))
|
||||
results.append(test_rxvt_notification_osc777(client))
|
||||
results.append(test_mark_read_on_focus_change(client))
|
||||
results.append(test_mark_read_on_app_active(client))
|
||||
results.append(test_mark_read_on_tab_switch(client))
|
||||
results.append(test_preserve_unread_on_focus_change(client))
|
||||
results.append(test_preserve_unread_on_app_active(client))
|
||||
results.append(test_preserve_unread_on_tab_switch(client))
|
||||
results.append(test_flash_on_tab_switch(client))
|
||||
results.append(test_focus_on_notification_click(client))
|
||||
results.append(test_restore_focus_on_tab_switch(client))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E: focusing a panel clears its notification and triggers a flash.
|
||||
E2E: focusing a panel preserves its notification and triggers a flash.
|
||||
|
||||
Note: This uses the socket focus command (no assistive access needed).
|
||||
"""
|
||||
|
|
@ -74,8 +74,12 @@ def main() -> int:
|
|||
client.send("x")
|
||||
time.sleep(0.2)
|
||||
|
||||
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification did not become read after focus")
|
||||
if wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification became read after focus")
|
||||
return 1
|
||||
items = client.list_notifications()
|
||||
if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items):
|
||||
print("FAIL: Notification did not remain present and unread after focus")
|
||||
return 1
|
||||
|
||||
final_flash = client.flash_count(term_b)
|
||||
|
|
@ -93,7 +97,7 @@ def main() -> int:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: Focus clears notification and flashes panel")
|
||||
print("PASS: Focus preserves notification and flashes panel")
|
||||
return 0
|
||||
except (cmuxError, RuntimeError) as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
|
|
|
|||
|
|
@ -58,6 +58,15 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout:
|
|||
return last
|
||||
|
||||
|
||||
def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if client.current_workspace() == expected:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return client.current_workspace() == expected
|
||||
|
||||
|
||||
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
|
||||
surfaces = client.list_surfaces()
|
||||
if len(surfaces) < 2:
|
||||
|
|
@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
|
|||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Panel Focus")
|
||||
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Panel Focus")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.reset_flash_counts()
|
||||
|
|
@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
|||
|
||||
client.set_app_focus(False)
|
||||
client.notify_surface(other[0], "focusread")
|
||||
time.sleep(0.1)
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if target is None or target["is_read"]:
|
||||
result.failure("Expected unread notification for target surface before focus")
|
||||
return result
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.focus_surface(other[0])
|
||||
time.sleep(0.1)
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure("Expected flash on panel focus")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
target = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if target is None:
|
||||
result.failure("Expected notification for target surface")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on focus")
|
||||
elif target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on focus")
|
||||
else:
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure("Expected flash on panel focus dismissal")
|
||||
else:
|
||||
result.success("Notification marked read on focus")
|
||||
result.success("Notification persisted across panel focus")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On App Active")
|
||||
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On App Active")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
client.notify("activate")
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
if not items or items[0]["is_read"]:
|
||||
result.failure("Expected unread notification before activation")
|
||||
return result
|
||||
|
||||
client.simulate_app_active()
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
if not items:
|
||||
result.failure("Expected notification to remain after activation")
|
||||
elif not items[0]["is_read"]:
|
||||
result.failure("Expected notification to be marked read on app active")
|
||||
elif items[0]["is_read"]:
|
||||
result.failure("Expected notification to remain unread on app active")
|
||||
else:
|
||||
result.success("Notification marked read on app active")
|
||||
result.success("Notification persisted across app activation")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Tab Switch")
|
||||
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Tab Switch")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
tab1 = client.current_workspace()
|
||||
client.notify("tabswitch")
|
||||
time.sleep(0.1)
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["workspace_id"] == tab1), None)
|
||||
if target is None or target["is_read"]:
|
||||
result.failure("Expected unread notification for original tab before switching")
|
||||
return result
|
||||
|
||||
tab2 = client.new_workspace()
|
||||
time.sleep(0.1)
|
||||
if not wait_for_current_workspace(client, tab2):
|
||||
result.failure("Expected new workspace to become selected")
|
||||
return result
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.select_workspace(tab1)
|
||||
time.sleep(0.1)
|
||||
if not wait_for_current_workspace(client, tab1):
|
||||
result.failure("Expected original workspace to become selected again")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["workspace_id"] == tab1), None)
|
||||
if target is None:
|
||||
result.failure("Expected notification for original tab")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on tab switch")
|
||||
elif target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on tab switch")
|
||||
else:
|
||||
result.success("Notification marked read on tab switch")
|
||||
result.success("Notification persisted across tab switch")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -371,11 +387,20 @@ def test_focus_on_notification_click(client: cmux) -> TestResult:
|
|||
result.failure("Expected notification surface to be focused")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
notification = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if notification is None:
|
||||
result.failure("Expected notification to remain listed after notification click")
|
||||
return result
|
||||
if notification["is_read"]:
|
||||
result.failure("Expected notification click to preserve unread state")
|
||||
return result
|
||||
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure(f"Expected flash count >= 1, got {count}")
|
||||
else:
|
||||
result.success("Notification click focuses and flashes panel")
|
||||
result.success("Notification click focuses, flashes, and preserves unread state")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -455,9 +480,9 @@ def run_tests() -> int:
|
|||
results.append(test_kitty_notification_simple(client))
|
||||
results.append(test_kitty_notification_chunked(client))
|
||||
results.append(test_rxvt_notification_osc777(client))
|
||||
results.append(test_mark_read_on_focus_change(client))
|
||||
results.append(test_mark_read_on_app_active(client))
|
||||
results.append(test_mark_read_on_tab_switch(client))
|
||||
results.append(test_preserve_unread_on_focus_change(client))
|
||||
results.append(test_preserve_unread_on_app_active(client))
|
||||
results.append(test_preserve_unread_on_tab_switch(client))
|
||||
results.append(test_flash_on_tab_switch(client))
|
||||
results.append(test_focus_on_notification_click(client))
|
||||
results.append(test_restore_focus_on_tab_switch(client))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue