Add basic Swift tests

This commit is contained in:
Peter Steinberger 2025-06-18 19:17:45 +02:00
parent 0f8299c7a0
commit a8876e9c69
10 changed files with 3872 additions and 0 deletions

View file

@ -0,0 +1,397 @@
import Testing
import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdCore
import HummingbirdTesting
import NIOCore
@testable import VibeTunnel
// MARK: - Mock Request Context
struct MockRequestContext: RequestContext {
var coreContext: CoreRequestContext
init(allocator: ByteBufferAllocator = ByteBufferAllocator(), logger: Logger = Logger(label: "test")) {
self.coreContext = CoreRequestContext(allocator: allocator, logger: logger)
}
}
// MARK: - Test Helpers
extension String {
/// Encode string as Base64 for Basic Auth
var base64Encoded: String {
Data(self.utf8).base64EncodedString()
}
}
// MARK: - Basic Auth Middleware Tests
@Suite("Basic Auth Middleware Tests", .tags(.security, .networking))
struct BasicAuthMiddlewareTests {
// Helper to create a test request
func createRequest(
path: String = "/",
method: HTTPRequest.Method = .get,
headers: HTTPFields = HTTPFields()
) -> Request {
Request(
head: HTTPRequest(
method: method,
scheme: "http",
authority: "localhost",
path: path,
headerFields: headers
),
body: RequestBody(byteBuffer: ByteBuffer())
)
}
// Helper to create a mock next handler
func createNextHandler() -> (Request, MockRequestContext) async throws -> Response {
return { request, context in
Response(status: .ok)
}
}
// MARK: - Valid Authentication Tests
@Test("Valid authentication", arguments: zip(
["user:pass", "admin:secret", "test:password123"],
["pass", "secret", "password123"]
))
func testValidAuth(credentials: String, expectedPassword: String) async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: expectedPassword)
var headers = HTTPFields()
headers[.authorization] = "Basic \(credentials.base64Encoded)"
let request = createRequest(headers: headers)
let context = MockRequestContext()
let response = try await middleware.handle(request, context: context, next: createNextHandler())
#expect(response.status == .ok)
}
@Test("Valid auth with special characters", arguments: [
"user:p@ssw0rd!",
"admin:test-password-123",
"test:password with spaces",
"user:пароль", // Cyrillic
"admin:パスワード", // Japanese
"test:🔐secure🔐" // Emoji
])
func testValidAuthSpecialChars(credentials: String) async throws {
let parts = credentials.split(separator: ":", maxSplits: 1)
let password = String(parts[1])
let middleware = BasicAuthMiddleware<MockRequestContext>(password: password)
var headers = HTTPFields()
headers[.authorization] = "Basic \(credentials.base64Encoded)"
let request = createRequest(headers: headers)
let context = MockRequestContext()
let response = try await middleware.handle(request, context: context, next: createNextHandler())
#expect(response.status == .ok)
}
// MARK: - Invalid Authentication Tests
@Test("Invalid authentication attempts")
func testInvalidAuth() async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "correct-password")
let context = MockRequestContext()
// Wrong password
var headers = HTTPFields()
headers[.authorization] = "Basic \("user:wrong-password".base64Encoded)"
let response = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(response.status == .unauthorized)
#expect(response.headers[.wwwAuthenticate]?.contains("Basic realm=") == true)
}
@Test("Missing authorization header")
func testMissingAuthHeader() async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "password")
let context = MockRequestContext()
let request = createRequest() // No auth header
let response = try await middleware.handle(request, context: context, next: createNextHandler())
#expect(response.status == .unauthorized)
#expect(response.headers[.wwwAuthenticate] == "Basic realm=\"VibeTunnel Dashboard\"")
}
@Test("Invalid authorization header format", arguments: [
"Bearer token123", // Wrong auth type
"Basic", // Missing credentials
"Basic ", // Empty credentials
"InvalidHeader", // Completely wrong format
"basic dXNlcjpwYXNz" // Lowercase 'basic'
])
func testInvalidAuthHeaderFormat(authHeader: String) async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "password")
let context = MockRequestContext()
var headers = HTTPFields()
headers[.authorization] = authHeader
let response = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(response.status == .unauthorized)
}
@Test("Invalid base64 encoding")
func testInvalidBase64() async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "password")
let context = MockRequestContext()
var headers = HTTPFields()
headers[.authorization] = "Basic !!!invalid-base64!!!"
let response = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(response.status == .unauthorized)
}
@Test("Missing colon in credentials")
func testMissingColon() async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "password")
let context = MockRequestContext()
var headers = HTTPFields()
headers[.authorization] = "Basic \("userpassword".base64Encoded)" // No colon separator
let response = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(response.status == .unauthorized)
}
// MARK: - Health Check Bypass Tests
@Test("Health check endpoint bypasses auth")
func testHealthCheckBypass() async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "password")
let context = MockRequestContext()
// Request to health endpoint without auth
let request = createRequest(path: "/api/health")
let response = try await middleware.handle(request, context: context, next: createNextHandler())
#expect(response.status == .ok) // Should pass without auth
}
@Test("Other endpoints require auth", arguments: [
"/",
"/api/sessions",
"/api/cleanup",
"/dashboard",
"/api/health/detailed" // Similar but different path
])
func testOtherEndpointsRequireAuth(path: String) async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "password")
let context = MockRequestContext()
// Request without auth
let request = createRequest(path: path)
let response = try await middleware.handle(request, context: context, next: createNextHandler())
#expect(response.status == .unauthorized)
}
// MARK: - Custom Realm Tests
@Test("Custom realm configuration")
func testCustomRealm() async throws {
let customRealm = "My Custom Realm"
let middleware = BasicAuthMiddleware<MockRequestContext>(
password: "password",
realm: customRealm
)
let context = MockRequestContext()
let request = createRequest() // No auth
let response = try await middleware.handle(request, context: context, next: createNextHandler())
#expect(response.status == .unauthorized)
#expect(response.headers[.wwwAuthenticate] == "Basic realm=\"\(customRealm)\"")
}
// MARK: - Rate Limiting Tests
@Test("Rate limiting", .tags(.security))
func testRateLimiting() async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "correct-password")
let context = MockRequestContext()
// Multiple failed attempts
var headers = HTTPFields()
headers[.authorization] = "Basic \("user:wrong".base64Encoded)"
// Make multiple requests
for _ in 0..<5 {
let response = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(response.status == .unauthorized)
}
// Note: Current implementation doesn't have rate limiting
// This test documents expected behavior for future implementation
}
// MARK: - Username Handling Tests
@Test("Username is ignored", arguments: [
"admin:password",
"user:password",
"any-username:password",
":password" // Empty username
])
func testUsernameIgnored(credentials: String) async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "password")
let context = MockRequestContext()
var headers = HTTPFields()
headers[.authorization] = "Basic \(credentials.base64Encoded)"
let response = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(response.status == .ok)
}
// MARK: - Response Body Tests
@Test("Unauthorized response includes message")
func testUnauthorizedResponseBody() async throws {
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "password")
let context = MockRequestContext()
let request = createRequest() // No auth
let response = try await middleware.handle(request, context: context, next: createNextHandler())
#expect(response.status == .unauthorized)
// Check response body
if case .byteBuffer(let buffer) = response.body {
let message = String(buffer: buffer)
#expect(message == "Authentication required")
} else {
Issue.record("Expected byte buffer response body")
}
}
// MARK: - Security Edge Cases
@Test("Empty password handling")
func testEmptyPassword() async throws {
// Middleware with empty password (should probably be prevented in real usage)
let middleware = BasicAuthMiddleware<MockRequestContext>(password: "")
let context = MockRequestContext()
var headers = HTTPFields()
headers[.authorization] = "Basic \("user:".base64Encoded)" // Empty password in request
let response = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(response.status == .ok) // Matches empty password
}
@Test("Very long credentials")
func testVeryLongCredentials() async throws {
let longPassword = String(repeating: "a", count: 1000)
let middleware = BasicAuthMiddleware<MockRequestContext>(password: longPassword)
let context = MockRequestContext()
var headers = HTTPFields()
headers[.authorization] = "Basic \("user:\(longPassword)".base64Encoded)"
let response = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(response.status == .ok)
}
// MARK: - Integration Tests
@Test("Full authentication flow", .tags(.integration))
func testFullAuthFlow() async throws {
let password = "secure-dashboard-password"
let middleware = BasicAuthMiddleware<MockRequestContext>(password: password)
let context = MockRequestContext()
// 1. No auth - should fail
let noAuthResponse = try await middleware.handle(
createRequest(),
context: context,
next: createNextHandler()
)
#expect(noAuthResponse.status == .unauthorized)
// 2. Wrong password - should fail
var headers = HTTPFields()
headers[.authorization] = "Basic \("admin:wrong".base64Encoded)"
let wrongAuthResponse = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(wrongAuthResponse.status == .unauthorized)
// 3. Correct password - should succeed
headers[.authorization] = "Basic \("admin:\(password)".base64Encoded)"
let correctAuthResponse = try await middleware.handle(
createRequest(headers: headers),
context: context,
next: createNextHandler()
)
#expect(correctAuthResponse.status == .ok)
// 4. Health check - should succeed without auth
let healthResponse = try await middleware.handle(
createRequest(path: "/api/health"),
context: context,
next: createNextHandler()
)
#expect(healthResponse.status == .ok)
}
}

View file

