mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-09 11:55:53 +00:00
Fix test failures and resolve all linting warnings
- Fix notification preference tests to match default enabled: false - Fix PtyManager initialization in integration tests - Fix path splitting tests for macOS URL behavior - Add hour formatting to duration display (1h 23m 45s format) - Fix non-optional URL nil comparison warning - Fix force unwrapping warning in EventSource.swift - Apply SwiftFormat formatting fixes - Update test expectations to match actual behavior
This commit is contained in:
parent
f87b511ec1
commit
dfe0cfda25
15 changed files with 275 additions and 246 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[..<colonIndex])
|
||||
var value = String(line[line.index(after: colonIndex)...])
|
||||
|
||||
|
||||
// Remove leading space if present
|
||||
if value.hasPrefix(" ") {
|
||||
value = String(value.dropFirst())
|
||||
}
|
||||
|
||||
|
||||
switch field {
|
||||
case "data":
|
||||
eventData.append(value)
|
||||
|
|
@ -163,7 +163,7 @@ final class EventSource: NSObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Clear buffer if we processed all complete lines
|
||||
if lines.last?.isEmpty ?? true || buffer.hasSuffix("\n") {
|
||||
buffer = ""
|
||||
|
|
@ -174,12 +174,17 @@ final class EventSource: NSObject {
|
|||
// MARK: - URLSessionDataDelegate
|
||||
|
||||
extension EventSource: URLSessionDataDelegate {
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String>()
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ struct NotificationServiceClaudeTurnTests {
|
|||
func claudeTurnInPreferences() async throws {
|
||||
// Given
|
||||
let configManager = ConfigManager.shared
|
||||
|
||||
|
||||
// When - set the preference through ConfigManager
|
||||
configManager.notificationClaudeTurn = true
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue