// VoiceCatClientSmokeTests — exercises the full connect → TOFU → auth → channels → // moderation flow purely through the Swift wrapper layer (VoiceCatClient), against a real // `voicecat-server` (the same binary the C++ ctest suite uses, built by `cmake --preset dev`). // This is the Swift analog of clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs. // // Why this exists (same rationale as the C# tests): the C++ ctest suite proves the protocol // works at the C++ level, but Swift-specific interop bugs — @convention(c) callback lifetime, // Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct // field layout — can only be caught by exercising the exact Swift→C boundary. These tests // catch the same class of bugs the C# P/Invoke tests catch, for Swift. // // Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server // and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the // VoiceCatCore.xcframework that the Swift Package links). import XCTest import Foundation @testable import VoiceCatCore /// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server /// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known /// admin account via `voicecat-admin`. Killed + cleaned up in deinit. private final class ServerHarness { let port: UInt16 private let process: Process let tempDir: String init() throws { let repoRoot = Self.findRepoRoot() let serverURL = URL(fileURLWithPath: repoRoot) .appendingPathComponent("build/dev/bin/voicecat-server") guard FileManager.default.isExecutableFile(atPath: serverURL.path) else { throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [ NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path) — " + "build the dev preset first: cmake --preset dev && cmake --build --preset dev", ]) } let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true) self.tempDir = tempDir let p = Process() p.executableURL = serverURL p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"] // Pipe stdout to read the bound port; stderr to /dev/null. let stdoutPipe = Pipe() p.standardOutput = stdoutPipe p.standardError = FileHandle(forWritingAtPath: "/dev/null") try p.run() self.process = p // Parse "[voicecat-server] ... — TCP : UDP :" from stdout. The server // prints several lines before the port line (version, first-run admin box, etc.), so // we keep reading until we find a line matching "TCP :". Read with a 10s timeout // so a crashed/hung server can't hang the test forever. guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else { p.terminate() throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [ NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s", ]) } self.port = port // Provision a known admin account for moderation/admin tests (M5). let adminURL = URL(fileURLWithPath: repoRoot) .appendingPathComponent("build/dev/bin/voicecat-admin") guard FileManager.default.isExecutableFile(atPath: adminURL.path) else { throw NSError(domain: "VoiceCatTest", code: 3, userInfo: [ NSLocalizedDescriptionKey: "voicecat-admin not found at \(adminURL.path)", ]) } let adminProc = Process() adminProc.executableURL = adminURL adminProc.arguments = ["--data-dir", tempDir, "account", "add", "admin2", "--admin", "--password", "testpassword123"] adminProc.standardOutput = FileHandle(forWritingAtPath: "/dev/null") adminProc.standardError = FileHandle(forWritingAtPath: "/dev/null") try adminProc.run() adminProc.waitUntilExit() guard adminProc.terminationStatus == 0 else { throw NSError(domain: "VoiceCatTest", code: 4, userInfo: [ NSLocalizedDescriptionKey: "voicecat-admin failed to provision admin2 (exit \(adminProc.terminationStatus))", ]) } } deinit { if process.isRunning { process.terminate() } try? FileManager.default.removeItem(atPath: tempDir) } private static func findRepoRoot() -> String { var url = URL(fileURLWithPath: #file) while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) { url = url.deletingLastPathComponent() } guard FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) else { fatalError("Could not find repo root (CMakePresets.json) above \(#file)") } return url.path } /// Read from the server's stdout until a line matching "TCP :" is found, or the /// timeout expires. The server prints several lines (version banner, first-run admin box, /// etc.) before the port line — see server/src/server.cpp. private static func readPortWithTimeout(_ pipe: Pipe, timeout: TimeInterval) -> UInt16? { let handle = pipe.fileHandleForReading let deadline = Date().addingTimeInterval(timeout) var buffer = Data() while Date() < deadline { let data = handle.availableData if !data.isEmpty { buffer.append(data) // Check each complete line in the buffer for "TCP :". while let newlineIdx = buffer.firstIndex(of: 0x0A) { let lineData = buffer.prefix(newlineIdx) buffer = buffer.suffix(from: buffer.index(after: newlineIdx)) if let line = String(data: lineData, encoding: .utf8), let port = parsePort(from: line) { return port } } } Thread.sleep(forTimeInterval: 0.05) } return nil } private static func parsePort(from line: String) -> UInt16? { // Match "TCP :" — see server/src/server.cpp. guard let range = line.range(of: #"TCP :(\d+)"#, options: .regularExpression) else { return nil } let digits = line[range].split(separator: ":").last ?? "" return UInt16(digits.trimmingCharacters(in: .whitespaces)) } } /// XCTest smoke tests against a real voicecat-server, through the Swift VoiceCatClient wrapper. final class VoiceCatClientSmokeTests: XCTestCase { private static var harness: ServerHarness? override class func setUp() { do { harness = try ServerHarness() } catch { // Store the error so each test fails with a clear message rather than a crash. NSLog("ServerHarness setup failed: \(error.localizedDescription)") harness = nil } } override class func tearDown() { harness = nil } private var port: UInt16 { guard let p = Self.harness?.port else { XCTFail("ServerHarness not started — see setUp error in log") return 0 } return p } private var tempDir: String { Self.harness?.tempDir ?? NSTemporaryDirectory() } /// Helper: wait until the predicate is satisfied, running the main runloop to process /// dispatched events. The Swift analog of the C# `PumpUntil` helper. Our events are /// delivered via DispatchQueue.main.async, which the main runloop processes during /// `RunLoop.current.run(until:)`. /// /// Uses RunLoop polling (not XCTestExpectation) so that the "assert something does NOT /// happen within N seconds" pattern works without generating spurious "Asynchronous wait /// failed" errors — `wait(for:timeout:)` logs an error when an expectation isn't /// fulfilled, which is wrong for negative checks. private func waitFor(timeout: TimeInterval = 5, _ predicate: @escaping () -> Bool) -> Bool { if predicate() { return true } let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { // Run the main runloop for ~20ms — processes DispatchQueue.main.async blocks // (where our events/levels are drained) and timer sources. RunLoop.current.run(until: Date().addingTimeInterval(0.02)) if predicate() { return true } } return predicate() } private func requireHarness() -> Bool { guard Self.harness != nil else { XCTFail("ServerHarness not started — see setUp error in log") return false } return true } // MARK: - Tests func testVersionStringIsNonEmpty() { XCTAssertFalse(VoiceCatClient.versionString.isEmpty) } func testResultStringRoundTrips() { XCTAssertFalse(VoiceCatClient.resultString(.ok).isEmpty) XCTAssertFalse(VoiceCatClient.resultString(.permissionDenied).isEmpty) } /// Full connect → TOFU → confirm → guest auth → list channels → permissions → guest /// ListAccounts rejected. Mirrors the C# `Connect_Tofu_Auth_ListChannels_RoundTrips`. func testConnectTofuAuthListChannelsRoundTrips() throws { guard requireHarness() else { return } var events: [VoiceCatEvent] = [] let client = VoiceCatClient(config: VoiceCatConfig( clientName: "vc-swift-smoke", clientVersion: "0.1", logLevel: .off, tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins.txt") )) client.onEvent = { events.append($0) } XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) XCTAssertEqual(client.authenticateGuest("SwiftSmoke"), .ok) // Wait for VC_EVENT_SERVER_IDENTITY. XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }, "did not receive .serverIdentity") let identityEvent = try XCTUnwrap(events.first { $0.type == .serverIdentity }) XCTAssertEqual(identityEvent.tofuStatus, .firstConnect) XCTAssertNotNil(identityEvent.text) XCTAssertEqual(identityEvent.text?.count, 64, "SHA-256 hex, no separators") // Auth must NOT complete before identity is confirmed (800ms, like the C# test). XCTAssertFalse(waitFor(timeout: 0.8) { events.contains { $0.type == .authResult } }, "auth completed before identity confirmation (should be held open)") XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) // Wait for VC_EVENT_AUTH_RESULT. XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }, "did not receive .authResult after confirming identity") let authEvent = try XCTUnwrap(events.first { $0.type == .authResult }) XCTAssertEqual(authEvent.result, .ok) // Wait for VC_EVENT_CHANNEL_LIST. XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }, "did not receive .channelList") let channels = client.listChannels() XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" }, "expected Lobby (channel 1) in \(channels.map { $0.name })") // M5: permissions getter round-trip. let perms = client.getPermissions() XCTAssertFalse(perms.isAdmin) XCTAssertFalse(perms.canKick) // M5: guest ListAccounts is rejected by the server with a GenericResult — proves the // moderation wrapper path works end-to-end through the Swift interop layer. events.removeAll() XCTAssertEqual(client.requestAccountList(), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult } }, "did not receive .genericResult for guest ListAccounts") let generic = try XCTUnwrap(events.first { $0.type == .genericResult }) XCTAssertEqual(generic.result, .permissionDenied) client.disconnect() } /// Admin auth → channel CRUD → account CRUD. Mirrors C# `Admin_ChannelCrud_AccountCrud_RoundTrips`. func testAdminChannelCrudAccountCrudRoundTrips() throws { guard requireHarness() else { return } var events: [VoiceCatEvent] = [] let client = VoiceCatClient(config: VoiceCatConfig( clientName: "vc-swift-admin", clientVersion: "0.1", logLevel: .off, tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_admin.txt") )) client.onEvent = { events.append($0) } XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) XCTAssertEqual(client.authenticateUser("admin2", password: "testpassword123"), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }) XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }) XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }) let perms = client.getPermissions() XCTAssertTrue(perms.isAdmin || perms.canAdminAccounts) // Channel CRUD — create. let audioConfig = AudioConfig(stereo: true, bitrateBps: 64000, frameMs: 20, application: 1, fec: true, expectedPacketLoss: 5, complexity: 10) XCTAssertEqual(client.createChannel(ChannelEdit( id: 0, parentId: 0, name: "Swift Test Channel", topic: "Created by Swift smoke test", passwordProtected: false, password: nil, maxUsers: 42, sortOrder: 0, audio: audioConfig )), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, "CreateChannel did not succeed") var channels = client.listChannels() let created = try XCTUnwrap(channels.first { $0.name == "Swift Test Channel" }) XCTAssertEqual(created.topic, "Created by Swift smoke test") XCTAssertFalse(created.passwordProtected) // Channel CRUD — edit. events.removeAll() XCTAssertEqual(client.editChannel(ChannelEdit( id: created.id, parentId: created.parentId, name: created.name, topic: "Updated topic", passwordProtected: false, password: nil, maxUsers: 100, sortOrder: 0, audio: audioConfig )), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, "EditChannel did not succeed") // Channel CRUD — delete. events.removeAll() XCTAssertEqual(client.deleteChannel(created.id), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, "DeleteChannel did not succeed") // Account CRUD — create. events.removeAll() XCTAssertEqual(client.createAccount("swift_smoke_user", password: "initialpw"), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, "CreateAccount did not succeed") // Account CRUD — list. events.removeAll() XCTAssertEqual(client.requestAccountList(), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .accountList } }, "did not receive .accountList") let accounts = client.listAccounts() XCTAssertTrue(accounts.contains { $0.username == "swift_smoke_user" }) // Account CRUD — reset password. events.removeAll() XCTAssertEqual(client.resetPassword("swift_smoke_user", newPassword: "newpw123"), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, "ResetPassword did not succeed") // Account CRUD — delete. events.removeAll() XCTAssertEqual(client.deleteAccount("swift_smoke_user"), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } }, "DeleteAccount did not succeed") client.disconnect() } /// Screen-audio (SCREEN_AUDIO) stream start/stop through the Swift wrapper. The core's /// macOS CoreAudio path starts the StreamAnnounce; this exercises the full /// startStream → .streamStarted → stopStream → .streamStopped path through Swift interop. /// Mirrors C# `ScreenAudioStream_Starts_And_Stops`. func testScreenAudioStreamStartsAndStops() throws { guard requireHarness() else { return } var events: [VoiceCatEvent] = [] let client = VoiceCatClient(config: VoiceCatConfig( clientName: "vc-swift-screen", clientVersion: "0.1", logLevel: .off, tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_screen.txt") )) client.onEvent = { events.append($0) } XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok) XCTAssertEqual(client.authenticateGuest("SwiftScreen"), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } }) XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } }) XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok) XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } }) // Give the async UDP binding handshake a moment to land (mirrors vccli's 500ms sleep). Thread.sleep(forTimeInterval: 0.5) let (startResult, streamId) = client.startStream( StreamDescriptor(kind: .screenAudio, label: "Desktop audio") ) XCTAssertEqual(startResult, .ok) XCTAssertNotEqual(streamId, 0, "streamId should be non-zero on success") // The core emits .streamStarted for the local client too. XCTAssertTrue(waitFor(timeout: 5) { events.contains { $0.type == .streamStarted && $0.streamId == streamId } }, "did not receive .streamStarted for screen-audio stream") XCTAssertEqual(client.stopStream(streamId), .ok) XCTAssertTrue(waitFor(timeout: 5) { events.contains { $0.type == .streamStopped && $0.streamId == streamId } }, "did not receive .streamStopped for screen-audio stream") client.disconnect() } /// Per-stream receive-side controls (gain/mute/NR) round-trip through Swift: two clients /// in a channel, one publishes a MIC stream, the other setRemoteStream's it then /// getRemoteStream's it back. Catches Swift-specific marshaling bugs (field order, /// bool-from-int, float precision) that the C++ ctest can't. Mirrors C# /// `PerStream_RecvControls_Round_Trip_Through_PInvoke`. func testPerStreamRecvControlsRoundTrip() throws { guard requireHarness() else { return } var eventsA: [VoiceCatEvent] = [] var eventsB: [VoiceCatEvent] = [] let a = VoiceCatClient(config: VoiceCatConfig( clientName: "vc-swift-mix-a", clientVersion: "0.1", logLevel: .off, tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_a.txt") )) let b = VoiceCatClient(config: VoiceCatConfig( clientName: "vc-swift-mix-b", clientVersion: "0.1", logLevel: .off, tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_b.txt") )) a.onEvent = { eventsA.append($0) } b.onEvent = { eventsB.append($0) } // Connect + auth A first, then B (staggering avoids concurrent TLS handshakes). XCTAssertEqual(a.connect(host: "127.0.0.1", port: port), .ok) XCTAssertEqual(a.authenticateGuest("SwiftMixA"), .ok) XCTAssertTrue(waitFor { eventsA.contains { $0.type == .serverIdentity } }) XCTAssertEqual(a.confirmServerIdentity(accept: true), .ok) XCTAssertTrue(waitFor { eventsA.contains { $0.type == .authResult } }) XCTAssertEqual(try XCTUnwrap(eventsA.first { $0.type == .authResult }).result, .ok) XCTAssertTrue(waitFor { eventsA.contains { $0.type == .channelList } }) XCTAssertEqual(b.connect(host: "127.0.0.1", port: port), .ok) XCTAssertEqual(b.authenticateGuest("SwiftMixB"), .ok) XCTAssertTrue(waitFor { eventsB.contains { $0.type == .serverIdentity } }) XCTAssertEqual(b.confirmServerIdentity(accept: true), .ok) XCTAssertTrue(waitFor { eventsB.contains { $0.type == .authResult } }) XCTAssertEqual(try XCTUnwrap(eventsB.first { $0.type == .authResult }).result, .ok) XCTAssertTrue(waitFor { eventsB.contains { $0.type == .channelList } }) // Both join Lobby (channel 1) so voice relays between them. XCTAssertEqual(a.joinChannel(1), .ok) XCTAssertTrue(waitFor { eventsA.contains { $0.type == .joinResult } }, "A did not receive .joinResult") XCTAssertEqual(b.joinChannel(1), .ok) XCTAssertTrue(waitFor { eventsB.contains { $0.type == .joinResult } }, "B did not receive .joinResult") // UDP binding handshake is async; give it a moment. Thread.sleep(forTimeInterval: 0.5) // A publishes a MIC stream. let (startResult, streamId) = a.startStream(StreamDescriptor(kind: .mic, label: "mix-test-mic")) XCTAssertEqual(startResult, .ok) XCTAssertNotEqual(streamId, 0) // B sees A's stream. XCTAssertTrue(waitFor(timeout: 5) { eventsB.contains { $0.type == .streamStarted && $0.streamId == streamId } }, "B did not see A's .streamStarted") // Resolve A's user id from B's user list. var aUid: UInt32 = 0 XCTAssertTrue(waitFor(timeout: 3) { aUid = b.listUsers().first { $0.nickname == "SwiftMixA" }?.id ?? 0 return aUid != 0 }, "could not resolve A's user id on B") XCTAssertNotEqual(aUid, 0) // B can enumerate A's stream. XCTAssertTrue(waitFor(timeout: 3) { b.listUserStreams(aUid).contains { $0.id == streamId } }, "B could not enumerate A's stream") let bStreams = b.listUserStreams(aUid) XCTAssertTrue(bStreams.contains { $0.id == streamId && $0.kind == .mic }) // Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off). let (r0, st0) = b.getRemoteStream(userId: aUid, streamId: streamId) XCTAssertEqual(r0, .ok) XCTAssertNotNil(st0) XCTAssertEqual(st0?.gain, 1.0) XCTAssertFalse(st0?.muted ?? true) XCTAssertFalse(st0?.noiseReduction ?? true) // B turns A down to 0.5×, mutes, enables NR — then reads it back. XCTAssertEqual(b.setRemoteStream(userId: aUid, streamId: streamId, gain: 0.5, muted: true, noiseReduction: true), .ok) let (r1, st1) = b.getRemoteStream(userId: aUid, streamId: streamId) XCTAssertEqual(r1, .ok) XCTAssertNotNil(st1) XCTAssertEqual(st1?.gain, 0.5) XCTAssertTrue(st1?.muted ?? false) XCTAssertTrue(st1?.noiseReduction ?? false) // Unknown stream id on a known user → .invalidArg. let (rBad, stBad) = b.getRemoteStream(userId: aUid, streamId: 0xDEADBEEF) XCTAssertEqual(rBad, .invalidArg) XCTAssertNil(stBad) a.disconnect() b.disconnect() } }