@ -0,0 +1,411 @@
import Testing
import Foundation
import AppKit
@testable import VibeTunnel
// MARK: - Mock CLI Installer
@MainActor
final class MockCLIInstaller: CLIInstaller {
// Mock state
var mockIsInstalled = false
var mockInstallShouldFail = false
var mockInstallError: String?
var mockResourcePath: String?
// Track method calls
var checkInstallationStatusCalled = false
var installCalled = false
var performInstallationCalled = false
var showSuccessCalled = false
var showErrorCalled = false
var lastErrorMessage: String?
override func checkInstallationStatus() {
checkInstallationStatusCalled = true
isInstalled = mockIsInstalled
}
override func install() async {
installCalled = true
await MainActor.run {
isInstalling = true
if mockInstallShouldFail {
lastError = mockInstallError ?? "Mock installation failed"
lastErrorMessage = lastError
isInstalling = false
showErrorCalled = true
} else {
isInstalled = true
isInstalling = false
showSuccessCalled = true
}
}
}
override func installCLITool() {
installCalled = true
isInstalling = true
if mockInstallShouldFail {
lastError = mockInstallError ?? "Mock installation failed"
lastErrorMessage = lastError
isInstalling = false
showErrorCalled = true
} else {
isInstalled = true
isInstalling = false
showSuccessCalled = true
}
}
func reset() {
mockIsInstalled = false
mockInstallShouldFail = false
mockInstallError = nil
mockResourcePath = nil
checkInstallationStatusCalled = false
installCalled = false
performInstallationCalled = false
showSuccessCalled = false
showErrorCalled = false
lastErrorMessage = nil
isInstalled = false
isInstalling = false
lastError = nil
}
}
// MARK: - Mock FileManager
final class MockFileManager {
var fileExistsResults: [String: Bool] = [:]
var createDirectoryShouldFail = false
var setAttributesShouldFail = false
func fileExists(atPath path: String) -> Bool {
fileExistsResults[path] ?? false
}
func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {
if createDirectoryShouldFail {
throw CocoaError(.fileWriteUnknown)
}
}
func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws {
if setAttributesShouldFail {
throw CocoaError(.fileWriteNoPermission)
}
}
}
// MARK: - CLI Installer Tests
@Suite("CLI Installer Tests")
@MainActor
struct CLIInstallerTests {
let tempDirectory: URL
init() throws {
self.tempDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("CLIInstallerTests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
}
deinit {
try? FileManager.default.removeItem(at: tempDirectory)
}
// MARK: - Installation Status Tests
@Test("Check installation status")
func testCheckInstallationStatus() throws {
let installer = MockCLIInstaller()
// Not installed
installer.mockIsInstalled = false
installer.checkInstallationStatus()
#expect(installer.checkInstallationStatusCalled)
#expect(!installer.isInstalled)
// Installed
installer.reset()
installer.mockIsInstalled = true
installer.checkInstallationStatus()
#expect(installer.isInstalled)
}
@Test("Installation status detects existing symlink")
func testDetectExistingSymlink() throws {
let installer = CLIInstaller()
// Check real status (may or may not be installed)
installer.checkInstallationStatus()
// Status should be set
#expect(installer.isInstalled == true || installer.isInstalled == false)
}
// MARK: - Installation Process Tests
@Test("Installing CLI tool to custom location")
func testCLIInstallation() async throws {
let installer = MockCLIInstaller()
// Set up mock
installer.mockResourcePath = Bundle.main.path(forResource: "vt", ofType: nil) ?? "/mock/path/vt"
installer.mockInstallShouldFail = false
// Perform installation
await installer.install()
#expect(installer.installCalled)
#expect(installer.isInstalled)
#expect(!installer.isInstalling)
#expect(installer.lastError == nil)
#expect(installer.showSuccessCalled)
}
@Test("Installation failure handling")
func testInstallationFailure() async throws {
let installer = MockCLIInstaller()
// Set up failure
installer.mockInstallShouldFail = true
installer.mockInstallError = "Permission denied"
// Attempt installation
await installer.install()
#expect(installer.installCalled)
#expect(!installer.isInstalled)
#expect(!installer.isInstalling)
#expect(installer.lastError == "Permission denied")
#expect(installer.showErrorCalled)
}
@Test("Updating existing CLI installation")
func testCLIUpdate() async throws {
let installer = MockCLIInstaller()
// Simulate existing installation
installer.mockIsInstalled = true
installer.checkInstallationStatus()
#expect(installer.isInstalled)
// Update (reinstall)
installer.mockInstallShouldFail = false
await installer.install()
#expect(installer.isInstalled)
#expect(installer.showSuccessCalled)
}
// MARK: - Resource Validation Tests
@Test("Missing CLI binary in bundle")
func testMissingCLIBinary() async throws {
let installer = MockCLIInstaller()
// Simulate missing resource
installer.mockResourcePath = nil
installer.mockInstallShouldFail = true
installer.mockInstallError = "The vt command line tool could not be found in the application bundle."
await installer.install()
#expect(!installer.isInstalled)
#expect(installer.lastError?.contains("could not be found") == true)
}
@Test("Valid resource path")
func testValidResourcePath() throws {
// Check if vt binary exists in bundle
let resourcePath = Bundle.main.path(forResource: "vt", ofType: nil)
// In test environment, this might be nil
if let path = resourcePath {
#expect(FileManager.default.fileExists(atPath: path))
}
}
// MARK: - Permission Tests
@Test("Permission handling", .enabled(if: ProcessInfo.processInfo.environment["CI"] == nil))
func testPermissions() async throws {
let installer = MockCLIInstaller()
// Simulate permission error
installer.mockInstallShouldFail = true
installer.mockInstallError = "Operation not permitted"
await installer.install()
#expect(!installer.isInstalled)
#expect(installer.lastError?.contains("not permitted") == true)
}
@Test("Administrator privileges required")
func testAdminPrivileges() throws {
// This test documents that admin privileges are required
// The actual installation uses osascript with administrator privileges
let installer = MockCLIInstaller()
// Installation requires admin
#expect(!installer.isInstalled)
// After successful installation with admin privileges
installer.mockIsInstalled = true
installer.checkInstallationStatus()
#expect(installer.isInstalled)
}
// MARK: - Script Generation Tests
@Test("Installation script generation")
func testScriptGeneration() throws {
let sourcePath = "/Applications/VibeTunnel.app/Contents/Resources/vt"
let targetPath = "/usr/local/bin/vt"
// Expected script content
let expectedScript = """
#!/bin/bash
set -e
# Create /usr/local/bin if it doesn't exist
if [ ! -d "/usr/local/bin" ]; then
mkdir -p "/usr/local/bin"
echo "Created directory /usr/local/bin"
fi
# Remove existing symlink if it exists
if [ -L "\(targetPath)" ] || [ -f "\(targetPath)" ]; then
rm -f "\(targetPath)"
echo "Removed existing file at \(targetPath)"
fi
# Create the symlink
ln -s "\(sourcePath)" "\(targetPath)"
echo "Created symlink from \(sourcePath) to \(targetPath)"
# Make sure the symlink is executable
chmod +x "\(targetPath)"
echo "Set executable permissions on \(targetPath)"
"""
// Verify script structure
#expect(expectedScript.contains("#!/bin/bash"))
#expect(expectedScript.contains("set -e"))
#expect(expectedScript.contains("mkdir -p"))
#expect(expectedScript.contains("ln -s"))
#expect(expectedScript.contains("chmod +x"))
}
// MARK: - State Management Tests
@Test("Installation state transitions")
func testStateTransitions() async throws {
let installer = MockCLIInstaller()
// Initial state
#expect(!installer.isInstalled)
#expect(!installer.isInstalling)
#expect(installer.lastError == nil)
// During installation
installer.installCLITool()
// Note: In mock, this completes immediately
// After successful installation
#expect(installer.isInstalled)
#expect(!installer.isInstalling)
#expect(installer.lastError == nil)
// Reset and test failure
installer.reset()
installer.mockInstallShouldFail = true
installer.mockInstallError = "Test error"
installer.installCLITool()
// After failed installation
#expect(!installer.isInstalled)
#expect(!installer.isInstalling)
#expect(installer.lastError == "Test error")
}
// MARK: - UI Alert Tests
@Test("User confirmation dialogs")
func testUserDialogs() async throws {
let installer = MockCLIInstaller()
// Test shows appropriate dialogs
// In real implementation:
// 1. Confirmation dialog before installation
// 2. Success dialog after successful installation
// 3. Error dialog on failure
// Success case
await installer.install()
#expect(installer.showSuccessCalled)
// Failure case
installer.reset()
installer.mockInstallShouldFail = true
await installer.install()
#expect(installer.showErrorCalled)
}
// MARK: - Concurrent Installation Tests
@Test("Concurrent installation attempts", .tags(.concurrency))
func testConcurrentInstallation() async throws {
let installer = MockCLIInstaller()
// Attempt multiple installations concurrently
await withTaskGroup(of: Void.self) { group in
for _ in 0..<3 {
group.addTask {
await installer.install()
}
}
await group.waitForAll()
}
// Should handle concurrent attempts gracefully
#expect(installer.installCalled)
#expect(installer.isInstalled || installer.lastError != nil)
#expect(!installer.isInstalling)
}
// MARK: - Integration Tests
@Test("Full installation workflow", .tags(.integration))
func testFullWorkflow() async throws {
let installer = MockCLIInstaller()
// 1. Check initial status
installer.checkInstallationStatus()
#expect(!installer.isInstalled)
// 2. Install CLI tool
await installer.install()
#expect(installer.isInstalled)
// 3. Verify installation
installer.checkInstallationStatus()
#expect(installer.isInstalled)
// 4. Attempt reinstall (should handle gracefully)
await installer.install()
#expect(installer.isInstalled)
}
}

View file

@ -0,0 +1,357 @@
import Testing
import Foundation
import Security
@testable import VibeTunnel
// MARK: - Mock DashboardKeychain for Testing
@MainActor
final class MockDashboardKeychain: DashboardKeychain {
// In-memory storage for testing
private var storedPassword: String?
var shouldFailOperations = false
var operationDelay: Duration?
override func getPassword() -> String? {
if shouldFailOperations { return nil }
return storedPassword
}
override func hasPassword() -> Bool {
if shouldFailOperations { return false }
return storedPassword != nil
}
override func setPassword(_ password: String) -> Bool {
if shouldFailOperations { return false }
if password.isEmpty { return false }
storedPassword = password
return true
}
override func deletePassword() -> Bool {
if shouldFailOperations { return false }
storedPassword = nil
return true
}
// Test helper to reset state
func reset() {
storedPassword = nil
shouldFailOperations = false
operationDelay = nil
}
}
// MARK: - Keychain Error Types for Testing
enum KeychainError: Error, Equatable {
case itemNotFound
case duplicateItem
case invalidData
case accessDenied
case unknown(OSStatus)
init(status: OSStatus) {
switch status {
case errSecItemNotFound:
self = .itemNotFound
case errSecDuplicateItem:
self = .duplicateItem
case errSecParam:
self = .invalidData
case errSecAuthFailed:
self = .accessDenied
default:
self = .unknown(status)
}
}
}
// MARK: - Dashboard Keychain Tests
@Suite("Dashboard Keychain Tests", .tags(.security))
@MainActor
struct DashboardKeychainTests {
// MARK: - Password Storage Tests
@Test("Storing and retrieving passwords")
func testPasswordStorage() throws {
let keychain = MockDashboardKeychain()
// Initially no password
#expect(keychain.getPassword() == nil)
#expect(!keychain.hasPassword())
// Store password
let testPassword = "secure-test-password-123"
let stored = keychain.setPassword(testPassword)
#expect(stored)
// Verify retrieval
#expect(keychain.getPassword() == testPassword)
#expect(keychain.hasPassword())
}
@Test("Password with special characters", arguments: [
"p@ssw0rd!",
"test-password-123",
"пароль-тест", // Cyrillic
"パスワード", // Japanese
"🔐secure🔐", // Emoji
"password with spaces"
])
func testPasswordSpecialCharacters(password: String) throws {
let keychain = MockDashboardKeychain()
let stored = keychain.setPassword(password)
#expect(stored)
let retrieved = keychain.getPassword()
#expect(retrieved == password)
}
@Test("Empty password is rejected")
func testEmptyPassword() throws {
let keychain = MockDashboardKeychain()
let stored = keychain.setPassword("")
#expect(!stored)
// Verify nothing was stored
#expect(keychain.getPassword() == nil)
#expect(!keychain.hasPassword())
}
// MARK: - Password Update Tests
@Test("Password update operations")
func testPasswordUpdate() throws {
let keychain = MockDashboardKeychain()
// Store initial password
let initialPassword = "initial-password"
#expect(keychain.setPassword(initialPassword))
#expect(keychain.getPassword() == initialPassword)
// Update password
let updatedPassword = "updated-password"
#expect(keychain.setPassword(updatedPassword))
// Verify update
#expect(keychain.getPassword() == updatedPassword)
#expect(keychain.getPassword() != initialPassword)
}
@Test("Multiple password updates", arguments: 1...5)
func testMultipleUpdates(iteration: Int) throws {
let keychain = MockDashboardKeychain()
let password = "password-v\(iteration)"
#expect(keychain.setPassword(password))
#expect(keychain.getPassword() == password)
}
// MARK: - Password Deletion Tests
@Test("Password deletion")
func testPasswordDeletion() throws {
let keychain = MockDashboardKeychain()
// Store password
let password = "password-to-delete"
#expect(keychain.setPassword(password))
#expect(keychain.hasPassword())
// Delete password
#expect(keychain.deletePassword())
// Verify deletion
#expect(keychain.getPassword() == nil)
#expect(!keychain.hasPassword())
}
@Test("Delete non-existent password")
func testDeleteNonExistent() throws {
let keychain = MockDashboardKeychain()
// Ensure no password exists
#expect(!keychain.hasPassword())
// Delete should still succeed (idempotent)
#expect(keychain.deletePassword())
}
// MARK: - Error Handling Tests
@Test("Keychain error handling", arguments: [
KeychainError.itemNotFound,
KeychainError.duplicateItem,
KeychainError.invalidData,
KeychainError.accessDenied
])
func testErrorHandling(error: KeychainError) throws {
// Test error descriptions
switch error {
case .itemNotFound:
#expect(error == .itemNotFound)
case .duplicateItem:
#expect(error == .duplicateItem)
case .invalidData:
#expect(error == .invalidData)
case .accessDenied:
#expect(error == .accessDenied)
case .unknown:
break
}
}
@Test("Handle keychain operation failures")
func testOperationFailures() throws {
let keychain = MockDashboardKeychain()
keychain.shouldFailOperations = true
// All operations should fail
#expect(!keychain.setPassword("test"))
#expect(keychain.getPassword() == nil)
#expect(!keychain.hasPassword())
#expect(!keychain.deletePassword())
}
// MARK: - Security Tests
@Test("Password is not logged in plain text")
func testPasswordLogging() throws {
// This test verifies that passwords are not exposed in logs
// In production, the logger should never output the actual password
let keychain = MockDashboardKeychain()
let sensitivePassword = "super-secret-password"
// Store password - in real implementation, this should not log the password
_ = keychain.setPassword(sensitivePassword)
// The test passes if no assertion fails
// In real implementation, we'd check log output doesn't contain the password
#expect(true)
}
@Test("Has password check doesn't retrieve data")
func testHasPasswordEfficiency() throws {
let keychain = MockDashboardKeychain()
// Store a password
#expect(keychain.setPassword("test-password"))
// hasPassword should be efficient and not retrieve the actual password
// This is important to avoid keychain prompts
#expect(keychain.hasPassword())
// In the real implementation, hasPassword uses kSecReturnData: false
// to avoid retrieving the actual password data
}
// MARK: - Concurrent Access Tests
@Test("Concurrent password operations", .tags(.concurrency))
func testConcurrentAccess() async throws {
let keychain = MockDashboardKeychain()
// Perform multiple operations concurrently
await withTaskGroup(of: Bool.self) { group in
// Multiple writes
for i in 0..<5 {
group.addTask {
keychain.setPassword("password-\(i)")
}
}
// Multiple reads
for _ in 0..<5 {
group.addTask {
_ = keychain.getPassword()
return keychain.hasPassword()
}
}
// Collect results
var results: [Bool] = []
for await result in group {
results.append(result)
}
// At least some operations should succeed
#expect(results.contains(true))
}
// Final state should be consistent
#expect(keychain.hasPassword() == (keychain.getPassword() != nil))
}
// MARK: - Debug vs Release Behavior Tests
@Test("Debug mode behavior")
func testDebugModeBehavior() throws {
// In debug mode, DashboardKeychain skips actual keychain access
#if DEBUG
let keychain = DashboardKeychain.shared
// getPassword returns nil in debug mode
#expect(keychain.getPassword() == nil)
// But setPassword still reports success
#expect(keychain.setPassword("debug-password"))
#endif
}
// MARK: - Password Generation Tests
@Test("Password complexity validation")
func testPasswordComplexity() throws {
let keychain = MockDashboardKeychain()
// Test various password complexities
let passwords = [
("weak", "123456"),
("medium", "Password123"),
("strong", "P@ssw0rd!2024#Secure"),
("very long", String(repeating: "a", count: 256))
]
for (_, password) in passwords {
#expect(keychain.setPassword(password))
#expect(keychain.getPassword() == password)
}
}
// MARK: - Integration Tests
@Test("Full password lifecycle", .tags(.integration))
func testFullLifecycle() throws {
let keychain = MockDashboardKeychain()
// 1. Initial state - no password
#expect(!keychain.hasPassword())
#expect(keychain.getPassword() == nil)
// 2. Set initial password
let initialPassword = "initial-secure-password"
#expect(keychain.setPassword(initialPassword))
#expect(keychain.hasPassword())
#expect(keychain.getPassword() == initialPassword)
// 3. Update password
let updatedPassword = "updated-secure-password"
#expect(keychain.setPassword(updatedPassword))
#expect(keychain.getPassword() == updatedPassword)
// 4. Delete password
#expect(keychain.deletePassword())
#expect(!keychain.hasPassword())
#expect(keychain.getPassword() == nil)
// 5. Delete again (idempotent)
#expect(keychain.deletePassword())
}
}

View file

@ -0,0 +1,334 @@
import Testing
import Foundation
@testable import VibeTunnel
// MARK: - Model Tests Suite
@Suite("Model Tests", .tags(.models))
struct ModelTests {
// MARK: - TunnelSession Tests
@Suite("TunnelSession Tests")
struct TunnelSessionTests {
@Test("TunnelSession initialization")
func testInitialization() throws {
let session = TunnelSession()
#expect(session.id != UUID())
#expect(session.createdAt <= Date())
#expect(session.lastActivity >= session.createdAt)
#expect(session.processID == nil)
#expect(session.isActive)
}
@Test("TunnelSession with process ID")
func testInitWithProcessID() throws {
let pid: Int32 = 12345
let session = TunnelSession(processID: pid)
#expect(session.processID == pid)
#expect(session.isActive)
}
@Test("TunnelSession activity update")
func testActivityUpdate() throws {
var session = TunnelSession()
let initialActivity = session.lastActivity
// Wait a bit to ensure time difference
Thread.sleep(forTimeInterval: 0.1)
session.updateActivity()
#expect(session.lastActivity > initialActivity)
#expect(session.lastActivity <= Date())
}
@Test("TunnelSession serialization", .tags(.models))
func testSerialization() throws {
let session = TunnelSession(id: UUID(), processID: 99999)
// Encode
let encoder = JSONEncoder()
let data = try encoder.encode(session)
// Decode
let decoder = JSONDecoder()
let decoded = try decoder.decode(TunnelSession.self, from: data)
#expect(decoded.id == session.id)
#expect(decoded.createdAt == session.createdAt)
#expect(decoded.processID == session.processID)
#expect(decoded.isActive == session.isActive)
}
@Test("TunnelSession Sendable conformance")
func testSendable() async throws {
let session = TunnelSession()
// Test that we can send across actor boundaries
let actor = TestActor()
await actor.receiveSession(session)
let received = await actor.getSession()
#expect(received?.id == session.id)
}
}
// MARK: - CreateSessionRequest Tests
@Suite("CreateSessionRequest Tests")
struct CreateSessionRequestTests {
@Test("CreateSessionRequest initialization")
func testInitialization() throws {
// Default initialization
let request1 = CreateSessionRequest()
#expect(request1.workingDirectory == nil)
#expect(request1.environment == nil)
#expect(request1.shell == nil)
// Full initialization
let request2 = CreateSessionRequest(
workingDirectory: "/tmp",
environment: ["KEY": "value"],
shell: "/bin/zsh"
)
#expect(request2.workingDirectory == "/tmp")
#expect(request2.environment?["KEY"] == "value")
#expect(request2.shell == "/bin/zsh")
}
@Test("CreateSessionRequest serialization")
func testSerialization() throws {
let request = CreateSessionRequest(
workingDirectory: "/Users/test",
environment: ["PATH": "/usr/bin", "LANG": "en_US.UTF-8"],
shell: "/bin/bash"
)
let data = try JSONEncoder().encode(request)
let decoded = try JSONDecoder().decode(CreateSessionRequest.self, from: data)
#expect(decoded.workingDirectory == request.workingDirectory)
#expect(decoded.environment?["PATH"] == request.environment?["PATH"])
#expect(decoded.environment?["LANG"] == request.environment?["LANG"])
#expect(decoded.shell == request.shell)
}
}
// MARK: - DashboardAccessMode Tests
@Suite("DashboardAccessMode Tests")
struct DashboardAccessModeTests {
@Test("DashboardAccessMode validation", arguments: DashboardAccessMode.allCases)
func testAccessModeValidation(mode: DashboardAccessMode) throws {
// Each mode should have valid properties
#expect(!mode.displayName.isEmpty)
#expect(!mode.bindAddress.isEmpty)
#expect(!mode.description.isEmpty)
// Verify bind addresses
switch mode {
case .localhost:
#expect(mode.bindAddress == "127.0.0.1")
case .network:
#expect(mode.bindAddress == "0.0.0.0")
}
}
@Test("DashboardAccessMode raw values")
func testRawValues() throws {
#expect(DashboardAccessMode.localhost.rawValue == "localhost")
#expect(DashboardAccessMode.network.rawValue == "network")
}
@Test("DashboardAccessMode descriptions")
func testDescriptions() throws {
#expect(DashboardAccessMode.localhost.description.contains("this Mac"))
#expect(DashboardAccessMode.network.description.contains("other devices"))
}
}
// MARK: - UpdateChannel Tests
@Suite("UpdateChannel Tests")
struct UpdateChannelTests {
@Test("UpdateChannel precedence", arguments: zip(
UpdateChannel.allCases,
["stable", "prerelease"]
))
func testUpdateChannelPrecedence(channel: UpdateChannel, expectedRawValue: String) throws {
#expect(channel.rawValue == expectedRawValue)
}
@Test("UpdateChannel properties")
func testChannelProperties() throws {
// Stable channel
let stable = UpdateChannel.stable
#expect(stable.displayName == "Stable Only")
#expect(stable.includesPreReleases == false)
#expect(stable.appcastURL.absoluteString.contains("appcast.xml"))
// Prerelease channel
let prerelease = UpdateChannel.prerelease
#expect(prerelease.displayName == "Include Pre-releases")
#expect(prerelease.includesPreReleases == true)
#expect(prerelease.appcastURL.absoluteString.contains("prerelease"))
}
@Test("UpdateChannel default detection", arguments: [
("1.0.0", UpdateChannel.stable),
("1.0.0-beta", UpdateChannel.prerelease),
("2.0-alpha.1", UpdateChannel.prerelease),
("1.0.0-rc1", UpdateChannel.prerelease),
("1.0.0-pre", UpdateChannel.prerelease),
("1.0.0-dev", UpdateChannel.prerelease),
("1.2.3", UpdateChannel.stable)
])
func testDefaultChannelDetection(version: String, expectedChannel: UpdateChannel) throws {
let detectedChannel = UpdateChannel.defaultChannel(for: version)
#expect(detectedChannel == expectedChannel)
}
@Test("UpdateChannel appcast URLs")
func testAppcastURLs() throws {
// URLs should be valid
for channel in UpdateChannel.allCases {
let url = channel.appcastURL
#expect(url.scheme == "https")
#expect(url.host?.contains("stats.store") == true)
#expect(url.pathComponents.contains("appcast"))
}
}
@Test("UpdateChannel serialization")
func testSerialization() throws {
for channel in UpdateChannel.allCases {
let data = try JSONEncoder().encode(channel)
let decoded = try JSONDecoder().decode(UpdateChannel.self, from: data)
#expect(decoded == channel)
}
}
@Test("UpdateChannel UserDefaults integration")
func testUserDefaultsIntegration() throws {
let defaults = UserDefaults.standard
let originalValue = defaults.updateChannel
// Set and retrieve
defaults.updateChannel = UpdateChannel.prerelease.rawValue
#expect(defaults.updateChannel == "prerelease")
// Test current channel
#expect(UpdateChannel.current == .prerelease)
// Cleanup
defaults.updateChannel = originalValue
}
@Test("UpdateChannel Identifiable conformance")
func testIdentifiable() throws {
#expect(UpdateChannel.stable.id == "stable")
#expect(UpdateChannel.prerelease.id == "prerelease")
}
}
// MARK: - AppConstants Tests
@Suite("AppConstants Tests")
struct AppConstantsTests {
@Test("Welcome version constant")
func testWelcomeVersion() throws {
#expect(AppConstants.currentWelcomeVersion > 0)
#expect(AppConstants.currentWelcomeVersion == 2)
}
@Test("UserDefaults keys")
func testUserDefaultsKeys() throws {
#expect(AppConstants.UserDefaultsKeys.welcomeVersion == "welcomeVersion")
}
}
// MARK: - ServerLogEntry Tests
@Suite("ServerLogEntry Tests")
struct ServerLogEntryTests {
@Test("ServerLogEntry creation")
func testCreation() throws {
let entry = ServerLogEntry(
level: .info,
message: "Test message",
source: .rust
)
#expect(entry.level == .info)
#expect(entry.message == "Test message")
#expect(entry.source == .rust)
#expect(entry.timestamp <= Date())
}
@Test("ServerLogEntry levels", arguments: [
ServerLogEntry.Level.debug,
ServerLogEntry.Level.info,
ServerLogEntry.Level.warning,
ServerLogEntry.Level.error
])
func testLogLevels(level: ServerLogEntry.Level) throws {
let entry = ServerLogEntry(
level: level,
message: "Test",
source: .hummingbird
)
#expect(entry.level == level)
}
}
// MARK: - ServerMode Tests
@Suite("ServerMode Tests")
struct ServerModeTests {
@Test("ServerMode properties")
func testProperties() throws {
// Hummingbird
let hummingbird = ServerMode.hummingbird
#expect(hummingbird.displayName == "Hummingbird")
#expect(hummingbird.description == "Built-in Swift server")
// Rust
let rust = ServerMode.rust
#expect(rust.displayName == "Rust")
#expect(rust.description == "External tty-fwd binary")
}
@Test("ServerMode all cases")
func testAllCases() throws {
let allCases = ServerMode.allCases
#expect(allCases.count == 2)
#expect(allCases.contains(.hummingbird))
#expect(allCases.contains(.rust))
}
}
}
// MARK: - Test Helpers
actor TestActor {
private var session: TunnelSession?
func receiveSession(_ session: TunnelSession) {
self.session = session
}
func getSession() -> TunnelSession? {
session
}
}

View file

@ -0,0 +1,279 @@
import Testing
import Foundation
import Network
@testable import VibeTunnel
// MARK: - Mock Network Utility for Testing
enum MockNetworkUtility {
static var mockLocalIP: String?
static var mockAllIPs: [String] = []
static var shouldFailGetAddresses = false
static func reset() {
mockLocalIP = nil
mockAllIPs = []
shouldFailGetAddresses = false
}
static func getLocalIPAddress() -> String? {
if shouldFailGetAddresses { return nil }
return mockLocalIP
}
static func getAllIPAddresses() -> [String] {
if shouldFailGetAddresses { return [] }
return mockAllIPs
}
}
// MARK: - Network Utility Tests
@Suite("Network Utility Tests", .tags(.networking))
struct NetworkUtilityTests {
// MARK: - Local IP Address Tests
@Test("Get local IP address")
func testGetLocalIPAddress() throws {
// Test real implementation
let localIP = NetworkUtility.getLocalIPAddress()
// On a real system, we should get some IP address
// It might be nil in some test environments
if let ip = localIP {
#expect(!ip.isEmpty)
// Should be a valid IPv4 address format
let components = ip.split(separator: ".")
#expect(components.count == 4)
// Each component should be a valid number 0-255
for component in components {
if let num = Int(component) {
#expect(num >= 0 && num <= 255)
} else {
Issue.record("Invalid IP component: \(component)")
}
}
}
}
@Test("Local IP address preferences")
func testLocalIPPreferences() throws {
// Test that we prefer local network addresses
let mockIPs = [
"192.168.1.100", // Preferred - local network
"10.0.0.50", // Preferred - local network
"172.16.0.10", // Preferred - local network
"8.8.8.8", // Not preferred - public IP
"127.0.0.1" // Should be filtered out - loopback
]
// Verify our preference logic
for ip in mockIPs {
if ip.hasPrefix("192.168.") || ip.hasPrefix("10.") || ip.hasPrefix("172.") {
#expect(true, "IP \(ip) should be preferred")
}
}
}
@Test("Get all IP addresses")
func testGetAllIPAddresses() throws {
let allIPs = NetworkUtility.getAllIPAddresses()
// Should return array (might be empty in test environment)
#expect(allIPs.count >= 0)
// If we have IPs, verify they're valid
for ip in allIPs {
#expect(!ip.isEmpty)
// Should not contain loopback
#expect(!ip.hasPrefix("127."))
// Should be valid IPv4 format
let components = ip.split(separator: ".")
#expect(components.count == 4)
}
}
// MARK: - Network Interface Tests
@Test("Network interface filtering")
func testInterfaceFiltering() throws {
// Test that we filter interfaces correctly
let allIPs = NetworkUtility.getAllIPAddresses()
// Should not contain any loopback addresses
for ip in allIPs {
#expect(!ip.hasPrefix("127.0.0"))
#expect(ip != "::1") // IPv6 loopback
}
}
@Test("IPv4 address validation")
func testIPv4Validation() throws {
let testIPs = [
("192.168.1.1", true),
("10.0.0.1", true),
("172.16.0.1", true),
("256.1.1.1", false), // Invalid - component > 255
("1.1.1", false), // Invalid - only 3 components
("1.1.1.1.1", false), // Invalid - too many components
("a.b.c.d", false), // Invalid - non-numeric
("", false), // Invalid - empty
]
for (ip, shouldBeValid) in testIPs {
let components = ip.split(separator: ".")
let isValid = components.count == 4 && components.allSatisfy { component in
if let num = Int(component) {
return num >= 0 && num <= 255
}
return false
}
#expect(isValid == shouldBeValid, "IP \(ip) validation failed")
}
}
// MARK: - Edge Cases Tests
@Test("Handle no network interfaces")
func testNoNetworkInterfaces() throws {
// In a real scenario where no interfaces are available
// the functions should return nil/empty array gracefully
MockNetworkUtility.shouldFailGetAddresses = true
#expect(MockNetworkUtility.getLocalIPAddress() == nil)
#expect(MockNetworkUtility.getAllIPAddresses().isEmpty)
MockNetworkUtility.reset()
}
@Test("Multiple network interfaces")
func testMultipleInterfaces() throws {
// When multiple interfaces exist, we should get all of them
MockNetworkUtility.mockAllIPs = [
"192.168.1.100", // Wi-Fi
"192.168.2.50", // Ethernet
"10.0.0.100" // VPN
]
let allIPs = MockNetworkUtility.getAllIPAddresses()
#expect(allIPs.count == 3)
#expect(Set(allIPs).count == 3) // All unique
MockNetworkUtility.reset()
}
// MARK: - Platform-Specific Tests
@Test("macOS network interface names")
func testMacOSInterfaceNames() throws {
// On macOS, typical interface names are:
// en0 - Primary network interface (often Wi-Fi)
// en1 - Secondary network interface (often Ethernet)
// en2, en3, etc. - Additional interfaces
// This test documents expected behavior
let expectedPrefixes = ["en"]
for prefix in expectedPrefixes {
#expect(prefix.hasPrefix("en"), "Network interfaces should start with 'en' on macOS")
}
}
// MARK: - Performance Tests
@Test("Performance of IP address retrieval", .tags(.performance))
func testIPRetrievalPerformance() async throws {
// Measure time to get IP addresses
let start = Date()
for _ in 0..<10 {
_ = NetworkUtility.getLocalIPAddress()
}
let elapsed = Date().timeIntervalSince(start)
// Should be reasonably fast (< 1 second for 10 calls)
#expect(elapsed < 1.0, "IP retrieval took too long: \(elapsed) seconds")
}
// MARK: - Concurrent Access Tests
@Test("Concurrent IP address retrieval", .tags(.concurrency))
func testConcurrentAccess() async throws {
await withTaskGroup(of: String?.self) { group in
// Multiple concurrent calls
for _ in 0..<10 {
group.addTask {
NetworkUtility.getLocalIPAddress()
}
}
var results: [String?] = []
for await result in group {
results.append(result)
}
// All calls should return the same value
let uniqueResults = Set(results.compactMap { $0 })
#expect(uniqueResults.count <= 1, "Concurrent calls returned different IPs")
}
}
// MARK: - Integration Tests
@Test("Network utility with system network state", .tags(.integration))
func testSystemNetworkState() throws {
let localIP = NetworkUtility.getLocalIPAddress()
let allIPs = NetworkUtility.getAllIPAddresses()
// If we have a local IP, it should be in the all IPs list
if let localIP = localIP {
#expect(allIPs.contains(localIP), "Local IP should be in all IPs list")
}
// All IPs should be unique
#expect(Set(allIPs).count == allIPs.count, "IP addresses should be unique")
}
@Test("IP address format consistency")
func testIPAddressFormat() throws {
let allIPs = NetworkUtility.getAllIPAddresses()
for ip in allIPs {
// Should not have leading/trailing whitespace
#expect(ip == ip.trimmingCharacters(in: .whitespacesAndNewlines))
// Should not contain port numbers
#expect(!ip.contains(":"))
// Should be standard dotted decimal notation
#expect(ip.contains("."))
}
}
// MARK: - Mock Tests
@Test("Mock network utility behavior")
func testMockUtility() throws {
// Set up mock
MockNetworkUtility.mockLocalIP = "192.168.1.100"
MockNetworkUtility.mockAllIPs = ["192.168.1.100", "10.0.0.50"]
#expect(MockNetworkUtility.getLocalIPAddress() == "192.168.1.100")
#expect(MockNetworkUtility.getAllIPAddresses().count == 2)
// Test failure scenario
MockNetworkUtility.shouldFailGetAddresses = true
#expect(MockNetworkUtility.getLocalIPAddress() == nil)
#expect(MockNetworkUtility.getAllIPAddresses().isEmpty)
MockNetworkUtility.reset()
}
}

View file

@ -0,0 +1,398 @@
import Testing
import Foundation
@testable import VibeTunnel
// MARK: - Mock Ngrok Service
@MainActor
final class MockNgrokService: NgrokService {
// Test control properties
var shouldFailStart = false
var startError: Error?
var mockPublicUrl = "https://test-tunnel.ngrok.io"
var mockAuthToken: String?
var mockIsInstalled = true
var processOutput: String?
// Override properties
override var authToken: String? {
get { mockAuthToken }
set { mockAuthToken = newValue }
}
override var hasAuthToken: Bool {
mockAuthToken != nil
}
// Mock the start method
override func start(port: Int) async throws -> String {
if shouldFailStart {
throw startError ?? NgrokError.tunnelCreationFailed("Mock failure")
}
if mockAuthToken == nil {
throw NgrokError.authTokenMissing
}
if !mockIsInstalled {
throw NgrokError.notInstalled
}
// Simulate successful start
return mockPublicUrl
}
}
// MARK: - Mock Process for Ngrok
@MainActor
final class MockNgrokProcess: Process {
var mockIsRunning = false
var mockOutput: String?
var mockError: String?
var shouldFailToRun = false
override var isRunning: Bool {
mockIsRunning
}
override func run() throws {
if shouldFailToRun {
throw CocoaError(.fileNoSuchFile)
}
mockIsRunning = true
// Simulate ngrok output
if let output = mockOutput,
let pipe = standardOutput as? Pipe {
pipe.fileHandleForWriting.write(output.data(using: .utf8)!)
}
}
override func terminate() {
mockIsRunning = false
}
override func waitUntilExit() {
// No-op for mock
}
}
// MARK: - Ngrok Service Tests
@Suite("Ngrok Service Tests")
@MainActor
struct NgrokServiceTests {
// MARK: - Tunnel Creation Tests
@Test("Tunnel creation with auth token", .tags(.networking, .integration))
func testTunnelCreation() async throws {
let service = MockNgrokService()
service.mockAuthToken = "test-auth-token"
let publicUrl = try await service.start(port: 4020)
#expect(publicUrl == "https://test-tunnel.ngrok.io")
}
@Test("Tunnel creation fails without auth token", .tags(.networking))
func testTunnelCreationWithoutAuthToken() async throws {
let service = MockNgrokService()
service.mockAuthToken = nil
await #expect(throws: NgrokError.authTokenMissing) {
_ = try await service.start(port: 4020)
}
}
@Test("Tunnel creation with different ports", arguments: [8080, 3000, 9999])
func testTunnelCreationPorts(port: Int) async throws {
let service = MockNgrokService()
service.mockAuthToken = "test-auth-token"
let publicUrl = try await service.start(port: port)
#expect(publicUrl.starts(with: "https://"))
#expect(publicUrl.contains("ngrok.io"))
}
// MARK: - Error Handling Tests
@Test("Handling ngrok errors", arguments: [
NgrokError.notInstalled,
NgrokError.authTokenMissing,
NgrokError.tunnelCreationFailed("Test failure"),
NgrokError.invalidConfiguration,
NgrokError.networkError("Connection timeout")
])
func testErrorHandling(error: NgrokError) async throws {
let service = MockNgrokService()
service.mockAuthToken = "test-token"
service.shouldFailStart = true
service.startError = error
await #expect(throws: error) {
_ = try await service.start(port: 4020)
}
// Verify error descriptions
#expect(error.errorDescription != nil)
#expect(!error.errorDescription!.isEmpty)
}
@Test("Ngrok not installed error")
func testNgrokNotInstalled() async throws {
let service = MockNgrokService()
service.mockAuthToken = "test-token"
service.mockIsInstalled = false
await #expect(throws: NgrokError.notInstalled) {
_ = try await service.start(port: 4020)
}
}
// MARK: - Tunnel Lifecycle Tests
@Test("Tunnel lifecycle management")
func testTunnelLifecycle() async throws {
let service = NgrokService.shared
// Initial state
#expect(!service.isActive)
#expect(service.publicUrl == nil)
#expect(await service.isRunning() == false)
// Note: Can't test actual start without ngrok installed
// Test stop doesn't throw when no tunnel is active
try await service.stop()
#expect(!service.isActive)
#expect(service.publicUrl == nil)
}
@Test("Tunnel status monitoring")
func testTunnelStatus() async throws {
let service = NgrokService.shared
// No status when tunnel is not running
let status = await service.getStatus()
#expect(status == nil)
// Would need mock to test active tunnel status
}
// MARK: - Auth Token Management Tests
@Test("Auth token storage and retrieval")
func testAuthTokenManagement() async throws {
let service = MockNgrokService()
// Set token
let testToken = "ngrok-auth-token-\(UUID().uuidString)"
service.authToken = testToken
// Verify retrieval
#expect(service.authToken == testToken)
#expect(service.hasAuthToken)
// Delete token
service.authToken = nil
#expect(service.authToken == nil)
#expect(!service.hasAuthToken)
}
@Test("Has auth token check")
func testHasAuthToken() async throws {
let service = MockNgrokService()
// No token initially
#expect(!service.hasAuthToken)
// With token
service.authToken = "test-token"
#expect(service.hasAuthToken)
}
// MARK: - Concurrent Operations Tests
@Test("Concurrent tunnel operations", .tags(.concurrency))
func testConcurrentOperations() async throws {
let service = MockNgrokService()
service.mockAuthToken = "test-token"
// Start multiple operations concurrently
await withTaskGroup(of: Result<String, Error>.self) { group in
for i in 0..<3 {
group.addTask {
do {
let url = try await service.start(port: 4020 + i)
return .success(url)
} catch {
return .failure(error)
}
}
}
for await result in group {
switch result {
case .success(let url):
#expect(url == service.mockPublicUrl)
case .failure:
// Some operations might fail due to concurrent access
// This is expected behavior
break
}
}
}
}
// MARK: - Reliability Tests
@Test("Reconnection after network failure", .tags(.reliability))
func testReconnection() async throws {
let service = MockNgrokService()
service.mockAuthToken = "test-token"
// First connection succeeds
let url1 = try await service.start(port: 4020)
#expect(url1 == service.mockPublicUrl)
// Simulate network failure
service.shouldFailStart = true
service.startError = NgrokError.networkError("Connection lost")
await #expect(throws: NgrokError.networkError) {
_ = try await service.start(port: 4020)
}
// Recovery
service.shouldFailStart = false
let url2 = try await service.start(port: 4020)
#expect(url2 == service.mockPublicUrl)
}
@Test("Timeout handling")
func testTimeoutHandling() async throws {
let service = MockNgrokService()
service.mockAuthToken = "test-token"
service.startError = NgrokError.networkError("Operation timed out")
service.shouldFailStart = true
await #expect(throws: NgrokError.networkError) {
_ = try await service.start(port: 4020)
}
}
// MARK: - Tunnel Status Tests
@Test("Tunnel metrics tracking")
func testTunnelMetrics() async throws {
let metrics = NgrokTunnelStatus.TunnelMetrics(
connectionsCount: 10,
bytesIn: 1024,
bytesOut: 2048
)
#expect(metrics.connectionsCount == 10)
#expect(metrics.bytesIn == 1024)
#expect(metrics.bytesOut == 2048)
let status = NgrokTunnelStatus(
publicUrl: "https://test.ngrok.io",
metrics: metrics,
startedAt: Date()
)
#expect(status.publicUrl == "https://test.ngrok.io")
#expect(status.metrics.connectionsCount == 10)
}
// MARK: - Process Output Parsing Tests
@Test("Parse ngrok JSON output")
func testParseNgrokOutput() async throws {
// Test parsing various ngrok output formats
let outputs = [
#"{"msg":"started tunnel","url":"https://abc123.ngrok.io","addr":"http://localhost:4020"}"#,
#"{"addr":"https://xyz789.ngrok.io","msg":"tunnel created"}"#,
#"{"level":"info","msg":"tunnel session started"}"#
]
for output in outputs {
if let data = output.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
// Check for URL in various fields
let url = json["url"] as? String ?? json["addr"] as? String
if let url = url, url.starts(with: "https://") {
#expect(url.contains("ngrok.io"))
}
}
}
}
// MARK: - Integration Tests
@Test("Full tunnel lifecycle integration", .tags(.integration), .enabled(if: ProcessInfo.processInfo.environment["CI"] == nil))
func testFullIntegration() async throws {
// Skip in CI or when ngrok is not available
let service = NgrokService.shared
// Check if we have an auth token
guard service.hasAuthToken else {
throw TestError.skip("Ngrok auth token not configured")
}
// This would require actual ngrok installation
// For now, just verify the service is ready
#expect(service != nil)
// Clean state
try await service.stop()
#expect(!service.isActive)
}
}
// MARK: - AsyncLineSequence Tests
@Suite("AsyncLineSequence Tests")
struct AsyncLineSequenceTests {
@Test("Read lines from file handle")
func testAsyncLineSequence() async throws {
// Create a pipe with test data
let pipe = Pipe()
let testData = """
Line 1
Line 2
Line 3
""".data(using: .utf8)!
pipe.fileHandleForWriting.write(testData)
pipe.fileHandleForWriting.closeFile()
var lines: [String] = []
for await line in pipe.fileHandleForReading.lines {
lines.append(line)
}
#expect(lines.count == 3)
#expect(lines[0] == "Line 1")
#expect(lines[1] == "Line 2")
#expect(lines[2] == "Line 3")
}
@Test("Handle empty file")
func testEmptyFile() async throws {
let pipe = Pipe()
pipe.fileHandleForWriting.closeFile()
var lineCount = 0
for await _ in pipe.fileHandleForReading.lines {
lineCount += 1
}
#expect(lineCount == 0)
}
}

View file

@ -0,0 +1,402 @@
import Testing
import Foundation
@testable import VibeTunnel
// MARK: - Test Tags
extension Tag {
@Tag static var critical: Self
@Tag static var networking: Self
@Tag static var concurrency: Self
@Tag static var reliability: Self
}
// MARK: - Mock Server Implementation
@MainActor
final class MockServer: ServerProtocol {
var isRunning: Bool = false
var port: String = "8080"
let serverType: ServerMode
private var logContinuation: AsyncStream<ServerLogEntry>.Continuation?
var logStream: AsyncStream<ServerLogEntry>
// Test control properties
var shouldFailStart = false
var startError: Error?
var startDelay: Duration?
var stopDelay: Duration?
init(serverType: ServerMode = .rust) {
self.serverType = serverType
self.logStream = AsyncStream { continuation in
self.logContinuation = continuation
}
}
func start() async throws {
if let delay = startDelay {
try await Task.sleep(for: delay)
}
if shouldFailStart {
throw startError ?? ServerError.portInUse(port: port)
}
isRunning = true
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Mock server started on port \(port)",
source: serverType
))
}
func stop() async {
if let delay = stopDelay {
try? await Task.sleep(for: delay)
}
isRunning = false
logContinuation?.yield(ServerLogEntry(
level: .info,
message: "Mock server stopped",
source: serverType
))
logContinuation?.finish()
}
func restart() async throws {
await stop()
try await start()
}
}
// MARK: - Custom Errors for Testing
enum ServerError: LocalizedError {
case portInUse(port: String)
case initializationFailed
case networkUnavailable
var errorDescription: String? {
switch self {
case .portInUse(let port):
return "Port \(port) is already in use"
case .initializationFailed:
return "Server initialization failed"
case .networkUnavailable:
return "Network is unavailable"
}
}
}
// MARK: - Server Manager Tests
@Suite("Server Manager Tests")
@MainActor
struct ServerManagerTests {
// We'll use a custom ServerManager instance for each test to ensure isolation
// Note: ServerManager is a singleton, so we'll need to be careful with state
// MARK: - Server Lifecycle Tests
@Test("Starting and stopping servers", .tags(.critical))
func testServerLifecycle() async throws {
let manager = ServerManager.shared
// Ensure clean state
await manager.stop()
#expect(manager.currentServer == nil)
#expect(!manager.isRunning)
// Start server
await manager.start()
// Verify server is running
#expect(manager.currentServer != nil)
#expect(manager.isRunning)
#expect(manager.lastError == nil)
// Stop server
await manager.stop()
// Verify server is stopped
#expect(manager.currentServer == nil)
#expect(!manager.isRunning)
}
@Test("Starting server when already running does not create duplicate", .tags(.critical))
func testStartingAlreadyRunningServer() async throws {
let manager = ServerManager.shared
// Start first server
await manager.start()
let firstServer = manager.currentServer
#expect(firstServer != nil)
// Try to start again
await manager.start()
// Should still have the same server instance
#expect(manager.currentServer === firstServer)
#expect(manager.isRunning)
// Cleanup
await manager.stop()
}
@Test("Switching between Rust and Hummingbird", .tags(.critical))
func testServerModeSwitching() async throws {
let manager = ServerManager.shared
// Start with Rust mode
manager.serverMode = .rust
await manager.start()
#expect(manager.serverMode == .rust)
#expect(manager.currentServer?.serverType == .rust)
#expect(manager.isRunning)
// Switch to Hummingbird
await manager.switchMode(to: .hummingbird)
#expect(manager.serverMode == .hummingbird)
#expect(manager.currentServer?.serverType == .hummingbird)
#expect(manager.isRunning)
#expect(!manager.isSwitching)
// Cleanup
await manager.stop()
}
@Test("Port configuration", arguments: ["8080", "3000", "9999"])
func testPortConfiguration(port: String) async throws {
let manager = ServerManager.shared
// Set port before starting
manager.port = port
await manager.start()
#expect(manager.port == port)
#expect(manager.currentServer?.port == port)
// Cleanup
await manager.stop()
}
@Test("Bind address configuration", arguments: [
DashboardAccessMode.localhost,
DashboardAccessMode.network
])
func testBindAddressConfiguration(mode: DashboardAccessMode) async throws {
let manager = ServerManager.shared
// Set bind address
manager.bindAddress = mode.bindAddress
#expect(manager.bindAddress == mode.bindAddress)
// Start server and verify it uses the correct bind address
await manager.start()
#expect(manager.isRunning)
// Cleanup
await manager.stop()
}
// MARK: - Concurrent Operations Tests
@Test("Concurrent server operations are serialized", .tags(.concurrency))
func testConcurrentServerOperations() async throws {
let manager = ServerManager.shared
// Ensure clean state
await manager.stop()
// Start multiple operations concurrently
await withTaskGroup(of: Void.self) { group in
// Start server
group.addTask {
await manager.start()
}
// Try to stop immediately
group.addTask {
try? await Task.sleep(for: .milliseconds(50))
await manager.stop()
}
// Try to restart
group.addTask {
try? await Task.sleep(for: .milliseconds(100))
await manager.restart()
}
await group.waitForAll()
}
// Server should be in a consistent state
let finalState = manager.isRunning
if finalState {
#expect(manager.currentServer != nil)
} else {
#expect(manager.currentServer == nil)
}
// Cleanup
await manager.stop()
}
@Test("Server restart maintains configuration", .tags(.critical))
func testServerRestart() async throws {
let manager = ServerManager.shared
// Configure server
let testPort = "4321"
manager.port = testPort
manager.serverMode = .hummingbird
// Start server
await manager.start()
#expect(manager.isRunning)
// Restart
await manager.restart()
// Verify configuration is maintained
#expect(manager.port == testPort)
#expect(manager.serverMode == .hummingbird)
#expect(manager.isRunning)
#expect(!manager.isRestarting)
// Cleanup
await manager.stop()
}
// MARK: - Error Handling Tests
@Test("Server start failure is handled gracefully", .tags(.reliability))
func testServerStartFailure() async throws {
let manager = ServerManager.shared
// This test would require dependency injection to mock server creation
// For now, we test that the manager remains in a consistent state
// Try to start with an invalid configuration (if possible)
// In real implementation, we might force a port conflict or similar
await manager.start()
// Even if start fails, manager should be in consistent state
if manager.lastError != nil {
#expect(!manager.isRunning || manager.currentServer != nil)
}
// Cleanup
await manager.stop()
}
// MARK: - Log Stream Tests
@Test("Server logs are captured in log stream")
func testServerLogStream() async throws {
let manager = ServerManager.shared
// Collect logs during server operations
var collectedLogs: [ServerLogEntry] = []
let logTask = Task {
for await log in manager.logStream {
collectedLogs.append(log)
if collectedLogs.count >= 2 {
break
}
}
}
// Start server to generate logs
await manager.start()
// Wait for logs
try await Task.sleep(for: .milliseconds(100))
logTask.cancel()
// Verify logs were captured
#expect(!collectedLogs.isEmpty)
#expect(collectedLogs.contains { $0.message.contains("Starting") || $0.message.contains("started") })
// Cleanup
await manager.stop()
}
// MARK: - Mode Switch via UserDefaults Tests
@Test("Server mode change via UserDefaults triggers switch")
func testServerModeChangeViaUserDefaults() async throws {
let manager = ServerManager.shared
// Start with Rust mode
manager.serverMode = .rust
await manager.start()
#expect(manager.currentServer?.serverType == .rust)
// Change mode via UserDefaults (simulating settings change)
UserDefaults.standard.set(ServerMode.hummingbird.rawValue, forKey: "serverMode")
// Post notification to trigger the change
NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: nil)
// Give time for the async handler to process
try await Task.sleep(for: .milliseconds(500))
// Verify server switched
#expect(manager.serverMode == .hummingbird)
#expect(manager.currentServer?.serverType == .hummingbird)
// Cleanup
await manager.stop()
UserDefaults.standard.removeObject(forKey: "serverMode")
}
// MARK: - Initial Cleanup Tests
@Test("Initial cleanup triggers after server start when enabled", .tags(.networking))
func testInitialCleanupEnabled() async throws {
let manager = ServerManager.shared
// Enable cleanup on startup
UserDefaults.standard.set(true, forKey: "cleanupOnStartup")
// Start server
await manager.start()
// Give time for cleanup request
try await Task.sleep(for: .seconds(1))
// In a real test, we'd verify the cleanup endpoint was called
// For now, we just verify the server started successfully
#expect(manager.isRunning)
// Cleanup
await manager.stop()
UserDefaults.standard.removeObject(forKey: "cleanupOnStartup")
}
@Test("Initial cleanup is skipped when disabled")
func testInitialCleanupDisabled() async throws {
let manager = ServerManager.shared
// Disable cleanup on startup
UserDefaults.standard.set(false, forKey: "cleanupOnStartup")
// Start server
await manager.start()
// Verify server started without cleanup
#expect(manager.isRunning)
// Cleanup
await manager.stop()
UserDefaults.standard.removeObject(forKey: "cleanupOnStartup")
}
}

