971 lines
39 KiB
Swift
971 lines
39 KiB
Swift
import XCTest
|
|
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import WebKit
|
|
import ObjectiveC.runtime
|
|
import Bonsplit
|
|
import UserNotifications
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
@MainActor
|
|
final class NotificationDockBadgeTests: XCTestCase {
|
|
private final class NotificationSettingsAlertSpy: NSAlert {
|
|
private(set) var beginSheetModalCallCount = 0
|
|
private(set) var runModalCallCount = 0
|
|
var nextResponse: NSApplication.ModalResponse = .alertFirstButtonReturn
|
|
|
|
override func beginSheetModal(
|
|
for sheetWindow: NSWindow,
|
|
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
|
|
) {
|
|
beginSheetModalCallCount += 1
|
|
handler?(nextResponse)
|
|
}
|
|
|
|
override func runModal() -> NSApplication.ModalResponse {
|
|
runModalCallCount += 1
|
|
return nextResponse
|
|
}
|
|
}
|
|
|
|
override func tearDown() {
|
|
TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting()
|
|
TerminalNotificationStore.shared.replaceNotificationsForTesting([])
|
|
TerminalNotificationStore.shared.resetNotificationDeliveryHandlerForTesting()
|
|
TerminalNotificationStore.shared.resetSuppressedNotificationFeedbackHandlerForTesting()
|
|
super.tearDown()
|
|
}
|
|
|
|
func testDockBadgeLabelEnabledAndCounted() {
|
|
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 1, isEnabled: true), "1")
|
|
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 42, isEnabled: true), "42")
|
|
XCTAssertEqual(TerminalNotificationStore.dockBadgeLabel(unreadCount: 100, isEnabled: true), "99+")
|
|
}
|
|
|
|
func testDockBadgeLabelHiddenWhenDisabledOrZero() {
|
|
XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true))
|
|
XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 5, isEnabled: false))
|
|
}
|
|
|
|
func testDockBadgeLabelShowsRunTagEvenWithoutUnread() {
|
|
XCTAssertEqual(
|
|
TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true, runTag: "verify-tag"),
|
|
"verify-tag"
|
|
)
|
|
}
|
|
|
|
func testDockBadgeLabelCombinesRunTagAndUnreadCount() {
|
|
XCTAssertEqual(
|
|
TerminalNotificationStore.dockBadgeLabel(unreadCount: 7, isEnabled: true, runTag: "verify"),
|
|
"verify:7"
|
|
)
|
|
XCTAssertEqual(
|
|
TerminalNotificationStore.dockBadgeLabel(unreadCount: 120, isEnabled: true, runTag: "verify"),
|
|
"verify:99+"
|
|
)
|
|
}
|
|
|
|
func testNotificationBadgePreferenceDefaultsToEnabled() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
|
|
XCTAssertFalse(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: NotificationBadgeSettings.dockBadgeEnabledKey)
|
|
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationPaneFlashPreferenceDefaultsToEnabled() {
|
|
let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey)
|
|
XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey)
|
|
XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
|
|
}
|
|
|
|
func testMenuBarExtraPreferenceDefaultsToVisible() {
|
|
let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
|
|
|
defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey)
|
|
XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
|
|
|
defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey)
|
|
XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
|
|
|
|
defaults.set("Ping", forKey: NotificationSoundSettings.key)
|
|
XCTAssertTrue(NotificationSoundSettings.usesSystemSound(defaults: defaults))
|
|
XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set("none", forKey: NotificationSoundSettings.key)
|
|
XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
|
|
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults))
|
|
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationCustomFileURLExpandsTildePath() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let rawPath = "~/Library/Sounds/my-custom.wav"
|
|
defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey)
|
|
let expectedPath = (rawPath as NSString).expandingTildeInPath
|
|
XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath)
|
|
}
|
|
|
|
func testNotificationCustomFileSelectionMustBeExplicit() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey)
|
|
|
|
defaults.set("none", forKey: NotificationSoundSettings.key)
|
|
XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
|
|
|
|
defaults.set("Ping", forKey: NotificationSoundSettings.key)
|
|
XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationCustomStagingPreservesSourceFileWithCmuxPrefix() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let fileManager = FileManager.default
|
|
let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Sounds", isDirectory: true)
|
|
do {
|
|
try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
|
|
} catch {
|
|
XCTFail("Failed to create sounds directory: \(error)")
|
|
return
|
|
}
|
|
|
|
let sourceURL = soundsDirectory.appendingPathComponent(
|
|
"cmux-custom-notification-sound.source-\(UUID().uuidString).wav",
|
|
isDirectory: false
|
|
)
|
|
defer {
|
|
try? fileManager.removeItem(at: sourceURL)
|
|
}
|
|
|
|
do {
|
|
try Data("test".utf8).write(to: sourceURL, options: .atomic)
|
|
} catch {
|
|
XCTFail("Failed to write source custom sound file: \(error)")
|
|
return
|
|
}
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
|
|
|
|
_ = NotificationSoundSettings.sound(defaults: defaults)
|
|
|
|
guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else {
|
|
XCTFail("Expected staged custom sound name")
|
|
return
|
|
}
|
|
let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
|
|
defer {
|
|
try? fileManager.removeItem(at: stagedURL)
|
|
}
|
|
|
|
XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path))
|
|
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
|
|
XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-"))
|
|
XCTAssertTrue(stagedName.hasSuffix(".wav"))
|
|
}
|
|
|
|
func testNotificationCustomUnsupportedExtensionsStageAsCaf() {
|
|
XCTAssertEqual(
|
|
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"),
|
|
"caf"
|
|
)
|
|
XCTAssertEqual(
|
|
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"),
|
|
"caf"
|
|
)
|
|
XCTAssertEqual(
|
|
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"),
|
|
"wav"
|
|
)
|
|
XCTAssertEqual(
|
|
NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"),
|
|
"aiff"
|
|
)
|
|
|
|
let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3")
|
|
let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3")
|
|
let stagedA = NotificationSoundSettings.stagedCustomSoundFileName(
|
|
forSourceURL: sourceA,
|
|
destinationExtension: "caf"
|
|
)
|
|
let stagedB = NotificationSoundSettings.stagedCustomSoundFileName(
|
|
forSourceURL: sourceB,
|
|
destinationExtension: "caf"
|
|
)
|
|
XCTAssertNotEqual(stagedA, stagedB)
|
|
XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-"))
|
|
XCTAssertTrue(stagedA.hasSuffix(".caf"))
|
|
}
|
|
|
|
func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let fileManager = FileManager.default
|
|
let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Sounds", isDirectory: true)
|
|
do {
|
|
try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true)
|
|
} catch {
|
|
XCTFail("Failed to create sounds directory: \(error)")
|
|
return
|
|
}
|
|
|
|
let sourceURL = soundsDirectory.appendingPathComponent(
|
|
"cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav",
|
|
isDirectory: false
|
|
)
|
|
do {
|
|
try Data("test".utf8).write(to: sourceURL, options: .atomic)
|
|
} catch {
|
|
XCTFail("Failed to write source custom sound file: \(error)")
|
|
return
|
|
}
|
|
defer {
|
|
try? fileManager.removeItem(at: sourceURL)
|
|
}
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
|
|
|
|
let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path)
|
|
let stagedName: String
|
|
switch prepareResult {
|
|
case .success(let name):
|
|
stagedName = name
|
|
case .failure(let issue):
|
|
XCTFail("Expected custom sound preparation success, got \(issue)")
|
|
return
|
|
}
|
|
|
|
let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false)
|
|
let metadataURL = stagedURL.appendingPathExtension("source-metadata")
|
|
defer {
|
|
try? fileManager.removeItem(at: stagedURL)
|
|
try? fileManager.removeItem(at: metadataURL)
|
|
}
|
|
|
|
XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path))
|
|
XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path))
|
|
}
|
|
|
|
func testNotificationCustomSoundReturnsNilWhenPreparationFails() {
|
|
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let invalidSourceURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: invalidSourceURL)
|
|
let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Sounds", isDirectory: true)
|
|
.appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false)
|
|
try? FileManager.default.removeItem(at: stagedURL)
|
|
}
|
|
|
|
do {
|
|
try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic)
|
|
} catch {
|
|
XCTFail("Failed to write invalid custom sound source: \(error)")
|
|
return
|
|
}
|
|
|
|
defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key)
|
|
defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey)
|
|
|
|
XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults))
|
|
}
|
|
|
|
func testNotificationCustomPreparationReportsMissingFile() {
|
|
let missingPath = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false)
|
|
.path
|
|
|
|
let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath)
|
|
switch result {
|
|
case .success:
|
|
XCTFail("Expected missing file failure")
|
|
case .failure(let issue):
|
|
guard case .missingFile = issue else {
|
|
XCTFail("Expected missingFile issue, got \(issue)")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func testFocusedTerminalNotificationStillRunsLocalSoundFeedbackWhenExternalDeliveryIsSuppressed() throws {
|
|
guard let appDelegate = AppDelegate.shared else {
|
|
XCTFail("AppDelegate.shared must be set for this test")
|
|
return
|
|
}
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
|
|
var deliveredNotificationIDs: [UUID] = []
|
|
var localFeedbackNotificationIDs: [UUID] = []
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, notification in
|
|
deliveredNotificationIDs.append(notification.id)
|
|
}
|
|
store.configureSuppressedNotificationFeedbackHandlerForTesting { _, notification in
|
|
localFeedbackNotificationIDs.append(notification.id)
|
|
}
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let terminalPanel = workspace.focusedTerminalPanel else {
|
|
XCTFail("Expected selected workspace with a focused terminal panel")
|
|
return
|
|
}
|
|
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: terminalPanel.id,
|
|
title: "Unread",
|
|
subtitle: "",
|
|
body: ""
|
|
)
|
|
|
|
let createdNotificationID = try XCTUnwrap(store.notifications.first?.id)
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
|
|
XCTAssertTrue(deliveredNotificationIDs.isEmpty)
|
|
XCTAssertEqual(localFeedbackNotificationIDs.count, 1)
|
|
XCTAssertEqual(localFeedbackNotificationIDs, [createdNotificationID])
|
|
}
|
|
|
|
func testFocusedTerminalSuppressedNotificationRunsCustomCommand() throws {
|
|
guard let appDelegate = AppDelegate.shared else {
|
|
XCTFail("AppDelegate.shared must be set for this test")
|
|
return
|
|
}
|
|
let manager = TabManager()
|
|
let store = TerminalNotificationStore.shared
|
|
let defaults = UserDefaults.standard
|
|
let commandOutputURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-notification-command-\(UUID().uuidString).txt", isDirectory: false)
|
|
|
|
let originalTabManager = appDelegate.tabManager
|
|
let originalNotificationStore = appDelegate.notificationStore
|
|
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
|
let hadSoundValue = defaults.object(forKey: NotificationSoundSettings.key) != nil
|
|
let originalSoundValue = defaults.object(forKey: NotificationSoundSettings.key)
|
|
let hadCommandValue = defaults.object(forKey: NotificationSoundSettings.customCommandKey) != nil
|
|
let originalCommandValue = defaults.object(forKey: NotificationSoundSettings.customCommandKey)
|
|
|
|
var deliveredNotificationIDs: [UUID] = []
|
|
|
|
store.replaceNotificationsForTesting([])
|
|
store.configureNotificationDeliveryHandlerForTesting { _, notification in
|
|
deliveredNotificationIDs.append(notification.id)
|
|
}
|
|
appDelegate.tabManager = manager
|
|
appDelegate.notificationStore = store
|
|
AppFocusState.overrideIsFocused = true
|
|
defaults.set("none", forKey: NotificationSoundSettings.key)
|
|
defaults.set(
|
|
"printf '%s\\n%s\\n%s' \"$CMUX_NOTIFICATION_TITLE\" \"$CMUX_NOTIFICATION_SUBTITLE\" \"$CMUX_NOTIFICATION_BODY\" > '\(commandOutputURL.path)'",
|
|
forKey: NotificationSoundSettings.customCommandKey
|
|
)
|
|
|
|
defer {
|
|
store.replaceNotificationsForTesting([])
|
|
appDelegate.tabManager = originalTabManager
|
|
appDelegate.notificationStore = originalNotificationStore
|
|
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
|
if hadSoundValue {
|
|
defaults.set(originalSoundValue, forKey: NotificationSoundSettings.key)
|
|
} else {
|
|
defaults.removeObject(forKey: NotificationSoundSettings.key)
|
|
}
|
|
if hadCommandValue {
|
|
defaults.set(originalCommandValue, forKey: NotificationSoundSettings.customCommandKey)
|
|
} else {
|
|
defaults.removeObject(forKey: NotificationSoundSettings.customCommandKey)
|
|
}
|
|
try? FileManager.default.removeItem(at: commandOutputURL)
|
|
}
|
|
|
|
guard let workspace = manager.selectedWorkspace,
|
|
let terminalPanel = workspace.focusedTerminalPanel else {
|
|
XCTFail("Expected selected workspace with a focused terminal panel")
|
|
return
|
|
}
|
|
|
|
store.addNotification(
|
|
tabId: workspace.id,
|
|
surfaceId: terminalPanel.id,
|
|
title: "",
|
|
subtitle: "Focused subtitle",
|
|
body: "Focused body"
|
|
)
|
|
|
|
let commandFinished = XCTNSPredicateExpectation(
|
|
predicate: NSPredicate { _, _ in
|
|
FileManager.default.fileExists(atPath: commandOutputURL.path)
|
|
},
|
|
object: NSObject()
|
|
)
|
|
XCTAssertEqual(XCTWaiter().wait(for: [commandFinished], timeout: 2.0), .completed)
|
|
XCTAssertTrue(deliveredNotificationIDs.isEmpty)
|
|
|
|
let output = try String(contentsOf: commandOutputURL, encoding: .utf8)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let expectedTitle = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
|
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
|
?? "cmux"
|
|
XCTAssertEqual(output.components(separatedBy: "\n"), [expectedTitle, "Focused subtitle", "Focused body"])
|
|
}
|
|
|
|
func testNotificationAuthorizationStateMappingCoversKnownUNAuthorizationStatuses() {
|
|
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .notDetermined), .notDetermined)
|
|
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .denied), .denied)
|
|
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .authorized), .authorized)
|
|
XCTAssertEqual(TerminalNotificationStore.authorizationState(from: .provisional), .provisional)
|
|
}
|
|
|
|
func testNotificationAuthorizationStateDeliveryCapability() {
|
|
XCTAssertFalse(NotificationAuthorizationState.unknown.allowsDelivery)
|
|
XCTAssertFalse(NotificationAuthorizationState.notDetermined.allowsDelivery)
|
|
XCTAssertFalse(NotificationAuthorizationState.denied.allowsDelivery)
|
|
XCTAssertTrue(NotificationAuthorizationState.authorized.allowsDelivery)
|
|
XCTAssertTrue(NotificationAuthorizationState.provisional.allowsDelivery)
|
|
XCTAssertTrue(NotificationAuthorizationState.ephemeral.allowsDelivery)
|
|
}
|
|
|
|
func testNotificationAuthorizationDefersFirstPromptWhileAppIsInactive() {
|
|
XCTAssertTrue(
|
|
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
|
|
status: .notDetermined,
|
|
isAppActive: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
|
|
status: .notDetermined,
|
|
isAppActive: true
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
TerminalNotificationStore.shouldDeferAutomaticAuthorizationRequest(
|
|
status: .authorized,
|
|
isAppActive: false
|
|
)
|
|
)
|
|
}
|
|
|
|
func testNotificationAuthorizationRequestGatingAllowsSettingsRetry() {
|
|
XCTAssertTrue(
|
|
TerminalNotificationStore.shouldRequestAuthorization(
|
|
isAutomaticRequest: false,
|
|
hasRequestedAutomaticAuthorization: true
|
|
)
|
|
)
|
|
XCTAssertTrue(
|
|
TerminalNotificationStore.shouldRequestAuthorization(
|
|
isAutomaticRequest: true,
|
|
hasRequestedAutomaticAuthorization: false
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
TerminalNotificationStore.shouldRequestAuthorization(
|
|
isAutomaticRequest: true,
|
|
hasRequestedAutomaticAuthorization: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testNotificationSettingsPromptUsesSheetAndNeverRunsModal() {
|
|
let store = TerminalNotificationStore.shared
|
|
let alertSpy = NotificationSettingsAlertSpy()
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
|
|
styleMask: [.titled],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
var openedURL: URL?
|
|
store.configureNotificationSettingsPromptHooksForTesting(
|
|
windowProvider: { window },
|
|
alertFactory: { alertSpy },
|
|
scheduler: { _, block in block() },
|
|
urlOpener: { openedURL = $0 }
|
|
)
|
|
|
|
store.promptToEnableNotificationsForTesting()
|
|
let drained = expectation(description: "main queue drained")
|
|
DispatchQueue.main.async { drained.fulfill() }
|
|
wait(for: [drained], timeout: 1.0)
|
|
|
|
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
|
|
XCTAssertEqual(alertSpy.runModalCallCount, 0)
|
|
XCTAssertEqual(
|
|
openedURL?.absoluteString,
|
|
"x-apple.systempreferences:com.apple.preference.notifications"
|
|
)
|
|
}
|
|
|
|
func testNotificationSettingsPromptRetriesUntilWindowExists() {
|
|
let store = TerminalNotificationStore.shared
|
|
let alertSpy = NotificationSettingsAlertSpy()
|
|
alertSpy.nextResponse = .alertSecondButtonReturn
|
|
|
|
var queuedRetryBlocks: [() -> Void] = []
|
|
var promptWindow: NSWindow?
|
|
store.configureNotificationSettingsPromptHooksForTesting(
|
|
windowProvider: { promptWindow },
|
|
alertFactory: { alertSpy },
|
|
scheduler: { _, block in queuedRetryBlocks.append(block) },
|
|
urlOpener: { _ in XCTFail("Should not open settings for Not Now response") }
|
|
)
|
|
|
|
store.promptToEnableNotificationsForTesting()
|
|
let drained = expectation(description: "main queue drained")
|
|
DispatchQueue.main.async { drained.fulfill() }
|
|
wait(for: [drained], timeout: 1.0)
|
|
|
|
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 0)
|
|
XCTAssertEqual(alertSpy.runModalCallCount, 0)
|
|
XCTAssertEqual(queuedRetryBlocks.count, 1)
|
|
|
|
promptWindow = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
|
|
styleMask: [.titled],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
queuedRetryBlocks.removeFirst()()
|
|
|
|
XCTAssertEqual(alertSpy.beginSheetModalCallCount, 1)
|
|
XCTAssertEqual(alertSpy.runModalCallCount, 0)
|
|
}
|
|
|
|
func testNotificationIndexesTrackUnreadCountsByTabAndSurface() {
|
|
let tabA = UUID()
|
|
let tabB = UUID()
|
|
let surfaceA = UUID()
|
|
let surfaceB = UUID()
|
|
let notificationAUnread = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tabA,
|
|
surfaceId: surfaceA,
|
|
title: "A unread",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: false
|
|
)
|
|
let notificationARead = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tabA,
|
|
surfaceId: surfaceB,
|
|
title: "A read",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: true
|
|
)
|
|
let notificationBUnread = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tabB,
|
|
surfaceId: nil,
|
|
title: "B unread",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: false
|
|
)
|
|
|
|
let store = TerminalNotificationStore.shared
|
|
store.replaceNotificationsForTesting([
|
|
notificationAUnread,
|
|
notificationARead,
|
|
notificationBUnread
|
|
])
|
|
|
|
XCTAssertEqual(store.unreadCount, 2)
|
|
XCTAssertEqual(store.unreadCount(forTabId: tabA), 1)
|
|
XCTAssertEqual(store.unreadCount(forTabId: tabB), 1)
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceA))
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: tabA, surfaceId: surfaceB))
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: tabB, surfaceId: nil))
|
|
XCTAssertEqual(store.latestNotification(forTabId: tabA)?.id, notificationAUnread.id)
|
|
XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id)
|
|
}
|
|
|
|
func testNotificationIndexesUpdateAfterReadAndClearMutations() {
|
|
let tab = UUID()
|
|
let surfaceUnread = UUID()
|
|
let surfaceRead = UUID()
|
|
let unreadNotification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tab,
|
|
surfaceId: surfaceUnread,
|
|
title: "Unread",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: false
|
|
)
|
|
let readNotification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: tab,
|
|
surfaceId: surfaceRead,
|
|
title: "Read",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(),
|
|
isRead: true
|
|
)
|
|
|
|
let store = TerminalNotificationStore.shared
|
|
store.replaceNotificationsForTesting([unreadNotification, readNotification])
|
|
XCTAssertEqual(store.unreadCount(forTabId: tab), 1)
|
|
XCTAssertTrue(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
|
|
|
|
store.markRead(forTabId: tab, surfaceId: surfaceUnread)
|
|
XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
|
|
XCTAssertFalse(store.hasUnreadNotification(forTabId: tab, surfaceId: surfaceUnread))
|
|
XCTAssertEqual(store.latestNotification(forTabId: tab)?.id, unreadNotification.id)
|
|
|
|
store.clearNotifications(forTabId: tab)
|
|
XCTAssertEqual(store.unreadCount(forTabId: tab), 0)
|
|
XCTAssertNil(store.latestNotification(forTabId: tab))
|
|
}
|
|
}
|
|
|
|
|
|
final class MenuBarBadgeLabelFormatterTests: XCTestCase {
|
|
func testBadgeLabelFormatting() {
|
|
XCTAssertNil(MenuBarBadgeLabelFormatter.badgeText(for: 0))
|
|
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 1), "1")
|
|
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 9), "9")
|
|
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 10), "9+")
|
|
XCTAssertEqual(MenuBarBadgeLabelFormatter.badgeText(for: 47), "9+")
|
|
}
|
|
}
|
|
|
|
|
|
final class NotificationMenuSnapshotBuilderTests: XCTestCase {
|
|
func testSnapshotCountsUnreadAndLimitsRecentItems() {
|
|
let notifications = (0..<8).map { index in
|
|
TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "N\(index)",
|
|
subtitle: "",
|
|
body: "",
|
|
createdAt: Date(timeIntervalSince1970: TimeInterval(index)),
|
|
isRead: index.isMultiple(of: 2)
|
|
)
|
|
}
|
|
|
|
let snapshot = NotificationMenuSnapshotBuilder.make(
|
|
notifications: notifications,
|
|
maxInlineNotificationItems: 3
|
|
)
|
|
|
|
XCTAssertEqual(snapshot.unreadCount, 4)
|
|
XCTAssertTrue(snapshot.hasNotifications)
|
|
XCTAssertTrue(snapshot.hasUnreadNotifications)
|
|
XCTAssertEqual(snapshot.recentNotifications.count, 3)
|
|
XCTAssertEqual(snapshot.recentNotifications.map(\.id), Array(notifications.prefix(3)).map(\.id))
|
|
}
|
|
|
|
func testStateHintTitleHandlesSingularPluralAndZero() {
|
|
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 0), "No unread notifications")
|
|
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 1), "1 unread notification")
|
|
XCTAssertEqual(NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: 2), "2 unread notifications")
|
|
}
|
|
}
|
|
|
|
|
|
final class MenuBarBuildHintFormatterTests: XCTestCase {
|
|
func testReleaseBuildShowsNoHint() {
|
|
XCTAssertNil(MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: false))
|
|
}
|
|
|
|
func testDebugBuildWithTagShowsTag() {
|
|
XCTAssertEqual(
|
|
MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV menubar-extra", isDebugBuild: true),
|
|
"Build Tag: menubar-extra"
|
|
)
|
|
}
|
|
|
|
func testDebugBuildWithoutTagShowsUntagged() {
|
|
XCTAssertEqual(
|
|
MenuBarBuildHintFormatter.menuTitle(appName: "cmux DEV", isDebugBuild: true),
|
|
"Build: DEV (untagged)"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
final class MenuBarNotificationLineFormatterTests: XCTestCase {
|
|
func testPlainTitleContainsUnreadDotBodyAndTab() {
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "Build finished",
|
|
subtitle: "",
|
|
body: "All checks passed",
|
|
createdAt: Date(timeIntervalSince1970: 0),
|
|
isRead: false
|
|
)
|
|
|
|
let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: "workspace-1")
|
|
XCTAssertTrue(line.hasPrefix("● Build finished"))
|
|
XCTAssertTrue(line.contains("All checks passed"))
|
|
XCTAssertTrue(line.contains("workspace-1"))
|
|
}
|
|
|
|
func testPlainTitleFallsBackToSubtitleWhenBodyEmpty() {
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "Deploy",
|
|
subtitle: "staging",
|
|
body: "",
|
|
createdAt: Date(timeIntervalSince1970: 0),
|
|
isRead: true
|
|
)
|
|
|
|
let line = MenuBarNotificationLineFormatter.plainTitle(notification: notification, tabTitle: nil)
|
|
XCTAssertTrue(line.hasPrefix(" Deploy"))
|
|
XCTAssertTrue(line.contains("staging"))
|
|
}
|
|
|
|
func testMenuTitleWrapsAndTruncatesToThreeLines() {
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "Extremely long notification title for wrapping behavior validation",
|
|
subtitle: "",
|
|
body: Array(repeating: "this body should wrap and eventually truncate", count: 8).joined(separator: " "),
|
|
createdAt: Date(timeIntervalSince1970: 0),
|
|
isRead: false
|
|
)
|
|
|
|
let title = MenuBarNotificationLineFormatter.menuTitle(
|
|
notification: notification,
|
|
tabTitle: "workspace-with-a-very-long-name",
|
|
maxWidth: 120,
|
|
maxLines: 3
|
|
)
|
|
|
|
XCTAssertLessThanOrEqual(title.components(separatedBy: "\n").count, 3)
|
|
XCTAssertTrue(title.hasSuffix("…"))
|
|
}
|
|
|
|
func testMenuTitlePreservesShortTextWithoutEllipsis() {
|
|
let notification = TerminalNotification(
|
|
id: UUID(),
|
|
tabId: UUID(),
|
|
surfaceId: nil,
|
|
title: "Done",
|
|
subtitle: "",
|
|
body: "All checks passed",
|
|
createdAt: Date(timeIntervalSince1970: 0),
|
|
isRead: false
|
|
)
|
|
|
|
let title = MenuBarNotificationLineFormatter.menuTitle(
|
|
notification: notification,
|
|
tabTitle: "w1",
|
|
maxWidth: 320,
|
|
maxLines: 3
|
|
)
|
|
|
|
XCTAssertFalse(title.hasSuffix("…"))
|
|
}
|
|
}
|
|
|
|
|
|
final class MenuBarIconDebugSettingsTests: XCTestCase {
|
|
func testDisplayedUnreadCountUsesPreviewOverrideWhenEnabled() {
|
|
let suiteName = "MenuBarIconDebugSettingsTests.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
defaults.set(true, forKey: MenuBarIconDebugSettings.previewEnabledKey)
|
|
defaults.set(7, forKey: MenuBarIconDebugSettings.previewCountKey)
|
|
|
|
XCTAssertEqual(MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: 2, defaults: defaults), 7)
|
|
}
|
|
|
|
func testBadgeRenderConfigClampsInvalidValues() {
|
|
let suiteName = "MenuBarIconDebugSettingsTests.Clamp.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
defaults.set(-100, forKey: MenuBarIconDebugSettings.badgeRectXKey)
|
|
defaults.set(200, forKey: MenuBarIconDebugSettings.badgeRectYKey)
|
|
defaults.set(-100, forKey: MenuBarIconDebugSettings.singleDigitFontSizeKey)
|
|
defaults.set(100, forKey: MenuBarIconDebugSettings.multiDigitXAdjustKey)
|
|
|
|
let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
|
|
XCTAssertEqual(config.badgeRect.origin.x, 0, accuracy: 0.001)
|
|
XCTAssertEqual(config.badgeRect.origin.y, 20, accuracy: 0.001)
|
|
XCTAssertEqual(config.singleDigitFontSize, 6, accuracy: 0.001)
|
|
XCTAssertEqual(config.multiDigitXAdjust, 4, accuracy: 0.001)
|
|
}
|
|
|
|
func testBadgeRenderConfigUsesLegacySingleDigitXAdjustWhenNewKeyMissing() {
|
|
let suiteName = "MenuBarIconDebugSettingsTests.LegacyX.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
XCTFail("Failed to create isolated UserDefaults suite")
|
|
return
|
|
}
|
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
|
|
|
defaults.set(2.5, forKey: MenuBarIconDebugSettings.legacySingleDigitXAdjustKey)
|
|
|
|
let config = MenuBarIconDebugSettings.badgeRenderConfig(defaults: defaults)
|
|
XCTAssertEqual(config.singleDigitXAdjust, 2.5, accuracy: 0.001)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
|
|
|
|
final class MenuBarIconRendererTests: XCTestCase {
|
|
func testImageWidthDoesNotShiftWhenBadgeAppears() {
|
|
let noBadge = MenuBarIconRenderer.makeImage(unreadCount: 0)
|
|
let withBadge = MenuBarIconRenderer.makeImage(unreadCount: 2)
|
|
|
|
XCTAssertEqual(noBadge.size.width, 18, accuracy: 0.001)
|
|
XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001)
|
|
}
|
|
}
|