using System.Diagnostics; using System.Text.RegularExpressions; using VoiceCat.Interop; namespace VoiceCat.Interop.Tests; /// /// Exercises the full connect -> TOFU event -> confirm -> guest auth -> list channels flow /// purely through the P/Invoke layer (NativeMethods/VoiceCatClient), against a real /// `voicecat-server.exe` (the same binary the C++ ctest suite uses) — this is the same flow /// tests/test_tofu_flow.cpp already proves at the C++ level, now proven reachable through /// P/Invoke specifically: marshaling bugs, callback-lifetime bugs, and calling-convention /// mistakes are all P/Invoke-specific failure modes ctest alone cannot catch. /// public sealed class VoiceCatClientSmokeTests : IDisposable { private readonly string _tempDir; private readonly Process _server; private readonly ushort _port; public VoiceCatClientSmokeTests() { _tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); string serverExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-server.exe"); Assert.True(File.Exists(serverExe), $"voicecat-server.exe not found at '{serverExe}' — build the m1-dev preset first " + "(cmake --preset m1-dev && cmake --build --preset m1-dev)."); var psi = new ProcessStartInfo(serverExe) { Arguments = $"--port 0 --data-dir \"{_tempDir}\" --name CSharpSmokeTest", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; _server = Process.Start(psi) ?? throw new InvalidOperationException("failed to start voicecat-server.exe"); // "[voicecat-server] — TCP : UDP :" — see server/src/server.cpp. // ReadLineAsync()+timeout (not a bare blocking ReadLine() in a deadline loop) so a // server that never prints anything (crash, hang) can't hang this constructor forever // — the deadline check must apply to the read itself, not just the loop around it. ushort? port = null; var deadline = DateTime.UtcNow.AddSeconds(10); while (port is null && DateTime.UtcNow < deadline) { var readTask = _server.StandardOutput.ReadLineAsync(); var remaining = deadline - DateTime.UtcNow; if (remaining <= TimeSpan.Zero || !readTask.Wait(remaining)) break; string? line = readTask.Result; if (line is null) break; var m = Regex.Match(line, @"TCP :(\d+)"); if (m.Success) port = ushort.Parse(m.Groups[1].Value); } Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s."); _port = port!.Value; } public void Dispose() { try { if (!_server.HasExited) _server.Kill(entireProcessTree: true); } catch { /* best effort */ } try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ } } private static string FindRepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "CMakePresets.json"))) dir = dir.Parent; return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root (CMakePresets.json) above " + AppContext.BaseDirectory); } private static bool PumpUntil(VoiceCatClient client, Func predicate, int timeoutMs) { var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (DateTime.UtcNow < deadline) { client.PumpEvents(); if (predicate()) return true; Thread.Sleep(20); } client.PumpEvents(); return predicate(); } [Fact] public void VersionString_IsNonEmpty() { Assert.False(string.IsNullOrEmpty(VoiceCatClient.VersionString)); } [Fact] public void Connect_Tofu_Auth_ListChannels_RoundTrips() { var events = new List(); using var client = new VoiceCatClient("vc-csharp-smoke", "0.1", VcLogLevel.Off, tofuStorePath: Path.Combine(_tempDir, "tofu_pins.txt")); client.EventReceived += events.Add; Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port)); Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpSmoke")); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000), "did not receive VC_EVENT_SERVER_IDENTITY"); var identityEvent = events.First(e => e.Type == VcEventType.ServerIdentity); Assert.Equal((uint)VcTofuStatus.FirstConnect, identityEvent.U32a); Assert.NotNull(identityEvent.Text); Assert.Equal(64, identityEvent.Text!.Length); // SHA-256 hex, no separators // Auth must NOT complete before the identity is confirmed. Assert.False(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 800)); Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true)); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000), "did not receive VC_EVENT_AUTH_RESULT after confirming identity"); var authEvent = events.First(e => e.Type == VcEventType.AuthResult); Assert.Equal(VcResult.Ok, authEvent.Result); Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000), "did not receive VC_EVENT_CHANNEL_LIST"); var channels = client.ListChannels(); Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby"); client.Disconnect(); } }