Files
sync_replay_project/apps/desktop/test/queue_switch_e2e_test.dart
T
foxyzandClaude Opus 5.5 f4ca02c3cf
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m4s
ci / updater-windows (push) Successful in 5m56s
Restructure step 0 done: notice table test, flaky-link proxy, liveness on the engine clock, STARSYNC_PROFILE
- notice_table_test: one row per AppEvent variant through noticeFor.
- FlakyProxy (core/testing.dart): pass, blackhole (half-open), delay,
  hold, cut after N bytes, reset; demo tests against today's server.
- Liveness (stale/drop/ping) on the engine clock, so grace can be tested
  with a fake clock; Connection takes an injectable clock.
- AppDirs with STARSYNC_PROFILE=<name>: separate data and logs, so two
  instances run on one machine.
- DEVELOPMENT: scenario list per restructure step; test tags follow the
  new step order.

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

361 lines
15 KiB
Dart

// restructure: rewrite in step 5 (Media pipeline): preload and the warm
// relay become one pipeline; keep the timing per source.
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/session_media.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/queue/queue_controller.dart' show WarmState;
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/session/switch_trace.dart';
// How long an Up next switch takes, end to end, over real mpv and a real
// sync server: two Jellyfin account holders and a guest (through the
// server's /j login) watch an episode, then ⏭ puts up the next episode,
// then a direct link, then a link passed on "through" alice, then one the
// server fetches (both relayed: the guests preload them from the server's
// warm relay of the item). The fake Jellyfin and the link's host answer with a
// delay and a capped rate, like a server elsewhere. Prints each client's
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
//
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _sid = 'ab12ab12ab12ab12ab12ab12ab12ab12';
const _series = '5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e';
const _ep1 = 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1';
const _ep2 = 'e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2';
/// Per API request, and before a stream's first byte (WT_SWITCH_DELAY_MS).
final _delay = Duration(milliseconds: int.tryParse(Platform.environment['WT_SWITCH_DELAY_MS'] ?? '') ?? 120);
/// Per stream connection, default 24 Mbit/s (WT_SWITCH_RATE_KBS).
final _bytesPerSec = (int.tryParse(Platform.environment['WT_SWITCH_RATE_KBS'] ?? '') ?? 3072) * 1024;
/// 60 s of 720p at about 4 Mbit/s with sound (headless mpv needs audio).
Future<String> _media() async {
final path = '$_tmp/queue_switch_60s.mkv';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '4M', '-g', '48', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
/// Serves [file] with ranges, after [_delay], at most [_bytesPerSec].
Future<void> _serveFile(HttpRequest req, String file) async {
final f = File(file);
final size = await f.length();
final res = req.response;
res.headers.set('Accept-Ranges', 'bytes');
res.headers.contentType = ContentType('video', 'x-matroska');
var start = 0, end = size - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
if (range != null) {
start = int.tryParse(range.group(1)!) ?? 0;
end = int.tryParse(range.group(2)!) ?? size - 1;
if (end >= size) end = size - 1;
res.statusCode = 206;
res.headers.set('Content-Range', 'bytes $start-$end/$size');
}
res.contentLength = end - start + 1;
await Future.delayed(_delay);
try {
final raf = await f.open();
try {
await raf.setPosition(start);
var at = start;
const chunk = 64 * 1024;
final clock = Stopwatch()..start();
var sent = 0;
while (at <= end) {
final n = (end - at + 1).clamp(0, chunk);
res.add(await raf.read(n));
await res.flush();
at += n;
sent += n;
final due = Duration(microseconds: sent * 1000000 ~/ _bytesPerSec);
if (due > clock.elapsed) await Future.delayed(due - clock.elapsed);
}
} finally {
await raf.close();
}
await res.close();
} catch (_) {}
}
class _FakeJellyfin {
final String file;
final tokens = {'ATOKEN': 'ua', 'CTOKEN': 'uc', 'HOSTTOKEN': 'uh'};
late HttpServer _server;
_FakeJellyfin(this.file);
String get base => 'http://127.0.0.1:${_server.port}';
Future<void> start() async {
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_server.listen(_handle);
}
Future<void> stop() => _server.close(force: true);
Map<String, Object?> _item(String id) => {
'Id': id,
'Name': id == _ep1 ? 'Pilot' : 'Second',
'Type': 'Episode',
'ServerId': _sid,
'SeriesId': _series,
'SeriesName': 'Fake Show',
'ParentIndexNumber': 1,
'IndexNumber': id == _ep1 ? 1 : 2,
'RunTimeTicks': 60 * jellyfinTicksPerSecond,
};
Future<void> _handle(HttpRequest req) async {
final auth = req.headers.value('authorization') ?? '';
await req.drain<void>();
final res = req.response;
final path = req.uri.path;
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _serveFile(req, file);
await Future.delayed(_delay);
Future<void> json(Object v) async {
res.headers.contentType = ContentType.json;
res.write(jsonEncode(v));
await res.close();
}
if (path == '/System/Info/Public') return json({'Id': _sid, 'ServerName': 'Fake', 'Version': '10.11.11'});
final token = RegExp(r'Token="([^"]*)"').firstMatch(auth)?.group(1);
final user = tokens[token];
if (user == null) {
res.statusCode = 401;
return res.close();
}
final m = RegExp(r'^/Items/(\w+)(/PlaybackInfo)?$').firstMatch(path);
if (m != null && m.group(2) != null) {
return json({
'PlaySessionId': 'ps-$user-${m.group(1)}',
'MediaSources': [
{
'Id': m.group(1),
'SupportsDirectPlay': true,
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
{'Index': 1, 'Type': 'Audio', 'Language': 'jpn', 'Codec': 'aac'},
],
}
],
});
}
if (m != null) return json(_item(m.group(1)!));
if (path == '/Shows/$_series/Episodes') {
return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2});
}
if (path.startsWith('/Sessions/Playing')) {
res.statusCode = 204;
return res.close();
}
if (path.startsWith('/UserPlayedItems/') || path.startsWith('/UserItems/')) {
return json({'Played': false, 'PlaybackPositionTicks': 0});
}
res.statusCode = 404;
await res.close();
}
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 40), Duration every = const Duration(milliseconds: 200)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(every);
}
fail('timed out waiting for $what');
}
Future<JellyfinAccounts> _account(String base, String token, String userId, String device) async {
final a = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: device);
await a.ensureLoaded(validate: false);
await a.link(
baseUrl: base,
server: const JellyfinServerInfo(id: _sid, name: 'Fake', version: '10.11.11'),
auth: JellyfinAuthResult(accessToken: token, user: JellyfinUser(id: userId, name: userId), serverId: _sid),
);
return a;
}
void main() {
test(
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
final file = await _media();
final jelly = _FakeJellyfin(file);
await jelly.start();
final link = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
link.listen((req) => _serveFile(req, file));
final linkUrl = 'http://127.0.0.1:${link.port}/clip.mkv';
final server = SyncServer(ytDlp: YtDlp('$_tmp/queue_switch_server_ytdlp', enabled: false), AppConfig(
port: 0,
secret: 'q',
name: 'q',
jellyfin: JellyfinCredential(
baseUrl: jelly.base,
serverId: _sid,
token: 'HOSTTOKEN',
client: 'Watch Together',
device: 'Host',
deviceId: 'host-1',
version: '9.9.9'),
));
// Relic logs errors writing a response (a relay response cut short
// when its relay is dropped at a swap or stop) as it does outside
// tests; anything else fails the test.
final serverErrors = <Object>[];
await runZonedGuarded(server.start, (e, _) {
if ('$e'.contains('Content size below specified contentLength') || '$e'.contains('did not arrive')) return;
serverErrors.add(e);
});
final ytDlp = AppYtDlp(YtDlp('$_tmp/queue_switch_ytdlp', enabled: false));
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
..playback.preloadEnabled = preload;
final a = coord(accA), b = coord(accB), c = coord(accC);
final all = {'alice': a, 'guest': b, 'carol': c};
ConnectParams params(String name, [HelloMedia? media]) =>
ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'q', name: name, media: media);
final report = <String>[];
Future<void> switchOnce(String what) async {
final before = a.serverState.peek()!.mediaId;
// Time to get ready (Up next's first item, on every client).
await Future.delayed(const Duration(seconds: 8));
if (preload) {
for (final x in all.values) {
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
}
}
final clock = Stopwatch()..start();
a.queue.skip();
await _poll(() {
final r = a.serverState.peek();
return r != null && r.mediaId > before && r.transport == 'playing';
}, '$what playing', every: const Duration(milliseconds: 10));
final total = clock.elapsedMilliseconds;
final id = a.serverState.peek()!.mediaId;
report.add('$what (${preload ? 'preload' : 'cold'}): ⏭ to server playing $total ms');
for (final e in all.entries) {
report.add(' ${e.key}: ${e.value.switchTrace.of(id)}');
}
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
// The promoted player is the session player: marks, tracks and
// the volume carried over, the clock moves.
for (final x in all.values) {
expect(x.playback.durationSecs, greaterThan(59));
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
}
final at = a.playback.positionSecs;
await Future.delayed(const Duration(seconds: 1));
expect(a.playback.positionSecs, greaterThan(at + 0.5));
}
try {
await a.connect(params('alice', const HelloMedia(
name: 'Pilot', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1, title: 'Pilot'))));
await b.connect(params('guest'));
await c.connect(params('carol'));
for (final x in all.values) {
await x.playback.player.setVolume(0);
}
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
isNull);
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => (a.serverState.peek()?.queue.length ?? 0) == 2, 'two queued');
for (final x in all.values) {
x.toggleReady();
}
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all playing episode 1');
await switchOnce('episode 2');
expect(refOfStateMedia(a.serverState.peek()!.media)?.itemId, _ep2);
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
if (preload) {
for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded));
}
}
await switchOnce('direct link');
expect(stateMediaUrl(a.serverState.peek()!), linkUrl);
// Relayed items: alice passes the link on, then the server fetches
// it. Each is fed into the server's warm relay while it waits, so
// bob and carol (and for the fetched one alice) preload from it,
// and the swap promotes that relay with its buffer.
for (final (what, mode) in [('link through alice', LinkRelay.me), ('server-fetched link', LinkRelay.server)]) {
expect(await a.queue.add(LinkMedia(linkUrl, relay: mode)), isNull);
await _poll(() => a.serverState.peek()?.queue.isNotEmpty ?? false, '$what queued');
final id = a.serverState.peek()!.queue.first.id;
await _poll(() => a.serverState.peek()?.queue.firstOrNull?.media.relay?.live ?? false, '$what warm relay live');
await switchOnce(what);
final relay = stateMediaRelay(a.serverState.peek()!);
expect(relay?.queueId, id, reason: 'the warm relay became the media\'s');
expect(relay?.fetch, mode == LinkRelay.server);
if (preload) {
for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded),
reason: '$what preloaded');
}
}
}
// ignore: avoid_print
print(report.join('\n'));
expect(serverErrors, isEmpty);
// Nothing queued: no preload. Queued and taken off again: dropped.
if (preload) {
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
a.queue.remove(a.serverState.peek()!.queue.first.id);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
}
// Preloaded everywhere: every client starts from its preload.
if (preload) {
for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded));
}
}
} finally {
for (final x in all.values) {
await x.dispose();
}
await server.stop();
await jelly.stop();
await link.close(force: true);
}
},
);
}