using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; using System.Text.Json; using MusicBridge.Agent.Media; using MusicBridge.Agent.Network; try { await RunAsync(args); } catch (Exception ex) { Console.Error.WriteLine($"TEST FAILED: {ex.GetType().Name}: {ex.Message}"); Environment.ExitCode = 1; } static async Task RunAsync(string[] args) { if (args.Contains("--test-failure")) throw new InvalidOperationException("Controlled failure to verify console-only reporting."); var checks = 0; void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException($"FAIL: {name}"); checks++; Console.WriteLine($"PASS: {name}"); } var now = DateTimeOffset.UtcNow; var startupExe = Path.Combine(AppContext.BaseDirectory, "MusicBridge.Agent.exe"); var startupRegistry = @"Software\MusicBridge\Tests\" + Guid.NewGuid().ToString("N"); try { var startup = new MusicBridge.Agent.Desktop.StartupSettings(startupExe, true, 18765, startupRegistry); Check(startup.Command == $"\"{startupExe}\" --tray --minimized --lan --port 18765", "autostart preserves quoted path, mode and port"); Check(!startup.Enabled, "autostart starts disabled"); startup.SetEnabled(true); Check(startup.Enabled, "autostart can be enabled in isolated registry key"); startup.SetEnabled(false); Check(!startup.Enabled, "autostart can be disabled"); } finally { Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree(startupRegistry, false); } var invitation = ConnectionInvitation.Create(new Uri("https://192.168.1.10:8765"), new string('A', 64), new("01234567", now.AddMinutes(5)), now); Check(invitation.Version == 1 && invitation.Code == "01234567", "invitation preserves leading zero in pairing code"); var encodedInvitation = invitation.ToQrPayload().Split("data=")[1].Replace('-', '+').Replace('_', '/'); encodedInvitation = encodedInvitation.PadRight((encodedInvitation.Length + 3) / 4 * 4, '='); using (var decoded = JsonDocument.Parse(Convert.FromBase64String(encodedInvitation))) Check(decoded.RootElement.GetProperty("endpoint").GetString() == "https://192.168.1.10:8765", "QR payload contains versioned endpoint JSON"); foreach (var endpoint in new[] { "https://127.0.0.1:8765", "http://192.168.1.2", "https://8.8.8.8", "https://192.168.1.2/?token=x", "https://user:pass@192.168.1.2" }) Check(!ConnectionInvitation.IsLocalEndpoint(new Uri(endpoint)), "invitation rejects unsafe or unusable endpoint"); var expiredInvitationRejected = false; try { ConnectionInvitation.Create(new Uri("https://10.0.0.1"), new string('A', 64), new("01234567", now), now); } catch (ArgumentException) { expiredInvitationRejected = true; } Check(expiredInvitationRejected, "expired pairing window cannot generate invitation"); var descriptor = DiscoveryDescriptor.Create(new string('a', 64)); Check(descriptor.AgentId == new string('A', 64) && descriptor.TxtRecords().Count == 2, "discovery descriptor stable and contains no pairing secrets"); var qrPng = PairingQr.Render(invitation); using (var qrStream = new MemoryStream(qrPng)) using (var bitmap = new Bitmap(qrStream)) { var pixels = new byte[bitmap.Width * bitmap.Height * 3]; for (var y = 0; y < bitmap.Height; y++) for (var x = 0; x < bitmap.Width; x++) { var color = bitmap.GetPixel(x, y); var offset = (y * bitmap.Width + x) * 3; pixels[offset] = color.R; pixels[offset + 1] = color.G; pixels[offset + 2] = color.B; } var reader = new ZXing.BarcodeReaderGeneric(); var decodedQr = reader.Decode(pixels, bitmap.Width, bitmap.Height, ZXing.RGBLuminanceSource.BitmapFormat.RGB24); Check(decodedQr?.Text == invitation.ToQrPayload(), "generated QR decodes to exact pairing invitation"); Check(bitmap.Width == bitmap.Height && bitmap.GetPixel(0, 0).R == 255, "QR is square with white quiet zone"); } var serviceProfile = MdnsPublisher.CreateProfile(new string('A', 64), 18765, [IPAddress.Parse("192.168.1.10")]); Check(serviceProfile.Resources.OfType().Single().Port == 18765, "DNS-SD advertises actual HTTPS port"); Check(serviceProfile.Resources.OfType().Single().Address.Equals(IPAddress.Parse("192.168.1.10")), "DNS-SD contains selected LAN address"); Check(serviceProfile.Resources.OfType().Single().Strings.Contains("id=" + new string('A', 64)), "DNS-SD includes stable agent id"); var invalidProfileRejected = false; try { MdnsPublisher.CreateProfile(new string('A', 64), 8765, [IPAddress.Loopback]); } catch (ArgumentException) { invalidProfileRejected = true; } Check(invalidProfileRejected, "mDNS cannot advertise loopback address"); Check(TimelineMath.Position(20, 0, 200, true, now.AddSeconds(-5), now, 2) == 30, "position uses elapsed time and rate"); Check(TimelineMath.Position(20, 0, 200, false, now.AddSeconds(-5), now, 1) == 20, "pause does not advance position"); Check(TimelineMath.Position(199, 0, 200, true, now.AddSeconds(-5), now, 1) == 200, "position clamps to end"); Check(TimelineMath.Position(-5, 3, 200, false, now, now, 1) == 3, "position clamps to start"); Check(TimelineMath.Position(20, 0, 200, true, now.AddSeconds(5), now, 1) == 20, "future update ignored"); Check(TimelineMath.Position(20, 0, 200, true, DateTimeOffset.UnixEpoch, now, 1) == 20, "missing update ignored"); Check(TimelineMath.SeekRange(10, 200, 20, 300) == (20, 200), "seek range intersects timeline"); Check(TimelineMath.SeekRange(0, 0, 20, 100) == (20, 100), "live seek range without duration"); foreach (var command in new[] { new MediaCommand("volume", -0.1), new("volume", 1.1), new("volume", double.NaN), new("seek", double.PositiveInfinity), new("seek", -1), new("seek"), new("next", 1), new("unknown"), new("pause", Id: new string('a', 65)) }) Check(CommandValidation.Validate(command) is not null, $"reject invalid {command.Type}/{command.Value}"); Check(CommandValidation.Validate(new("seek", 125.5)) is null, "absolute seek accepted"); Check(CommandValidation.Validate(new("volume", 0)) is null, "zero volume accepted"); Check(CommandValidation.Validate(new("volume", 1)) is null, "full volume accepted"); var clock = new TestClock(now); var trust = new PairingService(clock); Check(!trust.Pair("00000000").Success, "pairing closed initially"); var expired = trust.Open(); clock.Now = now.AddMinutes(6); Check(!trust.Pair(expired.Code).Success && trust.Current is null, "pairing code expires"); var locked = trust.Open(); for (var i = 0; i < 10; i++) trust.Pair("invalid"); Check(!trust.Pair(locked.Code).Success, "ten failed attempts close pairing"); var current = trust.Open(); var paired = trust.Pair(current.Code); Check(paired.Success && trust.Authorize(paired.Token), "valid pairing authorizes token"); Check(!trust.Pair(current.Code).Success, "pairing code is single use"); Check(!trust.Authorize(new string('0', 64)), "wrong token rejected"); Check(!new PairingService().Authorize(paired.Token), "isolated in-memory test services do not share trust"); var identityDirectory = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "MusicBridge-Test-" + Guid.NewGuid().ToString("N"))); Directory.CreateDirectory(identityDirectory); try { string savedToken; string savedId; string savedFingerprint; var identityPath = Path.Combine(identityDirectory, "identity.dat"); using (var identity = new IdentityStore(identityDirectory)) { savedFingerprint = identity.Certificate.GetCertHashString(HashAlgorithmName.SHA256); var persistent = new PairingService(store: identity); var newDevice = persistent.Pair(persistent.Open().Code, "My iPhone"); Check(newDevice.Success && newDevice.DeviceId is not null, "persistent pairing returns stable device id"); savedToken = newDevice.Token!; savedId = newDevice.DeviceId!; var clear = UserProtection.Unprotect(File.ReadAllBytes(identityPath)); try { var text = Encoding.UTF8.GetString(clear); Check(!text.Contains(savedToken) && text.Contains(persistent.Devices[0].TokenHash), "disk identity stores token hash, not bearer token"); } finally { CryptographicOperations.ZeroMemory(clear); } var lockedOut = false; try { using var secondOwner = new IdentityStore(identityDirectory); } catch (IOException) { lockedOut = true; } Check(lockedOut, "second agent cannot overwrite active identity"); } using (var reopened = new IdentityStore(identityDirectory)) { Check(reopened.Certificate.GetCertHashString(HashAlgorithmName.SHA256) == savedFingerprint, "certificate fingerprint survives restart"); var persistent = new PairingService(store: reopened); Check(persistent.Authorize(savedToken) && persistent.Devices[0].Name == "My iPhone", "trusted token and device name survive restart"); Check(persistent.Current is null, "restored trust does not open pairing window"); await using (var restoredServer = new RemoteServer(new FakeMedia(), persistent, reopened.Certificate, false, 0)) { await restoredServer.StartAsync(); using var restoredHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == savedFingerprint }; using var restoredClient = new HttpClient(restoredHandler) { BaseAddress = new Uri(restoredServer.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5) }; restoredClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", savedToken); Check((await restoredClient.GetAsync("/v1/state")).IsSuccessStatusCode, "restored certificate and token work over real HTTPS"); } var badName = persistent.Pair(persistent.Open().Code, "bad\nname"); Check(!badName.Success && badName.Code == "invalid_name", "control characters in device name rejected"); // Force an atomic-write failure without touching the valid identity file. var blockedTemporary = identityPath + ".tmp"; Directory.CreateDirectory(blockedTemporary); try { var failedPair = persistent.Pair(persistent.Open().Code, "Cannot save"); Check(!failedPair.Success && failedPair.Code == "storage_failed" && persistent.Devices.Count == 1, "failed persistence never authorizes new device"); var revokeFailed = false; try { persistent.Revoke(savedId); } catch (IOException) { revokeFailed = true; } catch (UnauthorizedAccessException) { revokeFailed = true; } Check(revokeFailed && persistent.Authorize(savedToken), "failed revocation preserves consistent active trust"); } finally { Directory.Delete(blockedTemporary); } Check(persistent.Revoke(savedId) && !persistent.Authorize(savedToken), "revocation removes token authorization"); } using (var reopened = new IdentityStore(identityDirectory)) Check(!new PairingService(store: reopened).Authorize(savedToken), "revocation survives restart"); var corrupted = File.ReadAllBytes(identityPath); corrupted[^1] ^= 0xff; File.WriteAllBytes(identityPath, corrupted); var corruptionRejected = false; try { using var broken = new IdentityStore(identityDirectory); } catch (System.ComponentModel.Win32Exception) { corruptionRejected = true; } Check(corruptionRejected && File.ReadAllBytes(identityPath).SequenceEqual(corrupted), "corrupt identity is rejected without silent replacement"); } finally { // Only files owned by this test in its newly generated directory are removed. foreach (var name in new[] { "identity.dat", "identity.lock", "identity.dat.tmp" }) { var file = Path.Combine(identityDirectory, name); if (File.Exists(file)) File.Delete(file); } Directory.Delete(identityDirectory); } var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="); var cache = new ArtworkCache(); var cover = await cache.LoadAsync(new MemoryStream(png), CancellationToken.None); Check(cover.ContentType == "image/png" && cover.Bytes.SequenceEqual(png), "cover preserves bytes and detects PNG"); var repeated = await cache.LoadAsync(new MemoryStream(png), CancellationToken.None); Check(cover.Id == repeated.Id, "identical cover keeps content id"); Check(cache.Get(cover.Id) is not null && cache.Get("unknown") is null, "cover lookup uses content id"); foreach (var invalidImage in new[] { Array.Empty(), Encoding.UTF8.GetBytes(""), new byte[ArtworkCache.MaxBytes + 1] }) { await cache.LoadAsync(new MemoryStream(png), CancellationToken.None); var rejected = false; try { await cache.LoadAsync(new MemoryStream(invalidImage), CancellationToken.None); } catch (InvalidDataException) { rejected = true; } Check(rejected && cache.Current is null, "invalid or oversized artwork clears old cover"); } using (var canceled = new CancellationTokenSource()) { canceled.Cancel(); var canceledRead = false; try { await cache.LoadAsync(new MemoryStream(png), canceled.Token); } catch (OperationCanceledException) { canceledRead = true; } Check(canceledRead && cache.Current is null, "artwork read supports cancellation"); } using var certificate = AgentCertificate.Create(); var fingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); var fake = new FakeMedia(); await fake.Artwork.LoadAsync(new MemoryStream(png), CancellationToken.None); var pairing = new PairingService(); var window = pairing.Open(); await using var server = new RemoteServer(fake, pairing, certificate, false, 0); await server.StartAsync(); using var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint }; using var client = new HttpClient(handler) { BaseAddress = new Uri(server.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5) }; Check((await client.GetAsync("/v1/state")).StatusCode == HttpStatusCode.Unauthorized, "state requires authorization"); Check((await client.GetAsync("/v1/info")).StatusCode == HttpStatusCode.Unauthorized, "agent info requires authorization"); Check((await client.GetAsync($"/v1/artwork/{cover.Id}")).StatusCode == HttpStatusCode.Unauthorized, "artwork requires authorization"); Check((await client.PostAsJsonAsync("/v1/command", new { type = "next" })).StatusCode == HttpStatusCode.Unauthorized && fake.Commands.Count == 0, "unauthorized command never reaches player"); var wsUri = new UriBuilder(client.BaseAddress) { Scheme = "wss", Path = "/v1/events" }.Uri; using (var anonymous = new ClientWebSocket()) { anonymous.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint; var denied = false; try { await anonymous.ConnectAsync(wsUri, CancellationToken.None); } catch (WebSocketException) { denied = true; } Check(denied, "WSS requires authorization"); } Check((await client.PostAsJsonAsync("/v1/pair", new { code = "invalid" })).StatusCode == HttpStatusCode.Forbidden, "invalid pairing code denied over HTTPS"); var response = await client.PostAsJsonAsync("/v1/pair", new { code = window.Code }); var result = await response.Content.ReadFromJsonAsync(); Check(response.IsSuccessStatusCode && result?.Token is not null, "pair over pinned TLS"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result!.Token); var info = await client.GetFromJsonAsync("/v1/info"); Check(info?.AgentId == fingerprint, "agent info matches pinned certificate"); var state = await client.GetFromJsonAsync("/v1/state"); Check(state?.ProtocolVersion == 1 && state.Title == "Test track", "authenticated state is structured"); Check(state?.ArtworkId == cover.Id, "state references cover without embedding image bytes"); var imageResponse = await client.GetAsync($"/v1/artwork/{cover.Id}"); Check(imageResponse.IsSuccessStatusCode && imageResponse.Content.Headers.ContentType?.MediaType == "image/png" && (await imageResponse.Content.ReadAsByteArrayAsync()).SequenceEqual(png), "authenticated cover download preserves MIME and bytes"); Check((await client.GetAsync("/v1/artwork/missing")).StatusCode == HttpStatusCode.NotFound, "missing artwork returns 404"); fake.Artwork.Clear(); Check((await client.GetAsync($"/v1/artwork/{cover.Id}")).StatusCode == HttpStatusCode.NotFound, "old cover unavailable after track cache clears"); var bad = await client.PostAsJsonAsync("/v1/command", new { type = "volume", value = 4 }); Check(bad.StatusCode == HttpStatusCode.BadRequest && fake.Commands.Count == 0, "invalid remote volume cannot reach player"); var malformed = await client.PostAsync("/v1/command", new StringContent("{", Encoding.UTF8, "application/json")); Check(malformed.StatusCode == HttpStatusCode.BadRequest, "malformed JSON gets 400"); var tooLarge = await client.PostAsync("/v1/command", new StringContent(new string(' ', 5000), Encoding.UTF8, "application/json")); Check(tooLarge.StatusCode == HttpStatusCode.RequestEntityTooLarge, "oversized command rejected"); var commandResponse = await client.PostAsJsonAsync("/v1/command", new MediaCommand("seek", 42.5, "seek-1")); var commandResult = await commandResponse.Content.ReadFromJsonAsync(); Check(commandResult is { Success: true, Id: "seek-1" } && fake.Commands.Single().Value == 42.5, "absolute seek and request id reach service"); using (var origin = new HttpRequestMessage(HttpMethod.Get, "/v1/state")) { origin.Headers.Add("Origin", "https://example.com"); Check((await client.SendAsync(origin)).StatusCode == HttpStatusCode.Forbidden, "browser origin rejected"); } using (var socket = new ClientWebSocket()) { socket.Options.SetRequestHeader("Authorization", $"Bearer {result.Token}"); socket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint; using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await socket.ConnectAsync(wsUri, timeout.Token); var buffer = new byte[8192]; var first = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); using var document = JsonDocument.Parse(buffer.AsMemory(0, first.Count)); Check(first.EndOfMessage && document.RootElement.GetProperty("type").GetString() == "state", "WSS delivers initial state"); var second = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); Check(second.Count > 0, "WSS delivers subsequent state"); await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "test complete", timeout.Token); Check(socket.State == WebSocketState.Closed, "WSS closes cleanly"); } // A long fallback interval proves delivery comes from the event, not the timer. var eventMedia = new FakeMedia(); var eventPairing = new PairingService(); var eventDevice = eventPairing.Pair(eventPairing.Open().Code); var eventToken = eventDevice.Token; await using (var eventServer = new RemoteServer(eventMedia, eventPairing, certificate, false, 0, TimeSpan.FromSeconds(30))) { await eventServer.StartAsync(); using var socket = new ClientWebSocket(); socket.Options.SetRequestHeader("Authorization", $"Bearer {eventToken}"); socket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint; using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await socket.ConnectAsync(new UriBuilder(eventServer.Addresses.Single()) { Scheme = "wss", Path = "/v1/events" }.Uri, timeout.Token); var buffer = new byte[8192]; await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); eventMedia.Title = "Track after event"; for (var i = 0; i < 1000; i++) eventMedia.Notify(); var update = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); using var document = JsonDocument.Parse(buffer.AsMemory(0, update.Count)); Check(document.RootElement.GetProperty("state").GetProperty("title").GetString() == "Track after event", "media event reaches WSS before 30-second fallback timer"); Check(eventMedia.ReadCount < 50, "event burst is coalesced"); eventPairing.Revoke(eventDevice.DeviceId!); var disconnected = false; try { var close = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token); disconnected = close.MessageType == WebSocketMessageType.Close; } catch (WebSocketException) { disconnected = true; } Check(disconnected, "revoking trust disconnects an existing WSS stream"); using var revokedHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint }; using var revokedClient = new HttpClient(revokedHandler) { BaseAddress = new Uri(eventServer.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5) }; revokedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", eventToken); Check((await revokedClient.PostAsJsonAsync("/v1/command", new { type = "next" })).StatusCode == HttpStatusCode.Unauthorized && eventMedia.Commands.Count == 0, "revoked token cannot send HTTP commands"); } Check(eventMedia.SubscriberCount == 0, "server unsubscribes from media events on shutdown"); var recoveryMedia = new FakeMedia { FailRead = true }; var recoveryPairing = new PairingService(); var recoveryToken = recoveryPairing.Pair(recoveryPairing.Open().Code).Token; await using (var recoveryServer = new RemoteServer(recoveryMedia, recoveryPairing, certificate, false, 0, TimeSpan.FromMilliseconds(50))) { await recoveryServer.StartAsync(); Check(recoveryServer.Latest.MediaError is not null, "initial media failure does not prevent server startup"); recoveryMedia.FailRead = false; recoveryMedia.Notify(); using var recoveredTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (recoveryServer.Latest.MediaError is not null) await Task.Delay(20, recoveredTimeout.Token); Check(recoveryServer.Latest.HasSession, "polling recovers after initial media failure"); recoveryMedia.FailRead = true; recoveryMedia.Notify(); while (recoveryServer.Latest.MediaError is null) await Task.Delay(20, recoveredTimeout.Token); Check(!recoveryServer.Latest.HasSession && recoveryServer.Latest.ArtworkId is null, "read failure clears stale playing state"); recoveryMedia.FailRead = false; recoveryMedia.Title = "Recovered track"; recoveryMedia.Notify(); while (recoveryServer.Latest.Title != "Recovered track") await Task.Delay(20, recoveredTimeout.Token); Check(recoveryServer.Latest.MediaError is null, "polling survives later transient exception"); var address = new UriBuilder(recoveryServer.Addresses.Single()) { Scheme = "wss", Path = "/v1/events" }.Uri; for (var i = 0; i < 12; i++) { using var clientSocket = new ClientWebSocket(); clientSocket.Options.SetRequestHeader("Authorization", $"Bearer {recoveryToken}"); clientSocket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint; await clientSocket.ConnectAsync(address, recoveredTimeout.Token); var data = new byte[8192]; await clientSocket.ReceiveAsync(data.AsMemory(), recoveredTimeout.Token); clientSocket.Abort(); await Task.Delay(30, recoveredTimeout.Token); } Check(true, "abrupt WSS disconnects release slots for reconnection"); } if (args.Contains("--live-read")) { var manager = await Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager.RequestAsync(); using var live = new MediaService(manager); var snapshot = await live.ReadAsync(CancellationToken.None); Check(snapshot.MediaError is null, "real Windows media read succeeds"); Console.WriteLine($"Live read: session={snapshot.HasSession}, artwork={snapshot.ArtworkId is not null}, artworkError={snapshot.ArtworkError ?? "none"}"); if (snapshot.ArtworkId is { } id) { var liveCover = live.GetArtwork(id); Check(liveCover is { Bytes.Length: > 0 }, "real cover bytes are cached"); Console.WriteLine($"Live cover: {liveCover!.ContentType}, {liveCover.Bytes.Length} bytes."); var next = await live.ReadAsync(CancellationToken.None); Check(next.ArtworkId == id, "real cover cache survives progress refresh"); } } if (args.Contains("--desktop-smoke")) { var desktopMedia = new FakeMedia(); var desktopPairing = new PairingService(); var desktopState = new MediaState { HasSession = true, Title = "Проверка MusicBridge", Artist = "Тестовый плеер", PlaybackStatus = "Playing", Volume = 0.5f, Muted = false, PositionSeconds = 45, EndSeconds = 180, MaxSeekSeconds = 180, Capabilities = new(true, true, true, true, true, true) }; var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using (var host = new MusicBridge.Agent.Desktop.DesktopHost(() => desktopState, desktopMedia, desktopPairing, () => "Домашняя сеть • https://192.168.1.10:8765", () => { }, () => exited.TrySetResult(), visible: false, minimized: true)) { await host.StartAsync().WaitAsync(TimeSpan.FromSeconds(8)); var field = typeof(MusicBridge.Agent.Desktop.DesktopHost).GetField("window", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; var view = (MusicBridge.Agent.Desktop.AgentWindow)field.GetValue(host)!; view.Invoke(() => { Check(!view.Visible, "minimized startup leaves main window hidden"); view.Show(); view.RefreshState(); using var frame = new Bitmap(view.Width, view.Height); view.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.Height)); frame.Save(Path.Combine(AppContext.BaseDirectory, "desktop-smoke.png")); var toggle = (Button)view.Controls.Find("toggle", true).Single(); Check(toggle.Enabled, "desktop enables supported commands"); toggle.PerformClick(); Check(desktopMedia.Commands.Last().Type == "toggle", "desktop play button reaches shared media service"); var slider = (TrackBar)view.Controls.Find("volume", true).Single(); slider.Value = 80; typeof(Control).GetMethod("OnKeyUp", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! .Invoke(slider, [new KeyEventArgs(Keys.Right)]); Check(desktopMedia.Commands.Last() is { Type: "volume", Value: 0.8 }, "desktop volume slider sends absolute volume"); desktopState = new MediaState(); view.RefreshState(); Check(!toggle.Enabled && !slider.Enabled, "desktop disables unavailable player and volume controls"); view.Close(); Check(!view.IsDisposed && !view.Visible, "closing desktop window hides it without stopping agent"); }); Check(!exited.Task.IsCompleted, "tray host stays alive while window is hidden"); } await exited.Task.WaitAsync(TimeSpan.FromSeconds(5)); Check(exited.Task.IsCompleted, "disposing tray host stops its message loop"); } if (args.Contains("--qr-window-smoke")) { var uiClock = new TestClock(DateTimeOffset.UtcNow); var uiPairing = new PairingService(uiClock); uiPairing.Open(); using var host = new MusicBridge.Agent.PairingWindowHost(uiPairing, new string('A', 64), () => [new Uri("https://192.168.1.10:8765")], visible: false); host.Open(); var field = typeof(MusicBridge.Agent.PairingWindowHost).GetField("form", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; Form? uiForm = null; using var uiTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); while (uiForm is null || !uiForm.IsHandleCreated) { await Task.Delay(50, uiTimeout.Token); uiForm = field.GetValue(host) as Form; } var actualWindow = uiForm; bool HasImage() => (bool)actualWindow.Invoke(() => actualWindow.Controls.OfType().Single() .Controls.OfType().Single().Image is not null); while (!HasImage()) await Task.Delay(50, uiTimeout.Token); Check(HasImage(), "QR window renders active pairing image"); actualWindow.Invoke(() => { using var frame = new Bitmap(actualWindow.Width, actualWindow.Height); actualWindow.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.Height)); frame.Save(Path.Combine(AppContext.BaseDirectory, "qr-window-smoke.png")); }); uiClock.Now = uiClock.Now.AddMinutes(6); while (HasImage()) await Task.Delay(50, uiTimeout.Token); Check(!HasImage(), "QR window removes expired pairing image"); } if (args.Contains("--mdns-smoke")) { var local = LocalAddresses.Read(); if (local.Length == 0) throw new InvalidOperationException("No private multicast-capable IPv4 interface for mDNS smoke test."); var testFingerprint = Convert.ToHexString(SHA256.HashData(Guid.NewGuid().ToByteArray())); var name = MdnsPublisher.CreateProfile(testFingerprint, 18799, local).FullyQualifiedName; using var listener = new Makaretu.Dns.MulticastService { UseIpv4 = true, UseIpv6 = false }; var seen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var gone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); listener.AnswerReceived += (_, e) => { foreach (var ptr in e.Message.Answers.OfType().Where(r => r.DomainName == name)) { if (ptr.TTL == TimeSpan.Zero) gone.TrySetResult(true); else seen.TrySetResult(true); } }; listener.Start(); using (var publisher = new MdnsPublisher(testFingerprint, 18799)) { publisher.Refresh(local); listener.SendQuery("_musicbridge._tcp.local", type: Makaretu.Dns.DnsType.PTR); Check(await seen.Task.WaitAsync(TimeSpan.FromSeconds(8)), "real multicast listener receives service announcement"); } Check(await gone.Task.WaitAsync(TimeSpan.FromSeconds(8)), "real multicast listener receives goodbye on shutdown"); } Console.WriteLine($"All {checks} checks passed. No real playback or volume was changed."); } sealed class TestClock(DateTimeOffset now) : TimeProvider { public DateTimeOffset Now { get; set; } = now; public override DateTimeOffset GetUtcNow() => Now; } sealed class FakeMedia : IMediaService { public volatile bool FailRead; public event Action? Changed; public int SubscriberCount => Changed?.GetInvocationList().Length ?? 0; public int ReadCount; public readonly ArtworkCache Artwork = new(); public Artwork? GetArtwork(string id) => Artwork.Get(id); public string Title { get; set; } = "Test track"; public void Notify() => Changed?.Invoke(); public List Commands { get; } = []; public Task ReadAsync(CancellationToken cancellationToken) { Interlocked.Increment(ref ReadCount); if (FailRead) throw new IOException("Simulated player disconnection"); return Task.FromResult(new MediaState { HasSession = true, Title = Title, ArtworkId = Artwork.Current?.Id, Volume = 0.5f, Timestamp = DateTimeOffset.UtcNow }); } public Task ExecuteAsync(MediaCommand command, CancellationToken cancellationToken) { Commands.Add(command); return Task.FromResult(new CommandResult(true, "ok", "Test command", command.Id)); } }