probe: updater tests on windows
probe-windows / windows (push) Failing after 2m19s

This commit is contained in:
foxyz committed 2026-10-07 22:02:15 +03:00
1 parent e29a5a3b0a
commit 96ef39ab1f
3 files changed
+262 -112

No files matched your search

+3 -7
View File
@@ -26,11 +26,7 @@ jobs:
Add-Content $env:GITHUB_PATH "$F\bin" Add-Content $env:GITHUB_PATH "$F\bin"
& "$F\bin\flutter.bat" config --no-analytics --enable-windows-desktop | Out-Null & "$F\bin\flutter.bat" config --no-analytics --enable-windows-desktop | Out-Null
& "$F\bin\flutter.bat" doctor -v & "$F\bin\flutter.bat" doctor -v
- name: Build zip - name: Updater tests
shell: powershell shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1 working-directory: apps/desktop
- name: Inspect run: flutter test test/updater_test.dart -r expanded
shell: powershell
run: |
Get-ChildItem dist
tar.exe -tf (Get-ChildItem dist\*.zip)[0].FullName | Select-Object -First 40
+96 -37
View File
@@ -25,12 +25,17 @@ final _log = createLogger('update');
const updateManifestUrl = const updateManifestUrl =
'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json'; 'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json';
const appVersion = String.fromEnvironment('APP_VERSION', const appVersion = String.fromEnvironment(
defaultValue: '0.0.0-dev'); 'APP_VERSION',
defaultValue: '0.0.0-dev',
);
// Process args as main() received them — restart relaunches with these. // Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const []; List<String> launchArgs = const [];
// Key into the manifest's `assets` map, e.g. linux-x64, windows-x64.
final updatePlatform = '${Platform.operatingSystem}-x64';
const _stagingDir = '.update-staging'; const _stagingDir = '.update-staging';
const _backupDir = '.update-backup'; const _backupDir = '.update-backup';
const _downloadFile = '.update-download'; const _downloadFile = '.update-download';
@@ -41,23 +46,36 @@ class UpdateManifest {
final String url; final String url;
final String? sha256; final String? sha256;
final String notes; final String notes;
const UpdateManifest( const UpdateManifest({
{required this.version, required this.version,
required this.protocol, required this.protocol,
required this.url, required this.url,
this.sha256, this.sha256,
required this.notes}); required this.notes,
});
factory UpdateManifest.fromJson(Map<String, Object?> json) { // `assets` maps platform → archive URL. Top-level `url` is the Linux
// tarball, kept for builds from before `assets` existed (<= 0.2.1).
factory UpdateManifest.fromJson(
Map<String, Object?> json, {
String? platform,
}) {
final version = json['version']; final version = json['version'];
final protocol = json['protocol']; final protocol = json['protocol'];
final url = json['url']; final assets = json['assets'];
final url = assets is Map
? assets[platform ?? updatePlatform]
: json['url'];
if (version is! String || if (version is! String ||
version.isEmpty || version.isEmpty ||
protocol is! int || protocol is! int ||
url is! String || url is! String ||
url.isEmpty) { url.isEmpty) {
throw const FormatException('update manifest missing version/protocol/url'); throw FormatException(
assets is Map && version is String
? 'release $version has no ${platform ?? updatePlatform} build'
: 'update manifest missing version/protocol/url',
);
} }
final sha = json['sha256']; final sha = json['sha256'];
return UpdateManifest( return UpdateManifest(
@@ -65,7 +83,8 @@ class UpdateManifest {
protocol: protocol, protocol: protocol,
url: url, url: url,
sha256: sha is String && sha.isNotEmpty ? sha : null, sha256: sha is String && sha.isNotEmpty ? sha : null,
notes: (json['notes'] as String?) ?? ''); notes: (json['notes'] as String?) ?? '',
);
} }
// Newer wire protocol: this build can't join servers running it. // Newer wire protocol: this build can't join servers running it.
@@ -129,8 +148,10 @@ int compareVersions(String a, String b) {
bool isNewerVersion(String current, String latest) => bool isNewerVersion(String current, String latest) =>
compareVersions(latest, current) > 0; compareVersions(latest, current) > 0;
Future<UpdateManifest> fetchManifest( Future<UpdateManifest> fetchManifest({
{HttpClient? http, String url = updateManifestUrl}) async { HttpClient? http,
String url = updateManifestUrl,
}) async {
final client = http ?? HttpClient(); final client = http ?? HttpClient();
try { try {
final req = await client final req = await client
@@ -153,8 +174,12 @@ Future<UpdateManifest> fetchManifest(
typedef DownloadProgress = void Function(int received, int? total); typedef DownloadProgress = void Function(int received, int? total);
Future<void> downloadFile(String url, File dest, Future<void> downloadFile(
{HttpClient? http, DownloadProgress? onProgress}) async { String url,
File dest, {
HttpClient? http,
DownloadProgress? onProgress,
}) async {
final client = http ?? HttpClient(); final client = http ?? HttpClient();
try { try {
final req = await client final req = await client
@@ -361,8 +386,13 @@ typedef ApplyHook = Future<void> Function(String step);
// Unpack [archive] into a staging dir inside [root], then swap it in. // Unpack [archive] into a staging dir inside [root], then swap it in.
// On failure the install is left exactly as it was. // On failure the install is left exactly as it was.
Future<void> applyUpdate(File archive, Directory root, Future<void> applyUpdate(
{String? executableName, bool? windows, ApplyHook? hook}) async { File archive,
Directory root, {
String? executableName,
bool? windows,
ApplyHook? hook,
}) async {
final win = windows ?? Platform.isWindows; final win = windows ?? Platform.isWindows;
final stage = Directory('${root.path}/$_stagingDir'); final stage = Directory('${root.path}/$_stagingDir');
if (await stage.exists()) await stage.delete(recursive: true); if (await stage.exists()) await stage.delete(recursive: true);
@@ -373,7 +403,8 @@ Future<void> applyUpdate(File archive, Directory root,
(executableName != null && (executableName != null &&
!await File('${stage.path}/$executableName').exists())) { !await File('${stage.path}/$executableName').exists())) {
throw const FormatException( throw const FormatException(
'archive is not a release bundle (missing data/, lib/ or binary)'); 'archive is not a release bundle (missing data/, lib/ or binary)',
);
} }
if (win) { if (win) {
await _swapWindows(stage, root, hook); await _swapWindows(stage, root, hook);
@@ -392,13 +423,17 @@ class _Moved {
_Moved(this.name, this.hadOld); _Moved(this.name, this.hadOld);
} }
Future<void> _swapPosix(Directory stage, Directory root, ApplyHook? hook) async { Future<void> _swapPosix(
Directory stage,
Directory root,
ApplyHook? hook,
) async {
final backup = Directory('${root.path}/$_backupDir'); final backup = Directory('${root.path}/$_backupDir');
if (await backup.exists()) await backup.delete(recursive: true); if (await backup.exists()) await backup.delete(recursive: true);
await backup.create(); await backup.create();
final names = [ final names = [
for (final e in await stage.list(followLinks: false).toList()) for (final e in await stage.list(followLinks: false).toList())
e.path.substring(stage.path.length + 1) e.path.substring(stage.path.length + 1),
]..sort(); ]..sort();
final done = <_Moved>[]; final done = <_Moved>[];
try { try {
@@ -423,8 +458,10 @@ Future<void> _swapPosix(Directory stage, Directory root, ApplyHook? hook) async
if (m.hadOld) await _rename('${backup.path}/${m.name}', cur); if (m.hadOld) await _rename('${backup.path}/${m.name}', cur);
} catch (re) { } catch (re) {
// Leave the backup dir in place so nothing is lost. // Leave the backup dir in place so nothing is lost.
_log.severe('rollback of ${m.name} failed: $re ' _log.severe(
'(old files kept in ${backup.path})'); 'rollback of ${m.name} failed: $re '
'(old files kept in ${backup.path})',
);
rethrow; rethrow;
} }
} }
@@ -437,10 +474,14 @@ Future<void> _swapPosix(Directory stage, Directory root, ApplyHook? hook) async
// UNTESTED on Windows. File-level: a running exe/dll can be renamed but // UNTESTED on Windows. File-level: a running exe/dll can be renamed but
// not deleted or overwritten, so every existing file goes to `<f>.old` // not deleted or overwritten, so every existing file goes to `<f>.old`
// first; leftovers are swept by cleanupUpdateLeftovers on next start. // first; leftovers are swept by cleanupUpdateLeftovers on next start.
Future<void> _swapWindows(Directory stage, Directory root, ApplyHook? hook) async { Future<void> _swapWindows(
Directory stage,
Directory root,
ApplyHook? hook,
) async {
final rels = <String>[ final rels = <String>[
await for (final e in stage.list(recursive: true, followLinks: false)) await for (final e in stage.list(recursive: true, followLinks: false))
if (e is File) e.path.substring(stage.path.length + 1) if (e is File) e.path.substring(stage.path.length + 1),
]..sort(); ]..sort();
final done = <(String rel, String? old, bool movedIn)>[]; final done = <(String rel, String? old, bool movedIn)>[];
try { try {
@@ -486,11 +527,14 @@ Future<void> _swapWindows(Directory stage, Directory root, ApplyHook? hook) asyn
// Shared by the CLI and the in-app banner: download next to the install // Shared by the CLI and the in-app banner: download next to the install
// (same filesystem), verify, apply, clean up. // (same filesystem), verify, apply, clean up.
Future<void> downloadAndApply(UpdateManifest manifest, Directory root, Future<void> downloadAndApply(
{HttpClient? http, UpdateManifest manifest,
Directory root, {
HttpClient? http,
DownloadProgress? onProgress, DownloadProgress? onProgress,
String? executableName, String? executableName,
void Function(String line)? say}) async { void Function(String line)? say,
}) async {
final file = File('${root.path}/$_downloadFile'); final file = File('${root.path}/$_downloadFile');
try { try {
say?.call('downloading ${manifest.url} ...'); say?.call('downloading ${manifest.url} ...');
@@ -498,8 +542,10 @@ Future<void> downloadAndApply(UpdateManifest manifest, Directory root,
if (manifest.sha256 != null) { if (manifest.sha256 != null) {
await verifySha256(file, manifest.sha256!); await verifySha256(file, manifest.sha256!);
} else { } else {
say?.call('no checksum in manifest — skipping verification ' say?.call(
'(HTTPS transit + gzip CRC still apply).'); 'no checksum in manifest — skipping verification '
'(HTTPS transit + gzip CRC still apply).',
);
} }
say?.call('verified, applying over ${root.path} ...'); say?.call('verified, applying over ${root.path} ...');
await applyUpdate(file, root, executableName: executableName); await applyUpdate(file, root, executableName: executableName);
@@ -512,9 +558,12 @@ Future<void> downloadAndApply(UpdateManifest manifest, Directory root,
// exit this process. // exit this process.
Future<Never> restartApp(String executable, List<String> args) async { Future<Never> restartApp(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList(); final keep = args.where((a) => a != '--upgrade').toList();
await Process.start(executable, keep, await Process.start(
executable,
keep,
mode: ProcessStartMode.detached, mode: ProcessStartMode.detached,
workingDirectory: Directory.current.path); workingDirectory: Directory.current.path,
);
exit(0); exit(0);
} }
@@ -524,13 +573,16 @@ String requiredUpdateNote(UpdateManifest m) =>
// Returns a process exit code: 0 ok / already current, 1 failure, // Returns a process exit code: 0 ok / already current, 1 failure,
// 2 not a packaged install (update manually). // 2 not a packaged install (update manually).
Future<int> runUpgrade( Future<int> runUpgrade({
{String currentVersion = appVersion, String currentVersion = appVersion,
String manifestUrl = updateManifestUrl, String manifestUrl = updateManifestUrl,
HttpClient? http, HttpClient? http,
String? executablePath, String? executablePath,
Directory? installRoot}) async { Directory? installRoot,
stdout.writeln('watch-together $currentVersion (proto ${RelayConstants.protoVersion})'); }) async {
stdout.writeln(
'watch-together $currentVersion (proto ${RelayConstants.protoVersion})',
);
late final UpdateManifest manifest; late final UpdateManifest manifest;
try { try {
manifest = await fetchManifest(http: http, url: manifestUrl); manifest = await fetchManifest(http: http, url: manifestUrl);
@@ -542,7 +594,9 @@ Future<int> runUpgrade(
stdout.writeln('already up to date (${manifest.version}).'); stdout.writeln('already up to date (${manifest.version}).');
return 0; return 0;
} }
stdout.writeln('update available: ${manifest.version} (proto ${manifest.protocol})'); stdout.writeln(
'update available: ${manifest.version} (proto ${manifest.protocol})',
);
if (manifest.notes.isNotEmpty) { if (manifest.notes.isNotEmpty) {
stdout.writeln(manifest.notes); stdout.writeln(manifest.notes);
} }
@@ -552,15 +606,20 @@ Future<int> runUpgrade(
final exe = executablePath ?? currentExecutablePath(); final exe = executablePath ?? currentExecutablePath();
final root = installRoot ?? findInstallRoot(exe); final root = installRoot ?? findInstallRoot(exe);
if (root == null) { if (root == null) {
stdout.writeln('not a packaged install — download the tarball and update manually.'); stdout.writeln(
'not a packaged install — download the tarball and update manually.',
);
return 2; return 2;
} }
try { try {
await cleanupUpdateLeftovers(root); await cleanupUpdateLeftovers(root);
await downloadAndApply(manifest, root, await downloadAndApply(
manifest,
root,
http: http, http: http,
executableName: installRoot == null ? _basename(exe) : null, executableName: installRoot == null ? _basename(exe) : null,
say: stdout.writeln); say: stdout.writeln,
);
} catch (e) { } catch (e) {
stdout.writeln('update failed: $e'); stdout.writeln('update failed: $e');
return 1; return 1;
+163 -68
View File
@@ -25,9 +25,15 @@ bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
// Builds the release archive exactly like tool/package_linux.sh: // Builds the release archive exactly like tool/package_linux.sh:
// bundle copied into <stage>/watch-together-<ver>/, then // bundle copied into <stage>/watch-together-<ver>/, then
// `tar -czf out -C <stage> watch-together-<ver>`. // `tar -czf out -C <stage> watch-together-<ver>`.
Future<List<int>> _releaseTarball(Directory work, Future<List<int>> _releaseTarball(
{String version = '0.2.0', bool windows = false, bool bundle = true}) async { Directory work, {
final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}'); String version = '0.2.0',
bool windows = false,
bool bundle = true,
}) async {
final stage = Directory(
'${work.path}/pkg-$version-${windows ? 'win' : 'lin'}',
);
final folder = Directory('${stage.path}/watch-together-$version'); final folder = Directory('${stage.path}/watch-together-$version');
await Directory('${folder.path}/data/flutter_assets').create(recursive: true); await Directory('${folder.path}/data/flutter_assets').create(recursive: true);
await File('${folder.path}/data/flutter_assets/fresh.cfg') await File('${folder.path}/data/flutter_assets/fresh.cfg')
@@ -44,8 +50,11 @@ Future<List<int>> _releaseTarball(Directory work,
} }
await File('${folder.path}/update.sh') await File('${folder.path}/update.sh')
.writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n'); .writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n');
final r = await Process.run('chmod', final r = await Process.run('chmod', [
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']); '755',
'${folder.path}/watch_together',
'${folder.path}/update.sh',
]);
expect(r.exitCode, 0); expect(r.exitCode, 0);
} }
final out = File('${work.path}/out-$version.tar.gz'); final out = File('${work.path}/out-$version.tar.gz');
@@ -99,14 +108,16 @@ class _Server {
http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
http.listen((req) async { http.listen((req) async {
if (req.uri.path == '/manifest.json' && !brokenManifest) { if (req.uri.path == '/manifest.json' && !brokenManifest) {
req.response.write(jsonEncode({ req.response.write(
jsonEncode({
'version': '0.2.0', 'version': '0.2.0',
'protocol': RelayConstants.protoVersion, 'protocol': RelayConstants.protoVersion,
'url': '$base/watch-together-0.2.0-linux-x64.tar.gz', 'url': '$base/watch-together-0.2.0-linux-x64.tar.gz',
'sha256': sha256.convert(tarball).toString(), 'sha256': sha256.convert(tarball).toString(),
'notes': 'test build', 'notes': 'test build',
...manifestExtra, ...manifestExtra,
})); }),
);
} else if (req.uri.path == '/watch-together-0.2.0-linux-x64.tar.gz') { } else if (req.uri.path == '/watch-together-0.2.0-linux-x64.tar.gz') {
tarHits++; tarHits++;
req.response.contentLength = tarball.length; req.response.contentLength = tarball.length;
@@ -145,11 +156,9 @@ void main() {
test('manifest rejects missing fields, sha256 is optional', () { test('manifest rejects missing fields, sha256 is optional', () {
expect(() => UpdateManifest.fromJson({}), throwsFormatException); expect(() => UpdateManifest.fromJson({}), throwsFormatException);
expect( expect(
() => UpdateManifest.fromJson({ () => UpdateManifest.fromJson({'version': '0.2.0', 'protocol': 4}),
'version': '0.2.0', throwsFormatException,
'protocol': 4, );
}),
throwsFormatException);
final noSha = UpdateManifest.fromJson({ final noSha = UpdateManifest.fromJson({
'version': '0.2.0', 'version': '0.2.0',
'protocol': RelayConstants.protoVersion, 'protocol': RelayConstants.protoVersion,
@@ -163,19 +172,51 @@ void main() {
'protocol': RelayConstants.protoVersion + 1, 'protocol': RelayConstants.protoVersion + 1,
'url': 'https://x/y.tar.gz', 'url': 'https://x/y.tar.gz',
}).isRequired, }).isRequired,
isTrue); isTrue,
);
});
test('manifest picks the asset for this platform', () {
final json = {
'version': '0.2.2',
'protocol': RelayConstants.protoVersion,
'url': 'https://x/linux.tar.gz',
'assets': {
'linux-x64': 'https://x/linux.tar.gz',
'windows-x64': 'https://x/windows.zip',
},
};
expect(
UpdateManifest.fromJson(json, platform: 'windows-x64').url,
'https://x/windows.zip',
);
expect(
UpdateManifest.fromJson(json, platform: 'linux-x64').url,
'https://x/linux.tar.gz',
);
expect(
() => UpdateManifest.fromJson(json, platform: 'macos-x64'),
throwsFormatException,
);
}); });
test('findInstallRoot honors bundle layout', () async { test('findInstallRoot honors bundle layout', () async {
final work = await _work(); final work = await _work();
try { try {
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
expect(findInstallRoot('${root.path}/watch_together', windows: false)?.path, expect(
root.path); findInstallRoot('${root.path}/watch_together', windows: false)?.path,
expect(findInstallRoot('${work.path}/stray-binary', windows: false), isNull); root.path,
);
expect(
findInstallRoot('${work.path}/stray-binary', windows: false),
isNull,
);
final win = await _fakeRoot(await work.createTemp('w'), windows: true); final win = await _fakeRoot(await work.createTemp('w'), windows: true);
expect(findInstallRoot('${win.path}/watch_together.exe', windows: true)?.path, expect(
win.path); findInstallRoot('${win.path}/watch_together.exe', windows: true)?.path,
win.path,
);
} finally { } finally {
await work.delete(recursive: true); await work.delete(recursive: true);
} }
@@ -196,19 +237,21 @@ void main() {
if (await work.exists()) await work.delete(recursive: true); if (await work.exists()) await work.delete(recursive: true);
}); });
test('real release layout lands at the install root, modes kept', test('real release layout lands at the install root, modes kept', () async {
() async {
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root,
);
expect(rc, 0); expect(rc, 0);
expect(server.tarHits, 1); expect(server.tarHits, 1);
final p = root.path; final p = root.path;
expect(await File('$p/watch_together').readAsString(), 'binary-v2'); expect(await File('$p/watch_together').readAsString(), 'binary-v2');
expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(), expect(
'fresh-config'); await File('$p/data/flutter_assets/fresh.cfg').readAsString(),
'fresh-config',
);
expect(Directory('$p/watch-together-0.2.0').existsSync(), isFalse); expect(Directory('$p/watch-together-0.2.0').existsSync(), isFalse);
expect(_isExec('$p/watch_together'), isTrue); expect(_isExec('$p/watch_together'), isTrue);
expect(_isExec('$p/update.sh'), isTrue); expect(_isExec('$p/update.sh'), isTrue);
@@ -226,11 +269,14 @@ void main() {
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.2.0', currentVersion: '0.2.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root,
);
expect(rc, 0); expect(rc, 0);
expect(server.tarHits, 0); expect(server.tarHits, 0);
expect(await File('${root.path}/watch_together').readAsString(), expect(
'binary-v1'); await File('${root.path}/watch_together').readAsString(),
'binary-v1',
);
}); });
test('bad hash aborts, install untouched', () async { test('bad hash aborts, install untouched', () async {
@@ -239,10 +285,13 @@ void main() {
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root,
);
expect(rc, 1); expect(rc, 1);
expect(await File('${root.path}/watch_together').readAsString(), expect(
'binary-v1'); await File('${root.path}/watch_together').readAsString(),
'binary-v1',
);
expect(_leftovers(root), isEmpty); expect(_leftovers(root), isEmpty);
}); });
@@ -252,34 +301,47 @@ void main() {
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root,
);
expect(rc, 0); expect(rc, 0);
expect(await File('${root.path}/watch_together').readAsString(), expect(
'binary-v2'); await File('${root.path}/watch_together').readAsString(),
'binary-v2',
);
}); });
test('unknown layout asks for manual update', () async { test('unknown layout asks for manual update', () async {
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary'); executablePath: '${work.path}/stray-binary',
);
expect(rc, 2); expect(rc, 2);
}); });
test('archive that is not a bundle is refused, install untouched', test(
'archive that is not a bundle is refused, install untouched',
() async { () async {
server.tarball = await _releaseTarball(work, version: '9.9.9', bundle: false); server.tarball = await _releaseTarball(
work,
version: '9.9.9',
bundle: false,
);
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root,
);
expect(rc, 1); expect(rc, 1);
expect(await File('${root.path}/watch_together').readAsString(), expect(
'binary-v1'); await File('${root.path}/watch_together').readAsString(),
'binary-v1',
);
expect(File('${root.path}/lib/libgone.so').existsSync(), isTrue); expect(File('${root.path}/lib/libgone.so').existsSync(), isTrue);
expect(_leftovers(root), isEmpty); expect(_leftovers(root), isEmpty);
}); },
);
}); });
group('apply', () { group('apply', () {
@@ -318,10 +380,16 @@ void main() {
test('failure at $step rolls back', () async { test('failure at $step rolls back', () async {
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
await expectLater( await expectLater(
applyUpdate(await archive(), root, windows: false, hook: (s) async { applyUpdate(
await archive(),
root,
windows: false,
hook: (s) async {
if (s == step) throw FileSystemException('injected', s); if (s == step) throw FileSystemException('injected', s);
}), },
throwsA(isA<FileSystemException>())); ),
throwsA(isA<FileSystemException>()),
);
await expectUntouched(root); await expectUntouched(root);
}); });
} }
@@ -329,13 +397,20 @@ void main() {
test('stages next to the install, never in the system temp', () async { test('stages next to the install, never in the system temp', () async {
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
final seen = <String>[]; final seen = <String>[];
await applyUpdate(await archive(), root, windows: false, hook: (s) async { await applyUpdate(
await archive(),
root,
windows: false,
hook: (s) async {
if (seen.isEmpty) { if (seen.isEmpty) {
seen.add(Directory('${root.path}/.update-staging').existsSync() seen.add(
Directory('${root.path}/.update-staging').existsSync()
? 'staged-in-root' ? 'staged-in-root'
: 'elsewhere'); : 'elsewhere',
);
} }
}); },
);
expect(seen, ['staged-in-root']); expect(seen, ['staged-in-root']);
}); });
@@ -346,12 +421,16 @@ void main() {
final f = File('${work.path}/evil.tar.gz') final f = File('${work.path}/evil.tar.gz')
..writeAsBytesSync(GZipEncoder().encode(TarEncoder().encode(evil))); ..writeAsBytesSync(GZipEncoder().encode(TarEncoder().encode(evil)));
await expectLater( await expectLater(
applyUpdate(f, root, windows: false), throwsFormatException); applyUpdate(f, root, windows: false),
throwsFormatException,
);
final link = Archive() final link = Archive()
..addFile(ArchiveFile.symlink('w/lib/x.so', '../../../etc/passwd')); ..addFile(ArchiveFile.symlink('w/lib/x.so', '../../../etc/passwd'));
f.writeAsBytesSync(GZipEncoder().encode(TarEncoder().encode(link))); f.writeAsBytesSync(GZipEncoder().encode(TarEncoder().encode(link)));
await expectLater( await expectLater(
applyUpdate(f, root, windows: false), throwsFormatException); applyUpdate(f, root, windows: false),
throwsFormatException,
);
expect(File('${work.path}/escape').existsSync(), isFalse); expect(File('${work.path}/escape').existsSync(), isFalse);
await expectUntouched(root); await expectUntouched(root);
}); });
@@ -375,8 +454,10 @@ void main() {
final p = root.path; final p = root.path;
expect(await File('$p/watch_together.exe').readAsString(), 'binary-v2'); expect(await File('$p/watch_together.exe').readAsString(), 'binary-v2');
expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v2'); expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v2');
expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(), expect(
'fresh-config'); await File('$p/data/flutter_assets/fresh.cfg').readAsString(),
'fresh-config',
);
// Not in use here, so the .old copies were deleted right away. // Not in use here, so the .old copies were deleted right away.
expect(File('$p/watch_together.exe.old').existsSync(), isFalse); expect(File('$p/watch_together.exe.old').existsSync(), isFalse);
expect(_leftovers(root), isEmpty); expect(_leftovers(root), isEmpty);
@@ -385,13 +466,18 @@ void main() {
test('windows-style swap rolls back', () async { test('windows-style swap rolls back', () async {
final root = await _fakeRoot(work, windows: true); final root = await _fakeRoot(work, windows: true);
await expectLater( await expectLater(
applyUpdate(await archive(windows: true), root, windows: true, applyUpdate(
await archive(windows: true),
root,
windows: true,
hook: (s) async { hook: (s) async {
if (s == 'in:watch_together.exe') { if (s == 'in:watch_together.exe') {
throw const FileSystemException('injected'); throw const FileSystemException('injected');
} }
}), },
throwsA(isA<FileSystemException>())); ),
throwsA(isA<FileSystemException>()),
);
final p = root.path; final p = root.path;
expect(await File('$p/watch_together.exe').readAsString(), 'binary-v1'); expect(await File('$p/watch_together.exe').readAsString(), 'binary-v1');
expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v1'); expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v1');
@@ -416,8 +502,7 @@ void main() {
if (await work.exists()) await work.delete(recursive: true); if (await work.exists()) await work.delete(recursive: true);
}); });
test('check, download with progress, apply, restart same binary', test('check, download with progress, apply, restart same binary', () async {
() async {
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
final restarts = <(String, List<String>)>[]; final restarts = <(String, List<String>)>[];
launchArgs = const ['--some-flag']; launchArgs = const ['--some-flag'];
@@ -425,7 +510,8 @@ void main() {
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together', executablePath: '${root.path}/watch_together',
restart: (exe, args) async => restarts.add((exe, args))); restart: (exe, args) async => restarts.add((exe, args)),
);
await c.checkOnce(); await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.available); expect(c.status.value.phase, UpdatePhase.available);
expect(c.status.value.canInstall, isTrue); expect(c.status.value.canInstall, isTrue);
@@ -438,21 +524,23 @@ void main() {
await c.install(); await c.install();
expect(progress.last, 1.0); expect(progress.last, 1.0);
expect(c.status.value.phase, UpdatePhase.restarting); expect(c.status.value.phase, UpdatePhase.restarting);
expect(await File('${root.path}/watch_together').readAsString(), expect(
'binary-v2'); await File('${root.path}/watch_together').readAsString(),
'binary-v2',
);
expect(_isExec('${root.path}/watch_together'), isTrue); expect(_isExec('${root.path}/watch_together'), isTrue);
expect(restarts.single.$1, '${root.path}/watch_together'); expect(restarts.single.$1, '${root.path}/watch_together');
expect(restarts.single.$2, ['--some-flag']); expect(restarts.single.$2, ['--some-flag']);
}); });
test('newer protocol is a required update, cannot be dismissed', test('newer protocol is a required update, cannot be dismissed', () async {
() async {
server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1}; server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1};
final c = UpdateController( final c = UpdateController(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary', executablePath: '${work.path}/stray-binary',
restart: (_, _) async {}); restart: (_, _) async {},
);
await c.checkOnce(); await c.checkOnce();
expect(c.status.value.manifest!.isRequired, isTrue); expect(c.status.value.manifest!.isRequired, isTrue);
expect(c.status.value.canInstall, isFalse); expect(c.status.value.canInstall, isFalse);
@@ -466,7 +554,8 @@ void main() {
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary', executablePath: '${work.path}/stray-binary',
restart: (_, _) async {}); restart: (_, _) async {},
);
await c.checkOnce(); await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.idle); expect(c.status.value.phase, UpdatePhase.idle);
server.brokenManifest = false; server.brokenManifest = false;
@@ -474,12 +563,14 @@ void main() {
currentVersion: '0.0.0-dev', currentVersion: '0.0.0-dev',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary', executablePath: '${work.path}/stray-binary',
restart: (_, _) async {}); restart: (_, _) async {},
);
await dev.checkOnce(); await dev.checkOnce();
expect(dev.status.value.phase, UpdatePhase.idle); expect(dev.status.value.phase, UpdatePhase.idle);
}); });
test('failed install shows error, install untouched, retry works', test(
'failed install shows error, install untouched, retry works',
() async { () async {
server.manifestExtra = {'sha256': '0' * 64}; server.manifestExtra = {'sha256': '0' * 64};
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
@@ -488,14 +579,18 @@ void main() {
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together', executablePath: '${root.path}/watch_together',
restart: (_, _) async => restarted = true); restart: (_, _) async => restarted = true,
);
await c.checkOnce(); await c.checkOnce();
await c.install(); await c.install();
expect(c.status.value.phase, UpdatePhase.failed); expect(c.status.value.phase, UpdatePhase.failed);
expect(c.status.value.error, contains('sha256')); expect(c.status.value.error, contains('sha256'));
expect(restarted, isFalse); expect(restarted, isFalse);
expect(await File('${root.path}/watch_together').readAsString(), expect(
'binary-v1'); await File('${root.path}/watch_together').readAsString(),
}); 'binary-v1',
);
},
);
}); });
} }