Files
sync_replay_project/apps/desktop/test/relay_e2e_test.dart
T
foxyzandClaude Opus 5.5 eac45746ce
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m6s
ci / updater-windows (push) Successful in 6m3s
Restructure step 0: Connection extracted from the coordinator
Connection owns the socket lifecycle, connect/dial with epochs,
reconnect, the clock offset, diagnostics upload and a sealed ConnState
signal; the hello is built by an injectable builder, the resume token
stays private, and the reconnect delay and silence limit are parameters.
The coordinator maps ConnState to the same health texts and keeps the
reconnect restore and drop handling (step 1 replaces them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:52:49 +03:00

303 lines
13 KiB
Dart

// restructure: rewrite in step 1 (Connection + Identity + Presence): "the
// source drops uncleanly and resumes" (resume token, D6 source resume); the
// rest stays.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/yt_dlp_app.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
// The relay with real coordinators, real mpv and a dedicated server that
// has no file and no Jellyfin login of its own (the VPS setup): a guest's
// file, and links passed on by a member or fetched by the server.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Future<String> _media() async {
final path = '$_tmp/relay_e2e_20s.mkv';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '20', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 30)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 200));
}
fail('timed out waiting for $what');
}
/// Serves one file with ranges; [requests] counts hits.
Future<(HttpServer, List<String>)> _origin(String file) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final requests = <String>[];
s.listen((req) async {
requests.add(req.uri.toString());
final size = await File(file).length();
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
req.response.statusCode = r.status;
r.headers.forEach(req.response.headers.set);
try {
if (r.status == 200 || r.status == 206) {
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
}
await req.response.close();
} catch (_) {}
});
return (s, requests);
}
void main() {
late SyncServer server;
late List<SessionCoordinator> all;
late List<Object> serverErrors;
Future<SessionCoordinator> member(String name) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final c = SessionCoordinator(
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: AppYtDlp(YtDlp('$_tmp/relay_e2e_no_ytdlp', enabled: false)));
all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'pw', name: name));
await c.playback.player.setVolume(0);
return c;
}
Future<void> playTogether(List<SessionCoordinator> xs) async {
await _poll(() => xs.every((x) => x.playback.durationSecs > 19), 'all opened it');
for (final x in xs) {
x.toggleReady();
}
await _poll(() => xs.every((x) => x.playback.player.state.playing), 'all playing');
await Future.delayed(const Duration(seconds: 3));
final pos = [for (final x in xs) x.playback.positionSecs];
// ignore: avoid_print
print('positions after 3 s: $pos');
for (final p in pos) {
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
}
for (final x in xs) {
x.toggleReady();
}
await _poll(() => xs.every((x) => !x.playback.player.state.playing), 'held');
}
setUp(() async {
MediaKit.ensureInitialized();
all = [];
server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'vps'));
// Relic reports an error writing a response (a relay read cut short
// when its relay is dropped at a swap or at the stop in tearDown) to
// the zone the server started in, as it logs it outside tests;
// anything else fails the test.
serverErrors = [];
await runZonedGuarded(server.start, (e, _) {
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
serverErrors.add(e);
});
});
tearDown(() async {
for (final x in all) {
await x.dispose();
}
await server.stop();
expect(serverErrors, isEmpty, reason: 'server errors');
});
test('a guest puts up a file: it plays for everyone through the relay', timeout: const Timeout(Duration(minutes: 2)),
() async {
final file = await _media();
final alice = await member('alice');
final bob = await member('bob');
expect(await bob.swapPicked(FileMedia(file)), isNull);
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.connection.memberId, 'relay state');
final state = alice.serverState.peek()!;
expect(state.media!.containsKey('url'), isFalse);
expect(bob.relaying.peek(), isTrue);
await playTogether([alice, bob]);
final relay = server.hub.relay!;
expect(relay.buffers.single.bytesToViewers, greaterThan(0), reason: 'alice played through the relay');
expect(relay.sourceToken, bob.connection.token);
// The next swap retires bob's source and the buffer. Nothing answers
// at that link, so its probe refuses it unless put up anyway.
expect(await alice.swapPicked(const LinkMedia('http://127.0.0.1:1/none.mkv', anyway: true)), isNull);
await _poll(() => !bob.relaying.peek(), 'source stopped');
expect(server.hub.relay, isNull);
});
test('links: through me, and fetched by the server; never broadcast', timeout: const Timeout(Duration(minutes: 3)),
() async {
final (origin, hits) = await _origin(await _media());
try {
final alice = await member('alice');
final bob = await member('bob');
final link = 'http://127.0.0.1:${origin.port}/private/ep.mkv?api_key=SECRET';
expect(await bob.swapPicked(LinkMedia(link, relay: LinkRelay.me)), isNull);
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source == bob.connection.memberId, 'me state');
expect(alice.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
await playTogether([alice, bob]);
expect(server.hub.relay!.buffers.single.bytesToViewers, greaterThan(0));
final before = hits.length;
expect(await alice.swapPicked(LinkMedia(link, relay: LinkRelay.server)), isNull);
await _poll(() => stateMediaRelay(bob.serverState.peek()!)?.fetch == true, 'fetch state');
expect(bob.serverState.peek()!.toJson().toString(), isNot(contains('SECRET')));
expect(alice.relaying.peek(), isFalse, reason: 'the server fetches, nobody sources');
await playTogether([alice, bob]);
expect(hits.length, greaterThan(before));
// A page is refused up front when this computer cannot read it (here
// yt-dlp is off, and the refusal says so).
final page = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
page.listen((r) {
r.response.headers.contentType = ContentType.html;
r.response.write('<html></html>');
r.response.close();
});
expect(await alice.swapPicked(LinkMedia('http://127.0.0.1:${page.port}/watch?v=x', relay: LinkRelay.me)),
'yt-dlp is turned off');
await page.close(force: true);
} finally {
await origin.close(force: true);
}
});
test('a source that dials in after mpv would have given up (5 s): viewers wait for it, then play',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = await _media();
final alice = await member('alice');
// Bob is a bare client: he puts a file up and starts passing it on
// only 7 s later (a slow first upstream read, a slow own player).
final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
final welcome = Completer<Map>();
ws.listen((f) {
final j = jsonDecode('$f') as Map;
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
}, onError: (_) {});
ws.add(jsonEncode(const HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'pw',
user: HelloUser(name: 'bob'),
media: HelloMedia(name: '', size: 0, duration: 0))
.toJson()));
final token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
final size = await File(file).length();
ws.add(jsonEncode({
't': 'swap',
'media': {'name': 'late.mkv', 'size': size, 'duration': 0, 'relay': <String, Object?>{}},
}));
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
await Future.delayed(const Duration(seconds: 7));
expect(alice.playerError.peek(), isEmpty, reason: 'no "could not be opened" while the source is on its way');
expect(alice.health.peek().message, contains('waiting for bob'));
final src = RelaySource(Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/$token/source'),
RelayMedia.single(FileRelayBytes(File(file))));
await src.start();
try {
await _poll(() => alice.playback.durationSecs > 19, 'alice opened it once the source was up');
expect(alice.playerError.peek(), isEmpty);
} finally {
await src.close();
await ws.close();
}
});
test('the source drops uncleanly and resumes: it keeps its copy and keeps serving',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = await _media();
final proxy = await _Blackhole.start(server.boundPort);
try {
final alice = await member('alice');
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final bob = SessionCoordinator(jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
all.add(bob);
await bob.connect(ConnectParams(host: '127.0.0.1', port: proxy.port, secret: 'pw', name: 'bob'));
await bob.playback.player.setVolume(0);
expect(await bob.swapPicked(FileMedia(file)), isNull);
await _poll(() => server.hub.relay?.live == true, 'source live');
await _poll(() => alice.playback.durationSecs > 19 && bob.playback.durationSecs > 19, 'both opened');
final token = bob.connection.token;
// Bob's network goes silent (no FIN): states stop, he reconnects
// with `resume`, the server still has him.
proxy.cut();
await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15));
await _poll(() => !bob.reconnecting.peek() && bob.serverState.peek() != null, 'back',
timeout: const Duration(seconds: 20));
expect(bob.connection.token, token, reason: 'resumed as the same member');
await _poll(() => server.hub.relay?.live == true, 'source redialled', timeout: const Duration(seconds: 15));
expect(bob.relaying.peek(), isTrue);
expect(bob.playback.durationSecs, greaterThan(19), reason: 'still his own copy');
// Alice can still get bytes the buffer never had.
alice.seekTo(17);
await _poll(() => (alice.playback.positionSecs - 17).abs() < 1.5, 'alice seeked');
} finally {
await proxy.close();
}
});
}
/// TCP proxy whose [cut] silently stops forwarding on every connection made
/// so far (no FIN, like a dead Wi-Fi); later connections work.
class _Blackhole {
final ServerSocket _server;
final _conns = <(Socket, Socket, List<bool>)>[];
_Blackhole(this._server);
int get port => _server.port;
static Future<_Blackhole> start(int target) async {
final b = _Blackhole(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
b._server.listen((c) async {
final up = await Socket.connect('127.0.0.1', target);
final dead = [false];
b._conns.add((c, up, dead));
c.listen((d) {
if (!dead[0]) up.add(d);
}, onDone: () => dead[0] ? null : up.destroy(), onError: (_) {});
up.listen((d) {
if (!dead[0]) c.add(d);
}, onDone: () => dead[0] ? null : c.destroy(), onError: (_) {});
});
return b;
}
void cut() {
for (final (_, _, dead) in _conns) {
dead[0] = true;
}
}
Future<void> close() async {
await _server.close();
for (final (a, b, _) in _conns) {
a.destroy();
b.destroy();
}
}
}