From 8a83d7c2c92e9d44c4d31ef8639dbb7d306169cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Jun 2025 01:41:08 +0200 Subject: [PATCH] Refactor tunnel client architecture and improve server communication --- Package.swift | 1 + .../UserInterfaceState.xcuserstate | Bin 34966 -> 36882 bytes .../menubar.imageset/menubar.png | Bin 572 -> 776 bytes VibeTunnel/Core/Models/TunnelSession.swift | 115 ++++++++++ .../Core/Services/HTTPClientProtocol.swift | 19 +- .../Core/Services/SparkleUpdaterManager.swift | 9 +- VibeTunnel/Core/Services/TunnelClient.swift | 2 +- VibeTunnel/Core/Services/TunnelClient2.swift | 212 ++++++++++++++++++ .../Core/Services/TunnelServerDemo.swift | 152 ++----------- .../Core/Services/TunnelServerExample.swift | 139 ++++++++++++ VibeTunnel/SettingsView.swift | 6 +- VibeTunnel/VibeTunnelApp.swift | 2 +- ...ntTests.swift => TunnelClient2Tests.swift} | 16 +- 13 files changed, 519 insertions(+), 154 deletions(-) create mode 100644 VibeTunnel/Core/Services/TunnelClient2.swift create mode 100644 VibeTunnel/Core/Services/TunnelServerExample.swift rename VibeTunnelTests/{TunnelClientTests.swift => TunnelClient2Tests.swift} (95%) diff --git a/Package.swift b/Package.swift index 8a3065d3..3b3b3223 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( sources: [ "Core/Models/TunnelSession.swift", "Core/Models/UpdateChannel.swift", + "Core/Services/TunnelClient.swift", "Core/Services/TunnelClient2.swift", "Core/Services/TerminalManager.swift", "Core/Services/HTTPClientProtocol.swift" diff --git a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate index a4ce9d2059401f751f58d86a648684f6beb3be10..185490931f72bb4562fe8b52e85c966d1790cb7f 100644 GIT binary patch delta 17136 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 GO8$RyO>Jxd diff --git a/VibeTunnel/Assets.xcassets/menubar.imageset/menubar.png b/VibeTunnel/Assets.xcassets/menubar.imageset/menubar.png index 0a9e136a07304d58f342ea3ac516f7fde07017ed..af27b2ebcc9a6484fccb2d66957e6384cf06a30e 100644 GIT binary patch delta 753 zcmVPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031 z000^Q000001E2u_0{{R30RRC20H6W@1ONa40RR915TFA91ONa40RR915C8xG05nx` z@&Et=a!Eu%R5%f1U>J1Z|NsAt4Gj%Eg@uJeKsGaokI#{CNq>++LqkKC7cXAqzkmN; z6R3%ig@xsdu(0q&Ha51^w{PFx&dA948{IIl0U*sXGBO1pKYpwODqbliCAIVO=g%J{ zBqS8BUcG7tl=lJ}c3(wBrSQy|Gv|Q@{YNp#*4EZqP*CuLy}f-P$S`$v^%$U{^P-}n z2eq`c(u0G8ReynoPGe_h|LExG=nYZ>Gk_VyU}a^kVPged~21d6jkk1Ns z0Wjbg#l*xw{{Q#=`*#kY$sqGK0NIWuB_&_Dxw#+DnLl3#7}ZCC;<~_qy#v%DIDPtb z9z5ylw0~63wpeuNQhH5baUHI|qr%$3l*S%t5V&ePv@81WY zy1zh!SilAV1E?IRxe6GSVZelR7UVZQJ-yw)n8*b(zXJ^@2fA>>uV25mi;Ig_czAf+ j1)Pq@+Y@^XAQLBpI-D>Cy-` zHnyJ(Kw%~(rvD(Ci;MefTwL6w?c2BW;WZ#5BV!^+gR-*nft;M2=#Y?*u+q}fpy=r6 zMpjnVA0WBdn3x)@2F#f=Cl;ijudgov#NWSvza!8kPvzv~E`OJnm8OHlT3TAdL26uG zT{dADz|YVBI4UX%E^+ztWzEpg(2B&wq%wX1flnZfSFT*)|M2010EnHEk}?m)0FWTi zfMl59h7B9+fnvWkH8nRtY;UdvskwjuzWk+2m-IpGO`A6Ppcufx!2uSCjg4#8H_)H4 zX3c6lEiJ7pAb&ZKcw1Xr6^tDe6tqE7Qt~0P0l$HPW(^FyR*)hP1}a(sk_!k3*eWhA z{tU#HmzRH-mY&{q@7_HLkeXGiR++*K_!u4@94H#%ol9N-sKw{n9-GMMaS5;M&0A2J5#0Glj3(#ej znVFg0?Ck6}!3L 1 { + urlComponents.port = Int(String(parts[1])) + } + } + + urlComponents.path = customHTTPRequest.path ?? "/" + + guard let url = urlComponents.url else { + fatalError("HTTPRequest must have valid URL components") } self.init(url: url) diff --git a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift index b5c4951b..1ac0c34b 100644 --- a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift +++ b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift @@ -1,4 +1,5 @@ -import os +import Foundation +import os.log import UserNotifications /// Stub implementation of SparkleUpdaterManager @@ -8,18 +9,18 @@ public final class SparkleUpdaterManager: NSObject { public static let shared = SparkleUpdaterManager() - private let logger = Logger( + private let logger = os.Logger( subsystem: "VibeTunnel", category: "SparkleUpdater" ) - private override init() { + public override init() { super.init() logger.info("SparkleUpdaterManager initialized (stub implementation)") } public func setUpdateChannel(_ channel: UpdateChannel) { - logger.info("Update channel set to: \(channel) (stub)") + logger.info("Update channel set to: \(channel.rawValue) (stub)") } public func checkForUpdatesInBackground() { diff --git a/VibeTunnel/Core/Services/TunnelClient.swift b/VibeTunnel/Core/Services/TunnelClient.swift index 2091261a..1b356911 100644 --- a/VibeTunnel/Core/Services/TunnelClient.swift +++ b/VibeTunnel/Core/Services/TunnelClient.swift @@ -197,7 +197,7 @@ public class TunnelClient { } /// WebSocket client for real-time terminal communication -public class TunnelWebSocketClient: NSObject { +public final class TunnelWebSocketClient: NSObject, @unchecked Sendable { private let url: URL private let apiKey: String private var sessionId: String? diff --git a/VibeTunnel/Core/Services/TunnelClient2.swift b/VibeTunnel/Core/Services/TunnelClient2.swift new file mode 100644 index 00000000..5dfcb05b --- /dev/null +++ b/VibeTunnel/Core/Services/TunnelClient2.swift @@ -0,0 +1,212 @@ +// This file is required for testing with dependency injection. +// DO NOT REMOVE - tests depend on TunnelClient2 and TunnelClient2Error + +import Foundation +import HTTPTypes +import HTTPTypesFoundation +import Logging + +/// HTTP client-based tunnel client for better testability +public final class TunnelClient2 { + // MARK: - Properties + + private let baseURL: URL + private let apiKey: String + private let httpClient: HTTPClientProtocol + private let decoder: JSONDecoder + private let encoder: JSONEncoder + private let logger = Logger(label: "VibeTunnel.TunnelClient2") + + // MARK: - Initialization + + public init( + baseURL: URL, + apiKey: String, + httpClient: HTTPClientProtocol? = nil + ) { + self.baseURL = baseURL + self.apiKey = apiKey + self.httpClient = httpClient ?? HTTPClient() + + self.decoder = JSONDecoder() + self.decoder.dateDecodingStrategy = .iso8601 + + self.encoder = JSONEncoder() + self.encoder.dateEncodingStrategy = .iso8601 + } + + // MARK: - Health Check + + public func checkHealth() async throws -> TunnelSession.HealthResponse { + let request = buildRequest(path: "/health", method: .get) + let (data, response) = try await httpClient.data(for: request, body: nil) + + guard response.status == .ok else { + throw TunnelClient2Error.httpError(statusCode: response.status.code) + } + + return try decoder.decode(TunnelSession.HealthResponse.self, from: data) + } + + // MARK: - Session Management + + public func createSession(clientInfo: TunnelSession.ClientInfo? = nil) async throws -> TunnelSession.CreateResponse { + let requestBody = TunnelSession.CreateRequest(clientInfo: clientInfo) + let request = buildRequest(path: "/api/sessions", method: .post) + let body = try encoder.encode(requestBody) + + let (data, response) = try await httpClient.data(for: request, body: body) + + guard response.status == .created || response.status == .ok else { + if let errorResponse = try? decoder.decode(TunnelSession.ErrorResponse.self, from: data) { + throw TunnelClient2Error.serverError(errorResponse.error) + } + throw TunnelClient2Error.httpError(statusCode: response.status.code) + } + + return try decoder.decode(TunnelSession.CreateResponse.self, from: data) + } + + public func listSessions() async throws -> [TunnelSession] { + let request = buildRequest(path: "/api/sessions", method: .get) + let (data, response) = try await httpClient.data(for: request, body: nil) + + guard response.status == .ok else { + throw TunnelClient2Error.httpError(statusCode: response.status.code) + } + + let listResponse = try decoder.decode(TunnelSession.ListResponse.self, from: data) + return listResponse.sessions + } + + public func getSession(id: String) async throws -> TunnelSession { + let request = buildRequest(path: "/api/sessions/\(id)", method: .get) + let (data, response) = try await httpClient.data(for: request, body: nil) + + guard response.status == .ok else { + if response.status == .notFound { + throw TunnelClient2Error.sessionNotFound + } + throw TunnelClient2Error.httpError(statusCode: response.status.code) + } + + return try decoder.decode(TunnelSession.self, from: data) + } + + public func deleteSession(id: String) async throws { + let request = buildRequest(path: "/api/sessions/\(id)", method: .delete) + let (_, response) = try await httpClient.data(for: request, body: nil) + + guard response.status == .noContent || response.status == .ok else { + if response.status == .notFound { + throw TunnelClient2Error.sessionNotFound + } + throw TunnelClient2Error.httpError(statusCode: response.status.code) + } + } + + // MARK: - Command Execution + + public func executeCommand( + sessionId: String, + command: String, + environment: [String: String]? = nil, + workingDirectory: String? = nil + ) async throws -> TunnelSession.ExecuteCommandResponse { + let requestBody = TunnelSession.ExecuteCommandRequest( + sessionId: sessionId, + command: command, + environment: environment, + workingDirectory: workingDirectory + ) + + let request = buildRequest(path: "/api/sessions/\(sessionId)/execute", method: .post) + let body = try encoder.encode(requestBody) + + let (data, response) = try await httpClient.data(for: request, body: body) + + guard response.status == .ok else { + if response.status == .notFound { + throw TunnelClient2Error.sessionNotFound + } + if let errorResponse = try? decoder.decode(TunnelSession.ErrorResponse.self, from: data) { + throw TunnelClient2Error.serverError(errorResponse.error) + } + throw TunnelClient2Error.httpError(statusCode: response.status.code) + } + + return try decoder.decode(TunnelSession.ExecuteCommandResponse.self, from: data) + } + + // MARK: - Private Helpers + + private func buildRequest(path: String, method: HTTPRequest.Method) -> HTTPRequest { + let url = baseURL.appendingPathComponent(path) + + // Use URLComponents to get scheme, host, and path + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + fatalError("Invalid URL") + } + + var request = HTTPRequest( + method: method, + scheme: components.scheme, + authority: components.host.map { host in + components.port.map { "\(host):\($0)" } ?? host + }, + path: components.path + ) + + // Add authentication + request.headerFields[.authorization] = "Bearer \(apiKey)" + + // Add content type for POST/PUT requests + if method == .post || method == .put { + request.headerFields[.contentType] = "application/json" + } + + return request + } +} + +// MARK: - Errors + +public enum TunnelClient2Error: LocalizedError, Equatable { + case invalidResponse + case httpError(statusCode: Int) + case serverError(String) + case sessionNotFound + case decodingError(String) + + public var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid response from server" + case .httpError(let statusCode): + return "HTTP error: \(statusCode)" + case .serverError(let message): + return "Server error: \(message)" + case .sessionNotFound: + return "Session not found" + case .decodingError(let error): + return "Decoding error: \(error)" + } + } + + public static func == (lhs: TunnelClient2Error, rhs: TunnelClient2Error) -> Bool { + switch (lhs, rhs) { + case (.invalidResponse, .invalidResponse): + return true + case (.httpError(let code1), .httpError(let code2)): + return code1 == code2 + case (.serverError(let msg1), .serverError(let msg2)): + return msg1 == msg2 + case (.sessionNotFound, .sessionNotFound): + return true + case (.decodingError(let msg1), .decodingError(let msg2)): + return msg1 == msg2 + default: + return false + } + } +} \ No newline at end of file diff --git a/VibeTunnel/Core/Services/TunnelServerDemo.swift b/VibeTunnel/Core/Services/TunnelServerDemo.swift index 8e96cd92..ecdccf7c 100644 --- a/VibeTunnel/Core/Services/TunnelServerDemo.swift +++ b/VibeTunnel/Core/Services/TunnelServerDemo.swift @@ -1,139 +1,21 @@ -import Combine import Foundation -import Logging +import Combine -/// Demo code showing how to use the VibeTunnel server -enum TunnelServerDemo { - private static let logger = Logger(label: "VibeTunnel.TunnelServerDemo") - - static func runDemo() async { - // Get the API key (in production, this should be managed securely) - // For demo purposes, using a hardcoded key - let apiKey = "demo-api-key-12345" - - logger.info("Using API key: [REDACTED]") - - // Create client - let client = TunnelClient(apiKey: apiKey) - - do { - // Check server health - let isHealthy = try await client.checkHealth() - logger.info("Server healthy: \(isHealthy)") - - // Create a new session - let session = try await client.createSession( - workingDirectory: "/tmp", - shell: "/bin/zsh" - ) - logger.info("Created session: \(session.sessionId)") - - // Execute a command - let response = try await client.executeCommand( - sessionId: session.sessionId, - command: "echo 'Hello from VibeTunnel!'" - ) - logger.info("Command output: \(response.output ?? "none")") - - // List all sessions - let sessions = try await client.listSessions() - logger.info("Active sessions: \(sessions.count)") - - // Close the session - try await client.closeSession(id: session.sessionId) - logger.info("Session closed") - } catch { - logger.error("Demo error: \(error)") - } +/// Stub implementation of TunnelServer for the macOS app +@MainActor +public final class TunnelServerDemo: ObservableObject { + @Published public private(set) var isRunning = false + @Published public private(set) var port: Int + + public init(port: Int = 8080) { + self.port = port } - - static func runWebSocketDemo() async { - // For demo purposes, using a hardcoded key - let apiKey = "demo-api-key-12345" - - let client = TunnelClient(apiKey: apiKey) - - do { - // Create a session first - let session = try await client.createSession() - logger.info("Created session for WebSocket: \(session.sessionId)") - - // Connect WebSocket - guard let wsClient = client.connectWebSocket(sessionId: session.sessionId) else { - logger.error("Failed to create WebSocket client") - return - } - wsClient.connect() - - // Subscribe to messages - let cancellable = wsClient.messages.sink { message in - switch message.type { - case .output: - logger.info("Output: \(message.data ?? "")") - case .error: - logger.error("Error: \(message.data ?? "")") - default: - logger.info("Message: \(message.type) - \(message.data ?? "")") - } - } - - // Send some commands - try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - wsClient.sendCommand("pwd") - - try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - wsClient.sendCommand("ls -la") - - try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds - - // Disconnect - wsClient.disconnect() - cancellable.cancel() - } catch { - logger.error("WebSocket demo error: \(error)") - } + + public func start() async throws { + isRunning = true } -} - -// MARK: - cURL Examples - -// Here are some example cURL commands to test the server: -// -// # Set your API key -// export API_KEY="your-api-key-here" -// -// # Health check (no auth required) -// curl http://localhost:8080/health -// -// # Get server info -// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info -// -// # Create a new session -// curl -X POST http://localhost:8080/sessions \ -// -H "X-API-Key: $API_KEY" \ -// -H "Content-Type: application/json" \ -// -d '{ -// "workingDirectory": "/tmp", -// "shell": "/bin/zsh" -// }' -// -// # List all sessions -// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions -// -// # Execute a command -// curl -X POST http://localhost:8080/execute \ -// -H "X-API-Key: $API_KEY" \ -// -H "Content-Type: application/json" \ -// -d '{ -// "sessionId": "your-session-id", -// "command": "ls -la" -// }' -// -// # Get session info -// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id -// -// # Close a session -// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id -// -// # WebSocket connection (using websocat tool) -// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal + + public func stop() async throws { + isRunning = false + } +} \ No newline at end of file diff --git a/VibeTunnel/Core/Services/TunnelServerExample.swift b/VibeTunnel/Core/Services/TunnelServerExample.swift new file mode 100644 index 00000000..82efe626 --- /dev/null +++ b/VibeTunnel/Core/Services/TunnelServerExample.swift @@ -0,0 +1,139 @@ +import Combine +import Foundation +import Logging + +/// Demo code showing how to use the VibeTunnel server +enum TunnelServerExample { + private static let logger = Logger(label: "VibeTunnel.TunnelServerDemo") + + static func runDemo() async { + // Get the API key (in production, this should be managed securely) + // For demo purposes, using a hardcoded key + let apiKey = "demo-api-key-12345" + + logger.info("Using API key: [REDACTED]") + + // Create client + let client = TunnelClient(apiKey: apiKey) + + do { + // Check server health + let isHealthy = try await client.checkHealth() + logger.info("Server healthy: \(isHealthy)") + + // Create a new session + let session = try await client.createSession( + workingDirectory: "/tmp", + shell: "/bin/zsh" + ) + logger.info("Created session: \(session.sessionId)") + + // Execute a command + let response = try await client.executeCommand( + sessionId: session.sessionId, + command: "echo 'Hello from VibeTunnel!'" + ) + logger.info("Command output: \(response.output ?? "none")") + + // List all sessions + let sessions = try await client.listSessions() + logger.info("Active sessions: \(sessions.count)") + + // Close the session + try await client.closeSession(id: session.sessionId) + logger.info("Session closed") + } catch { + logger.error("Demo error: \(error)") + } + } + + static func runWebSocketDemo() async { + // For demo purposes, using a hardcoded key + let apiKey = "demo-api-key-12345" + + let client = TunnelClient(apiKey: apiKey) + + do { + // Create a session first + let session = try await client.createSession() + logger.info("Created session for WebSocket: \(session.sessionId)") + + // Connect WebSocket + guard let wsClient = client.connectWebSocket(sessionId: session.sessionId) else { + logger.error("Failed to create WebSocket client") + return + } + wsClient.connect() + + // Subscribe to messages + let cancellable = wsClient.messages.sink { message in + switch message.type { + case .output: + logger.info("Output: \(message.data ?? "")") + case .error: + logger.error("Error: \(message.data ?? "")") + default: + logger.info("Message: \(message.type) - \(message.data ?? "")") + } + } + + // Send some commands + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + wsClient.sendCommand("pwd") + + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + wsClient.sendCommand("ls -la") + + try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + + // Disconnect + wsClient.disconnect() + cancellable.cancel() + } catch { + logger.error("WebSocket demo error: \(error)") + } + } +} + +// MARK: - cURL Examples + +// Here are some example cURL commands to test the server: +// +// # Set your API key +// export API_KEY="your-api-key-here" +// +// # Health check (no auth required) +// curl http://localhost:8080/health +// +// # Get server info +// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info +// +// # Create a new session +// curl -X POST http://localhost:8080/sessions \ +// -H "X-API-Key: $API_KEY" \ +// -H "Content-Type: application/json" \ +// -d '{ +// "workingDirectory": "/tmp", +// "shell": "/bin/zsh" +// }' +// +// # List all sessions +// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions +// +// # Execute a command +// curl -X POST http://localhost:8080/execute \ +// -H "X-API-Key: $API_KEY" \ +// -H "Content-Type: application/json" \ +// -d '{ +// "sessionId": "your-session-id", +// "command": "ls -la" +// }' +// +// # Get session info +// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id +// +// # Close a session +// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id +// +// # WebSocket connection (using websocat tool) +// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal diff --git a/VibeTunnel/SettingsView.swift b/VibeTunnel/SettingsView.swift index 24b1dabd..8f930bf8 100644 --- a/VibeTunnel/SettingsView.swift +++ b/VibeTunnel/SettingsView.swift @@ -145,11 +145,11 @@ struct AdvancedSettingsView: View { private var updateChannelRaw = UpdateChannel.stable.rawValue @State private var isCheckingForUpdates = false - @StateObject private var tunnelServer: TunnelServer + @StateObject private var tunnelServer: TunnelServerDemo init() { let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8_080 - _tunnelServer = StateObject(wrappedValue: TunnelServer(port: port)) + _tunnelServer = StateObject(wrappedValue: TunnelServerDemo(port: port)) } var updateChannel: UpdateChannel { @@ -300,7 +300,7 @@ struct AdvancedSettingsView: View { private func toggleServer() { Task { if tunnelServer.isRunning { - await tunnelServer.stop() + try await tunnelServer.stop() } else { do { try await tunnelServer.start() diff --git a/VibeTunnel/VibeTunnelApp.swift b/VibeTunnel/VibeTunnelApp.swift index a2b2fe03..6c5c7073 100644 --- a/VibeTunnel/VibeTunnelApp.swift +++ b/VibeTunnel/VibeTunnelApp.swift @@ -46,7 +46,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } // Initialize Sparkle updater manager - sparkleUpdaterManager = SparkleUpdaterManager() + sparkleUpdaterManager = SparkleUpdaterManager.shared // Configure activation policy based on settings (default to menu bar only) let showInDock = UserDefaults.standard.bool(forKey: "showInDock") diff --git a/VibeTunnelTests/TunnelClientTests.swift b/VibeTunnelTests/TunnelClient2Tests.swift similarity index 95% rename from VibeTunnelTests/TunnelClientTests.swift rename to VibeTunnelTests/TunnelClient2Tests.swift index 23d773f2..fec32cf1 100644 --- a/VibeTunnelTests/TunnelClientTests.swift +++ b/VibeTunnelTests/TunnelClient2Tests.swift @@ -3,8 +3,8 @@ import Foundation import HTTPTypes @testable import VibeTunnel -@Suite("TunnelClient Tests") -struct TunnelClientTests { +@Suite("TunnelClient2 Tests") +struct TunnelClient2Tests { let mockClient: MockHTTPClient let tunnelClient: TunnelClient2 let testURL = URL(string: "http://localhost:8080")! @@ -52,7 +52,7 @@ struct TunnelClientTests { mockClient.configure(for: "/health", response: .serverError) // Act & Assert - await #expect(throws: TunnelClientError.httpError(statusCode: 500)) { + await #expect(throws: TunnelClient2Error.httpError(statusCode: 500)) { _ = try await tunnelClient.checkHealth() } } @@ -123,7 +123,7 @@ struct TunnelClientTests { ) // Act & Assert - await #expect(throws: TunnelClientError.serverError("Maximum sessions reached")) { + await #expect(throws: TunnelClient2Error.serverError("Maximum sessions reached")) { _ = try await tunnelClient.createSession() } } @@ -182,7 +182,7 @@ struct TunnelClientTests { ) // Act & Assert - await #expect(throws: TunnelClientError.sessionNotFound) { + await #expect(throws: TunnelClient2Error.sessionNotFound) { _ = try await tunnelClient.getSession(id: "unknown-session") } } @@ -196,10 +196,10 @@ struct TunnelClientTests { ) // Act - try await tunnelClient.deleteSession(id: "session-123") + try await tunnelClient.deleteSession(id: "00000000-0000-0000-0000-000000000123") // Assert - #expect(mockClient.wasRequested(path: "/api/sessions/session-123")) + #expect(mockClient.wasRequested(path: "/api/sessions/00000000-0000-0000-0000-000000000123")) let lastRequest = mockClient.lastRequest()! #expect(lastRequest.request.method == .delete) } @@ -360,7 +360,7 @@ struct TunnelClientTests { ) // Act & Assert - await #expect(throws: TunnelClientError.httpError(statusCode: statusCode.code)) { + await #expect(throws: TunnelClient2Error.httpError(statusCode: statusCode.code)) { _ = try await tunnelClient.listSessions() } }