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:
@@ -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>());
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user