diff --git a/mac/VibeTunnel/Core/Models/Worktree.swift b/mac/VibeTunnel/Core/Models/Worktree.swift index 7fdf65dd..b1c526e3 100644 --- a/mac/VibeTunnel/Core/Models/Worktree.swift +++ b/mac/VibeTunnel/Core/Models/Worktree.swift @@ -188,10 +188,10 @@ struct FollowModeStatus: Codable { struct CreateWorktreeRequest: Codable { /// The repository path where the worktree will be created. let repoPath: String - + /// The branch name for the new worktree. let branch: String - + /// The file system path where the worktree will be created. let path: String @@ -208,7 +208,7 @@ struct CreateWorktreeRequest: Codable { struct SwitchBranchRequest: Codable { /// The repository path where the branch switch will occur. let repoPath: String - + /// The branch to switch to. let branch: String } @@ -227,12 +227,12 @@ struct SwitchBranchRequest: Codable { struct FollowModeRequest: Codable { /// The repository path where follow mode will be configured. let repoPath: String - + /// The branch to follow when enabling. /// /// Required when `enable` is true, ignored otherwise. let branch: String? - + /// Whether to enable or disable follow mode. let enable: Bool } diff --git a/mac/VibeTunnel/Core/Services/ConfigManager.swift b/mac/VibeTunnel/Core/Services/ConfigManager.swift index 44f0b67d..1e6c321f 100644 --- a/mac/VibeTunnel/Core/Services/ConfigManager.swift +++ b/mac/VibeTunnel/Core/Services/ConfigManager.swift @@ -227,7 +227,7 @@ final class ConfigManager { // Set notification defaults to match TypeScript defaults // Master switch is OFF by default, but individual preferences are set to true - self.notificationsEnabled = false // Changed from true to match web defaults + self.notificationsEnabled = false // Changed from true to match web defaults self.notificationSessionStart = true self.notificationSessionExit = true self.notificationCommandCompletion = true diff --git a/mac/VibeTunnel/Core/Services/EventSource.swift b/mac/VibeTunnel/Core/Services/EventSource.swift index 30d06465..8d095824 100644 --- a/mac/VibeTunnel/Core/Services/EventSource.swift +++ b/mac/VibeTunnel/Core/Services/EventSource.swift @@ -15,65 +15,65 @@ struct Event { /// It handles automatic reconnection and follows the EventSource specification. final class EventSource: NSObject { // MARK: - Properties - + private let url: URL private let headers: [String: String] private nonisolated(unsafe) var urlSession: URLSession? private nonisolated(unsafe) var dataTask: URLSessionDataTask? private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "EventSource") - + // MARK: - Callbacks - + nonisolated(unsafe) var onOpen: (() -> Void)? nonisolated(unsafe) var onMessage: ((Event) -> Void)? nonisolated(unsafe) var onError: ((Error?) -> Void)? - + // MARK: - State - + private nonisolated(unsafe) var isConnected = false private nonisolated(unsafe) var buffer = "" private nonisolated(unsafe) var lastEventId: String? private nonisolated(unsafe) var reconnectTime: TimeInterval = 3.0 - + // MARK: - Initialization - + init(url: URL, headers: [String: String] = [:]) { self.url = url self.headers = headers super.init() - + // Create a custom URLSession with streaming delegate let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 0 // No timeout for SSE configuration.timeoutIntervalForResource = 0 self.urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) } - + // MARK: - Connection Management - + func connect() { guard !isConnected else { return } - + var request = URLRequest(url: url) request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") - + // Add custom headers for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } - + // Add last event ID if available - if let lastEventId = lastEventId { + if let lastEventId { request.setValue(lastEventId, forHTTPHeaderField: "Last-Event-ID") } - + logger.debug("Connecting to EventSource: \(self.url)") - + dataTask = urlSession?.dataTask(with: request) dataTask?.resume() } - + func disconnect() { isConnected = false dataTask?.cancel() @@ -81,16 +81,16 @@ final class EventSource: NSObject { buffer = "" logger.debug("Disconnected from EventSource") } - + // MARK: - Event Parsing - + private func processBuffer() { let lines = buffer.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var eventData: [String] = [] var eventType: String? var eventId: String? var eventRetry: Int? - + for (index, line) in lines.enumerated() { // Check if this is the last line and it's not empty (incomplete line) if index == lines.count - 1 && !line.isEmpty && !buffer.hasSuffix("\n") { @@ -98,7 +98,7 @@ final class EventSource: NSObject { buffer = line break } - + if line.isEmpty { // Empty line signals end of event if !eventData.isEmpty { @@ -109,23 +109,23 @@ final class EventSource: NSObject { data: data, retry: eventRetry ) - + // Update last event ID if let id = eventId { lastEventId = id } - + // Update reconnect time if let retry = eventRetry { - reconnectTime = TimeInterval(retry) / 1000.0 + reconnectTime = TimeInterval(retry) / 1_000.0 } - + // Dispatch event DispatchQueue.main.async { self.onMessage?(event) } } - + // Reset for next event eventData = [] eventType = nil @@ -137,12 +137,12 @@ final class EventSource: NSObject { } else if let colonIndex = line.firstIndex(of: ":") { let field = String(line[.. Void) { + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { guard let httpResponse = response as? HTTPURLResponse else { completionHandler(.cancel) return } - + if httpResponse.statusCode == 200 { isConnected = true DispatchQueue.main.async { @@ -194,21 +199,21 @@ extension EventSource: URLSessionDataDelegate { } } } - + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { guard let text = String(data: data, encoding: .utf8) else { return } - + buffer += text processBuffer() } - + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { isConnected = false - - if let error = error { + + if let error { logger.error("EventSource error: \(error)") } - + DispatchQueue.main.async { self.onError?(error) } @@ -218,13 +223,19 @@ extension EventSource: URLSessionDataDelegate { // MARK: - URLSessionDelegate extension EventSource: URLSessionDelegate { - func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { // Accept the server's certificate for localhost connections - if challenge.protectionSpace.host == "localhost" { - let credential = URLCredential(trust: challenge.protectionSpace.serverTrust!) + if challenge.protectionSpace.host == "localhost", + let serverTrust = challenge.protectionSpace.serverTrust + { + let credential = URLCredential(trust: serverTrust) completionHandler(.useCredential, credential) } else { completionHandler(.performDefaultHandling, nil) } } -} \ No newline at end of file +} diff --git a/mac/VibeTunnel/Core/Services/NotificationControlHandler.swift b/mac/VibeTunnel/Core/Services/NotificationControlHandler.swift index 887570d1..a8f94a84 100644 --- a/mac/VibeTunnel/Core/Services/NotificationControlHandler.swift +++ b/mac/VibeTunnel/Core/Services/NotificationControlHandler.swift @@ -69,9 +69,9 @@ final class NotificationControlHandler { logger.info("Received notification: \(title) - \(body) (type: \(typeString ?? "unknown"))") // Map type string to ServerEventType and create ServerEvent - if let typeString = typeString, - let eventType = ServerEventType(rawValue: typeString) { - + if let typeString, + let eventType = ServerEventType(rawValue: typeString) + { let serverEvent = ServerEvent( type: eventType, sessionId: sessionId, @@ -81,7 +81,7 @@ final class NotificationControlHandler { duration: duration, message: body ) - + // Use the consolidated notification method await notificationService.sendNotification(for: serverEvent) } else { diff --git a/mac/VibeTunnel/Core/Services/NotificationService.swift b/mac/VibeTunnel/Core/Services/NotificationService.swift index 6191f086..b8b1e56d 100644 --- a/mac/VibeTunnel/Core/Services/NotificationService.swift +++ b/mac/VibeTunnel/Core/Services/NotificationService.swift @@ -141,7 +141,7 @@ final class NotificationService: NSObject { func sendNotification(for event: ServerEvent) async { // Check master switch first guard configManager.notificationsEnabled else { return } - + // Check preferences based on event type switch event.type { case .sessionStart: @@ -160,9 +160,9 @@ final class NotificationService: NSObject { // Connected events don't trigger notifications return } - + let content = UNMutableNotificationContent() - + // Configure notification based on event type switch event.type { case .sessionStart: @@ -170,7 +170,7 @@ final class NotificationService: NSObject { content.body = event.displayName content.categoryIdentifier = "SESSION" content.interruptionLevel = .passive - + case .sessionExit: content.title = "Session Ended" content.body = event.displayName @@ -178,7 +178,7 @@ final class NotificationService: NSObject { if let exitCode = event.exitCode, exitCode != 0 { content.subtitle = "Exit code: \(exitCode)" } - + case .commandFinished: content.title = "Your Turn" content.body = event.command ?? event.displayName @@ -187,7 +187,7 @@ final class NotificationService: NSObject { if let duration = event.duration, duration > 0, let formattedDuration = event.formattedDuration { content.subtitle = formattedDuration } - + case .commandError: content.title = "Command Failed" content.body = event.command ?? event.displayName @@ -195,7 +195,7 @@ final class NotificationService: NSObject { if let exitCode = event.exitCode { content.subtitle = "Exit code: \(exitCode)" } - + case .bell: content.title = "Terminal Bell" content.body = event.displayName @@ -203,29 +203,29 @@ final class NotificationService: NSObject { if let message = event.message { content.subtitle = message } - + case .claudeTurn: content.title = event.type.description content.body = event.message ?? "Claude has finished responding" content.subtitle = event.displayName content.categoryIdentifier = "CLAUDE_TURN" content.interruptionLevel = .active - + case .connected: return // Already handled above } - + // Set sound based on event type content.sound = event.type == .commandError ? getNotificationSound(critical: true) : getNotificationSound() - + // Add session ID to user info if available if let sessionId = event.sessionId { content.userInfo = ["sessionId": sessionId, "type": event.type.rawValue] } - + // Generate identifier let identifier = "\(event.type.rawValue)-\(event.sessionId ?? UUID().uuidString)" - + // Deliver notification with appropriate method if event.type == .sessionStart { deliverNotificationWithAutoDismiss(content, identifier: identifier, dismissAfter: 5.0) @@ -278,7 +278,7 @@ final class NotificationService: NSObject { // Format duration if provided if duration > 0 { - let seconds = duration / 1000 + let seconds = duration / 1_000 if seconds < 60 { content.subtitle = "\(seconds)s" } else { @@ -294,7 +294,7 @@ final class NotificationService: NSObject { /// Send a generic notification func sendGenericNotification(title: String, body: String) async { guard configManager.notificationsEnabled else { return } - + let content = UNMutableNotificationContent() content.title = title content.body = body @@ -438,7 +438,7 @@ final class NotificationService: NSObject { eventSource?.onError = { [weak self] error in Task { @MainActor in - if let error = error { + if let error { self?.logger.error("❌ EventSource error: \(error)") } self?.isConnected = false @@ -526,7 +526,7 @@ final class NotificationService: NSObject { } case "connected": logger.info("🔗 Received connected event from server") - // No notification for connected events + // No notification for connected events default: logger.warning("Unknown event type: \(type)") } @@ -614,7 +614,7 @@ final class NotificationService: NSObject { // Format duration if provided if duration > 0 { - let seconds = duration / 1000 + let seconds = duration / 1_000 if seconds < 60 { content.subtitle = "\(seconds)s" } else { @@ -698,7 +698,7 @@ final class NotificationService: NSObject { let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) { [weak self] error in - if let error = error { + if let error { self?.logger.error("Failed to deliver notification: \(error)") } else { self?.logger.debug("Notification delivered: \(identifier)") @@ -740,4 +740,4 @@ final class NotificationService: NSObject { extension Notification.Name { static let serverStateChanged = Notification.Name("ServerStateChanged") -} \ No newline at end of file +} diff --git a/mac/VibeTunnel/Core/Services/ServerEvent.swift b/mac/VibeTunnel/Core/Services/ServerEvent.swift index 481f2985..496acd52 100644 --- a/mac/VibeTunnel/Core/Services/ServerEvent.swift +++ b/mac/VibeTunnel/Core/Services/ServerEvent.swift @@ -1,6 +1,3 @@ -// Server event model for notification handling -// - import Foundation /// Types of server events that can be received from the VibeTunnel server. @@ -28,25 +25,25 @@ import Foundation enum ServerEventType: String, Codable, CaseIterable { /// Indicates a new terminal session has been started. case sessionStart = "session-start" - + /// Indicates a terminal session has ended. case sessionExit = "session-exit" - + /// Indicates a command has finished executing successfully. case commandFinished = "command-finished" - + /// Indicates a command has failed with an error. case commandError = "command-error" - + /// Indicates a terminal bell character was received. case bell = "bell" - + /// Indicates Claude (AI assistant) has finished responding and it's the user's turn. case claudeTurn = "claude-turn" - + /// Indicates the SSE connection has been established. case connected = "connected" - + /// Returns a human-readable description of the event type. /// /// This property provides user-friendly labels suitable for display in @@ -54,22 +51,22 @@ enum ServerEventType: String, Codable, CaseIterable { var description: String { switch self { case .sessionStart: - return "Session Started" + "Session Started" case .sessionExit: - return "Session Ended" + "Session Ended" case .commandFinished: - return "Command Completed" + "Command Completed" case .commandError: - return "Command Error" + "Command Error" case .bell: - return "Terminal Bell" + "Terminal Bell" case .claudeTurn: - return "Your Turn" + "Your Turn" case .connected: - return "Connected" + "Connected" } } - + /// Determines whether this event type should trigger a user notification. /// /// This property helps filter which events should result in system notifications. @@ -80,9 +77,9 @@ enum ServerEventType: String, Codable, CaseIterable { var shouldNotify: Bool { switch self { case .sessionStart, .sessionExit, .claudeTurn: - return true + true case .commandFinished, .commandError, .bell, .connected: - return false + false } } } @@ -134,34 +131,34 @@ enum ServerEventType: String, Codable, CaseIterable { struct ServerEvent: Codable, Identifiable, Equatable { /// Unique identifier for the event instance. let id = UUID() - + /// The type of server event. let type: ServerEventType - + /// The terminal session identifier this event relates to. let sessionId: String? - + /// Human-readable name of the session. let sessionName: String? - + /// The command that was executed (for command-related events). let command: String? - + /// The process exit code (for exit and error events). let exitCode: Int? - + /// Duration in milliseconds (for command completion events). let duration: Int? - + /// Additional process information. let processInfo: String? - + /// Optional message providing additional context. let message: String? - + /// When the event occurred. let timestamp: Date - + /// Creates a new server event with the specified properties. /// /// - Parameters: @@ -195,9 +192,9 @@ struct ServerEvent: Codable, Identifiable, Equatable { self.message = message self.timestamp = timestamp } - + // MARK: - Convenience Initializers - + /// Creates a session start event. /// /// Use this convenience method when a new terminal session is created. @@ -207,15 +204,15 @@ struct ServerEvent: Codable, Identifiable, Equatable { /// - sessionName: Optional human-readable name for the session. /// - command: Optional command that started the session. /// - Returns: A configured `ServerEvent` of type ``ServerEventType/sessionStart``. - static func sessionStart(sessionId: String, sessionName: String? = nil, command: String? = nil) -> ServerEvent { - ServerEvent( + static func sessionStart(sessionId: String, sessionName: String? = nil, command: String? = nil) -> Self { + Self( type: .sessionStart, sessionId: sessionId, sessionName: sessionName, command: command ) } - + /// Creates a session exit event. /// /// Use this convenience method when a terminal session ends. @@ -225,15 +222,15 @@ struct ServerEvent: Codable, Identifiable, Equatable { /// - sessionName: Optional human-readable name for the session. /// - exitCode: Optional process exit code. /// - Returns: A configured `ServerEvent` of type ``ServerEventType/sessionExit``. - static func sessionExit(sessionId: String, sessionName: String? = nil, exitCode: Int? = nil) -> ServerEvent { - ServerEvent( + static func sessionExit(sessionId: String, sessionName: String? = nil, exitCode: Int? = nil) -> Self { + Self( type: .sessionExit, sessionId: sessionId, sessionName: sessionName, exitCode: exitCode ) } - + /// Creates a command finished event. /// /// Use this convenience method when a command completes execution. @@ -244,8 +241,15 @@ struct ServerEvent: Codable, Identifiable, Equatable { /// - duration: Execution time in milliseconds. /// - exitCode: Optional process exit code. /// - Returns: A configured `ServerEvent` of type ``ServerEventType/commandFinished``. - static func commandFinished(sessionId: String, command: String, duration: Int, exitCode: Int? = nil) -> ServerEvent { - ServerEvent( + static func commandFinished( + sessionId: String, + command: String, + duration: Int, + exitCode: Int? = nil + ) + -> Self + { + Self( type: .commandFinished, sessionId: sessionId, command: command, @@ -253,7 +257,7 @@ struct ServerEvent: Codable, Identifiable, Equatable { duration: duration ) } - + /// Creates a command error event. /// /// Use this convenience method when a command fails with a non-zero exit code. @@ -264,8 +268,8 @@ struct ServerEvent: Codable, Identifiable, Equatable { /// - exitCode: The process exit code. /// - duration: Optional execution time in milliseconds. /// - Returns: A configured `ServerEvent` of type ``ServerEventType/commandError``. - static func commandError(sessionId: String, command: String, exitCode: Int, duration: Int? = nil) -> ServerEvent { - ServerEvent( + static func commandError(sessionId: String, command: String, exitCode: Int, duration: Int? = nil) -> Self { + Self( type: .commandError, sessionId: sessionId, command: command, @@ -273,7 +277,7 @@ struct ServerEvent: Codable, Identifiable, Equatable { duration: duration ) } - + /// Creates a Claude turn event. /// /// Use this convenience method when Claude (AI assistant) finishes responding @@ -283,31 +287,31 @@ struct ServerEvent: Codable, Identifiable, Equatable { /// - sessionId: The unique identifier for the session. /// - sessionName: Optional human-readable name for the session. /// - Returns: A configured `ServerEvent` of type ``ServerEventType/claudeTurn``. - static func claudeTurn(sessionId: String, sessionName: String? = nil) -> ServerEvent { - ServerEvent( + static func claudeTurn(sessionId: String, sessionName: String? = nil) -> Self { + Self( type: .claudeTurn, sessionId: sessionId, sessionName: sessionName, message: "Claude has finished responding" ) } - + /// Creates a bell event. /// /// Use this convenience method when a terminal bell character is received. /// /// - Parameter sessionId: The unique identifier for the session. /// - Returns: A configured `ServerEvent` of type ``ServerEventType/bell``. - static func bell(sessionId: String) -> ServerEvent { - ServerEvent( + static func bell(sessionId: String) -> Self { + Self( type: .bell, sessionId: sessionId, message: "Terminal bell" ) } - + // MARK: - Computed Properties - + /// Returns a user-friendly display name for the event. /// /// The display name is determined by the following priority: @@ -318,14 +322,14 @@ struct ServerEvent: Codable, Identifiable, Equatable { var displayName: String { sessionName ?? command ?? sessionId ?? "Unknown Session" } - + /// Determines whether this event should trigger a user notification. /// /// This delegates to the event type's ``ServerEventType/shouldNotify`` property. var shouldNotify: Bool { type.shouldNotify } - + /// Returns a human-readable formatted duration string. /// /// The duration is formatted based on its length: @@ -336,24 +340,24 @@ struct ServerEvent: Codable, Identifiable, Equatable { /// /// - Returns: A formatted duration string, or `nil` if no duration is set. var formattedDuration: String? { - guard let duration = duration else { return nil } - - if duration < 1000 { + guard let duration else { return nil } + + if duration < 1_000 { return "\(duration)ms" - } else if duration < 60000 { - return String(format: "%.1fs", Double(duration) / 1000.0) - } else if duration < 3600000 { - let minutes = duration / 60000 - let seconds = (duration % 60000) / 1000 + } else if duration < 60_000 { + return String(format: "%.1fs", Double(duration) / 1_000.0) + } else if duration < 3_600_000 { + let minutes = duration / 60_000 + let seconds = (duration % 60_000) / 1_000 return "\(minutes)m \(seconds)s" } else { - let hours = duration / 3600000 - let minutes = (duration % 3600000) / 60000 - let seconds = (duration % 60000) / 1000 + let hours = duration / 3_600_000 + let minutes = (duration % 3_600_000) / 60_000 + let seconds = (duration % 60_000) / 1_000 return "\(hours)h \(minutes)m \(seconds)s" } } - + /// Returns a formatted timestamp string. /// /// The timestamp is formatted using medium time style, which typically @@ -363,9 +367,9 @@ struct ServerEvent: Codable, Identifiable, Equatable { formatter.timeStyle = .medium return formatter.string(from: timestamp) } - + // MARK: - Codable - + /// Coding keys to exclude `id` from encoding/decoding since it's auto-generated enum CodingKeys: String, CodingKey { case type @@ -378,4 +382,4 @@ struct ServerEvent: Codable, Identifiable, Equatable { case message case timestamp } -} +} diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift index f35ef7c4..823f932b 100644 --- a/mac/VibeTunnel/Core/Services/ServerManager.swift +++ b/mac/VibeTunnel/Core/Services/ServerManager.swift @@ -59,7 +59,7 @@ class ServerManager { } set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.serverPort) } } - + /// The local authentication token for the current server instance var localAuthToken: String? { bunServer?.localToken diff --git a/mac/VibeTunnel/Core/Services/SessionMonitor.swift b/mac/VibeTunnel/Core/Services/SessionMonitor.swift index b9d5f717..c4a45023 100644 --- a/mac/VibeTunnel/Core/Services/SessionMonitor.swift +++ b/mac/VibeTunnel/Core/Services/SessionMonitor.swift @@ -212,7 +212,12 @@ final class SessionMonitor { } /// Pre-cache Git repositories for sessions, deduplicating by repository root - private func preCacheGitRepositories(for sessions: [ServerSessionInfo], using gitMonitor: GitRepositoryMonitor) async { + private func preCacheGitRepositories( + for sessions: [ServerSessionInfo], + using gitMonitor: GitRepositoryMonitor + ) + async + { // Track unique directories we need to check var uniqueDirectoriesToCheck = Set() @@ -316,7 +321,7 @@ final class SessionMonitor { if previousActive && !currentActive && !alreadyNotified { logger.info("🔔 Detected Claude transition to idle for session: \(id)") let sessionName = newSession.name - + // Create a claude-turn event for the notification let claudeTurnEvent = ServerEvent.claudeTurn( sessionId: id, diff --git a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift index 9eba2f19..403cc345 100644 --- a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift +++ b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift @@ -214,12 +214,12 @@ struct NewSessionForm: View { .replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: " ", with: "-") .lowercased() - + // Create worktree path in a 'worktrees' subdirectory let repoURL = URL(fileURLWithPath: repoPath) let worktreesDir = repoURL.appendingPathComponent("worktrees") let worktreePath = worktreesDir.appendingPathComponent(slugifiedBranch).path - + // Create the worktree try await service.createWorktree( gitRepoPath: repoPath, diff --git a/mac/VibeTunnelTests/NotificationServiceClaudeTurnTests.swift b/mac/VibeTunnelTests/NotificationServiceClaudeTurnTests.swift index c4048f85..ad1e1267 100644 --- a/mac/VibeTunnelTests/NotificationServiceClaudeTurnTests.swift +++ b/mac/VibeTunnelTests/NotificationServiceClaudeTurnTests.swift @@ -57,7 +57,7 @@ struct NotificationServiceClaudeTurnTests { func claudeTurnInPreferences() async throws { // Given let configManager = ConfigManager.shared - + // When - set the preference through ConfigManager configManager.notificationClaudeTurn = true diff --git a/mac/VibeTunnelTests/NotificationServiceTests.swift b/mac/VibeTunnelTests/NotificationServiceTests.swift index e0d56dbc..282f7be0 100644 --- a/mac/VibeTunnelTests/NotificationServiceTests.swift +++ b/mac/VibeTunnelTests/NotificationServiceTests.swift @@ -21,24 +21,24 @@ struct NotificationServiceTests { #expect(preferences.soundEnabled == configManager.notificationSoundEnabled) #expect(preferences.vibrationEnabled == configManager.notificationVibrationEnabled) } - + @Test("Default notification values match expected defaults") @MainActor func verifyDefaultValues() { // This test documents what the default values SHOULD be // In production, these would be set when no config file exists - + // Expected defaults based on TypeScript config: // - Master switch (notificationsEnabled) should be false // - Individual preferences should be true (except claudeTurn) // - Sound and vibration should be enabled - + // Note: In actual tests, ConfigManager loads from ~/.vibetunnel/config.json // To test true defaults, we would need to: // 1. Mock ConfigManager // 2. Clear the config file // 3. Force ConfigManager to use defaults - + // For now, we document the expected behavior let expectedMasterSwitch = false let expectedSessionStart = true @@ -49,7 +49,7 @@ struct NotificationServiceTests { let expectedClaudeTurn = false let expectedSound = true let expectedVibration = true - + // These are the values that SHOULD be used when no config exists #expect(expectedMasterSwitch == false, "Master switch should be OFF by default") #expect(expectedSessionStart == true, "Session start should be enabled by default") @@ -89,7 +89,7 @@ struct NotificationServiceTests { // Enable notifications master switch configManager.updateNotificationPreferences(enabled: true) - + // Enable session start notifications var preferences = NotificationService.NotificationPreferences(fromConfig: configManager) preferences.sessionStart = true @@ -113,7 +113,7 @@ struct NotificationServiceTests { // Enable notifications master switch configManager.updateNotificationPreferences(enabled: true) - + // Enable session exit notifications var preferences = NotificationService.NotificationPreferences(fromConfig: configManager) preferences.sessionExit = true @@ -137,7 +137,7 @@ struct NotificationServiceTests { // Enable notifications master switch configManager.updateNotificationPreferences(enabled: true) - + // Enable command completion notifications var preferences = NotificationService.NotificationPreferences(fromConfig: configManager) preferences.commandCompletion = true @@ -167,7 +167,7 @@ struct NotificationServiceTests { // Enable notifications master switch configManager.updateNotificationPreferences(enabled: true) - + // Enable command error notifications var preferences = NotificationService.NotificationPreferences(fromConfig: configManager) preferences.commandError = true @@ -193,7 +193,7 @@ struct NotificationServiceTests { // Enable notifications master switch configManager.updateNotificationPreferences(enabled: true) - + // Enable bell notifications var preferences = NotificationService.NotificationPreferences(fromConfig: configManager) preferences.bell = true @@ -215,7 +215,7 @@ struct NotificationServiceTests { // Test 1: Master switch disabled (default) configManager.updateNotificationPreferences(enabled: false) - + // Even with individual preferences enabled, nothing should fire var preferences = NotificationService.NotificationPreferences(fromConfig: configManager) preferences.sessionStart = true @@ -236,21 +236,21 @@ struct NotificationServiceTests { // Master switch should block all notifications #expect(configManager.notificationsEnabled == false) - + // Test 2: Master switch enabled but individual preferences disabled configManager.updateNotificationPreferences(enabled: true) - + preferences.sessionStart = false preferences.sessionExit = false preferences.commandCompletion = false preferences.commandError = false preferences.bell = false service.updatePreferences(preferences) - + // Try to send notifications again await service.sendSessionStartNotification(sessionName: "Test") await service.sendSessionExitNotification(sessionName: "Test", exitCode: 0) - + // Individual preferences should block notifications #expect(preferences.sessionStart == false) #expect(preferences.sessionExit == false) @@ -266,7 +266,7 @@ struct NotificationServiceTests { // Enable notifications configManager.updateNotificationPreferences(enabled: true) - + var preferences = NotificationService.NotificationPreferences(fromConfig: configManager) preferences.sessionExit = true service.updatePreferences(preferences) diff --git a/mac/VibeTunnelTests/PathSplittingTests.swift b/mac/VibeTunnelTests/PathSplittingTests.swift index 0ffb5cdf..4527de11 100644 --- a/mac/VibeTunnelTests/PathSplittingTests.swift +++ b/mac/VibeTunnelTests/PathSplittingTests.swift @@ -85,8 +85,11 @@ struct PathSplittingTests { // List contents of parent directory let fileManager = FileManager.default - let contents = try #require(try? fileManager.contentsOfDirectory(at: parentURL, includingPropertiesForKeys: nil)) - + let contents = try #require(try? fileManager.contentsOfDirectory( + at: parentURL, + includingPropertiesForKeys: nil + )) + let matching = contents.filter { $0.lastPathComponent.hasPrefix(prefix) } // We can't assert specific matches as they depend on the user's home directory // But we can verify the filtering logic works diff --git a/mac/VibeTunnelTests/ServerEventTests.swift b/mac/VibeTunnelTests/ServerEventTests.swift index b5eeeacb..1b4084e0 100644 --- a/mac/VibeTunnelTests/ServerEventTests.swift +++ b/mac/VibeTunnelTests/ServerEventTests.swift @@ -1,13 +1,13 @@ -import Testing import Foundation +import Testing @testable import VibeTunnel @Suite("ServerEvent") struct ServerEventTests { - // MARK: - Codable Tests + // These are valuable - testing JSON encoding/decoding with optional fields - + @Test("Codable round-trip with multiple optional fields") func codableRoundTrip() throws { let originalEvent = ServerEvent( @@ -20,17 +20,17 @@ struct ServerEventTests { processInfo: nil, message: "Session started successfully" ) - + let data = try JSONEncoder().encode(originalEvent) let decodedEvent = try JSONDecoder().decode(ServerEvent.self, from: data) - + #expect(originalEvent.type == decodedEvent.type) #expect(originalEvent.sessionId == decodedEvent.sessionId) #expect(originalEvent.sessionName == decodedEvent.sessionName) #expect(originalEvent.command == decodedEvent.command) #expect(originalEvent.message == decodedEvent.message) } - + @Test("Codable with all fields populated") func codableWithAllFields() throws { let event = ServerEvent( @@ -39,31 +39,31 @@ struct ServerEventTests { sessionName: "Long Running Command", command: "npm install", exitCode: 0, - duration: 15000, + duration: 15_000, processInfo: "Node.js process", message: "Command completed successfully" ) - + let data = try JSONEncoder().encode(event) let decoded = try JSONDecoder().decode(ServerEvent.self, from: data) - + #expect(decoded.type == .commandFinished) #expect(decoded.sessionId == "session-456") #expect(decoded.sessionName == "Long Running Command") #expect(decoded.command == "npm install") #expect(decoded.exitCode == 0) - #expect(decoded.duration == 15000) + #expect(decoded.duration == 15_000) #expect(decoded.processInfo == "Node.js process") #expect(decoded.message == "Command completed successfully") } - + @Test("Codable with minimal fields preserves nils") func codableWithMinimalFields() throws { let event = ServerEvent(type: .bell) - + let data = try JSONEncoder().encode(event) let decoded = try JSONDecoder().decode(ServerEvent.self, from: data) - + #expect(decoded.type == .bell) #expect(decoded.sessionId == nil) #expect(decoded.sessionName == nil) @@ -74,10 +74,11 @@ struct ServerEventTests { #expect(decoded.message == nil) #expect(decoded.timestamp != nil) } - + // MARK: - Event Type Logic Tests + // Testing actual business logic, not Swift's enum implementation - + @Test("Event type descriptions are user-friendly") func eventTypeDescriptions() { #expect(ServerEventType.sessionStart.description == "Session Started") @@ -88,24 +89,25 @@ struct ServerEventTests { #expect(ServerEventType.claudeTurn.description == "Your Turn") #expect(ServerEventType.connected.description == "Connected") } - + @Test("shouldNotify returns correct values for notification logic") func eventTypeShouldNotify() { // These events should trigger notifications #expect(ServerEventType.sessionStart.shouldNotify) #expect(ServerEventType.sessionExit.shouldNotify) #expect(ServerEventType.claudeTurn.shouldNotify) - + // These events should not trigger notifications #expect(!ServerEventType.commandFinished.shouldNotify) #expect(!ServerEventType.commandError.shouldNotify) #expect(!ServerEventType.bell.shouldNotify) #expect(!ServerEventType.connected.shouldNotify) } - + // MARK: - Edge Cases + // These test important edge cases for data integrity - + @Test("Handles empty strings correctly") func handlesEmptyStrings() throws { let event = ServerEvent( @@ -115,17 +117,17 @@ struct ServerEventTests { command: "", message: "" ) - + let data = try JSONEncoder().encode(event) let decoded = try JSONDecoder().decode(ServerEvent.self, from: data) - + // Empty strings should be preserved, not converted to nil #expect(decoded.sessionId == "") #expect(decoded.sessionName == "") #expect(decoded.command == "") #expect(decoded.message == "") } - + @Test("Handles special characters in JSON encoding") func handlesSpecialCharacters() throws { let event = ServerEvent( @@ -136,18 +138,19 @@ struct ServerEventTests { exitCode: -1, message: "Error: Command failed with special chars: <>&\"'" ) - + let data = try JSONEncoder().encode(event) let decoded = try JSONDecoder().decode(ServerEvent.self, from: data) - + #expect(decoded.sessionName == "Test Session with \"quotes\" and 'apostrophes'") #expect(decoded.command == "echo 'Hello, World!' && echo \"Test\"") #expect(decoded.message == "Error: Command failed with special chars: <>&\"'") } - + // MARK: - Convenience Initializers + // These test that convenience initializers create properly configured events - + @Test("sessionStart convenience initializer sets correct fields") func sessionStartInitializer() { let event = ServerEvent.sessionStart( @@ -155,14 +158,14 @@ struct ServerEventTests { sessionName: "Test Session", command: "ls -la" ) - + #expect(event.type == .sessionStart) #expect(event.sessionId == "test-123") #expect(event.sessionName == "Test Session") #expect(event.command == "ls -la") #expect(event.shouldNotify) } - + @Test("sessionExit convenience initializer sets correct fields") func sessionExitInitializer() { let event = ServerEvent.sessionExit( @@ -170,103 +173,104 @@ struct ServerEventTests { sessionName: "Test Session", exitCode: 0 ) - + #expect(event.type == .sessionExit) #expect(event.sessionId == "test-456") #expect(event.sessionName == "Test Session") #expect(event.exitCode == 0) #expect(event.shouldNotify) } - + @Test("commandFinished convenience initializer sets correct fields") func commandFinishedInitializer() { let event = ServerEvent.commandFinished( sessionId: "test-789", command: "npm install", - duration: 15000, + duration: 15_000, exitCode: 0 ) - + #expect(event.type == .commandFinished) #expect(event.sessionId == "test-789") #expect(event.command == "npm install") - #expect(event.duration == 15000) + #expect(event.duration == 15_000) #expect(event.exitCode == 0) #expect(!event.shouldNotify) } - + @Test("claudeTurn convenience initializer includes default message") func claudeTurnInitializer() { let event = ServerEvent.claudeTurn( sessionId: "claude-session", sessionName: "Claude Chat" ) - + #expect(event.type == .claudeTurn) #expect(event.sessionId == "claude-session") #expect(event.sessionName == "Claude Chat") #expect(event.message == "Claude has finished responding") #expect(event.shouldNotify) } - + @Test("bell convenience initializer includes default message") func bellInitializer() { let event = ServerEvent.bell(sessionId: "bell-session") - + #expect(event.type == .bell) #expect(event.sessionId == "bell-session") #expect(event.message == "Terminal bell") #expect(!event.shouldNotify) } - + // MARK: - Computed Properties with Logic + // These test actual business logic in computed properties - + @Test("displayName fallback logic works correctly") func displayNameLogic() { // Priority 1: Session name let event1 = ServerEvent(type: .sessionStart, sessionName: "My Session") #expect(event1.displayName == "My Session") - + // Priority 2: Command (when no session name) let event2 = ServerEvent(type: .sessionStart, command: "ls -la") #expect(event2.displayName == "ls -la") - + // Priority 3: Session ID (when no name or command) let event3 = ServerEvent(type: .sessionStart, sessionId: "session-123") #expect(event3.displayName == "session-123") - + // Fallback: Unknown Session let event4 = ServerEvent(type: .sessionStart) #expect(event4.displayName == "Unknown Session") } - + @Test("formattedDuration handles different time ranges", arguments: [ (500, "500ms"), - (2500, "2.5s"), - (125000, "2m 5s"), - (3661000, "1h 1m 1s") + (2_500, "2.5s"), + (125_000, "2m 5s"), + (3_661_000, "1h 1m 1s") ]) func formattedDurationLogic(duration: Int, expected: String) { let event = ServerEvent(type: .commandFinished, duration: duration) #expect(event.formattedDuration == expected) } - + @Test("formattedDuration returns nil when duration is nil") func formattedDurationNil() { let event = ServerEvent(type: .sessionStart) #expect(event.formattedDuration == nil) } - + @Test("formattedTimestamp uses correct format") func formattedTimestampFormat() { let timestamp = Date() let event = ServerEvent(type: .sessionStart, timestamp: timestamp) - + let formatter = DateFormatter() formatter.timeStyle = .medium let expected = formatter.string(from: timestamp) - + #expect(event.formattedTimestamp == expected) } -} \ No newline at end of file +} diff --git a/mac/VibeTunnelTests/TunnelSessionTests.swift b/mac/VibeTunnelTests/TunnelSessionTests.swift index 387a5349..1fe3085c 100644 --- a/mac/VibeTunnelTests/TunnelSessionTests.swift +++ b/mac/VibeTunnelTests/TunnelSessionTests.swift @@ -1,34 +1,34 @@ -import Testing import Foundation +import Testing @testable import VibeTunnel @Suite("TunnelSession & Related Types") struct TunnelSessionTests { - // MARK: - TunnelSession Logic Tests + // Only testing actual logic, not property synthesis - + @Test("updateActivity updates lastActivity timestamp") func updateActivityLogic() async throws { var session = TunnelSession() let originalActivity = session.lastActivity - + // Use async sleep instead of Thread.sleep try await Task.sleep(for: .milliseconds(100)) - + session.updateActivity() - + #expect(session.lastActivity > originalActivity) } - + @Test("TunnelSession is Codable with all fields") func tunnelSessionCodable() throws { - var originalSession = TunnelSession(processID: 67890) + var originalSession = TunnelSession(processID: 67_890) originalSession.updateActivity() - + let data = try JSONEncoder().encode(originalSession) let decodedSession = try JSONDecoder().decode(TunnelSession.self, from: data) - + #expect(originalSession.id == decodedSession.id) #expect(originalSession.processID == decodedSession.processID) #expect(originalSession.isActive == decodedSession.isActive) @@ -36,10 +36,11 @@ struct TunnelSessionTests { #expect(abs(originalSession.createdAt.timeIntervalSince(decodedSession.createdAt)) < 0.001) #expect(abs(originalSession.lastActivity.timeIntervalSince(decodedSession.lastActivity)) < 0.001) } - + // MARK: - CreateSessionRequest Tests + // Testing optional field handling in Codable - + @Test("CreateSessionRequest encodes/decodes with all optional fields") func createSessionRequestFullCodable() throws { let originalRequest = CreateSessionRequest( @@ -47,15 +48,15 @@ struct TunnelSessionTests { environment: ["TEST": "value", "PATH": "/usr/bin"], shell: "/bin/bash" ) - + let data = try JSONEncoder().encode(originalRequest) let decodedRequest = try JSONDecoder().decode(CreateSessionRequest.self, from: data) - + #expect(originalRequest.workingDirectory == decodedRequest.workingDirectory) #expect(originalRequest.environment == decodedRequest.environment) #expect(originalRequest.shell == decodedRequest.shell) } - + @Test("CreateSessionRequest handles empty and nil values correctly") func createSessionRequestEdgeCases() throws { // Test with empty environment (not nil) @@ -63,7 +64,7 @@ struct TunnelSessionTests { let data1 = try JSONEncoder().encode(requestWithEmpty) let decoded1 = try JSONDecoder().decode(CreateSessionRequest.self, from: data1) #expect(decoded1.environment == [:]) - + // Test with all nils let requestWithNils = CreateSessionRequest() let data2 = try JSONEncoder().encode(requestWithNils) @@ -72,7 +73,7 @@ struct TunnelSessionTests { #expect(decoded2.environment == nil) #expect(decoded2.shell == nil) } - + @Test("CreateSessionRequest handles special characters in paths and environment") func createSessionRequestSpecialCharacters() throws { let request = CreateSessionRequest( @@ -80,31 +81,32 @@ struct TunnelSessionTests { environment: ["PATH": "/usr/bin:/usr/local/bin", "HOME": "/home/user with spaces"], shell: "/bin/bash -l" ) - + let data = try JSONEncoder().encode(request) let decoded = try JSONDecoder().decode(CreateSessionRequest.self, from: data) - + #expect(decoded.workingDirectory == "/path/with spaces/and\"quotes\"") #expect(decoded.environment?["PATH"] == "/usr/bin:/usr/local/bin") #expect(decoded.environment?["HOME"] == "/home/user with spaces") #expect(decoded.shell == "/bin/bash -l") } - + // MARK: - CreateSessionResponse Tests + // Simple type but worth testing Codable with Date precision - + @Test("CreateSessionResponse handles date encoding correctly") func createSessionResponseDateHandling() throws { let originalResponse = CreateSessionResponse( sessionId: "response-test-456", createdAt: Date() ) - + let data = try JSONEncoder().encode(originalResponse) let decodedResponse = try JSONDecoder().decode(CreateSessionResponse.self, from: data) - + #expect(originalResponse.sessionId == decodedResponse.sessionId) // Date encoding/decoding can lose some precision #expect(abs(originalResponse.createdAt.timeIntervalSince(decodedResponse.createdAt)) < 0.001) } -} \ No newline at end of file +} diff --git a/mac/VibeTunnelTests/Views/Settings/GeneralSettingsViewTests.swift b/mac/VibeTunnelTests/Views/Settings/GeneralSettingsViewTests.swift index b8984323..aaa185ce 100644 --- a/mac/VibeTunnelTests/Views/Settings/GeneralSettingsViewTests.swift +++ b/mac/VibeTunnelTests/Views/Settings/GeneralSettingsViewTests.swift @@ -31,7 +31,7 @@ struct GeneralSettingsViewTests { #expect(prefs.commandError == true) #expect(prefs.bell == true) #expect(prefs.claudeTurn == false) - + // Verify ConfigManager properties directly #expect(configManager.notificationSessionStart == true) #expect(configManager.notificationSessionExit == true) @@ -44,19 +44,19 @@ struct GeneralSettingsViewTests { @Test("Notification checkbox toggle updates preferences") func notificationCheckboxToggle() { let configManager = ConfigManager.shared - + // Set initial value through ConfigManager configManager.notificationSessionStart = false - + // Verify initial state #expect(configManager.notificationSessionStart == false) - + // Simulate toggle configManager.notificationSessionStart = true - + // Verify the value was updated #expect(configManager.notificationSessionStart == true) - + // Test that NotificationService reads the updated preferences let prefs = NotificationService.NotificationPreferences(fromConfig: configManager) #expect(prefs.sessionStart == true) @@ -66,7 +66,7 @@ struct GeneralSettingsViewTests { func notificationPreferencesSave() { // Test that ConfigManager properties work correctly let configManager = ConfigManager.shared - + // Update values through ConfigManager configManager.notificationSessionStart = false configManager.notificationSessionExit = false @@ -80,7 +80,7 @@ struct GeneralSettingsViewTests { #expect(configManager.notificationCommandCompletion == true) #expect(configManager.notificationCommandError == true) #expect(configManager.notificationBell == false) - + // Verify that NotificationPreferences reads the updated values let prefs = NotificationService.NotificationPreferences(fromConfig: configManager) #expect(prefs.sessionStart == false)