2026-09-09 01:05:52 +03:00
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 ;
2026-09-09 18:27:32 +03:00
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 ); }
2026-09-09 01:05:52 +03:00
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" );
2026-09-09 09:53:37 +03:00
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 < Makaretu . Dns . SRVRecord >(). Single (). Port == 18765 , "DNS-SD advertises actual HTTPS port" );
Check ( serviceProfile . Resources . OfType < Makaretu . Dns . ARecord >(). Single (). Address . Equals ( IPAddress . Parse ( "192.168.1.10" )), "DNS-SD contains selected LAN address" );
Check ( serviceProfile . Resources . OfType < Makaretu . Dns . TXTRecord >(). 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" );
2026-09-09 01:05:52 +03:00
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 < byte >(), Encoding . UTF8 . GetBytes ( "<svg></svg>" ), 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 < PairingResult >();
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 < DiscoveryDescriptor >( "/v1/info" );
Check ( info ?. AgentId == fingerprint , "agent info matches pinned certificate" );
var state = await client . GetFromJsonAsync < MediaState >( "/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 < CommandResult >();
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" );
2026-09-09 18:27:32 +03:00
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" );
}
2026-09-09 01:05:52 +03:00
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" );
}
}
2026-09-09 10:01:39 +03:00
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 ,
2026-09-09 18:27:32 +03:00
() => "Домашняя сеть • https://192.168.1.10:8765" , () => { }, () => exited . TrySetResult (), visible : false , minimized : true ))
2026-09-09 10:01:39 +03:00
{
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 (() =>
{
2026-09-09 18:27:32 +03:00
Check (! view . Visible , "minimized startup leaves main window hidden" );
view . Show ();
2026-09-09 10:01:39 +03:00
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" );
}
2026-09-09 09:53:37 +03:00
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 < TableLayoutPanel >(). Single ()
. Controls . OfType < PictureBox >(). 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 < bool >( TaskCreationOptions . RunContinuationsAsynchronously );
var gone = new TaskCompletionSource < bool >( TaskCreationOptions . RunContinuationsAsynchronously );
listener . AnswerReceived += ( _ , e ) =>
{
foreach ( var ptr in e . Message . Answers . OfType < Makaretu . Dns . PTRRecord >(). 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" );
}
2026-09-09 01:05:52 +03:00
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
{
2026-09-09 18:27:32 +03:00
public volatile bool FailRead ;
2026-09-09 01:05:52 +03:00
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 < MediaCommand > Commands { get ; } = [];
public Task < MediaState > ReadAsync ( CancellationToken cancellationToken )
{
Interlocked . Increment ( ref ReadCount );
2026-09-09 18:27:32 +03:00
if ( FailRead ) throw new IOException ( "Simulated player disconnection" );
2026-09-09 01:05:52 +03:00
return Task . FromResult ( new MediaState
{
HasSession = true , Title = Title , ArtworkId = Artwork . Current ?. Id , Volume = 0.5f , Timestamp = DateTimeOffset . UtcNow
});
}
public Task < CommandResult > ExecuteAsync ( MediaCommand command , CancellationToken cancellationToken )
{
Commands . Add ( command );
return Task . FromResult ( new CommandResult ( true , "ok" , "Test command" , command . Id ));
}
}