Files
voice-cat/clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift
Talon bba605401d
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
chore: comment cleanup pass ahead of open-sourcing
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces,
dead nick_buf_ptr, a no-op --print-config flag now implemented for real),
fixes stale/misleading comments (channel passwords are no longer a "future
M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention,
an incomplete BanRecord::subject_type doc, and a smoke test pointing at a
build/m1-dev preset that no longer exists), strips internal M1-M5 milestone
jargon from comments now that the roadmap is done, trims comments that just
restated the following line, and consolidates a few "why" explanations that
were duplicated 2-3 times in the same file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:20:18 +01:00

506 lines
24 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 SwiftC 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 :<port> UDP :<port>" 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 :<port>". 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.
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 :<port>" 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 :<port>".
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 :<port>" 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 })")
// Permissions getter round-trip.
let perms = client.getPermissions()
XCTAssertFalse(perms.isAdmin)
XCTAssertFalse(perms.canKick)
// 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()
}
}