View file

@ -0,0 +1,410 @@
import Testing
import Foundation
@testable import VibeTunnel
// MARK: - Mock URLSession for Testing
@MainActor
final class MockURLSession: URLSession {
var responses: [URL: (Data, URLResponse)] = [:]
var errors: [URL: Error] = [:]
var requestDelay: Duration?
var requestCount = 0
override func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requestCount += 1
// Simulate delay if configured
if let delay = requestDelay {
try await Task.sleep(for: delay)
}
guard let url = request.url else {
throw URLError(.badURL)
}
// Check for configured error
if let error = errors[url] {
throw error
}
// Check for configured response
if let response = responses[url] {
return response
}
// Default to 404
let response = HTTPURLResponse(
url: url,
statusCode: 404,
httpVersion: nil,
headerFields: nil
)!
return (Data(), response)
}
}
// MARK: - Mock Session Monitor
@MainActor
final class MockSessionMonitor: SessionMonitor {
var mockSessions: [String: SessionInfo] = [:]
var mockSessionCount = 0
var mockLastError: String?
var fetchSessionsCalled = false
override func fetchSessions() async {
fetchSessionsCalled = true
self.sessions = mockSessions
self.sessionCount = mockSessionCount
self.lastError = mockLastError
}
func reset() {
mockSessions = [:]
mockSessionCount = 0
mockLastError = nil
fetchSessionsCalled = false
}
}
// MARK: - Session Monitor Tests
@Suite("Session Monitor Tests")
@MainActor
struct SessionMonitorTests {
// Helper to create test session data
func createTestSession(
id: String = UUID().uuidString,
status: String = "running",
exitCode: Int? = nil
) -> SessionMonitor.SessionInfo {
SessionMonitor.SessionInfo(
cmdline: ["/bin/bash"],
cwd: "/Users/test",
exitCode: exitCode,
name: id,
pid: Int.random(in: 1000...9999),
startedAt: ISO8601DateFormatter().string(from: Date()),
status: status,
stdin: "/tmp/\(id)/stdin",
streamOut: "/tmp/\(id)/stdout"
)
}
// MARK: - Monitoring Active Sessions Tests
@Test("Monitoring active sessions")
func testActiveSessionMonitoring() async throws {
let monitor = MockSessionMonitor()
// Set up mock sessions
let session1 = createTestSession(id: "session-1", status: "running")
let session2 = createTestSession(id: "session-2", status: "running")
let session3 = createTestSession(id: "session-3", status: "exited", exitCode: 0)
monitor.mockSessions = [
"session-1": session1,
"session-2": session2,
"session-3": session3
]
monitor.mockSessionCount = 2 // Only running sessions
// Fetch sessions
await monitor.fetchSessions()
#expect(monitor.fetchSessionsCalled)
#expect(monitor.sessionCount == 2)
#expect(monitor.sessions.count == 3)
#expect(monitor.lastError == nil)
// Verify running sessions
#expect(monitor.sessions["session-1"]?.isRunning == true)
#expect(monitor.sessions["session-2"]?.isRunning == true)
#expect(monitor.sessions["session-3"]?.isRunning == false)
}
@Test("Detecting stale sessions", .timeLimit(.seconds(5)))
func testStaleSessionDetection() async throws {
let monitor = SessionMonitor.shared
// This test documents expected behavior for detecting stale sessions
// In real implementation, stale sessions would be those that haven't
// updated their status for a certain period
// For now, verify that exited sessions are properly identified
let staleSession = createTestSession(status: "exited", exitCode: 1)
#expect(!staleSession.isRunning)
#expect(staleSession.exitCode == 1)
}
@Test("Session timeout handling", arguments: [30, 60, 120])
func testSessionTimeout(seconds: Int) async throws {
// Test that monitor can handle sessions with different timeout configurations
let monitor = MockSessionMonitor()
let session = createTestSession(status: "running")
monitor.mockSessions = [session.name: session]
monitor.mockSessionCount = 1
await monitor.fetchSessions()
#expect(monitor.sessionCount == 1)
// Simulate session timeout
let timedOutSession = createTestSession(
id: session.name,
status: "exited",
exitCode: 124 // Common timeout exit code
)
monitor.mockSessions = [session.name: timedOutSession]
monitor.mockSessionCount = 0
await monitor.fetchSessions()
#expect(monitor.sessionCount == 0)
#expect(monitor.sessions[session.name]?.exitCode == 124)
}
// MARK: - Session Lifecycle Tests
@Test("Monitor start and stop lifecycle")
func testMonitorLifecycle() async throws {
let monitor = SessionMonitor.shared
// Stop any existing monitoring
monitor.stopMonitoring()
// Start monitoring
monitor.startMonitoring()
// Give it a moment to start
try await Task.sleep(for: .milliseconds(100))
// Stop monitoring
monitor.stopMonitoring()
// Verify clean state
#expect(monitor.sessionCount >= 0)
}
@Test("Refresh on demand")
func testRefreshNow() async throws {
let monitor = MockSessionMonitor()
// Set up a session
let session = createTestSession()
monitor.mockSessions = [session.name: session]
monitor.mockSessionCount = 1
// Refresh
await monitor.refreshNow()
#expect(monitor.fetchSessionsCalled)
#expect(monitor.sessionCount == 1)
}
// MARK: - Error Handling Tests
@Test("Handle server not running")
func testServerNotRunning() async throws {
let monitor = SessionMonitor.shared
// When server is not running, sessions should be empty
// This test assumes server might not be running during tests
await monitor.fetchSessions()
// Should gracefully handle server not being available
#expect(monitor.sessions.isEmpty || monitor.sessions.count >= 0)
#expect(monitor.lastError == nil || monitor.lastError?.isEmpty == false)
}
@Test("Handle invalid session data")
func testInvalidSessionData() async throws {
let monitor = MockSessionMonitor()
monitor.mockLastError = "Error fetching sessions: Invalid JSON"
monitor.mockSessionCount = 0
await monitor.fetchSessions()
#expect(monitor.sessions.isEmpty)
#expect(monitor.sessionCount == 0)
#expect(monitor.lastError?.contains("Invalid JSON") == true)
}
// MARK: - Session Information Tests
@Test("Session info properties")
func testSessionInfoProperties() throws {
let session = createTestSession(
id: "test-session",
status: "running"
)
#expect(session.name == "test-session")
#expect(session.status == "running")
#expect(session.isRunning)
#expect(session.exitCode == nil)
#expect(session.cmdline == ["/bin/bash"])
#expect(session.cwd == "/Users/test")
#expect(session.pid > 0)
#expect(!session.stdin.isEmpty)
#expect(!session.streamOut.isEmpty)
}
@Test("Session JSON encoding/decoding")
func testSessionCoding() throws {
let session = createTestSession()
// Encode
let encoder = JSONEncoder()
let data = try encoder.encode(session)
// Decode
let decoder = JSONDecoder()
let decoded = try decoder.decode(SessionMonitor.SessionInfo.self, from: data)
#expect(decoded.name == session.name)
#expect(decoded.status == session.status)
#expect(decoded.pid == session.pid)
#expect(decoded.exitCode == session.exitCode)
}
// MARK: - Performance Tests
@Test("Memory management with many sessions", .tags(.performance))
func testMemoryManagement() async throws {
let monitor = MockSessionMonitor()
// Create many sessions
var sessions: [String: SessionMonitor.SessionInfo] = [:]
let sessionCount = 100
for i in 0..<sessionCount {
let session = createTestSession(
id: "session-\(i)",
status: i % 3 == 0 ? "exited" : "running"
)
sessions[session.name] = session
}
monitor.mockSessions = sessions
monitor.mockSessionCount = sessions.values.count { $0.isRunning }
await monitor.fetchSessions()
#expect(monitor.sessions.count == sessionCount)
#expect(monitor.sessionCount == sessions.values.count { $0.isRunning })
// Clear sessions
monitor.mockSessions = [:]
monitor.mockSessionCount = 0
await monitor.fetchSessions()
#expect(monitor.sessions.isEmpty)
#expect(monitor.sessionCount == 0)
}
// MARK: - Port Configuration Tests
@Test("Port configuration from UserDefaults")
func testPortConfiguration() async throws {
// Save current value
let originalPort = UserDefaults.standard.integer(forKey: "serverPort")
// Test custom port
UserDefaults.standard.set(8080, forKey: "serverPort")
let monitor = SessionMonitor.shared
monitor.startMonitoring()
// The monitor should use the configured port
// (Can't directly test private serverPort property)
monitor.stopMonitoring()
// Restore original
UserDefaults.standard.set(originalPort, forKey: "serverPort")
}
@Test("Default port when not configured")
func testDefaultPort() async throws {
// Remove port setting
UserDefaults.standard.removeObject(forKey: "serverPort")
let monitor = SessionMonitor.shared
monitor.startMonitoring()
// Should use default port 4020
// (Can't directly test private serverPort property)
monitor.stopMonitoring()
}
// MARK: - Concurrent Access Tests
@Test("Concurrent session updates", .tags(.concurrency))
func testConcurrentUpdates() async throws {
let monitor = MockSessionMonitor()
await withTaskGroup(of: Void.self) { group in
// Multiple concurrent fetches
for i in 0..<5 {
group.addTask {
let session = self.createTestSession(id: "concurrent-\(i)")
monitor.mockSessions[session.name] = session
monitor.mockSessionCount = monitor.mockSessions.values.count { $0.isRunning }
await monitor.fetchSessions()
}
}
await group.waitForAll()
}
// Should handle concurrent updates gracefully
#expect(monitor.sessions.count <= 5)
#expect(monitor.sessionCount >= 0)
}
// MARK: - Integration Tests
@Test("Full monitoring cycle", .tags(.integration))
func testFullMonitoringCycle() async throws {
let monitor = MockSessionMonitor()
// 1. Start with no sessions
#expect(monitor.sessions.isEmpty)
#expect(monitor.sessionCount == 0)
// 2. Add running sessions
let session1 = createTestSession(id: "cycle-1", status: "running")
let session2 = createTestSession(id: "cycle-2", status: "running")
monitor.mockSessions = [
session1.name: session1,
session2.name: session2
]
monitor.mockSessionCount = 2
await monitor.fetchSessions()
#expect(monitor.sessionCount == 2)
// 3. One session exits
let exitedSession = createTestSession(id: "cycle-1", status: "exited", exitCode: 0)
monitor.mockSessions[session1.name] = exitedSession
monitor.mockSessionCount = 1
await monitor.fetchSessions()
#expect(monitor.sessionCount == 1)
#expect(monitor.sessions["cycle-1"]?.isRunning == false)
#expect(monitor.sessions["cycle-2"]?.isRunning == true)
// 4. All sessions end
monitor.mockSessions = [:]
monitor.mockSessionCount = 0
await monitor.fetchSessions()
#expect(monitor.sessions.isEmpty)
#expect(monitor.sessionCount == 0)
}
}

