mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-26 05:09:30 +00:00
446 lines
15 KiB
Swift
446 lines
15 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import VibeTunnel
|
|
|
|
// MARK: - Test Mocks
|
|
// TODO: Move these to a separate file once Xcode project is updated
|
|
|
|
/// Mock WebSocket for testing
|
|
@MainActor
|
|
class MockWebSocket: WebSocketProtocol {
|
|
weak var delegate: WebSocketDelegate?
|
|
|
|
// State tracking
|
|
var isConnected = false
|
|
private(set) var lastConnectURL: URL?
|
|
private(set) var lastConnectHeaders: [String: String]?
|
|
var sentMessages: [WebSocketMessage] = []
|
|
private(set) var pingCount = 0
|
|
private(set) var disconnectCalled = false
|
|
private(set) var lastDisconnectCode: URLSessionWebSocketTask.CloseCode?
|
|
private(set) var lastDisconnectReason: Data?
|
|
|
|
// Message queue for async delivery
|
|
private var messageHandlers: [() async -> Void] = []
|
|
|
|
func connect(to url: URL, with headers: [String: String]) async throws {
|
|
lastConnectURL = url
|
|
lastConnectHeaders = headers
|
|
isConnected = true
|
|
delegate?.webSocketDidConnect(self)
|
|
}
|
|
|
|
func send(_ message: WebSocketMessage) async throws {
|
|
guard isConnected else { throw WebSocketError.connectionFailed }
|
|
sentMessages.append(message)
|
|
}
|
|
|
|
func sendPing() async throws {
|
|
guard isConnected else { throw WebSocketError.connectionFailed }
|
|
pingCount += 1
|
|
}
|
|
|
|
func disconnect(with code: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
|
disconnectCalled = true
|
|
lastDisconnectCode = code
|
|
lastDisconnectReason = reason
|
|
|
|
if isConnected {
|
|
isConnected = false
|
|
delegate?.webSocketDidDisconnect(self, closeCode: code, reason: reason)
|
|
}
|
|
}
|
|
|
|
// Test helpers
|
|
func simulateMessage(_ message: WebSocketMessage) {
|
|
guard isConnected else { return }
|
|
// Queue the message for async delivery
|
|
messageHandlers.append { [weak self] in
|
|
guard let self = self else { return }
|
|
self.delegate?.webSocket(self, didReceiveMessage: message)
|
|
}
|
|
// Trigger async delivery
|
|
Task {
|
|
while !messageHandlers.isEmpty {
|
|
let handler = messageHandlers.removeFirst()
|
|
await handler()
|
|
}
|
|
}
|
|
}
|
|
|
|
func simulateError(_ error: Error) {
|
|
guard isConnected else { return }
|
|
delegate?.webSocket(self, didFailWithError: error)
|
|
}
|
|
|
|
func simulateDisconnection() {
|
|
guard isConnected else { return }
|
|
isConnected = false
|
|
delegate?.webSocketDidDisconnect(self, closeCode: .abnormalClosure, reason: nil)
|
|
}
|
|
|
|
func reset() {
|
|
isConnected = false
|
|
sentMessages.removeAll()
|
|
pingCount = 0
|
|
}
|
|
|
|
func sentJSONMessages() -> [[String: Any]] {
|
|
sentMessages.compactMap { message in
|
|
guard case .string(let text) = message,
|
|
let data = text.data(using: .utf8),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return nil
|
|
}
|
|
return json
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mock WebSocket factory for testing
|
|
@MainActor
|
|
class MockWebSocketFactory: WebSocketFactory {
|
|
private(set) var createdWebSockets: [MockWebSocket] = []
|
|
|
|
func createWebSocket() -> WebSocketProtocol {
|
|
let webSocket = MockWebSocket()
|
|
createdWebSockets.append(webSocket)
|
|
return webSocket
|
|
}
|
|
|
|
var lastCreatedWebSocket: MockWebSocket? {
|
|
createdWebSockets.last
|
|
}
|
|
|
|
func reset() {
|
|
createdWebSockets.forEach { $0.reset() }
|
|
createdWebSockets.removeAll()
|
|
}
|
|
}
|
|
|
|
@Suite("BufferWebSocketClient Tests", .tags(.critical, .websocket), .disabled("Needs async mock refactoring"))
|
|
@MainActor
|
|
final class BufferWebSocketClientTests {
|
|
// Test dependencies
|
|
let mockFactory: MockWebSocketFactory
|
|
let client: BufferWebSocketClient
|
|
|
|
// Initialize test environment
|
|
init() {
|
|
mockFactory = MockWebSocketFactory()
|
|
client = BufferWebSocketClient(webSocketFactory: mockFactory)
|
|
|
|
// Setup test server configuration
|
|
TestFixtures.saveServerConfig(.init(
|
|
host: "localhost",
|
|
port: 8888,
|
|
name: nil
|
|
))
|
|
}
|
|
|
|
deinit {
|
|
// Cleanup is handled by test framework
|
|
// Main actor isolated methods cannot be called from deinit
|
|
}
|
|
@Test("Connects successfully with valid configuration", .timeLimit(.minutes(1)))
|
|
func successfulConnection() async throws {
|
|
// Act
|
|
client.connect()
|
|
|
|
// Give it a moment to process
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
// Assert
|
|
#expect(mockFactory.createdWebSockets.count == 1)
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
#expect(mockWebSocket.isConnected)
|
|
#expect(mockWebSocket.lastConnectURL?.absoluteString.contains("/buffers") ?? false)
|
|
#expect(client.isConnected)
|
|
#expect(client.connectionError == nil)
|
|
}
|
|
|
|
@Test("Handles connection failure gracefully")
|
|
func connectionFailure() async throws {
|
|
// Act
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
mockWebSocket.simulateError(WebSocketError.connectionFailed)
|
|
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
|
|
// Assert
|
|
#expect(!client.isConnected)
|
|
#expect(client.connectionError != nil)
|
|
}
|
|
|
|
@Test("Parses binary buffer messages", arguments: [
|
|
(cols: 80, rows: 24),
|
|
(cols: 120, rows: 30),
|
|
(cols: 160, rows: 50)
|
|
])
|
|
func binaryMessageParsing(cols: Int, rows: Int) async throws {
|
|
// Arrange
|
|
var receivedEvent: TerminalWebSocketEvent?
|
|
let sessionId = "test-session-123"
|
|
|
|
// Subscribe to events
|
|
client.subscribe(to: sessionId) { event in
|
|
receivedEvent = event
|
|
}
|
|
|
|
// Connect
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
#expect(mockWebSocket.isConnected)
|
|
|
|
// Create test message
|
|
let bufferData = TestFixtures.bufferSnapshot(cols: cols, rows: rows)
|
|
let messageData = TestFixtures.wrappedBufferMessage(sessionId: sessionId, bufferData: bufferData)
|
|
|
|
// Act - Simulate receiving the message
|
|
mockWebSocket.simulateMessage(WebSocketMessage.data(messageData))
|
|
|
|
// Wait for processing
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
// Assert
|
|
let event = try #require(receivedEvent)
|
|
guard case .bufferUpdate(let snapshot) = event else {
|
|
Issue.record("Expected buffer update event, got \(event)")
|
|
return
|
|
}
|
|
|
|
#expect(snapshot.cols == cols)
|
|
#expect(snapshot.rows == rows)
|
|
}
|
|
|
|
@Test("Handles text messages", arguments: [
|
|
(type: "ping", expectedResponse: "pong"),
|
|
(type: "error", expectedResponse: nil)
|
|
])
|
|
func textMessageHandling(type: String, expectedResponse: String?) async throws {
|
|
// Connect
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
|
|
// Act - Simulate message
|
|
let message = TestFixtures.terminalEvent(type: type)
|
|
mockWebSocket.simulateMessage(WebSocketMessage.string(message))
|
|
|
|
// Wait for processing
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
|
|
// Assert
|
|
let sentMessages = mockWebSocket.sentJSONMessages()
|
|
|
|
if let expectedResponse = expectedResponse {
|
|
#expect(sentMessages.contains { $0["type"] as? String == expectedResponse })
|
|
} else {
|
|
// For error messages, we expect no response
|
|
#expect(!sentMessages.contains { $0["type"] as? String == type })
|
|
}
|
|
}
|
|
|
|
@Test("Subscribes to sessions correctly")
|
|
func sessionSubscription() async throws {
|
|
// Arrange
|
|
let sessionId = "test-session-456"
|
|
|
|
// Act
|
|
client.subscribe(to: sessionId) { _ in
|
|
// Event handler
|
|
}
|
|
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
|
|
// Assert - Check if subscribe message was sent
|
|
let sentMessages = mockWebSocket.sentJSONMessages()
|
|
#expect(sentMessages.contains { msg in
|
|
msg["type"] as? String == "subscribe" &&
|
|
msg["sessionId"] as? String == sessionId
|
|
})
|
|
}
|
|
|
|
@Test("Handles reconnection after disconnection", .timeLimit(.minutes(1)))
|
|
func reconnection() async throws {
|
|
// Connect
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
let firstWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
#expect(client.isConnected)
|
|
|
|
// Act - Simulate disconnection
|
|
firstWebSocket.simulateDisconnection()
|
|
|
|
// Wait for reconnection attempt
|
|
try await waitFor { [weak self] in
|
|
(self?.mockFactory.createdWebSockets.count ?? 0) > 1
|
|
}
|
|
|
|
// Assert
|
|
let secondWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
#expect(secondWebSocket !== firstWebSocket)
|
|
}
|
|
|
|
@Test("Sends ping messages periodically", .disabled("Ping timing is unpredictable in tests"))
|
|
func pingMessages() async throws {
|
|
// Act
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
let initialPingCount = mockWebSocket.pingCount
|
|
|
|
// Wait longer to see if pings are sent
|
|
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
|
|
|
// Assert - Should have sent at least one ping
|
|
#expect(mockWebSocket.pingCount > initialPingCount)
|
|
}
|
|
|
|
@Test("Unsubscribes from sessions correctly")
|
|
func sessionUnsubscription() async throws {
|
|
// Arrange
|
|
let sessionId = "test-session-789"
|
|
|
|
// Subscribe first
|
|
client.subscribe(to: sessionId) { _ in }
|
|
|
|
// Connect
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
|
|
// Clear sent messages to isolate unsubscribe message
|
|
let prevConnected = mockWebSocket.isConnected
|
|
mockWebSocket.reset()
|
|
mockWebSocket.isConnected = prevConnected // Keep connected state
|
|
|
|
// Act - Unsubscribe
|
|
client.unsubscribe(from: sessionId)
|
|
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
|
|
// Assert
|
|
let sentMessages = mockWebSocket.sentJSONMessages()
|
|
#expect(sentMessages.contains { msg in
|
|
msg["type"] as? String == "unsubscribe" &&
|
|
msg["sessionId"] as? String == sessionId
|
|
})
|
|
}
|
|
|
|
@Test("Cleans up on disconnect")
|
|
func cleanup() async throws {
|
|
// Subscribe to a session
|
|
client.subscribe(to: "test-session") { _ in }
|
|
|
|
// Connect
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
#expect(client.isConnected)
|
|
|
|
// Act
|
|
client.disconnect()
|
|
|
|
// Assert
|
|
#expect(!client.isConnected)
|
|
#expect(mockWebSocket.disconnectCalled)
|
|
#expect(mockWebSocket.lastDisconnectCode == URLSessionWebSocketTask.CloseCode.goingAway)
|
|
}
|
|
|
|
// MARK: - Error Handling Tests
|
|
|
|
@Test("Handles invalid magic byte in binary messages")
|
|
func invalidMagicByte() async throws {
|
|
// Arrange
|
|
var receivedEvent: TerminalWebSocketEvent?
|
|
let sessionId = "test-session"
|
|
|
|
client.subscribe(to: sessionId) { event in
|
|
receivedEvent = event
|
|
}
|
|
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
|
|
// Create message with wrong magic byte
|
|
var messageData = Data()
|
|
messageData.append(0xFF) // Wrong magic byte
|
|
messageData.append(contentsOf: [0, 0, 0, 4]) // Session ID length
|
|
messageData.append("test".data(using: .utf8)!)
|
|
|
|
// Act
|
|
mockWebSocket.simulateMessage(WebSocketMessage.data(messageData))
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
// Assert - Should not receive any event
|
|
#expect(receivedEvent == nil)
|
|
}
|
|
|
|
@Test("Handles malformed buffer data gracefully")
|
|
func malformedBufferData() async throws {
|
|
// Arrange
|
|
var receivedEvent: TerminalWebSocketEvent?
|
|
let sessionId = "test-session"
|
|
|
|
client.subscribe(to: sessionId) { event in
|
|
receivedEvent = event
|
|
}
|
|
|
|
client.connect()
|
|
try await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
let mockWebSocket = try #require(mockFactory.lastCreatedWebSocket)
|
|
|
|
// Create message with valid wrapper but invalid buffer data
|
|
var bufferData = Data()
|
|
bufferData.append(contentsOf: [0xFF, 0xFF]) // Invalid magic for buffer
|
|
bufferData.append(contentsOf: [1, 2, 3, 4]) // Random data
|
|
|
|
let messageData = TestFixtures.wrappedBufferMessage(sessionId: sessionId, bufferData: bufferData)
|
|
|
|
// Act
|
|
mockWebSocket.simulateMessage(WebSocketMessage.data(messageData))
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
|
|
// Assert - Should not crash and not receive event
|
|
#expect(receivedEvent == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Test Extensions
|
|
|
|
extension BufferWebSocketClientTests {
|
|
/// Wait for condition with timeout
|
|
func waitFor(
|
|
_ condition: @escaping () async -> Bool,
|
|
timeout: Duration = .seconds(5),
|
|
pollingInterval: Duration = .milliseconds(100)
|
|
) async throws {
|
|
let deadline = ContinuousClock.now.advanced(by: timeout)
|
|
|
|
while ContinuousClock.now < deadline {
|
|
if await condition() {
|
|
return
|
|
}
|
|
try await Task.sleep(for: pollingInterval)
|
|
}
|
|
|
|
Issue.record("Timeout waiting for condition")
|
|
}
|
|
}
|