@Timeout(Duration(seconds: 15)) library; import 'package:flutter_test/flutter_test.dart'; import 'package:timbre/playback/playback_engine.dart'; import 'package:timbre/remote/discovery.dart'; import 'package:timbre/remote/host_server.dart'; import 'package:timbre/remote/messages.dart'; import 'package:timbre/remote/remote_session.dart'; import 'package:timbre/subsonic/models.dart'; /// End-to-end over a real loopback WebSocket: a [RemoteHost] driving a /// (headless, audio-unsupported) [PlaybackController] and a [RemoteSession] /// client in the same process. Exercises the handshake, HMAC auth, snapshot /// broadcast, and command application — the whole stack minus the platform /// audio backend and mDNS discovery. void main() { const serverKey = 'server-key'; const password = 'hunter2'; late PlaybackController controller; late RemoteHost host; final sessions = []; PlaybackController buildController() => PlaybackController( // Non-null so songs count as streamable; no real player is created // (audioEnabled: false), so this URI is never opened. Forcing audio off // keeps the test headless on any host — a macOS/iOS CI runner would // otherwise build a just_audio AudioPlayer against uninitialized // platform channels and throw. streamUriFor: (s) => Uri.parse('http://host.local/stream/${s.id}'), coverArtUriFor: (_) => null, serverKeyGetter: () => serverKey, onArt: (_) {}, onPlay: (_) {}, audioEnabled: false, ); RemoteSession newSession( int port, { String key = serverKey, String pass = password, }) { final s = RemoteSession( device: DiscoveredDevice( name: 'Host', host: '127.0.0.1', port: port, serverKey: key, version: kRemoteProtocolVersion, ), serverKey: key, password: pass, deviceName: 'Remote', ); sessions.add(s); return s; } Song song(String id) => Song(id: id, title: 'Song $id'); // Subscribe to the "connected" event *before* dialing, so a fast handshake // can't fire it before we're listening (broadcast streams drop unheard // events). Future connectAuthed(RemoteSession s) async { final connected = s.status.firstWhere((x) => x == RemoteConnStatus.connected); await s.connect(); await connected; } setUp(() async { controller = buildController(); host = RemoteHost( controller: controller, serverKey: serverKey, password: password, deviceName: 'Host', ); await host.start(); }); tearDown(() async { for (final s in sessions) { await s.close(); } sessions.clear(); await host.stop(); controller.dispose(); }); test('valid remote completes the handshake and receives a snapshot', () async { final session = newSession(host.port!); final firstSnapshot = session.snapshots.first; await connectAuthed(session); expect(session.hostDevice, 'Host'); await firstSnapshot; // initial state pushed on connect }); test('host pushes a snapshot when local playback changes', () async { final session = newSession(host.port!); await connectAuthed(session); final gotQueue = session.snapshots.firstWhere((s) => s.queue.length == 2); await controller.playSongs([song('a'), song('b')]); final snap = await gotQueue; expect(snap.queue.map((s) => s.id), ['a', 'b']); expect(snap.currentIndex, 0); }); test('a command from the remote drives the host engine', () async { await controller.playSongs([song('a'), song('b'), song('c')]); final session = newSession(host.port!); await connectAuthed(session); // Jump to index 2 from the remote; the host engine should follow, and a // fresh snapshot should reflect it. final jumped = session.snapshots.firstWhere((s) => s.currentIndex == 2); session.send(const RemoteCommand.jumpTo(2)); final snap = await jumped; expect(snap.currentIndex, 2); expect(controller.currentState.currentIndex, 2); }); test('remote can enqueue a track by sending its Song', () async { await controller.playSongs([song('a')]); final session = newSession(host.port!); await connectAuthed(session); final grew = session.snapshots.firstWhere((s) => s.queue.length == 2); session.send(RemoteCommand.addToQueue(song('z'))); final snap = await grew; expect(snap.queue.map((s) => s.id), ['a', 'z']); }); test('wrong password is denied', () async { final session = newSession(host.port!, pass: 'wrong-password'); final denied = session.status.firstWhere((s) => s == RemoteConnStatus.denied); await session.connect(); await denied; expect(host.clientCount, 0); }); test('different server key is denied', () async { final session = newSession(host.port!, key: 'other-server'); final denied = session.status.firstWhere((s) => s == RemoteConnStatus.denied); await session.connect(); await denied; expect(host.clientCount, 0); }); test('a host broadcast reaches every connected remote', () async { final a = newSession(host.port!); final b = newSession(host.port!); await connectAuthed(a); await connectAuthed(b); expect(host.clientCount, 2); // Subscribe both before the host changes, then drive from the host: both // remotes must observe the new queue. final aSees = a.snapshots.firstWhere((s) => s.queue.length == 1); final bSees = b.snapshots.firstWhere((s) => s.queue.length == 1); await controller.playSongs([song('a')]); await aSees; await bSees; }); }