View file

@ -0,0 +1,429 @@
import Testing
import Foundation
@testable import VibeTunnel
// MARK: - Mock Process for Testing
@MainActor
final class MockProcess: Process {
// Override properties we need to control
private var _executableURL: URL?
override var executableURL: URL? {
get { _executableURL }
set { _executableURL = newValue }
}
private var _arguments: [String]?
override var arguments: [String]? {
get { _arguments }
set { _arguments = newValue }
}
private var _standardOutput: Any?
override var standardOutput: Any? {
get { _standardOutput }
set { _standardOutput = newValue }
}
private var _standardError: Any?
override var standardError: Any? {
get { _standardError }
set { _standardError = newValue }
}
private var _terminationStatus: Int32 = 0
override var terminationStatus: Int32 {
get { _terminationStatus }
}
private var _isRunning: Bool = false
override var isRunning: Bool {
get { _isRunning }
}
// Test control properties
var shouldFailToRun = false
var runError: Error?
var simulatedOutput: String?
var simulatedError: String?
var simulatedTerminationStatus: Int32 = 0
override func run() throws {
if shouldFailToRun {
throw runError ?? CocoaError(.fileNoSuchFile)
}
_isRunning = true
// Simulate output if provided
if let output = simulatedOutput,
let outputPipe = standardOutput as? Pipe {
outputPipe.fileHandleForWriting.write(output.data(using: .utf8)!)
}
// Simulate error if provided
if let error = simulatedError,
let errorPipe = standardError as? Pipe {
errorPipe.fileHandleForWriting.write(error.data(using: .utf8)!)
}
// Simulate termination
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(10))
self._isRunning = false
self._terminationStatus = self.simulatedTerminationStatus
self.terminationHandler?(self)
}
}
override func terminate() {
_isRunning = false
_terminationStatus = 15 // SIGTERM
terminationHandler?(self)
}
}
// MARK: - Mock TTYForwardManager for Testing
@MainActor
final class MockTTYForwardManager: TTYForwardManager {
var mockExecutableURL: URL?
var mockExecutableExists = true
var mockIsExecutable = true
var processFactory: (() -> Process)?
override var ttyForwardExecutableURL: URL? {
mockExecutableURL
}
override func createTTYForwardProcess(with arguments: [String]) -> Process? {
guard mockExecutableURL != nil else { return nil }
if let factory = processFactory {
let process = factory()
process.executableURL = mockExecutableURL
process.arguments = arguments
return process
}
return super.createTTYForwardProcess(with: arguments)
}
}
// MARK: - TTYForwardManager Tests
@Suite("TTY Forward Manager Tests")
@MainActor
struct TTYForwardManagerTests {
// MARK: - Session Creation Tests
@Test("Creating TTY sessions", .tags(.critical, .networking))
func testSessionCreation() async throws {
let manager = TTYForwardManager.shared
// Test that executable URL is available in the bundle
let executableURL = manager.ttyForwardExecutableURL
#expect(executableURL != nil, "tty-fwd executable should be found in bundle")
// Test creating a process with typical session arguments
let sessionName = "test-session-\(UUID().uuidString)"
let arguments = [
"--session-name", sessionName,
"--port", "4020",
"--",
"/bin/bash"
]
let process = manager.createTTYForwardProcess(with: arguments)
#expect(process != nil)
#expect(process?.arguments == arguments)
#expect(process?.executableURL == executableURL)
}
@Test("Execute tty-fwd with valid arguments")
func testExecuteTTYForward() async throws {
let expectation = Expectation()
let manager = TTYForwardManager.shared
// Skip if executable not found (in test environment)
guard manager.ttyForwardExecutableURL != nil else {
throw TestError.skip("tty-fwd executable not available in test bundle")
}
let arguments = ["--help"] // Safe argument that should work
manager.executeTTYForward(with: arguments) { result in
switch result {
case .success(let process):
#expect(process.executableURL != nil)
#expect(process.arguments == arguments)
case .failure(let error):
Issue.record("Failed to execute tty-fwd: \(error)")
}
expectation.fulfill()
}
await expectation.fulfillment(timeout: .seconds(2))
}
// MARK: - Error Handling Tests
@Test("Handle missing executable")
func testMissingExecutable() async throws {
let expectation = Expectation()
// Create a mock manager with no executable
let mockManager = MockTTYForwardManager()
mockManager.mockExecutableURL = nil
mockManager.executeTTYForward(with: ["test"]) { result in
switch result {
case .success:
Issue.record("Should have failed with executableNotFound")
case .failure(let error):
#expect(error is TTYForwardError)
if let ttyError = error as? TTYForwardError {
#expect(ttyError == .executableNotFound)
}
}
expectation.fulfill()
}
await expectation.fulfillment(timeout: .seconds(1))
}
@Test("Handle non-executable file")
func testNonExecutableFile() async throws {
// This test would require mocking FileManager
// For now, we test the error type
let error = TTYForwardError.notExecutable
#expect(error.errorDescription?.contains("executable permissions") == true)
}
// MARK: - Command Execution Tests
@Test("Command execution through TTY", arguments: ["ls", "pwd", "echo test"])
func testCommandExecution(command: String) async throws {
let manager = TTYForwardManager.shared
// Create process for command execution
let sessionName = "cmd-test-\(UUID().uuidString)"
let arguments = [
"--session-name", sessionName,
"--port", "4020",
"--",
"/bin/bash", "-c", command
]
let process = manager.createTTYForwardProcess(with: arguments)
#expect(process != nil)
#expect(process?.arguments?.contains(command) == true)
}
@Test("Process termination handling")
func testProcessTermination() async throws {
let expectation = Expectation()
let mockProcess = MockProcess()
mockProcess.simulatedTerminationStatus = 0
// Set up mock manager
let mockManager = MockTTYForwardManager()
mockManager.mockExecutableURL = URL(fileURLWithPath: "/usr/bin/tty-fwd")
mockManager.processFactory = { mockProcess }
mockManager.executeTTYForward(with: ["test"]) { result in
switch result {
case .success(let process):
#expect(process === mockProcess)
case .failure:
Issue.record("Should have succeeded")
}
expectation.fulfill()
}
await expectation.fulfillment(timeout: .seconds(1))
// Wait for termination handler
try await Task.sleep(for: .milliseconds(50))
#expect(mockProcess.terminationStatus == 0)
}
@Test("Process failure handling")
func testProcessFailure() async throws {
let expectation = Expectation()
let mockProcess = MockProcess()
mockProcess.simulatedTerminationStatus = 1
mockProcess.simulatedError = "Error: Failed to create session"
// Set up mock manager
let mockManager = MockTTYForwardManager()
mockManager.mockExecutableURL = URL(fileURLWithPath: "/usr/bin/tty-fwd")
mockManager.processFactory = { mockProcess }
mockManager.executeTTYForward(with: ["test"]) { result in
// The execute method returns success even if process will fail later
switch result {
case .success(let process):
#expect(process === mockProcess)
case .failure:
Issue.record("Should have succeeded in starting process")
}
expectation.fulfill()
}
await expectation.fulfillment(timeout: .seconds(1))
// Wait for termination
try await Task.sleep(for: .milliseconds(50))
#expect(mockProcess.terminationStatus == 1)
}
// MARK: - Concurrent Sessions Tests
@Test("Multiple concurrent sessions", .tags(.concurrency))
func testConcurrentSessions() async throws {
let manager = TTYForwardManager.shared
// Create multiple sessions concurrently
let sessionCount = 5
var processes: [Process?] = []
await withTaskGroup(of: Process?.self) { group in
for i in 0..<sessionCount {
group.addTask {
let sessionName = "concurrent-\(i)-\(UUID().uuidString)"
let arguments = [
"--session-name", sessionName,
"--port", String(4020 + i),
"--",
"/bin/bash"
]
return manager.createTTYForwardProcess(with: arguments)
}
}
for await process in group {
processes.append(process)
}
}
// Verify all processes were created
#expect(processes.count == sessionCount)
#expect(processes.allSatisfy { $0 != nil })
// Verify each has unique port
let ports = processes.compactMap { process -> String? in
guard let args = process?.arguments,
let portIndex = args.firstIndex(of: "--port"),
portIndex + 1 < args.count else { return nil }
return args[portIndex + 1]
}
#expect(Set(ports).count == sessionCount, "Each session should have unique port")
}
// MARK: - Session Cleanup Tests
@Test("Session cleanup on disconnect")
func testSessionCleanup() async throws {
let mockProcess = MockProcess()
mockProcess.simulatedTerminationStatus = 0
// Verify process can be terminated
let expectation = Expectation()
mockProcess.terminationHandler = { _ in
expectation.fulfill()
}
// Start the process
try mockProcess.run()
#expect(mockProcess.isRunning)
// Terminate it
mockProcess.terminate()
await expectation.fulfillment(timeout: .seconds(1))
#expect(!mockProcess.isRunning)
#expect(mockProcess.terminationStatus == 15) // SIGTERM
}
// MARK: - Output Capture Tests
@Test("Capture session ID from stdout")
func testCaptureSessionId() async throws {
let mockProcess = MockProcess()
let sessionId = UUID().uuidString
mockProcess.simulatedOutput = sessionId
// Set up pipes
let outputPipe = Pipe()
mockProcess.standardOutput = outputPipe
// Run the process
try mockProcess.run()
// Read output
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
#expect(output == sessionId)
}
@Test("Handle stderr output")
func testStderrCapture() async throws {
let mockProcess = MockProcess()
let errorMessage = "Error: Port already in use"
mockProcess.simulatedError = errorMessage
mockProcess.simulatedTerminationStatus = 1
// Set up pipes
let errorPipe = Pipe()
mockProcess.standardError = errorPipe
// Run the process
try mockProcess.run()
// Read error output
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
let error = String(data: data, encoding: .utf8)
#expect(error == errorMessage)
}
}
// MARK: - Test Helpers
enum TestError: Error {
case skip(String)
}
// MARK: - Expectation Helper for Async Testing
@MainActor
final class Expectation {
private var continuation: CheckedContinuation<Void, Never>?
func fulfill() {
continuation?.resume()
continuation = nil
}
func fulfillment(timeout: Duration) async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
await withCheckedContinuation { continuation in
self.continuation = continuation
}
}
group.addTask {
try? await Task.sleep(for: timeout)
self.continuation?.resume()
self.continuation = nil
}
await group.next()
group.cancelAll()
}
}
}

