Files
sync_replay_project/packages/server/test/session_log_test.dart
T
foxyzandClaude Opus 5.5 9f2586b7ff
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s
Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a
client id maps to the same member and token for the server's life; a
drop is `gone` for a 10-minute grace keeping ready, presence, queue
items, relay and avatar; `bye` leaves at once; ordered takeover (seq,
replaced/stale); presence joining/in/away/gone with one PresencePolicy;
joiners never pause playback and hold a held gate at most ~15 s;
state.hold; feed ids, seq and replay; the relay lives through its
source's grace and continues on re-dial; queue `by` lasting, auto
items and cancel; MediaRef carried; client ids never logged.

App: persisted client id and seq; Connection with unstable/lost/
replaced states, backoff with jitter, no lost/back pair per flap; a drop
keeps the session (state, player, media, relay source); presence in the
strip and members card; sit out = away intent; feed matched by id;
auto sit-out (idle when not playing, minimised for a while; never when
alone) replaces auto-unready; bye on close and Leave, not on update
restart; relay source re-dials through its grace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:39:47 +03:00

130 lines
6.1 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
void main() {
late Directory dir;
setUp(() async => dir = await Directory.systemTemp.createTemp('session_log'));
tearDown(() async => dir.delete(recursive: true));
String read() => File('${dir.path}/session-2026-10-12.log').readAsStringSync();
test('merges lines of every side in server-clock order after the window', () async {
var now = DateTime(2026, 10, 12, 18, 0, 30);
final log = SessionLog(dir.path, window: const Duration(seconds: 15), now: () => now);
int at(int s, [int ms = 0]) => DateTime(2026, 10, 12, 18, 0, s, ms).millisecondsSinceEpoch;
log.add(at(12), 'Bob (m2)', 'INFO', 'relay', 'second');
log.add(at(10, 5), 'server', 'WARNING', 'server_hub', 'first\n error: boom');
log.add(at(20), 'Alice (m1)', 'INFO', 'mpv', 'too new');
log.add(at(14), 'Alice (m1)', 'INFO', 'media', 'open http://h:1/r/default/50989dda73c45a4710751f8aff4a1982/2/0/x?sig=abc');
log.mark(at(13), 'Alice (m1)', 'Alice', 'stutter\nhere');
log.flush();
expect(read().trimRight().split('\n'), [
'18:00:10.005 [server] WARNING server_hub: first',
' error: boom',
'18:00:12.000 [Bob (m2)] relay: second',
'18:00:13.000 [Alice (m1)] === PROBLEM marked by Alice: stutter here ===',
'18:00:14.000 [Alice (m1)] media: open http://h:1/r/default/***/2/0/x?sig=…',
]);
expect(log.recent(const Duration(hours: 1)), endsWith('[Alice (m1)] mpv: too new'), reason: 'waiting lines too');
now = DateTime(2026, 10, 12, 18, 1);
await log.close();
expect(read(), contains('18:00:20.000 [Alice (m1)] mpv: too new'));
});
test('takeBatch: notes the version once, opt-out once, dropped lines', () async {
final now = DateTime(2026, 10, 12, 18);
final log = SessionLog(dir.path, now: () => now);
final t = now.millisecondsSinceEpoch;
log.takeBatch('tok', 'Bob (m2)', 'Bob',
DiagBatch(version: 'WT 1.0', lines: [DiagLine(t: t, level: 'INFO', logger: 'app', message: 'hi')], dropped: 3));
log.takeBatch('tok', 'Bob (m2)', 'Bob', DiagBatch(version: 'WT 1.0', lines: [DiagLine(t: t, level: 'INFO', logger: 'app', message: 'again')]));
log.takeBatch('tok2', 'Cleo (m3)', 'Cleo', const DiagBatch(version: 'WT 1.0', share: false, lines: [DiagLine(t: 0, level: 'INFO', logger: 'app', message: 'never')]));
log.takeBatch('tok2', 'Cleo (m3)', 'Cleo', const DiagBatch(version: 'WT 1.0', share: false));
await log.close();
final text = read();
expect('shares diagnostics'.allMatches(text).length, 1);
expect(text, contains('[server] diag: Bob (m2) shares diagnostics (WT 1.0)'));
expect(text, contains("[server] diag: Cleo (m3) doesn't share diagnostics (WT 1.0)"));
expect("doesn't share".allMatches(text).length, 1);
expect(text, contains('[Bob (m2)] WARNING diag: 3 lines dropped'));
expect(text, isNot(contains('never')));
});
group('live', () {
late SyncServer server;
setUp(() async {
server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'), logDir: dir.path);
await server.start();
});
tearDown(() async => server.stop());
Future<(WebSocketChannel, String)> join(String name) async {
final ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
final msgs = StreamIterator(ch.stream);
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 's',
user: HelloUser(name: name))
.toJson()));
while (await msgs.moveNext()) {
final m = jsonDecode('${msgs.current}') as Map;
if (m['t'] == 'welcome') {
unawaited(() async {
while (await msgs.moveNext()) {}
}());
return (ch, m['token'] as String);
}
}
throw StateError('no welcome');
}
DiagUploader uploader(String token, {String version = 'WT 1.0'}) => DiagUploader(
target: () => (base: Uri.parse('http://127.0.0.1:${server.boundPort}'), token: token, offsetMs: 0),
version: version);
test('members send lines and marks; chat never lands; any member reads it back', () async {
final (alice, aliceTok) = await join('Alice');
final (bob, bobTok) = await join('Bob');
final a = uploader(aliceTok)..start();
final b = uploader(bobTok)
..share = false
..start();
Logger('relay').info('alice line password=hunter2');
alice.sink.add(jsonEncode(const ChatMessage(text: 'secret chat words').toJson()));
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(await a.flush(), isTrue);
expect(await b.flush(), isTrue);
expect(await a.markProblem('audio drifts'), isTrue);
final text = await a.fetchSessionLog(hours: 1);
expect(text, isNotNull);
expect(text, contains('[Alice (m1)] relay: alice line password=***'));
expect(text, contains('=== PROBLEM marked by Alice: audio drifts ==='));
expect(text, contains("Bob (m2) doesn't share diagnostics"));
expect(text, contains('[server] server_hub: join Alice'));
expect(text, isNot(contains('secret chat words')));
// Bob reads it too (friends); a stranger does not.
expect(await b.fetchSessionLog(), contains('PROBLEM'));
expect(await uploader('0' * 32).fetchSessionLog(), isNull);
final bad = await HttpClient().postUrl(Uri.parse('http://127.0.0.1:${server.boundPort}/l/default/$aliceTok'));
bad.add(utf8.encode('not gzip'));
expect((await bad.close()).statusCode, 400);
await a.stop();
await b.stop();
await alice.sink.close();
await bob.sink.close();
await server.stop();
final files = dir.listSync().map((e) => e.uri.pathSegments.last).where((n) => n.startsWith('session-'));
final all = [for (final n in files) File('${dir.path}/$n').readAsStringSync()].join();
expect(all, contains('PROBLEM'));
expect(all, isNot(contains('secret chat words')));
});
});
}