Add build-and-test suite (in-app self-test + publish gate); fix release-zip missing sounds
The test suite, modelled on Andre's Sensor Readout (an in-app self-test + a build script), runnable as one step before every publish. Part 1 - in-app multi-step self-test (SelfTest.cs), run by --selftest: audio round-trip (PCM + Opus over localhost, dedicated test port so it never clashes with a running instance), encryption right/wrong-password + fingerprint, packet framing + malformed rejection, client<->server wire-format compatibility, settings save/reload, profile save/reload (temp folder), diagnostics-report privacy (never leaks a password), and bundled-resources present. Each step is timed and reported PASS/FAIL/SKIP; exit 0 only if nothing failed. Replaces the old single-shot --selftest. RunDiagnostics refactored to expose BuildDiagnosticsReport(AppConfig) for the privacy step. Part 2 - run-tests.ps1: builds, then checks the package (sounds, readme, native opus, framework-dependent, dll version == csproj), the About-box changelog, the client/server wire contract (relay magic/version/port still match RemPacket), the CLI surface, and runs --selftest. build-release.ps1 now runs this gate first and aborts the release if it fails. Bug caught + fixed: the published release zip carried ZERO cue sounds (startup sound + connect/disconnect/etc.) - MSBuild's incremental Content-copy marker skipped sounds\ on a fresh publish. Added an AfterTargets=Publish copy in the csproj that lands every cue WAV in the published sounds\ folder regardless of the marker. Verified: a staging publish now contains all 9 cue WAVs. Manual/help: --selftest description updated (readme.html + MANUAL.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
05b218825d
commit
141c5e8ce1
@@ -62,7 +62,7 @@ internal static class CommandLine
|
||||
case "--devices": case "--list-devices":
|
||||
return WithConsole(() => { WriteDevices(Console.Out); return 0; });
|
||||
case "--selftest": case "--self-test":
|
||||
return WithConsole(() => RunSelfTest(args));
|
||||
return WithConsole(() => SelfTest.Run(args));
|
||||
case "--diagnostics": case "--diag":
|
||||
return WithConsole(() => RunDiagnostics(ValueAfter(args, raw)));
|
||||
case "--log":
|
||||
@@ -109,7 +109,7 @@ internal static class CommandLine
|
||||
|
||||
// ---------------- commands ----------------
|
||||
|
||||
private static string AppVersion =>
|
||||
internal static string AppVersion =>
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
|
||||
private static int PrintVersion()
|
||||
@@ -129,8 +129,9 @@ internal static class CommandLine
|
||||
Console.WriteLine(" --version Show the installed version.");
|
||||
Console.WriteLine(" --devices List all microphones, outputs and ASIO drivers,");
|
||||
Console.WriteLine(" with their formats and device ids.");
|
||||
Console.WriteLine(" --selftest [--opus] Run a localhost audio round-trip (capture -> encode ->");
|
||||
Console.WriteLine(" [--seconds N] network -> decode) and report PASS or FAIL.");
|
||||
Console.WriteLine(" --selftest [--seconds N] Run the built-in self-test - a localhost audio");
|
||||
Console.WriteLine(" round-trip plus checks of encryption, the wire format,");
|
||||
Console.WriteLine(" settings, profiles and bundled files - and report PASS/FAIL.");
|
||||
Console.WriteLine(" --diagnostics [path] Write a diagnostics report (version, config, profiles,");
|
||||
Console.WriteLine(" devices, mic-privacy check, recent log) and exit. With");
|
||||
Console.WriteLine(" no path, it is saved in the user settings and logs folder.");
|
||||
@@ -201,56 +202,6 @@ internal static class CommandLine
|
||||
w.WriteLine();
|
||||
}
|
||||
|
||||
/// <summary>Localhost audio round-trip: capture the default output (as loopback) → encode →
|
||||
/// send to 127.0.0.1 → receive → decode. The receiver renders to nothing (no sound), so this is
|
||||
/// safe to run any time. PASS when packets flow end-to-end; exit code 0 = PASS, 1 = FAIL.</summary>
|
||||
private static int RunSelfTest(string[] args)
|
||||
{
|
||||
var opus = args.Any(a => a.Equals("--opus", StringComparison.OrdinalIgnoreCase));
|
||||
var seconds = int.TryParse(ValueAfter(args, "--seconds"), out var s) && s is > 0 and <= 60 ? s : 5;
|
||||
|
||||
Console.WriteLine($"RemSound self-test: localhost {(opus ? "Opus" : "PCM")} round-trip for {seconds}s...");
|
||||
|
||||
IReadOnlyList<AudioDeviceChoice> outputs;
|
||||
try { outputs = AudioDeviceCatalog.LoadOutputs(); }
|
||||
catch (Exception ex) { Console.WriteLine($" could not enumerate outputs: {ex.Message}"); return 1; }
|
||||
var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
|
||||
var deviceId = dev?.DeviceId;
|
||||
if (dev is null || deviceId is null)
|
||||
{
|
||||
Console.WriteLine(" RESULT: SKIP - no usable output device to capture from.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using var receiver = new AudioReceiver();
|
||||
using var sender = new AudioSender();
|
||||
try
|
||||
{
|
||||
receiver.Start();
|
||||
receiver.SetOutputDevices(Array.Empty<string>()); // decode only - never make sound during a test
|
||||
sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
|
||||
sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
|
||||
sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, RemPacket.DefaultPort) });
|
||||
sender.Start();
|
||||
Console.WriteLine($" capturing \"{dev.Name}\" -> 127.0.0.1:{RemPacket.DefaultPort}");
|
||||
Thread.Sleep(seconds * 1000);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { sender.Stop(); } catch { /* ignore */ }
|
||||
try { receiver.Stop(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var sent = sender.PacketsSent;
|
||||
var got = receiver.PacketsReceived;
|
||||
Console.WriteLine($" packets sent={sent} received={got} bytes received={receiver.BytesReceived}");
|
||||
var pass = sent > 0 && got > 0;
|
||||
Console.WriteLine(pass
|
||||
? " RESULT: PASS - capture, encode, network and decode are all working."
|
||||
: " RESULT: FAIL - audio did not flow end-to-end (sent or received was zero).");
|
||||
return pass ? 0 : 1;
|
||||
}
|
||||
|
||||
private static int SetLogging(string? value)
|
||||
{
|
||||
var on = value is not null && value.ToLowerInvariant() is "on" or "true" or "1" or "enable" or "enabled" or "yes";
|
||||
@@ -279,9 +230,10 @@ internal static class CommandLine
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Write a support-friendly diagnostics report and exit. Always writes a file so it
|
||||
/// works even when launched without a terminal; prints the path if a console is attached.</summary>
|
||||
private static int RunDiagnostics(string? pathArg)
|
||||
/// <summary>Build the support diagnostics report text for a given config (version, settings,
|
||||
/// profiles, devices, mic-privacy, recent log). Shared by <c>--diagnostics</c> and the
|
||||
/// self-test's privacy check. Lists profile titles only - never their contents.</summary>
|
||||
internal static string BuildDiagnosticsReport(AppConfig cfg)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"RemSound diagnostics");
|
||||
@@ -293,8 +245,6 @@ internal static class CommandLine
|
||||
sb.AppendLine($"Exe: {Environment.ProcessPath}");
|
||||
sb.AppendLine();
|
||||
|
||||
AppConfig cfg;
|
||||
try { cfg = AppConfig.Load(); } catch { cfg = new AppConfig(); }
|
||||
sb.AppendLine("Settings:");
|
||||
sb.AppendLine($" Logging enabled: {cfg.LoggingEnabled}");
|
||||
sb.AppendLine($" Start minimised: {cfg.StartMinimised}");
|
||||
@@ -326,6 +276,17 @@ internal static class CommandLine
|
||||
sb.AppendLine(TailNewestLog(40));
|
||||
sb.AppendLine();
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Write a support-friendly diagnostics report and exit. Always writes a file so it
|
||||
/// works even when launched without a terminal; prints the path if a console is attached.</summary>
|
||||
private static int RunDiagnostics(string? pathArg)
|
||||
{
|
||||
AppConfig cfg;
|
||||
try { cfg = AppConfig.Load(); } catch { cfg = new AppConfig(); }
|
||||
var report = BuildDiagnosticsReport(cfg);
|
||||
|
||||
var path = !string.IsNullOrWhiteSpace(pathArg)
|
||||
? pathArg!
|
||||
: Path.Combine(AppConfig.UserDataDirectory,
|
||||
@@ -333,15 +294,15 @@ internal static class CommandLine
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
Console.WriteLine($"Diagnostics written to:");
|
||||
File.WriteAllText(path, report);
|
||||
Console.WriteLine("Diagnostics written to:");
|
||||
Console.WriteLine($" {path}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Could not write the diagnostics file: {ex.Message}");
|
||||
Console.WriteLine();
|
||||
Console.Write(sb.ToString()); // last resort - dump to the terminal
|
||||
Console.Write(report); // last resort - dump to the terminal
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -119,4 +119,21 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Guarantee the cue WAVs land in a PUBLISHED build. The sounds\ Content items above use
|
||||
CopyToOutputDirectory=PreserveNewest, but MSBuild's incremental "copy already done" marker
|
||||
(obj\...\.csproj.CopyComplete) can skip that copy when publishing into a fresh output folder
|
||||
whose marker is up to date — which silently shipped the v3.9 zip with NO cue sounds at all
|
||||
(startup sound + connect/disconnect/record/etc.). This explicit post-publish copy is marker-
|
||||
independent: it always copies every WAV from the source sounds\ folder into the published
|
||||
sounds\ folder. Build-release.ps1 zips the publish output, so this is what makes the release
|
||||
reliably contain the sounds. (The self-test "Bundled resources present" step verifies it.) -->
|
||||
<Target Name="EnsureCueSoundsPublished" AfterTargets="Publish">
|
||||
<ItemGroup>
|
||||
<_CueWavs Include="..\..\sounds\*.wav" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_CueWavs)"
|
||||
DestinationFolder="$(PublishDir)sounds"
|
||||
SkipUnchangedFiles="false" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using RemSound.Core;
|
||||
using RemSound.Receiver;
|
||||
using RemSound.Sender;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// The in-app self-test, run by <c>--selftest</c>. Modelled on Andre's Sensor Readout: a list of
|
||||
/// named steps, each timed and reported PASS / FAIL / SKIP, a one-line summary, and an exit code
|
||||
/// (0 = every step passed or skipped, 1 = at least one failed) so a build-and-publish script can
|
||||
/// gate on it.
|
||||
///
|
||||
/// The steps run INSIDE a real RemSound process on purpose — that's the only way to exercise the
|
||||
/// genuine audio path, encryption, wire format and config/profile code rather than a stand-in.
|
||||
/// Everything here is read-only or temp-folder-scoped: a self-test never touches the user's real
|
||||
/// settings, profiles or logs, and never makes a sound.
|
||||
/// </summary>
|
||||
internal static class SelfTest
|
||||
{
|
||||
private sealed class Result
|
||||
{
|
||||
public string Name = "";
|
||||
public string Status = ""; // PASS | FAIL | SKIP
|
||||
public string Message = "";
|
||||
public long Ms;
|
||||
}
|
||||
|
||||
/// <summary>A step asserts with <see cref="Check"/> (failure) or bails with <see cref="Skip"/>
|
||||
/// (not applicable on this machine, e.g. no audio device). Both are signalled by exception so a
|
||||
/// step body reads as straight-line code.</summary>
|
||||
private sealed class CheckFailed : Exception { public CheckFailed(string m) : base(m) { } }
|
||||
private sealed class StepSkipped : Exception { public StepSkipped(string m) : base(m) { } }
|
||||
|
||||
private static void Check(bool condition, string failMessage)
|
||||
{
|
||||
if (!condition) throw new CheckFailed(failMessage);
|
||||
}
|
||||
|
||||
private static string Skip(string why) => throw new StepSkipped(why);
|
||||
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
var seconds = int.TryParse(ValueAfter(args, "--seconds"), out var s) && s is > 0 and <= 30 ? s : 3;
|
||||
|
||||
Console.WriteLine($"RemSound self-test {CommandLine.AppVersion} ({DateTime.Now:yyyy-MM-dd HH:mm:ss})");
|
||||
Console.WriteLine();
|
||||
|
||||
var results = new List<Result>();
|
||||
RunStep(results, "Audio round-trip (PCM)", () => AudioRoundTrip(opus: false, seconds));
|
||||
RunStep(results, "Audio round-trip (Opus)", () => AudioRoundTrip(opus: true, seconds));
|
||||
RunStep(results, "Encryption round-trip", Encryption);
|
||||
RunStep(results, "Packet framing and rejection", PacketFraming);
|
||||
RunStep(results, "Server wire-format compatibility", ServerWireCompat);
|
||||
RunStep(results, "App settings save and reload", SettingsRoundTrip);
|
||||
RunStep(results, "Profile save and reload", ProfileRoundTrip);
|
||||
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
|
||||
RunStep(results, "Bundled resources present", ResourcesPresent);
|
||||
|
||||
var failed = results.Count(r => r.Status == "FAIL");
|
||||
var skipped = results.Count(r => r.Status == "SKIP");
|
||||
var passed = results.Count(r => r.Status == "PASS");
|
||||
|
||||
Console.WriteLine();
|
||||
if (failed == 0)
|
||||
{
|
||||
Console.WriteLine($"RESULT: PASS - {passed} passed{(skipped > 0 ? $", {skipped} skipped" : "")} of {results.Count}.");
|
||||
return 0;
|
||||
}
|
||||
var names = string.Join(", ", results.Where(r => r.Status == "FAIL").Select(r => r.Name));
|
||||
Console.WriteLine($"RESULT: FAIL - {failed} failed, {passed} passed{(skipped > 0 ? $", {skipped} skipped" : "")} of {results.Count}.");
|
||||
Console.WriteLine($" Failed: {names}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void RunStep(List<Result> results, string name, Func<string?> body)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var r = new Result { Name = name };
|
||||
try { r.Message = body() ?? ""; r.Status = "PASS"; }
|
||||
catch (StepSkipped sk) { r.Status = "SKIP"; r.Message = sk.Message; }
|
||||
catch (CheckFailed cf) { r.Status = "FAIL"; r.Message = cf.Message; }
|
||||
catch (Exception ex) { r.Status = "FAIL"; r.Message = $"{ex.GetType().Name}: {ex.Message}"; }
|
||||
sw.Stop();
|
||||
r.Ms = sw.ElapsedMilliseconds;
|
||||
results.Add(r);
|
||||
Console.WriteLine($" [{r.Status}] {name} ({r.Ms} ms){(r.Message.Length > 0 ? " - " + r.Message : "")}");
|
||||
}
|
||||
|
||||
// ---------------- steps ----------------
|
||||
|
||||
/// <summary>Capture the default output as loopback → encode → send to 127.0.0.1 → receive →
|
||||
/// decode, for a few seconds, with the receiver rendering to nothing (no sound). PASS when
|
||||
/// packets flow both ways. SKIP on a machine with no usable output device (e.g. a headless CI
|
||||
/// box) so the suite stays green where there's simply nothing to capture.</summary>
|
||||
private static string? AudioRoundTrip(bool opus, int seconds)
|
||||
{
|
||||
IReadOnlyList<AudioDeviceChoice> outputs;
|
||||
try { outputs = AudioDeviceCatalog.LoadOutputs(); }
|
||||
catch (Exception ex) { return Skip("could not enumerate outputs: " + ex.Message); }
|
||||
var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
|
||||
if (dev?.DeviceId is not { } deviceId) return Skip("no usable output device to capture from");
|
||||
|
||||
// A dedicated test port, separate from the live DefaultPort (47830), so the self-test
|
||||
// doesn't clash with a RemSound instance the user already has running.
|
||||
const int testPort = 47929;
|
||||
using var receiver = new AudioReceiver();
|
||||
using var sender = new AudioSender();
|
||||
try
|
||||
{
|
||||
try { receiver.Start(testPort); }
|
||||
catch (Exception ex) { return Skip($"could not bind test port {testPort}: {ex.Message}"); }
|
||||
receiver.SetOutputDevices(Array.Empty<string>()); // decode only - never make sound during a test
|
||||
sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
|
||||
sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
|
||||
sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, testPort) });
|
||||
sender.Start();
|
||||
Thread.Sleep(seconds * 1000);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { sender.Stop(); } catch { /* ignore */ }
|
||||
try { receiver.Stop(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var sent = sender.PacketsSent;
|
||||
var got = receiver.PacketsReceived;
|
||||
Check(sent > 0 && got > 0, $"audio did not flow end-to-end (sent={sent}, received={got})");
|
||||
return $"sent={sent}, received={got}";
|
||||
}
|
||||
|
||||
/// <summary>Audio encryption: the right password decrypts to the original, the wrong one fails
|
||||
/// (silence, never garbage), fingerprints match/differ correctly, and the on-disk password
|
||||
/// scramble round-trips without leaving the password in plain text.</summary>
|
||||
private static string? Encryption()
|
||||
{
|
||||
var message = Encoding.UTF8.GetBytes("RemSound self-test payload 0123456789 the quick brown fox");
|
||||
var keyA = RemSoundCrypto.DeriveKey("correct horse battery staple");
|
||||
var keyB = RemSoundCrypto.DeriveKey("a different password entirely");
|
||||
|
||||
var cipher = RemSoundCrypto.Encrypt(keyA, message);
|
||||
Check(RemSoundCrypto.TryDecrypt(keyA, cipher, out var plain) && plain.AsSpan().SequenceEqual(message),
|
||||
"the right password must decrypt to the original bytes");
|
||||
Check(!RemSoundCrypto.TryDecrypt(keyB, cipher, out _),
|
||||
"the wrong password must fail to decrypt (silence, not garbage)");
|
||||
|
||||
Check(RemSoundCrypto.FingerprintsEqual(RemSoundCrypto.Fingerprint("shared"), RemSoundCrypto.Fingerprint("shared")),
|
||||
"the same password must produce the same fingerprint");
|
||||
Check(!RemSoundCrypto.FingerprintsEqual(RemSoundCrypto.Fingerprint("shared"), RemSoundCrypto.Fingerprint("other")),
|
||||
"different passwords must produce different fingerprints");
|
||||
|
||||
const string pw = "p@ss w0rd!";
|
||||
Check(RemSoundCrypto.Obfuscate(pw) != pw, "a stored password must not be plain text");
|
||||
Check(RemSoundCrypto.Deobfuscate(RemSoundCrypto.Obfuscate(pw)) == pw, "the stored-password scramble must round-trip");
|
||||
return "AES-256-GCM, PBKDF2 fingerprint, on-disk scramble";
|
||||
}
|
||||
|
||||
/// <summary>The packet header writes and reads back for every type, and malformed packets
|
||||
/// (too short, bad magic, wrong version) are rejected rather than mis-parsed. Plus the PCM
|
||||
/// multi-part sub-header round-trips.</summary>
|
||||
private static string? PacketFraming()
|
||||
{
|
||||
Span<byte> header = stackalloc byte[RemPacket.HeaderSize];
|
||||
foreach (var type in new[] { RemPacketType.Format, RemPacketType.Audio, RemPacketType.Heartbeat, RemPacketType.Control })
|
||||
{
|
||||
RemPacket.WriteHeader(header, type, streamId: 7, sequence: 42);
|
||||
Check(RemPacket.TryReadHeader(header, out var t, out var sid, out var seq) && t == type && sid == 7 && seq == 42,
|
||||
$"header round-trip failed for {type}");
|
||||
}
|
||||
|
||||
Check(!RemPacket.TryReadHeader(new byte[5], out _, out _, out _), "a too-short packet must be rejected");
|
||||
Check(!RemPacket.TryReadHeader(new byte[RemPacket.HeaderSize], out _, out _, out _), "a zero/bad-magic packet must be rejected");
|
||||
|
||||
var wrongVersion = new byte[RemPacket.HeaderSize];
|
||||
RemPacket.WriteHeader(wrongVersion, RemPacketType.Audio, 1, 1);
|
||||
wrongVersion[4] = 99;
|
||||
Check(!RemPacket.TryReadHeader(wrongVersion, out _, out _, out _), "a wrong-version packet must be rejected");
|
||||
|
||||
Span<byte> sub = stackalloc byte[RemPcmFrame.SubHeaderSize];
|
||||
RemPcmFrame.WriteSubHeader(sub, frameId: 12345, partIndex: 1, totalParts: 3);
|
||||
Check(RemPcmFrame.TryReadSubHeader(sub, out var fid, out var pi, out var tp) && fid == 12345 && pi == 1 && tp == 3,
|
||||
"PCM sub-header round-trip failed");
|
||||
return "header + PCM sub-header round-trip, malformed rejected";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client-to-server compatibility guard. The Pi relay (<c>server/remsound-relay.py</c>)
|
||||
/// forwards packets by reading ONLY the wire header at fixed byte offsets — it never looks at
|
||||
/// the audio. These are the exact field positions and values it assumes. If RemSound's header
|
||||
/// ever changes shape, this step FAILS, which is the reminder that the relay must be updated
|
||||
/// and a new <c>server-*</c> release cut before shipping. Ideally we never touch the server —
|
||||
/// this check is how we keep proving that, in case the network stack changes underneath us.
|
||||
/// </summary>
|
||||
private static string? ServerWireCompat()
|
||||
{
|
||||
// Golden contract the relay parses (see remsound-relay.py: MAGIC, V1_VERSION, header offsets).
|
||||
Check(RemPacket.HeaderSize == 12, "the relay reads a 12-byte header; RemPacket.HeaderSize must stay 12");
|
||||
Check(RemPacket.Version == 1, "the relay matches version byte 1 (V1_VERSION); RemPacket.Version must stay 1");
|
||||
Check(RemPacket.DefaultPort == 47830, "the relay listens on UDP 47830; RemPacket.DefaultPort must stay 47830");
|
||||
|
||||
// Packet-type values both ends agree on — changing any breaks interop with the relay/peers.
|
||||
Check((byte)RemPacketType.Format == 1 && (byte)RemPacketType.Audio == 2
|
||||
&& (byte)RemPacketType.KeepAlive == 3 && (byte)RemPacketType.Heartbeat == 4
|
||||
&& (byte)RemPacketType.Control == 5,
|
||||
"packet type values must stay Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5");
|
||||
|
||||
// Build a real header and assert the byte-level layout the relay reads.
|
||||
Span<byte> h = stackalloc byte[RemPacket.HeaderSize];
|
||||
RemPacket.WriteHeader(h, RemPacketType.Audio, streamId: 0x1234, sequence: 0xAABBCCDD);
|
||||
Check(h[0] == (byte)'R' && h[1] == (byte)'M' && h[2] == (byte)'N' && h[3] == (byte)'D',
|
||||
"magic must be ASCII 'RMND' at offset 0 (the relay's first-four-byte check)");
|
||||
Check(h[4] == 1, "version byte must be at offset 4");
|
||||
Check(h[5] == (byte)RemPacketType.Audio, "type byte must be at offset 5");
|
||||
Check(BinaryPrimitives.ReadUInt16LittleEndian(h.Slice(6, 2)) == 0x1234,
|
||||
"streamId must be a little-endian uint16 at offset 6 (the relay's pairing key)");
|
||||
Check(BinaryPrimitives.ReadUInt32LittleEndian(h.Slice(8, 4)) == 0xAABBCCDD,
|
||||
"sequence must be a little-endian uint32 at offset 8");
|
||||
return "12-byte 'RMND' header; relay-visible fields unchanged";
|
||||
}
|
||||
|
||||
/// <summary>App settings survive a save-and-reload (the same JSON serialisation
|
||||
/// <see cref="AppConfig.Save"/> / <see cref="AppConfig.Load"/> use) without touching the real
|
||||
/// config on disk.</summary>
|
||||
private static string? SettingsRoundTrip()
|
||||
{
|
||||
var original = new AppConfig
|
||||
{
|
||||
LoggingEnabled = true,
|
||||
StartMinimised = true,
|
||||
EnableStartupCue = false,
|
||||
UpdateCheckFrequency = UpdateCheckFrequency.EveryHour,
|
||||
StartWithProfileTitle = "Studio link",
|
||||
ProfilesDirectory = @"X:\some\profiles\folder",
|
||||
};
|
||||
var json = JsonSerializer.Serialize(original, new JsonSerializerOptions { WriteIndented = true });
|
||||
var loaded = JsonSerializer.Deserialize<AppConfig>(json);
|
||||
Check(loaded is not null, "config must deserialise");
|
||||
Check(loaded!.LoggingEnabled == original.LoggingEnabled
|
||||
&& loaded.StartMinimised == original.StartMinimised
|
||||
&& loaded.EnableStartupCue == original.EnableStartupCue
|
||||
&& loaded.UpdateCheckFrequency == original.UpdateCheckFrequency
|
||||
&& loaded.StartWithProfileTitle == original.StartWithProfileTitle
|
||||
&& loaded.ProfilesDirectory == original.ProfilesDirectory,
|
||||
"settings must survive a save/reload unchanged");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>A profile saved through <see cref="ProfileStore"/> reloads with its fields intact.
|
||||
/// Runs entirely inside a throwaway temp folder — the user's real profiles are never touched.</summary>
|
||||
private static string? ProfileRoundTrip()
|
||||
{
|
||||
var temp = Path.Combine(Path.GetTempPath(), "remsound-selftest-" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var store = new ProfileStore(temp);
|
||||
var p = Profile.NewBlank();
|
||||
p.Title = "selftest roundtrip";
|
||||
p.ReceiveAudioOn = true;
|
||||
p.SendAudioOn = false;
|
||||
p.Volume = 73;
|
||||
p.AudioPort = 47830;
|
||||
p.AsioDriverName = "Some ASIO Driver";
|
||||
p.SelectedWasapiSendInputs.Add("device-id-abc");
|
||||
store.Save(p);
|
||||
|
||||
var back = store.Load("selftest roundtrip");
|
||||
Check(back is not null, "the profile must load back from disk");
|
||||
Check(back!.Title == p.Title
|
||||
&& back.Volume == 73
|
||||
&& back.ReceiveAudioOn && !back.SendAudioOn
|
||||
&& back.AudioPort == 47830
|
||||
&& back.AsioDriverName == "Some ASIO Driver"
|
||||
&& back.SelectedWasapiSendInputs.Contains("device-id-abc"),
|
||||
"profile fields must survive a save/reload");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(temp, recursive: true); } catch { /* best-effort temp cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The diagnostics report lists a profile's title but never its password (plain or
|
||||
/// scrambled). Guards against a future change accidentally dumping profile contents into a
|
||||
/// support bundle. Uses a throwaway temp profiles folder with a known canary password.</summary>
|
||||
private static string? DiagnosticsPrivacy()
|
||||
{
|
||||
var temp = Path.Combine(Path.GetTempPath(), "remsound-selftest-priv-" + Guid.NewGuid().ToString("N"));
|
||||
const string canaryTitle = "PrivacyCanaryProfile";
|
||||
const string canaryPassword = "SENTINEL-PW-DO-NOT-LEAK-7f3a91";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(temp);
|
||||
var store = new ProfileStore(temp);
|
||||
var p = Profile.NewBlank();
|
||||
p.Title = canaryTitle;
|
||||
p.Password = canaryPassword;
|
||||
store.Save(p);
|
||||
|
||||
var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp });
|
||||
Check(report.Contains("RemSound diagnostics") && report.Contains(Environment.MachineName),
|
||||
"the diagnostics report must contain its basic header");
|
||||
Check(report.Contains(canaryTitle), "the diagnostics report should list the profile title");
|
||||
Check(!report.Contains(canaryPassword), "the diagnostics report must NOT contain a profile password (plain text)");
|
||||
Check(!report.Contains(RemSoundCrypto.Obfuscate(canaryPassword)),
|
||||
"the diagnostics report must NOT contain a profile password (scrambled form)");
|
||||
return "title listed, password withheld";
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(temp, recursive: true); } catch { /* best-effort temp cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The files a shipped RemSound needs at runtime are actually next to the exe: the
|
||||
/// bundled manual, the cue sounds, and the native Opus library.</summary>
|
||||
private static string? ResourcesPresent()
|
||||
{
|
||||
var root = AppContext.BaseDirectory;
|
||||
Check(File.Exists(Path.Combine(root, "readme.html")), "readme.html (the F1 manual) must ship next to the exe");
|
||||
|
||||
// Cues are consolidated from the shipped sounds\ folder into the runtime sounds folder at
|
||||
// startup (Program.ConsolidateSounds), so by the time the self-test runs they live here.
|
||||
// An empty runtime folder means the shipped build had no sounds to seed from - exactly the
|
||||
// bug that shipped the v3.9 zip with no cue sounds.
|
||||
var soundsDir = AppConfig.SoundsDirectory;
|
||||
Check(Directory.Exists(soundsDir), "the runtime sounds folder must exist (cues are consolidated at startup)");
|
||||
foreach (var cue in new[] { "connect.wav", "disconnect.wav", "start up.wav" })
|
||||
{
|
||||
Check(File.Exists(Path.Combine(soundsDir, cue)), $"cue sound '{cue}' must be present (was the shipped sounds\\ folder empty?)");
|
||||
}
|
||||
|
||||
// Native Opus (Concentus.Native) keeps the encoder off the allocation-heavy managed fallback.
|
||||
var nativeOpus = Path.Combine(root, "runtimes", "win-x64", "native", "opus.dll");
|
||||
Check(File.Exists(nativeOpus), "native opus.dll must ship under runtimes\\win-x64\\native\\");
|
||||
return "manual, cue sounds, native Opus";
|
||||
}
|
||||
|
||||
// ---------------- helper ----------------
|
||||
|
||||
private static string? ValueAfter(string[] args, string flag)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals(flag, StringComparison.OrdinalIgnoreCase) && !args[i + 1].StartsWith('-'))
|
||||
return args[i + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user