feat(menubar): manual "Check for updates" in the About box (T-47 P1)

Help → About gains a "Check for updates" button that fetches the latest GitHub
release, semver-compares it to clideVersion, and shows the result inline:
up-to-date, available (with a tappable link to the release notes), or a clear
error. clide's first and only outbound HTTP call — a plain GET with no user
data, run ONLY on this explicit tap, never on a launch path or a timer. So it's
D-64-clean with no amendment; a background/periodic poll stays deferred (would
need the narrow opt-in amendment first).

The fetch is injectable so no test touches the network. compareSemver handles
2.3.10 > 2.3.9 and ranks pre-releases below their release. Closes T-492 (P1);
the release-channel CI for downloadable signed packages is T-491, and download/
apply (P2/P3) depend on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 12:17:29 +02:00
co-authored by Claude Opus 4.8
parent 095c45a023
commit 7c7a91545e
9 changed files with 950 additions and 1 deletions
@@ -0,0 +1,62 @@
/// T-47 P1: the About box "Check for updates" button. The check runs ONLY on
/// the explicit tap (never on open — POLICY/D-64), and surfaces the result
/// inline: up-to-date, available (with a release link), or a clear error.
library;
import 'package:clide/builtin/menubar/src/about_dialog.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
Future<void> pump(WidgetTester tester, Future<String> Function(Uri) fetch) async {
tester.view.physicalSize = const Size(700, 1000);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(harness(f, AboutDialog(onDismiss: () {}, updateFetch: fetch)));
await tester.pump();
}
testWidgets('does not fetch until the user taps Check for updates (no network on open)', (tester) async {
var calls = 0;
await pump(tester, (_) async {
calls++;
return '{"tag_name":"v2.9.0","html_url":"https://x/r"}';
});
expect(calls, 0, reason: 'opening the About box must not touch the network');
await tester.tap(find.text('Check for updates'));
await pumpAsync(tester);
expect(calls, 1);
});
testWidgets('shows an update-available link when a newer release exists', (tester) async {
await pump(tester, (_) async => '{"tag_name":"v99.0.0","html_url":"https://github.com/postmeridiem/clide/releases/v99.0.0"}');
await tester.tap(find.text('Check for updates'));
await pumpAsync(tester);
expect(find.textContaining('99.0.0'), findsOneWidget);
});
testWidgets('shows up-to-date when the latest release is not newer', (tester) async {
await pump(tester, (_) async => '{"tag_name":"v0.0.1","html_url":"https://x/r"}');
await tester.tap(find.text('Check for updates'));
await pumpAsync(tester);
expect(find.textContaining('latest version'), findsOneWidget);
});
testWidgets('surfaces a clear error when the check fails', (tester) async {
await pump(tester, (_) => Future.error('offline'));
await tester.tap(find.text('Check for updates'));
await pumpAsync(tester);
expect(find.textContaining("Couldn't check"), findsOneWidget);
});
}
@@ -0,0 +1,68 @@
/// T-47 P1: the manual update-check logic — semver comparison, repo parsing,
/// and the GitHub-release check (against a fake fetch, so no test hits the
/// network). Flutter-free.
library;
import 'package:clide/builtin/menubar/src/update_check.dart';
import 'package:test/test.dart';
void main() {
group('compareSemver', () {
test('compares major.minor.patch numerically (2.3.10 > 2.3.9)', () {
expect(compareSemver('2.3.10', '2.3.9'), 1);
expect(compareSemver('2.3.9', '2.3.10'), -1);
expect(compareSemver('2.8.1', '2.8.1'), 0);
expect(compareSemver('3.0.0', '2.9.9'), 1);
});
test('a pre-release ranks below the release of the same core', () {
expect(compareSemver('2.8.2-rc1', '2.8.2'), -1);
expect(compareSemver('2.8.2', '2.8.2-rc1'), 1);
expect(compareSemver('2.8.2-rc2', '2.8.2-rc1'), 1);
});
test('missing components count as 0', () {
expect(compareSemver('2.8', '2.8.0'), 0);
});
});
group('parseGithubRepo', () {
test('extracts owner/repo from an https URL', () {
final r = parseGithubRepo('https://github.com/postmeridiem/clide');
expect(r?.owner, 'postmeridiem');
expect(r?.repo, 'clide');
});
test('strips a .git suffix and the ssh form; rejects non-github', () {
expect(parseGithubRepo('git@github.com:foo/bar.git')?.repo, 'bar');
expect(parseGithubRepo('https://gitlab.com/x/y'), isNull);
});
});
group('checkForUpdate', () {
String release(String tag) => '{"tag_name": "$tag", "html_url": "https://github.com/postmeridiem/clide/releases/$tag"}';
const repo = 'https://github.com/postmeridiem/clide';
test('a newer release returns UpdateAvailable with version + url', () async {
final r = await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) async => release('v2.9.0'));
expect(r, isA<UpdateAvailable>());
expect((r as UpdateAvailable).latest, '2.9.0');
expect(r.url, contains('releases/v2.9.0'));
});
test('the same or older release returns UpToDate', () async {
expect(await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) async => release('v2.8.1')), isA<UpdateUpToDate>());
expect(await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) async => release('v2.8.0')), isA<UpdateUpToDate>());
});
test('a fetch failure returns UpdateCheckFailed and never throws', () async {
final r = await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) => Future.error('offline'));
expect(r, isA<UpdateCheckFailed>());
});
test('an unrecognized repo URL or a tagless response fails cleanly', () async {
expect(await checkForUpdate(repositoryUrl: 'not-a-url', currentVersion: '2.8.1', fetch: (_) async => '{}'), isA<UpdateCheckFailed>());
expect(await checkForUpdate(repositoryUrl: repo, currentVersion: '2.8.1', fetch: (_) async => '{}'), isA<UpdateCheckFailed>());
});
});
}