import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sync_relay_core/core.dart'; import 'package:watch_together/src/update/update_controller.dart'; // Work dir base: WT_TEST_TMP or the system temp. Staging lives inside // the install root, so the result doesn't depend on which filesystem // this is (the old /tmp staging broke when /tmp was tmpfs). final _tmpBase = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; Future _work() async { final base = Directory(_tmpBase); await base.create(recursive: true); return base.createTemp('wt-up-test'); } // Linux-layout tests check exec bits and symlinks, which NTFS lacks. final _posixOnly = Platform.isWindows ? 'POSIX install layout' : null; bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0; // Builds the release archive exactly like tool/package_linux.sh: // bundle copied into /watch-together-/, then // `tar -czf out -C watch-together-`. [zip]: like // tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only). Future> _releaseTarball(Directory work, {String version = '0.2.0', bool windows = false, bool bundle = true, bool zip = false}) async { final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}'); final folder = Directory('${stage.path}/watch-together-$version'); await Directory('${folder.path}/data/flutter_assets').create(recursive: true); await File('${folder.path}/data/flutter_assets/fresh.cfg') .writeAsString('fresh-config'); if (windows) { await File('${folder.path}/watch_together.exe').writeAsString('binary-v2'); await File('${folder.path}/flutter_windows.dll').writeAsString('dll-v2'); } else { await File('${folder.path}/watch_together').writeAsString('binary-v2'); if (bundle) { await Directory('${folder.path}/lib').create(); await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2'); await Link('${folder.path}/lib/libapp.so').create('libapp.so.1'); } await File('${folder.path}/update.sh') .writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n'); if (!Platform.isWindows) { final r = await Process.run('chmod', ['755', '${folder.path}/watch_together', '${folder.path}/update.sh']); expect(r.exitCode, 0); } } final out = File('${work.path}/out-$version.${zip ? 'zip' : 'tar.gz'}'); final r = await Process.run('tar', [ ...zip ? ['-a', '-c', '-f'] : ['-czf'], out.path, '-C', stage.path, 'watch-together-$version', ]); expect(r.exitCode, 0, reason: '${r.stderr}'); return out.readAsBytes(); } Future _fakeRoot(Directory work, {bool windows = false}) async { final root = Directory('${work.path}/install'); await Directory('${root.path}/data').create(recursive: true); await File('${root.path}/data/old.cfg').writeAsString('old-config'); if (windows) { await File('${root.path}/watch_together.exe').writeAsString('binary-v1'); await File('${root.path}/flutter_windows.dll').writeAsString('dll-v1'); } else { await Directory('${root.path}/lib').create(); await File('${root.path}/lib/libgone.so').writeAsString('lib-v1'); await File('${root.path}/watch_together').writeAsString('binary-v1'); if (!Platform.isWindows) { await Process.run('chmod', ['755', '${root.path}/watch_together']); } } await File('${root.path}/user-notes.txt').writeAsString('mine'); return root; } // Serves /manifest.json and the tarball. Fields can be overridden per test. class _Server { late HttpServer http; List tarball; Map manifestExtra = {}; bool brokenManifest = false; int tarHits = 0; _Server(this.tarball); String get base => 'http://127.0.0.1:${http.port}'; String get manifestUrl => '$base/manifest.json'; Future start() async { http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); http.listen((req) async { if (req.uri.path == '/manifest.json' && !brokenManifest) { req.response.write(jsonEncode({ 'version': '0.2.0', 'protocol': RelayConstants.protoVersion, 'url': '$base/watch-together-0.2.0-linux-x64.tar.gz', 'sha256': sha256.convert(tarball).toString(), 'notes': 'test build', ...manifestExtra, })); } else if (req.uri.path == '/watch-together-0.2.0-linux-x64.tar.gz') { tarHits++; req.response.contentLength = tarball.length; req.response.add(tarball); } else { req.response.statusCode = 404; } await req.response.close(); }); } Future stop() => http.close(force: true); } // The updater itself is tested in packages/core/test/updater_test.dart // (fixtures above mirror it); this covers the app's UpdateController. void main() { group('in-app controller', () { late Directory work; late _Server server; setUp(() async { work = await _work(); server = _Server(await _releaseTarball(work)); await server.start(); }); tearDown(() async { await server.stop(); if (await work.exists()) await work.delete(recursive: true); }); test('check, download with progress, apply, restart same binary', () async { final root = await _fakeRoot(work); final restarts = <(String, List)>[]; launchArgs = const ['--some-flag']; final c = UpdateController( currentVersion: '0.1.0', manifestUrl: server.manifestUrl, executablePath: '${root.path}/watch_together', restart: (exe, args) async => restarts.add((exe, args))); await c.checkOnce(); expect(c.status.value.phase, UpdatePhase.available); expect(c.status.value.canInstall, isTrue); expect(c.status.value.manifest!.isRequired, isFalse); final progress = []; c.status.addListener(() { final p = c.status.value.progress; if (p != null) progress.add(p); }); await c.install(); expect(progress.last, 1.0); expect(c.status.value.phase, UpdatePhase.restarting); expect(await File('${root.path}/watch_together').readAsString(), 'binary-v2'); expect(_isExec('${root.path}/watch_together'), isTrue); expect(restarts.single.$1, '${root.path}/watch_together'); expect(restarts.single.$2, ['--some-flag']); }, skip: _posixOnly); test('newer protocol is a required update, cannot be dismissed', () async { server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1}; final c = UpdateController( currentVersion: '0.1.0', manifestUrl: server.manifestUrl, executablePath: '${work.path}/stray-binary', restart: (_, _) async {}); await c.checkOnce(); expect(c.status.value.manifest!.isRequired, isTrue); expect(c.status.value.canInstall, isFalse); c.dismiss(); expect(c.status.value.phase, UpdatePhase.available); }); test('failed check stays quiet; dev builds skip the check', () async { server.brokenManifest = true; final c = UpdateController( currentVersion: '0.1.0', manifestUrl: server.manifestUrl, executablePath: '${work.path}/stray-binary', restart: (_, _) async {}); await c.checkOnce(); expect(c.status.value.phase, UpdatePhase.idle); server.brokenManifest = false; final dev = UpdateController( currentVersion: '0.0.0-dev', manifestUrl: server.manifestUrl, executablePath: '${work.path}/stray-binary', restart: (_, _) async {}); await dev.checkOnce(); expect(dev.status.value.phase, UpdatePhase.idle); }); test('failed install shows error, install untouched, retry works', () async { server.manifestExtra = {'sha256': '0' * 64}; final root = await _fakeRoot(work); var restarted = false; final c = UpdateController( currentVersion: '0.1.0', manifestUrl: server.manifestUrl, executablePath: '${root.path}/watch_together', restart: (_, _) async => restarted = true); await c.checkOnce(); await c.install(); expect(c.status.value.phase, UpdatePhase.failed); expect(c.status.value.error, contains('sha256')); expect(restarted, isFalse); expect(await File('${root.path}/watch_together').readAsString(), 'binary-v1'); }, skip: _posixOnly); }); }