Files
voice-cat/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs
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

396 lines
19 KiB
C#
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.

using System.Diagnostics;
using System.Text.RegularExpressions;
using VoiceCat.Interop;
namespace VoiceCat.Interop.Tests;
/// <summary>
/// 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.
/// </summary>
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", "dev", "bin", "voicecat-server.exe");
Assert.True(File.Exists(serverExe),
$"voicecat-server.exe not found at '{serverExe}' — build the dev preset first " +
"(cmake --preset dev && cmake --build --preset 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] <name> — TCP :<port> UDP :<port>" — 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;
// Provision a known admin account so we can exercise moderation wrappers end-to-end.
string adminExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-admin.exe");
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the dev preset.");
var adminPsi = new ProcessStartInfo(adminExe)
{
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using (var adminProc = Process.Start(adminPsi) ?? throw new InvalidOperationException("failed to start voicecat-admin.exe"))
{
Assert.True(adminProc.WaitForExit(10000), "voicecat-admin.exe did not exit within 10s.");
Assert.Equal(0, adminProc.ExitCode);
}
}
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<bool> 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<VoiceCatEvent>();
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");
// Permissions getter round-trip.
var perms = client.GetPermissions();
Assert.False(perms.IsAdmin);
Assert.False(perms.CanKick);
// Moderation request wrappers queue without error. As a guest, account listing
// is rejected by the server with a GenericResult, which proves the wrapper path works
// end-to-end and that the event type is delivered through P/Invoke.
Assert.Equal(VcResult.Ok, client.RequestAccountList());
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
"did not receive VC_EVENT_GENERIC_RESULT for guest ListAccounts");
var generic = events.First(e => e.Type == VcEventType.GenericResult);
Assert.Equal(VcResult.PermissionDenied, generic.Result);
client.Disconnect();
}
[Fact]
public void Admin_ChannelCrud_AccountCrud_RoundTrips()
{
var events = new List<VoiceCatEvent>();
using var client = new VoiceCatClient("vc-csharp-admin", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_admin.txt"));
client.EventReceived += events.Add;
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, client.AuthenticateUser("admin2", "testpassword123"));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
var perms = client.GetPermissions();
Assert.True(perms.IsAdmin || perms.CanAdminAccounts);
// Channel CRUD
Assert.Equal(VcResult.Ok, client.CreateChannel(new ChannelEditInfo(
Id: 0,
ParentId: 0,
Name: "CSharp Test Channel",
Topic: "Created by C# smoke test",
PasswordProtected: false,
Password: null,
MaxUsers: 42,
SortOrder: 0,
Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"CreateChannel did not succeed");
var channels = client.ListChannels();
var created = channels.FirstOrDefault(c => c.Name == "CSharp Test Channel");
Assert.NotNull(created);
Assert.Equal("Created by C# smoke test", created.Topic);
Assert.True(created.PasswordProtected == false);
Assert.Equal(VcResult.Ok, client.EditChannel(new ChannelEditInfo(
created.Id,
created.ParentId,
created.Name,
"Updated topic",
created.PasswordProtected,
null,
100,
0,
new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"EditChannel did not succeed");
Assert.Equal(VcResult.Ok, client.DeleteChannel(created.Id));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"DeleteChannel did not succeed");
// Account CRUD
Assert.Equal(VcResult.Ok, client.CreateAccount("csharp_smoke_user", "initialpw"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"CreateAccount did not succeed");
Assert.Equal(VcResult.Ok, client.RequestAccountList());
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AccountList), 3000));
var accounts = client.ListAccounts();
Assert.Contains(accounts, a => a.Username == "csharp_smoke_user");
Assert.Equal(VcResult.Ok, client.ResetPassword("csharp_smoke_user", "newpw123"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"ResetPassword did not succeed");
Assert.Equal(VcResult.Ok, client.DeleteAccount("csharp_smoke_user"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"DeleteAccount did not succeed");
client.Disconnect();
}
/// <summary>
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the P/Invoke layer. The core's
/// WASAPI loopback path (VOICECAT_HAS_LOOPBACK) captures the default render endpoint; the
/// StreamAnnounce succeeds regardless of whether the loopback device actually initializes
/// on a headless box, so this test passes in CI while still exercising the full
/// StartStream -> StreamStarted -> StopStream -> StreamStopped path through P/Invoke.
/// See docs/voice.md §9 and MainForm's BtnScreenShareToggle_Click.
/// </summary>
[Fact]
public void ScreenAudioStream_Starts_And_Stops()
{
var events = new List<VoiceCatEvent>();
using var client = new VoiceCatClient("vc-csharp-screen", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_screen.txt"));
client.EventReceived += events.Add;
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpScreen"));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
// Give the async UDP binding handshake a moment to land before announcing a stream
// (mirrors vccli's 500ms sleep after auth).
Thread.Sleep(500);
var (startResult, streamId) = client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
Assert.Equal(VcResult.Ok, startResult);
Assert.True(streamId != 0, "StreamId should be non-zero on success");
// The core emits VC_EVENT_STREAM_STARTED for the local client too (client.cpp
// handle_stream_announce_result), so we see our own screen-audio stream start.
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId), 5000),
"did not receive VC_EVENT_STREAM_STARTED for screen-audio stream");
Assert.Equal(VcResult.Ok, client.StopStream(streamId));
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.StreamStopped && e.StreamId == streamId), 5000),
"did not receive VC_EVENT_STREAM_STOPPED for screen-audio stream");
client.Disconnect();
}
/// <summary>
/// Per-stream receive-side controls (gain/mute/NR) round-trip through P/Invoke: two
/// clients in a channel, one publishes a MIC stream, the other SetRemoteStream's it then
/// GetRemoteStream's it back. Catches P/Invoke-specific marshaling bugs in
/// VcRemoteStreamStateNative (field order, bool-from-int, float precision) that the C++
/// ctest (test_m3_multistream) cannot. See docs/voice.md §1, §10.
/// </summary>
[Fact]
public void PerStream_RecvControls_RoundTrip_Through_PInvoke()
{
var eventsA = new List<VoiceCatEvent>();
var eventsB = new List<VoiceCatEvent>();
using var a = new VoiceCatClient("vc-csharp-mix-a", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_a.txt"));
using var b = new VoiceCatClient("vc-csharp-mix-b", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_b.txt"));
a.EventReceived += eventsA.Add;
b.EventReceived += eventsB.Add;
// Connect + auth A first, then B — staggering avoids concurrent TLS handshakes against
// the same server (mirrors the C++ test_m3_multistream harness, which connects A to
// completion before starting B).
Assert.Equal(VcResult.Ok, a.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, a.AuthenticateGuest("CSharpMixA"));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, a.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, eventsA.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ChannelList), 3000));
Assert.Equal(VcResult.Ok, b.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, b.AuthenticateGuest("CSharpMixB"));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, b.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, eventsB.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ChannelList), 3000));
// Both join Lobby (channel 1) so voice relays between them.
Assert.Equal(VcResult.Ok, a.JoinChannel(1, null));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.JoinResult), 5000),
"A did not receive VC_EVENT_JOIN_RESULT");
Assert.Equal(VcResult.Ok, b.JoinChannel(1, null));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000),
"B did not receive VC_EVENT_JOIN_RESULT");
// UDP binding handshake is async; give it a moment (mirrors ScreenAudio test).
Thread.Sleep(500);
// A publishes a MIC stream.
var (startResult, streamId) = a.StartStream(VcStreamKind.Mic, "mix-test-mic");
Assert.Equal(VcResult.Ok, startResult);
Assert.True(streamId != 0);
// B sees A's stream and can enumerate it.
uint aUid = 0;
Assert.True(PumpUntil(b, () =>
{
return eventsB.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId);
}, 5000), "B did not see A's STREAM_STARTED");
// Resolve A's user id from B's user list.
Assert.True(PumpUntil(b, () =>
{
aUid = b.ListUsers().FirstOrDefault(u => u.Nickname == "CSharpMixA")?.Id ?? 0;
return aUid != 0;
}, 3000), "could not resolve A's user id on B");
Assert.True(aUid != 0);
Assert.True(PumpUntil(b, () => b.ListUserStreams(aUid).Any(s => s.StreamId == streamId), 3000),
"B could not enumerate A's stream");
var bStreams = b.ListUserStreams(aUid);
Assert.Contains(bStreams, s => s.StreamId == streamId && s.Kind == VcStreamKind.Mic);
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
var (r0, st0) = b.GetRemoteStream(aUid, streamId);
Assert.Equal(VcResult.Ok, r0);
Assert.NotNull(st0);
Assert.Equal(1.0f, st0!.Gain);
Assert.False(st0.Muted);
Assert.False(st0.NoiseReduction);
// B turns A down to 0.4×, mutes, and enables NR — then reads it back.
Assert.Equal(VcResult.Ok, b.SetRemoteStream(aUid, streamId, 0.4f, muted: true, noiseReduction: true));
var (r1, st1) = b.GetRemoteStream(aUid, streamId);
Assert.Equal(VcResult.Ok, r1);
Assert.NotNull(st1);
Assert.Equal(0.4f, st1!.Gain);
Assert.True(st1.Muted);
Assert.True(st1.NoiseReduction);
// Unknown stream id on a known user -> INVALID_ARG.
var (rBad, stBad) = b.GetRemoteStream(aUid, 0xDEADBEEF);
Assert.Equal(VcResult.InvalidArg, rBad);
Assert.Null(stBad);
a.Disconnect();
b.Disconnect();
}
}