Files
voice-cat/clients/apple/Tests/VoiceCatCoreTests/VoiceCatClientSmokeTests.swift

506 lines
24 KiB
Swift
Raw Normal View History

feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer. Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3 decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2. Build infrastructure (Phase 0): - clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library (libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice), stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers, runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework. VoiceCatCore Swift Package (Phase 1): - Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target. - Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config, Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models (10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*), Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s [UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer, all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event delivery on main queue via coalesced DispatchQueue.main drain). Tests — 6/6 green (swift test against a real voicecat-server): - testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips, testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green. Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2, clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
// 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.
feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer. Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3 decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2. Build infrastructure (Phase 0): - clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library (libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice), stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers, runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework. VoiceCatCore Swift Package (Phase 1): - Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target. - Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config, Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models (10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*), Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s [UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer, all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event delivery on main queue via coalesced DispatchQueue.main drain). Tests — 6/6 green (swift test against a real voicecat-server): - testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips, testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green. Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2, clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
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.
feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer. Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3 decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2. Build infrastructure (Phase 0): - clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library (libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice), stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers, runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework. VoiceCatCore Swift Package (Phase 1): - Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target. - Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config, Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models (10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*), Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s [UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer, all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event delivery on main queue via coalesced DispatchQueue.main drain). Tests — 6/6 green (swift test against a real voicecat-server): - testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips, testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green. Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2, clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
let perms = client.getPermissions()
XCTAssertFalse(perms.isAdmin)
XCTAssertFalse(perms.canKick)
// Guest ListAccounts is rejected by the server with a GenericResult proves the
feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer. Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3 decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2. Build infrastructure (Phase 0): - clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library (libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice), stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers, runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework. VoiceCatCore Swift Package (Phase 1): - Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target. - Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config, Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models (10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*), Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s [UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer, all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event delivery on main queue via coalesced DispatchQueue.main drain). Tests — 6/6 green (swift test against a real voicecat-server): - testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips, testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green. Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2, clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
// 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()
}
}