diff --git a/lib/core/updater/github_releases_client.dart b/lib/core/updater/github_releases_client.dart index d262040e..e1f61a36 100644 --- a/lib/core/updater/github_releases_client.dart +++ b/lib/core/updater/github_releases_client.dart @@ -49,13 +49,7 @@ class GitHubReleasesClient { throw const GitHubReleasesException('Unexpected GitHub Releases list payload'); } - for (final entry in decoded) { - if (entry is! Map) continue; - if (entry['draft'] == true) continue; - return parseGitHubRelease(entry); - } - - throw const GitHubReleasesException('No published releases found'); + return pickNewestRelease(decoded); } Future downloadText(String url) async { @@ -71,6 +65,34 @@ class GitHubReleasesClient { void close() => _httpClient.close(); } +/// The release with the highest version in a `/releases` list, for the Beta +/// channel (#1493). +/// +/// The API lists releases by creation date, not by version: with `0.5.0-beta.2` +/// followed by a hotfix `0.4.20`, the first entry is the hotfix and the newer +/// beta would never be offered. Drafts and tags that are not versions are +/// skipped; a stable release outranks a pre-release of the same version. Only +/// the first page of the API is read. +UpdateManifest pickNewestRelease(List releases) { + Map? best; + UpdateVersion? bestVersion; + for (final entry in releases) { + if (entry is! Map) continue; + if (entry['draft'] == true) continue; + final tag = entry['tag_name']?.toString(); + final version = tag == null ? null : UpdateVersion.tryParse(tag); + if (version == null) continue; + if (bestVersion == null || version.compareTo(bestVersion) > 0) { + best = entry; + bestVersion = version; + } + } + if (best == null) { + throw const GitHubReleasesException('No published releases found'); + } + return parseGitHubRelease(best); +} + /// Parses a single GitHub release object into [UpdateManifest]. UpdateManifest parseGitHubRelease(Map json) { final tagName = json['tag_name']?.toString(); diff --git a/test/core/updater/github_releases_beta_test.dart b/test/core/updater/github_releases_beta_test.dart new file mode 100644 index 00000000..10e6a787 --- /dev/null +++ b/test/core/updater/github_releases_beta_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:querya_desktop/core/updater/github_releases_client.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; + +Map _release(String tag, {bool draft = false}) => { + 'tag_name': tag, + 'draft': draft, + 'published_at': '2026-10-01T00:00:00Z', + 'body': '', + 'assets': const [], + }; + +// The Beta channel reads `/releases`, which is ordered by creation date; the +// answer must be the highest version, whatever the order (#1493). +void main() { + group('pickNewestRelease', () { + String newest(List> list) => + pickNewestRelease(list).version; + + test('a hotfix published after a beta does not hide the newer beta', () { + expect( + newest([ + _release('0.4.20'), + _release('0.5.0-beta.2'), + _release('0.5.0-beta.1'), + ]), + '0.5.0-beta.2', + ); + }); + + test('a stable release outranks its own beta', () { + expect(newest([_release('0.5.0-beta.1'), _release('0.5.0')]), '0.5.0'); + expect(newest([_release('0.5.0'), _release('0.5.0-beta.3')]), '0.5.0'); + }); + + test('pre-release numbers compare as numbers: beta.10 is above beta.2', () { + expect( + newest([_release('0.5.0-beta.2'), _release('0.5.0-beta.10')]), + '0.5.0-beta.10', + ); + }); + + test('a leading v is ignored', () { + expect(newest([_release('v0.4.19'), _release('v0.4.21')]), '0.4.21'); + }); + + test('drafts and tags that are not versions are skipped', () { + expect( + newest([ + _release('0.9.0', draft: true), + _release('nightly'), + _release('0.4.19'), + ]), + '0.4.19', + ); + }); + + test('nothing usable is "No published releases found"', () { + expect( + () => pickNewestRelease([_release('0.9.0', draft: true), _release('x')]), + throwsA(isA().having( + (e) => e.message, + 'message', + 'No published releases found', + )), + ); + expect(() => pickNewestRelease(const []), + throwsA(isA())); + }); + }); + + group('GitHubReleasesClient.fetchLatest', () { + GitHubReleasesClient clientFor(List> releases) => + GitHubReleasesClient( + httpClient: MockClient((request) async { + expect(request.url.toString(), kGitHubReleasesListUrl); + return http.Response(jsonEncode(releases), 200); + }), + ); + + test('the Beta channel returns the highest version of the list', () async { + final manifest = await clientFor([ + _release('0.4.20'), + _release('0.5.0-beta.2'), + ]).fetchLatest(channel: UpdateChannel.dev); + expect(manifest.version, '0.5.0-beta.2'); + }); + }); +}