mobile-music/test/remote_integration_test.dart
2026-08-04 19:29:58 -04:00

209 lines
6.9 KiB
Dart

@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 = <RemoteSession>[];
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<void> 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 dropped session retries instead of detaching (bug-fixes #5)',
() async {
final session = newSession(host.port!);
await connectAuthed(session);
// Watch for the *next* time we fall back to connecting (i.e. a retry) and
// assert no terminal status slips through before it — a drop after a good
// connection must be healed, never surfaced as error/disconnected.
final retrying = session.status.firstWhere((s) {
expect(s, isNot(RemoteConnStatus.error));
expect(s, isNot(RemoteConnStatus.disconnected));
expect(s, isNot(RemoteConnStatus.denied));
return s == RemoteConnStatus.connecting;
});
// Kill the connection from under the client.
await host.stop();
await retrying;
expect(session.currentStatus, RemoteConnStatus.connecting);
});
test('a device that never answers gives up (bug-fixes #5)', () async {
// Point at a port with nothing listening: cold dials fail, and after the
// capped cold-retry budget the session goes terminal so the controller can
// fall back to local playback rather than spin forever.
final session = newSession(host.port! + 1);
final failed =
session.status.firstWhere((s) => s == RemoteConnStatus.error);
await session.connect();
await failed;
});
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;
});
}