mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-24 14:47:39 +00:00
Add basic Swift tests
This commit is contained in:
parent
0f8299c7a0
commit
a8876e9c69
10 changed files with 3872 additions and 0 deletions
397
VibeTunnelTests/BasicAuthMiddlewareTests.swift
Normal file
397
VibeTunnelTests/BasicAuthMiddlewareTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
411
VibeTunnelTests/CLIInstallerTests.swift
Normal file
411
VibeTunnelTests/CLIInstallerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
357
VibeTunnelTests/DashboardKeychainTests.swift
Normal file
357
VibeTunnelTests/DashboardKeychainTests.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
334
VibeTunnelTests/ModelTests.swift
Normal file
334
VibeTunnelTests/ModelTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
279
VibeTunnelTests/NetworkUtilityTests.swift
Normal file
279
VibeTunnelTests/NetworkUtilityTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
398
VibeTunnelTests/NgrokServiceTests.swift
Normal file
398
VibeTunnelTests/NgrokServiceTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
402
VibeTunnelTests/ServerManagerTests.swift
Normal file
402
VibeTunnelTests/ServerManagerTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
410
VibeTunnelTests/SessionMonitorTests.swift
Normal file
410
VibeTunnelTests/SessionMonitorTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
429
VibeTunnelTests/TTYForwardManagerTests.swift
Normal file
429
VibeTunnelTests/TTYForwardManagerTests.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
455
VibeTunnelTests/TerminalManagerTests.swift
Normal file
455
VibeTunnelTests/TerminalManagerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue