506 lines
24 KiB
Swift
506 lines
24 KiB
Swift
|
|
// 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 :<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 (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 :<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 })")
|
|||
|
|
|
|||
|
|
// 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()
|
|||
|
|
}
|
|||
|
|
}
|