View file

@ -0,0 +1,455 @@
import Testing
import Foundation
@testable import VibeTunnel
// MARK: - Mock Process for Testing
final class MockProcess: Process {
var mockIsRunning = false
var mockProcessIdentifier: Int32 = 12345
var mockShouldFailToRun = false
var runCalled = false
var terminateCalled = false
override var isRunning: Bool {
mockIsRunning
}
override var processIdentifier: Int32 {
mockProcessIdentifier
}
override func run() throws {
runCalled = true
if mockShouldFailToRun {
throw CocoaError(.fileNoSuchFile)
}
mockIsRunning = true
}
override func terminate() {
terminateCalled = true
mockIsRunning = false
}
}
// MARK: - Mock Terminal Manager
actor MockTerminalManager: TerminalManager {
var mockSessions: [UUID: TunnelSession] = [:]
var mockProcesses: [UUID: MockProcess] = [:]
var createSessionShouldFail = false
var executeCommandShouldFail = false
var executeCommandOutput = ("", "")
override func createSession(request: CreateSessionRequest) throws -> TunnelSession {
if createSessionShouldFail {
throw TunnelError.invalidRequest
}
let session = TunnelSession()
mockSessions[session.id] = session
let process = MockProcess()
process.mockProcessIdentifier = Int32.random(in: 1000...9999)
mockProcesses[session.id] = process
return session
}
override func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) {
if executeCommandShouldFail {
throw TunnelError.commandExecutionFailed("Mock failure")
}
guard mockSessions[sessionId] != nil else {
throw TunnelError.sessionNotFound
}
return executeCommandOutput
}
override func listSessions() -> [TunnelSession] {
Array(mockSessions.values)
}
override func getSession(id: UUID) -> TunnelSession? {
mockSessions[id]
}
override func closeSession(id: UUID) {
mockProcesses[id]?.terminate()
mockProcesses.removeValue(forKey: id)
mockSessions.removeValue(forKey: id)
}
func reset() {
mockSessions = [:]
mockProcesses = [:]
createSessionShouldFail = false
executeCommandShouldFail = false
executeCommandOutput = ("", "")
}
}
// MARK: - Terminal Manager Tests
@Suite("Terminal Manager Tests")
struct TerminalManagerTests {
// MARK: - Terminal Detection Tests
@Test("Detecting installed terminals", arguments: [
"/bin/bash",
"/bin/zsh",
"/bin/sh"
])
func testTerminalDetection(shell: String) throws {
// Verify common shells exist on the system
let shellExists = FileManager.default.fileExists(atPath: shell)
if shellExists {
#expect(FileManager.default.isExecutableFile(atPath: shell))
}
}
@Test("Default terminal selection")
func testDefaultTerminal() async throws {
let manager = MockTerminalManager()
// Create session with default shell
let request = CreateSessionRequest()
let session = try await manager.createSession(request: request)
#expect(session.id != UUID())
#expect(session.isActive)
#expect(await manager.mockSessions.count == 1)
}
// MARK: - Session Creation Tests
@Test("Create terminal session with custom shell", arguments: [
"/bin/bash",
"/bin/zsh",
"/usr/bin/env"
])
func testCreateSessionWithShell(shell: String) async throws {
let manager = MockTerminalManager()
let request = CreateSessionRequest(shell: shell)
let session = try await manager.createSession(request: request)
#expect(session.isActive)
#expect(session.createdAt <= Date())
#expect(session.lastActivity >= session.createdAt)
}
@Test("Create session with working directory")
func testCreateSessionWithWorkingDirectory() async throws {
let manager = MockTerminalManager()
let tempDir = FileManager.default.temporaryDirectory.path
let request = CreateSessionRequest(workingDirectory: tempDir)
let session = try await manager.createSession(request: request)
#expect(session.isActive)
#expect(await manager.getSession(id: session.id) != nil)
}
@Test("Create session with environment variables")
func testCreateSessionWithEnvironment() async throws {
let manager = MockTerminalManager()
let env = [
"CUSTOM_VAR": "test_value",
"PATH": "/custom/path:/usr/bin"
]
let request = CreateSessionRequest(environment: env)
let session = try await manager.createSession(request: request)
#expect(session.isActive)
}
@Test("Session creation failure")
func testSessionCreationFailure() async throws {
let manager = MockTerminalManager()
await manager.reset()
manager.createSessionShouldFail = true
await #expect(throws: TunnelError.invalidRequest) {
_ = try await manager.createSession(request: CreateSessionRequest())
}
#expect(await manager.mockSessions.isEmpty)
}
// MARK: - Command Execution Tests
@Test("Execute command in session", arguments: [
"ls -la",
"pwd",
"echo 'Hello, World!'",
"date"
])
func testCommandExecution(command: String) async throws {
let manager = MockTerminalManager()
// Create session
let session = try await manager.createSession(request: CreateSessionRequest())
// Set expected output
manager.executeCommandOutput = ("Command output\n", "")
// Execute command
let (output, error) = try await manager.executeCommand(
sessionId: session.id,
command: command
)
#expect(output == "Command output\n")
#expect(error.isEmpty)
}
@Test("Execute command with error output")
func testCommandWithError() async throws {
let manager = MockTerminalManager()
let session = try await manager.createSession(request: CreateSessionRequest())
manager.executeCommandOutput = ("", "Command not found\n")
let (output, error) = try await manager.executeCommand(
sessionId: session.id,
command: "nonexistent-command"
)
#expect(output.isEmpty)
#expect(error == "Command not found\n")
}
@Test("Execute command in non-existent session")
func testCommandInNonExistentSession() async throws {
let manager = MockTerminalManager()
let fakeId = UUID()
await #expect(throws: TunnelError.sessionNotFound) {
_ = try await manager.executeCommand(
sessionId: fakeId,
command: "ls"
)
}
}
@Test("Command execution timeout")
func testCommandTimeout() async throws {
// Test that timeout is handled properly
let error = TunnelError.timeout
#expect(error.errorDescription == "Operation timed out")
}
// MARK: - Session Management Tests
@Test("List all sessions")
func testListSessions() async throws {
let manager = MockTerminalManager()
// Create multiple sessions
let session1 = try await manager.createSession(request: CreateSessionRequest())
let session2 = try await manager.createSession(request: CreateSessionRequest())
let session3 = try await manager.createSession(request: CreateSessionRequest())
let sessions = await manager.listSessions()
#expect(sessions.count == 3)
#expect(sessions.map(\.id).contains(session1.id))
#expect(sessions.map(\.id).contains(session2.id))
#expect(sessions.map(\.id).contains(session3.id))
}
@Test("Get specific session")
func testGetSession() async throws {
let manager = MockTerminalManager()
let session = try await manager.createSession(request: CreateSessionRequest())
let retrieved = await manager.getSession(id: session.id)
#expect(retrieved?.id == session.id)
#expect(retrieved?.isActive == true)
// Non-existent session
let nonExistent = await manager.getSession(id: UUID())
#expect(nonExistent == nil)
}
@Test("Close session")
func testCloseSession() async throws {
let manager = MockTerminalManager()
let session = try await manager.createSession(request: CreateSessionRequest())
#expect(await manager.mockSessions.count == 1)
await manager.closeSession(id: session.id)
#expect(await manager.mockSessions.isEmpty)
#expect(await manager.getSession(id: session.id) == nil)
// Verify process was terminated
let process = await manager.mockProcesses[session.id]
#expect(process == nil)
}
@Test("Close non-existent session")
func testCloseNonExistentSession() async throws {
let manager = MockTerminalManager()
let fakeId = UUID()
// Should not throw, just silently do nothing
await manager.closeSession(id: fakeId)
#expect(await manager.mockSessions.isEmpty)
}
// MARK: - Session Cleanup Tests
@Test("Cleanup inactive sessions")
func testCleanupInactiveSessions() async throws {
let manager = TerminalManager()
// This test documents expected behavior
// In real implementation, sessions older than specified minutes would be cleaned up
await manager.cleanupInactiveSessions(olderThan: 30)
// After cleanup, only active/recent sessions should remain
let remainingSessions = await manager.listSessions()
for session in remainingSessions {
#expect(session.lastActivity > Date().addingTimeInterval(-30 * 60))
}
}
// MARK: - Concurrent Operations Tests
@Test("Concurrent session creation", .tags(.concurrency))
func testConcurrentSessionCreation() async throws {
let manager = MockTerminalManager()
let sessionIds = await withTaskGroup(of: UUID?.self) { group in
for i in 0..<5 {
group.addTask {
do {
let request = CreateSessionRequest(
workingDirectory: "/tmp/session-\(i)"
)
let session = try await manager.createSession(request: request)
return session.id
} catch {
return nil
}
}
}
var ids: [UUID] = []
for await id in group {
if let id = id {
ids.append(id)
}
}
return ids
}
#expect(sessionIds.count == 5)
#expect(Set(sessionIds).count == 5) // All unique
#expect(await manager.mockSessions.count == 5)
}
@Test("Concurrent command execution", .tags(.concurrency))
func testConcurrentCommandExecution() async throws {
let manager = MockTerminalManager()
// Create a session
let session = try await manager.createSession(request: CreateSessionRequest())
manager.executeCommandOutput = ("OK\n", "")
// Execute multiple commands concurrently
let results = await withTaskGroup(of: Result<String, Error>.self) { group in
for i in 0..<3 {
group.addTask {
do {
let (output, _) = try await manager.executeCommand(
sessionId: session.id,
command: "echo \(i)"
)
return .success(output)
} catch {
return .failure(error)
}
}
}
var outputs: [String] = []
for await result in group {
if case .success(let output) = result {
outputs.append(output)
}
}
return outputs
}
#expect(results.count == 3)
#expect(results.allSatisfy { $0 == "OK\n" })
}
// MARK: - Error Handling Tests
@Test("Terminal error types")
func testErrorTypes() throws {
let errors: [TunnelError] = [
.sessionNotFound,
.commandExecutionFailed("Test failure"),
.timeout,
.invalidRequest
]
for error in errors {
#expect(error.errorDescription != nil)
#expect(!error.errorDescription!.isEmpty)
}
}
// MARK: - Integration Tests
@Test("Full session lifecycle", .tags(.integration))
func testFullSessionLifecycle() async throws {
let manager = MockTerminalManager()
// 1. Create session
let request = CreateSessionRequest(
workingDirectory: "/tmp",
environment: ["TEST": "value"],
shell: "/bin/bash"
)
let session = try await manager.createSession(request: request)
// 2. Verify session exists
let retrieved = await manager.getSession(id: session.id)
#expect(retrieved != nil)
#expect(retrieved?.isActive == true)
// 3. Execute commands
manager.executeCommandOutput = ("test output\n", "")
let (output1, _) = try await manager.executeCommand(
sessionId: session.id,
command: "echo test"
)
#expect(output1 == "test output\n")
// 4. List sessions
let sessions = await manager.listSessions()
#expect(sessions.count == 1)
// 5. Close session
await manager.closeSession(id: session.id)
// 6. Verify cleanup
#expect(await manager.getSession(id: session.id) == nil)
#expect(await manager.listSessions().isEmpty)
}
}