refactor: remove proxy API (#1596)

This commit is contained in:
Levi authored and GitHub committed 2026-10-21 22:48:17 +08:00
1 parent 47cafb7575
commit 4214765d39
98 files changed
+5958 -1225

No files matched your search

+105 -40
View File
@@ -8,71 +8,136 @@ import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
test('summary requests are shared and cached; explicit refresh requests fresh data', () async {
final pending = Completer<Response<String>>();
final transport = _Transport()..onProxy = (_) => pending.future;
late Dio client;
late _SiteAdapter adapter;
late GopeedSiteApi site;
late _Transport transport;
setUp(() {
client = Dio();
adapter = _SiteAdapter();
client.httpClientAdapter = adapter;
site = GopeedSiteApi(client: client);
transport = _Transport();
api.setTransportForTesting(transport);
final site = GopeedSiteApi.instance;
});
tearDown(() {
client.close(force: true);
expect(transport.calls, 0);
for (final request in adapter.requests) {
expect(request.uri.origin, 'https://gopeed.com');
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
}
});
test('install counts are reported directly with JSON and failures remain observable', () async {
await site.reportExtensionInstall('开发者@name');
final request = adapter.requests.single;
expect(request.uri.toString(), 'https://gopeed.com/api/extensions/install');
expect(request.method, 'POST');
expect(request.contentType, Headers.jsonContentType);
expect(request.data, {'id': '开发者@name'});
adapter.statusCode = 503;
await expectLater(site.reportExtensionInstall('test'), throwsA(isA<DioException>()));
});
test('summary requests go directly to the website and remain shared and cached', () async {
final pending = Completer<Map<String, dynamic>>();
final started = Completer<void>();
adapter.onRequest = (_) {
if (!started.isCompleted) started.complete();
return pending.future;
};
final first = site.getExtensions(query: 'cache-test', forceRefresh: true);
final second = site.getExtensions(query: 'cache-test');
expect(transport.urls.length, 1);
expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary');
pending.complete(
_response({
'data': [
{'id': 'test'},
],
}),
);
await started.future;
expect(adapter.requests.length, 1);
final request = adapter.requests.single;
expect(request.method, 'GET');
expect(request.uri.path, '/api/extensions');
expect(request.uri.queryParameters, {
'view': 'summary',
'page': '1',
'limit': '20',
'sort': 'stars',
'order': 'desc',
'q': 'cache-test',
});
pending.complete({
'data': [
{'id': 'test'},
],
});
expect((await first).data.single.hasDetails, isFalse);
await second;
await site.getExtensions(query: 'cache-test');
expect(transport.urls.length, 1);
expect(adapter.requests.length, 1);
await site.getExtensions(query: 'cache-test', forceRefresh: true);
expect(transport.urls.length, 2);
expect(adapter.requests.length, 2);
});
test('failed lists are not cached', () async {
final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline'));
api.setTransportForTesting(transport);
final site = GopeedSiteApi.instance;
await expectLater(site.getExtensions(query: 'error-test'), throwsStateError);
transport.onProxy = (_) async => _response({
adapter.statusCode = 503;
await expectLater(site.getExtensions(query: 'error-test'), throwsA(isA<DioException>()));
adapter.statusCode = 200;
adapter.onRequest = (_) async => {
'data': [
{'id': 'recovered'},
],
});
};
expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered');
expect(transport.urls.length, 2);
expect(adapter.requests.length, 2);
});
test('detail IDs are encoded once and a null README is still loaded detail', () async {
final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null});
api.setTransportForTesting(transport);
final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3');
final uri = Uri.parse(transport.urls.single);
test('detail IDs are encoded once and version remains a query parameter', () async {
adapter.onRequest = (_) async => {'id': '开发者@name', 'readme': null};
final result = await site.getExtension('开发者@name', version: '1.2.3');
final uri = adapter.requests.single.uri;
expect(uri.pathSegments, ['api', 'extensions', '开发者@name']);
expect(uri.queryParameters['version'], '1.2.3');
expect(result.hasDetails, isTrue);
await site.getExtension('literal%2Fname');
expect(adapter.requests.last.uri.pathSegments.last, 'literal%2Fname');
});
}
Response<String> _response(Map<String, dynamic> json) => Response(
requestOptions: RequestOptions(path: '/test'),
data: jsonEncode(json),
statusCode: 200,
);
class _Transport implements GopeedTransport {
final urls = <String>[];
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
int calls = 0;
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
urls.add(uri);
return onProxy(uri);
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
calls++;
throw StateError('Store requests must not use the Downloader transport');
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
String join(String path) => path;
}
class _SiteAdapter implements HttpClientAdapter {
final requests = <RequestOptions>[];
int statusCode = 200;
Future<Map<String, dynamic>> Function(RequestOptions) onRequest = (_) async => {'data': []};
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
) async {
requests.add(options);
return ResponseBody.fromString(
jsonEncode(await onRequest(options)),
statusCode,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}
@@ -0,0 +1,98 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
test('protocol jobs use the common protocol and job name API', () async {
final transport = _Transport();
api.setTransportForTesting(transport);
await LocalAppCapabilities.instance.capabilities.gopeed.triggerProtocolJob('bt', 'update-trackers');
expect(transport.path, 'api/v1/protocols/bt/jobs/update-trackers/trigger');
expect(transport.method, 'POST');
});
test('mirror settings and tracker settings serialize at their backend-owned locations', () {
final config = DownloaderConfig();
config.githubMirror = GithubMirrorConfig(enabled: false, mirrors: []);
config.protocolConfig.bt.customTrackers = ['udp://example.com:80/announce'];
config.protocolConfig.bt.state.trackerSubscriptionStatuses = {
'https://example.com/list': TrackerSubscriptionStatus(
success: true,
trackerCount: 12,
lastFetchTime: DateTime.utc(2026, 10, 19),
),
};
final json = config.toJson();
expect(json['githubMirror'], {'enabled': false, 'mirrors': []});
expect(json['extra'], isNot(contains('githubMirror')));
expect(json['extra'], isNot(contains('bt')));
expect((json['protocolConfig'] as Map)['bt']['customTrackers'], ['udp://example.com:80/announce']);
final restored = DownloaderConfig.fromJson(
json,
).protocolConfig.bt.state.trackerSubscriptionStatuses['https://example.com/list']!;
expect(restored.success, isTrue);
expect(restored.trackerCount, 12);
expect(restored.lastFetchTime, DateTime.utc(2026, 10, 19));
expect(BtState.fromJson({'lastTrackerUpdateTime': '0001-01-01T00:00:00Z'}).lastTrackerUpdateTime, isNull);
});
test('protocol operations survive the shared capability codec', () {
final codecs = createAppCapabilityCodecs();
final payload = {'protocol': 'bt', 'name': 'update-trackers'};
expect(codecs.decode<Map<String, dynamic>>(codecs.encode(payload)), payload);
expect(codecs.decode<Map<String, dynamic>?>(null), isNull);
expect(GopeedMethods.triggerProtocolJob.name, 'gopeed.protocol.job.trigger');
expect(const RpcUnit(), isA<RpcUnit>());
});
test('mirror candidates use provider dispatch and the shared capability codec', () async {
final transport = _Transport();
api.setTransportForTesting(transport);
const url = 'https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip';
final result = await LocalAppCapabilities.instance.capabilities.gopeed.getMirrorCandidates('github', url);
expect(transport.path, 'api/v1/mirrors/github/candidates');
expect(transport.method, 'POST');
expect(transport.data, {'url': url});
expect(result, ['https://example.com/package.dmg']);
final codecs = createAppCapabilityCodecs();
expect(codecs.decode<List<String>>(codecs.encode(result)), result);
expect(GopeedMethods.getMirrorCandidates.name, 'gopeed.mirrors.candidates');
final request = {'provider': 'another-resource', 'url': 'https://example.com/resource'};
expect(codecs.decode<Map<String, dynamic>>(codecs.encode(request)), request);
await LocalAppCapabilities.instance.capabilities.gopeed.getMirrorCandidates('another-resource', request['url']!);
expect(transport.path, 'api/v1/mirrors/another-resource/candidates');
expect(transport.data, {'url': request['url']});
});
}
class _Transport implements GopeedTransport {
String? path;
String? method;
dynamic data;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
this.path = path;
this.method = method;
this.data = data;
return {
'code': 0,
'data': path.endsWith('/trigger')
? null
: {
'urls': ['https://example.com/package.dmg'],
},
};
}
@override
String join(String path) => path;
}
-6
View File
@@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:dio/dio.dart' as dio;
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/create_task.dart';
@@ -77,9 +76,4 @@ class _RecordingTransport implements GopeedTransport {
@override
String join(String path) => path;
@override
Future<dio.Response<String>> proxyRequest(String uri, {dynamic data, dio.Options? options}) {
throw UnimplementedError();
}
}
@@ -0,0 +1,232 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/core/network/app_update_client.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
late Dio dio;
late _Adapter adapter;
late AppUpdateClient updates;
late _RejectTransport transport;
setUp(() {
transport = _RejectTransport();
api.setTransportForTesting(transport);
dio = Dio();
adapter = _Adapter();
dio.httpClientAdapter = adapter;
updates = AppUpdateClient(
client: dio,
site: GopeedSiteApi(client: dio),
);
});
tearDown(() {
dio.close(force: true);
expect(transport.calls, 0);
for (final request in adapter.requests) {
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
}
});
test('update metadata comes directly from the official GitHub API without mirror lookup', () async {
adapter.reply = (_, _) async => _json([
{'tag_name': 'v2.0.0', 'body': 'notes'},
]);
final info = await updates.check('1.0.0');
expect(info!.version, '2.0.0');
expect(info.changeLog, 'notes');
expect(info.releaseUrl, 'https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0');
expect(adapter.requests.single.uri.toString(), AppUpdateClient.releasesUrl);
});
test('release asset hashes are bound to the selected release and exact file name', () async {
final good = 'aB' * 32;
adapter.reply = (_, _) async => _json([
{
'tag_name': 'v1.9.9',
'assets': [
{'name': 'old.zip', 'digest': 'sha256:${'c' * 64}'},
],
},
{
'tag_name': 'v2.0.0',
'assets': [
{'name': 'package.zip', 'digest': 'sha256:$good'},
{'name': 'wrong-algorithm.zip', 'digest': 'md5:${'a' * 32}'},
{'name': 'missing.zip', 'digest': null},
{'name': 'bad-hex.zip', 'digest': 'sha256:${'g' * 64}'},
{'name': 'short.zip', 'digest': 'sha256:1234'},
],
},
]);
final info = (await updates.check('1.0.0'))!;
expect(info.assetSha256, {'package.zip': good.toLowerCase()});
expect(() => info.assetSha256['package.zip'] = 'd' * 64, throwsUnsupportedError);
expect(adapter.requests.single.followRedirects, isFalse);
});
test('the official website fallback preserves asset digests', () async {
final checksum = 'e' * 64;
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
? _json({'message': 'rate limited'}, status: 403)
: _json([
{
'tag_name': 'v2.0.0',
'assets': [
{'name': 'package.dmg', 'digest': 'sha256:$checksum'},
],
},
]);
final info = (await updates.check('1.0.0'))!;
expect(info.assetSha256, {'package.dmg': checksum});
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
});
test('API redirects fail instead of obtaining metadata from another host', () async {
adapter.reply = (_, _) async => ResponseBody.fromString(
'',
302,
headers: {
'location': ['https://mirror.example/releases'],
},
);
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
});
test('website fallback runs once after the official GitHub request fails', () async {
adapter.reply = (request, _) async => switch (request.uri.host) {
'api.github.com' => _json({'message': 'rate limited'}, status: 403),
_ => _json([
{'tag_name': 'v2.0.0'},
]),
};
expect((await updates.check('1.0.0'))!.version, '2.0.0');
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
final website = adapter.requests.where((r) => r.uri.host == 'gopeed.com').single;
expect(website.method, 'GET');
expect(website.uri.toString(), 'https://gopeed.com/api/releases?per_page=10');
});
test('invalid GitHub metadata falls back to valid website metadata', () async {
for (final invalid in [
null,
'<html>error</html>',
{'message': 'error'},
[
{'tag_name': 'invalid'},
],
]) {
adapter.requests.clear();
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
? _json(invalid)
: _json([
{'tag_name': 'v2.0.0'},
]);
expect((await updates.check('1.0.0'))!.version, '2.0.0');
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
}
});
test('valid empty and no-update lists do not fall back', () async {
for (final releases in [
[],
[
{'tag_name': 'v1.0.0'},
],
]) {
adapter.requests.clear();
adapter.reply = (_, _) async => _json(releases);
expect(await updates.check('1.0.0'), isNull);
expect(adapter.requests.single.uri.host, 'api.github.com');
}
});
test('null, objects, invalid releases and failed website responses propagate', () async {
for (final invalid in [
null,
{'message': 'error'},
[
{'tag_name': 'not a version'},
],
]) {
adapter.requests.clear();
adapter.reply = (_, _) async => _json(invalid);
await expectLater(updates.check('1.0.0'), throwsFormatException);
expect(adapter.requests.map((r) => r.uri.host), ['api.github.com', 'gopeed.com']);
}
adapter.reply = (_, _) async => _json({}, status: 503);
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
});
test('SemVer selection preserves stable and same-series preview rules', () async {
var releases = [
{'tag_name': 'v2.1.0-beta.1', 'prerelease': true},
{'tag_name': 'v2.0.0-beta.2', 'prerelease': true},
{'tag_name': 'v2.0.0-rc.1', 'prerelease': true},
{'tag_name': 'v1.9.9'},
{'tag_name': 'v2.0.0', 'draft': true},
];
adapter.reply = (_, _) async => _json(releases);
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-rc.1');
expect((await updates.check('1.9.8'))!.version, '1.9.9');
releases = [
{'tag_name': 'v2.0.0-beta.10'},
{'tag_name': 'v2.0.0-beta.2'},
];
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-beta.10');
releases = [
{'tag_name': 'v2.0.0'},
{'tag_name': 'v2.0.1'},
];
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.1');
releases = [
{'tag_name': 'v2.0.0+build.2'},
];
expect(await updates.check('2.0.0+build.1'), isNull);
await expectLater(updates.check('invalid'), throwsFormatException);
});
}
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
jsonEncode(body),
status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
class _Adapter implements HttpClientAdapter {
final requests = <RequestOptions>[];
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
requests.add(options);
return reply(options, cancelFuture);
}
@override
void close({bool force = false}) {}
}
class _RejectTransport implements GopeedTransport {
int calls = 0;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
calls++;
throw StateError('Update checks must not use the Downloader or mirror API');
}
@override
String join(String path) => path;
}
@@ -0,0 +1,196 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:crypto/crypto.dart';
import 'package:gopeed/core/network/candidate_download.dart';
void main() {
final expectedSha256 = sha256.convert([for (var i = 1; i <= 3; i++) ...List.filled(32768, i)]).toString();
late HttpServer server;
var requests = 0;
late Directory directory;
late String savePath;
late List<String> downloads;
late Future<void> Function(HttpRequest request) handle;
setUp(() async {
directory = await Directory.systemTemp.createTemp('gopeed-update-test-');
savePath = '${directory.path}/package.zip';
downloads = [];
requests = 0;
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) async {
requests++;
if (request.method == 'GET') downloads.add(request.uri.path);
await handle(request);
await request.response.close();
});
});
tearDown(() async {
await server.close(force: true);
await directory.delete(recursive: true);
});
String url(String path) => 'http://127.0.0.1:${server.port}$path';
Future<void> package(HttpRequest request) async {
const chunkSize = 32768;
request.response.headers.contentType = ContentType.binary;
request.response.contentLength = chunkSize * 3;
if (request.method == 'HEAD') return;
for (var i = 0; i < 3; i++) {
request.response.add(List.filled(chunkSize, i + 1));
await request.response.flush();
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
test('downloads the fastest successful probe and reports intermediate byte progress', () async {
handle = (request) async {
if (request.uri.path == '/slow' && request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 120));
}
await package(request);
};
final progress = <(int, int)>[];
await downloadFromCandidates(
[url('/slow'), url('/fast'), url('/fast')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (received, total) => progress.add((received, total)),
);
expect(downloads, ['/fast']);
expect(await File(savePath).length(), 98304);
expect(progress.any((p) => p.$1 > 0 && p.$1 < p.$2), isTrue);
expect(progress.last, (98304, 98304));
});
test('falls back when a successful HEAD is followed by a failed GET', () async {
handle = (request) async {
if (request.uri.path == '/broken') {
request.response.headers.contentType = ContentType.binary;
if (request.method == 'GET') {
request.response.statusCode = HttpStatus.serviceUnavailable;
request.response.write('unavailable');
}
return;
}
if (request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 60));
}
await package(request);
};
await downloadFromCandidates(
[url('/broken'), url('/working')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/broken', '/working']);
final bytes = await File(savePath).readAsBytes();
expect(bytes.length, 98304);
expect(bytes.take(32768), everyElement(1));
});
test('tries GET even when HEAD is unsupported', () async {
handle = (request) async {
if (request.method == 'HEAD') {
request.response.statusCode = HttpStatus.methodNotAllowed;
return;
}
await package(request);
};
await downloadFromCandidates(
[url('/no-head')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/no-head']);
expect(await File(savePath).length(), 98304);
});
test('bounds probe time and still allows a normal download', () async {
handle = (request) async {
if (request.method == 'HEAD') {
await Future<void>.delayed(const Duration(milliseconds: 100));
return;
}
await package(request);
};
await downloadFromCandidates(
[url('/head-timeout')],
savePath,
expectedSha256: expectedSha256,
probeTimeout: const Duration(milliseconds: 20),
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/head-timeout']);
expect(await File(savePath).length(), 98304);
});
test('rejects mirror landing pages and removes an unsuccessful download', () async {
handle = (request) async {
request.response.headers.contentType = ContentType.html;
if (request.method == 'GET') request.response.write('<html>mirror error</html>');
};
await expectLater(
downloadFromCandidates([url('/landing')], savePath, expectedSha256: expectedSha256, onReceiveProgress: (_, _) {}),
throwsStateError,
);
expect(await File(savePath).exists(), isFalse);
});
test('same-sized modified mirror bytes fail the official hash and fall back', () async {
handle = (request) async {
if (request.uri.path == '/tampered') {
request.response.headers.contentType = ContentType.binary;
// A mirror-provided hash must never replace the expected official hash.
request.response.headers.set('x-checksum-sha256', sha256.convert(List.filled(98304, 9)).toString());
request.response.contentLength = 98304;
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
return;
}
if (request.method == 'HEAD') await Future<void>.delayed(const Duration(milliseconds: 60));
await package(request);
};
await downloadFromCandidates(
[url('/tampered'), url('/official')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
);
expect(downloads, ['/tampered', '/official']);
expect((await sha256.bind(File(savePath).openRead()).first).toString(), expectedSha256);
});
test('all checksum failures abort and delete the untrusted package', () async {
handle = (request) async {
request.response.headers.contentType = ContentType.binary;
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
};
await expectLater(
downloadFromCandidates(
[url('/bad-one'), url('/bad-two')],
savePath,
expectedSha256: expectedSha256,
onReceiveProgress: (_, _) {},
),
throwsA(isA<DownloadChecksumException>()),
);
expect(downloads.toSet(), {'/bad-one', '/bad-two'});
expect(await File(savePath).exists(), isFalse);
});
test('missing or malformed official checksums cannot start a probe or download', () async {
handle = package;
for (final checksum in ['', 'sha256:$expectedSha256', '0' * 63, 'g' * 64, '$expectedSha256\n']) {
await expectLater(
downloadFromCandidates([url('/official')], savePath, expectedSha256: checksum, onReceiveProgress: (_, _) {}),
throwsA(isA<DownloadChecksumException>()),
);
}
expect(requests, 0);
expect(await File(savePath).exists(), isFalse);
});
}
@@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/network/mirror_client.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
late _CandidatesTransport transport;
late Dio dio;
late _Adapter adapter;
late MirrorClient client;
const resourceUrl = 'https://raw.githubusercontent.com/owner/repo/main/manifest.json';
const mirror = 'https://mirror.example/https://raw.githubusercontent.com/owner/repo/main/manifest.json';
setUp(() {
transport = _CandidatesTransport([resourceUrl, mirror]);
api.setTransportForTesting(transport);
dio = Dio();
adapter = _Adapter();
dio.httpClientAdapter = adapter;
client = MirrorClient(service: LocalAppCapabilities.instance.capabilities.gopeed, provider: 'github', client: dio);
});
tearDown(() {
dio.close(force: true);
for (final request in adapter.requests) {
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
}
});
test('an invalid fast mirror cannot hide a slower valid release list', () async {
final official = Completer<ResponseBody>();
final mirrorRead = Completer<void>();
adapter.reply = (request, _) {
if (request.uri.host == 'raw.githubusercontent.com') return official.future;
mirrorRead.complete();
return Future.value(_json('<html>mirror error</html>'));
};
final result = client.getJson(resourceUrl, decode: _decodeList);
await mirrorRead.future;
official.complete(
_json([
{'tag_name': 'v2.0.0', 'body': 'notes'},
]),
);
final info = await result;
expect(info.single['tag_name'], 'v2.0.0');
expect(info.single['body'], 'notes');
expect(adapter.requests.any((r) => r.uri.host == 'gopeed.com'), isFalse);
expect(transport.requested, [resourceUrl]);
});
test('a complete validated winner cancels a stalled candidate', () async {
final officialStarted = Completer<void>();
final canceled = Completer<void>();
adapter.reply = (request, cancellation) async {
if (request.uri.host == 'raw.githubusercontent.com') {
officialStarted.complete();
await cancellation;
canceled.complete();
throw StateError('canceled');
}
await officialStarted.future;
return _json([
{'tag_name': 'v2.0.0'},
]);
};
expect((await client.getJson(resourceUrl, decode: _decodeList)).single['tag_name'], 'v2.0.0');
await canceled.future;
expect(adapter.requests.length, 2);
});
test('the mirror client supports other providers and non-GitHub resources', () async {
const resource = 'https://resources.example/manifest.json';
transport.provider = 'resources';
transport.urls = [resource, 'https://cdn.example/manifest.json'];
adapter.reply = (request, _) async =>
request.uri.host == 'resources.example' ? _json({}, status: 503) : _json({'revision': 42});
final client = MirrorClient(
service: LocalAppCapabilities.instance.capabilities.gopeed,
provider: 'resources',
client: dio,
);
final revision = await client.getJson<int>(resource, decode: (data) => (data as Map)['revision'] as int);
expect(revision, 42);
expect(transport.requested, [resource]);
expect(adapter.requests.map((request) => request.uri.host).toSet(), {'resources.example', 'cdn.example'});
});
test('metadata attempts have a total timeout even if the transport stalls', () async {
final canceled = Completer<void>();
adapter.reply = (_, cancellation) async {
await cancellation;
canceled.complete();
throw StateError('canceled');
};
transport.urls = [resourceUrl];
final github = MirrorClient(
service: LocalAppCapabilities.instance.capabilities.gopeed,
provider: 'github',
client: dio,
timeout: const Duration(milliseconds: 30),
);
await expectLater(github.getJson(resourceUrl, decode: (data) => data), throwsA(isA<TimeoutException>()));
await canceled.future;
});
}
List<dynamic> _decodeList(dynamic data) {
if (data is! List) throw const FormatException('Invalid manifest');
return data;
}
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
jsonEncode(body),
status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
class _Adapter implements HttpClientAdapter {
final requests = <RequestOptions>[];
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
@override
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
requests.add(options);
return reply(options, cancelFuture);
}
@override
void close({bool force = false}) {}
}
class _CandidatesTransport implements GopeedTransport {
_CandidatesTransport(this.urls);
List<String> urls;
String provider = 'github';
bool fail = false;
final requested = <String>[];
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
expect(path, 'api/v1/mirrors/$provider/candidates');
expect(method, 'POST');
requested.add((data as Map)['url'] as String);
if (fail) throw StateError('Downloader unavailable');
return {
'code': 0,
'data': {'urls': urls},
};
}
@override
String join(String path) => path;
}
@@ -1,4 +1,3 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
@@ -31,11 +30,6 @@ class _LoginTransport implements GopeedTransport {
return {'code': 0};
}
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
throw UnimplementedError();
}
@override
String join(String path) => path;
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
@@ -67,6 +69,106 @@ void main() {
expect(saved?.extra.createHistory, ['https://example.com/existing.zip']);
expect(saved?.extra.analyticsClientId, 'existing-client');
});
test('protocol edits use a single full PUT and reload backend-owned tracker results', () async {
final backend = DownloaderConfig();
final puts = <DownloaderConfig>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
..bind(GopeedMethods.putConfig, (config) {
puts.add(config);
// Simulate the backend retaining its read-only results on a full PUT.
backend.protocolConfig.bt.listenPort = config.protocolConfig.bt.listenPort;
return const RpcUnit();
});
final container = _settingsContainer(registry);
addTearDown(container.dispose);
final initial = await container.read(settingsControllerProvider.future);
final draft = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
backend.protocolConfig.bt.state.trackerSources = {
'https://example.com/list': ['udp://fresh.example:80/announce'],
};
backend.protocolConfig.bt.state.lastTrackerUpdateTime = DateTime.utc(2026, 10, 19);
await container.read(settingsControllerProvider.notifier).save(draft);
expect(puts, hasLength(1));
expect(puts.single.protocolConfig.bt.listenPort, 6881);
final saved = container.read(settingsControllerProvider).requireValue;
expect(saved.config.protocolConfig.bt.state.trackerSources['https://example.com/list'], [
'udp://fresh.example:80/announce',
]);
expect(saved.saving, isFalse);
});
test('rapid full config saves run in order and keep the latest user edits', () async {
final backend = DownloaderConfig();
final entered = Completer<void>();
final release = Completer<void>();
final puts = <DownloaderConfig>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
..bind(GopeedMethods.putConfig, (config) async {
puts.add(DownloaderConfig.fromJson(config.toJson()));
if (puts.length == 1) {
entered.complete();
await release.future;
}
backend.protocolConfig = config.protocolConfig;
return const RpcUnit();
});
final container = _settingsContainer(registry);
addTearDown(container.dispose);
final initial = await container.read(settingsControllerProvider.future);
final controller = container.read(settingsControllerProvider.notifier);
final first = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
final savingFirst = controller.save(first);
await entered.future;
final second = DownloaderConfig.fromJson(first.toJson())
..protocolConfig.bt.customTrackers = ['udp://custom.example:80/announce'];
final savingSecond = controller.save(second);
expect(puts.length, 1);
release.complete();
await Future.wait([savingFirst, savingSecond]);
expect(puts, hasLength(2));
expect(puts[0].protocolConfig.bt.listenPort, 6881);
expect(puts[1].protocolConfig.bt.listenPort, 6881);
expect(puts[1].protocolConfig.bt.customTrackers, ['udp://custom.example:80/announce']);
expect(container.read(settingsControllerProvider).requireValue.config.protocolConfig.bt.customTrackers, [
'udp://custom.example:80/announce',
]);
});
test('a failed save clears saving state and the same change can be retried', () async {
final backend = DownloaderConfig();
var attempts = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
..bind(GopeedMethods.putConfig, (config) {
if (++attempts == 1) throw StateError('offline');
backend.protocolConfig = config.protocolConfig;
return const RpcUnit();
});
final container = _settingsContainer(registry);
addTearDown(container.dispose);
final initial = await container.read(settingsControllerProvider.future);
final controller = container.read(settingsControllerProvider.notifier);
final draft = DownloaderConfig.fromJson(initial.config.toJson())..protocolConfig.bt.listenPort = 6881;
await expectLater(controller.save(draft), throwsStateError);
expect(container.read(settingsControllerProvider).requireValue.saving, isFalse);
await controller.save(draft);
expect(attempts, 2);
expect(backend.protocolConfig.bt.listenPort, 6881);
});
}
ProviderContainer _settingsContainer(CapabilityRegistry registry) {
final container = ProviderContainer(
overrides: [
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new),
],
);
container.listen(settingsControllerProvider, (_, _) {});
return container;
}
class _TestRuntimeController extends AppRuntimeController {
@@ -0,0 +1,223 @@
import 'package:flutter/gestures.dart' show PointerDeviceKind;
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_control.dart';
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_schedule.dart';
import 'package:gopeed/features/settings/presentation/widgets/tracker_subscriptions_table.dart';
import 'package:gopeed/l10n/l10n.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
void main() {
for (final width in [360.0, 768.0, 1024.0]) {
testWidgets('subscription results fit at width $width', (tester) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final time = DateTime.utc(2026, 10, 19);
await tester.pumpWidget(
ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AppComponentThemes(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: TrackerSubscriptionsControl(
urls: const [
'https://success.example/very/long/path/to/my/custom/list.txt',
'https://failed.example/list',
'https://new.example/list',
],
statuses: {
'https://success.example/very/long/path/to/my/custom/list.txt': TrackerSubscriptionStatus(
success: true,
trackerCount: 7,
lastFetchTime: time,
),
'https://failed.example/list': TrackerSubscriptionStatus(
success: false,
trackerCount: 0,
lastFetchTime: time,
),
},
autoUpdate: true,
lastUpdated: time,
onChanged: (_) {},
onAutoUpdateChanged: (_) {},
onUpdate: () async {},
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Update status'), findsOneWidget);
expect(find.text('Tracker count'), findsOneWidget);
expect(find.text('Actions'), findsOneWidget);
for (final entry in const [
('https://success.example/very/long/path/to/my/custom/list.txt', 'Succeeded', '7'),
('https://failed.example/list', 'Failed', '0'),
('https://new.example/list', 'Not updated', '—'),
]) {
final row = find.byKey(ValueKey('tracker-subscription-${entry.$1}'));
expect(find.descendant(of: row, matching: find.text(entry.$2)), findsOneWidget);
expect(find.descendant(of: row, matching: find.text(entry.$3)), findsOneWidget);
}
final table = find.byKey(const ValueKey('tracker-subscriptions-table'));
expect(find.descendant(of: table, matching: find.textContaining('2026-')), findsNothing);
expect(find.textContaining('Last updated: '), findsOneWidget);
final updateRect = tester.getRect(find.byKey(const ValueKey('tracker-update-button')));
final addRect = tester.getRect(find.byKey(const ValueKey('add-tracker-subscription')));
final tableRect = tester.getRect(table);
final autoUpdateRect = tester.getRect(find.byKey(const ValueKey('tracker-auto-update-control')));
final timeRect = tester.getRect(find.byKey(const ValueKey('tracker-last-updated')));
expect(addRect.right, closeTo(tableRect.right, 1));
expect(updateRect.right, lessThan(addRect.left));
expect(addRect.center.dy, closeTo(updateRect.center.dy, 1));
expect(updateRect.top, greaterThan(tableRect.bottom));
expect(autoUpdateRect.bottom, lessThan(tableRect.top));
expect(autoUpdateRect.right, closeTo(tableRect.right, 1));
expect(timeRect.top, greaterThan(autoUpdateRect.bottom));
expect(timeRect.bottom, lessThan(tableRect.top));
expect(timeRect.right, closeTo(tableRect.right, 1));
expect(find.byKey(const ValueKey('add-tracker-subscription')), findsOneWidget);
if (width < 672) {
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
await tester.dragFrom(tester.getTopLeft(table) + const Offset(100, 20), const Offset(-600, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Subscription URL')).dx, lessThan(headerPosition.dx));
}
expect(tester.takeException(), isNull);
});
}
for (final locale in const [Locale('en'), Locale('zh')]) {
for (final width in [320.0, 768.0, 1024.0]) {
testWidgets('schedule hides missing update time at width $width in $locale', (tester) async {
tester.view.physicalSize = Size(width, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
bool? autoUpdate;
await tester.pumpWidget(
ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Localizations.override(
context: context,
locale: locale,
delegates: const [AppLocalizations.delegate],
child: AppComponentThemes(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: TrackerSubscriptionsSchedule(
lastUpdated: null,
autoUpdate: false,
onAutoUpdateChanged: (value) => autoUpdate = value,
),
),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('tracker-last-updated')), findsNothing);
expect(find.text('Never updated'), findsNothing);
expect(find.text('尚未更新'), findsNothing);
final scheduleRect = tester.getRect(find.byKey(const ValueKey('tracker-subscriptions-schedule')));
final autoRect = tester.getRect(find.byKey(const ValueKey('tracker-auto-update-control')));
expect(find.byKey(const ValueKey('tracker-update-button')), findsNothing);
expect(autoRect.right, closeTo(scheduleRect.right, 1));
await tester.tap(find.byType(Switch));
expect(autoUpdate, true);
expect(tester.takeException(), isNull);
});
}
}
for (final width in [360.0, 768.0, 1024.0]) {
testWidgets('long subscription table scrolls within its viewport at width $width', (tester) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final urls = List.generate(50, (index) => 'https://tracker-$index.example/list.txt');
final deletions = <int>[];
await tester.pumpWidget(
ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AppComponentThemes(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: TrackerSubscriptionsTable(urls: urls, statuses: const {}, onDelete: deletions.add),
),
),
),
),
);
await tester.pumpAndSettle();
final table = find.byKey(const ValueKey('tracker-subscriptions-table'));
final rows = find.byKey(const ValueKey('tracker-subscriptions-rows'));
expect(tester.getSize(table).height, lessThan(300));
expect(tester.getSize(table).width, width - 32);
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
expect(find.byKey(ValueKey('tracker-subscription-${urls.last}')), findsNothing);
await tester.dragFrom(tester.getTopLeft(rows) + const Offset(40, 80), const Offset(0, -3000));
await tester.pumpAndSettle();
expect(find.byKey(ValueKey('tracker-subscription-${urls.last}')), findsOneWidget);
expect(tester.getTopLeft(find.text('Subscription URL')), headerPosition);
final delete = find.byKey(ValueKey('delete-settings-list-${urls.last}'));
final deleteRect = tester.getRect(delete);
final actionsHeader = tester.getTopLeft(find.byKey(const ValueKey('tracker-subscriptions-actions-header')));
expect(deleteRect.right, lessThanOrEqualTo(tester.getRect(table).right));
expect(deleteRect.left, greaterThan(tester.getRect(table).left));
expect(delete.hitTestable(), findsOneWidget);
// Data columns scroll, while the delete column remains visible and fixed.
if (width < 672) {
final headerPosition = tester.getTopLeft(find.text('Subscription URL'));
await tester.dragFrom(tester.getTopLeft(table) + const Offset(100, 20), const Offset(-30, 0));
await tester.pumpAndSettle();
await tester.dragFrom(
tester.getBottomLeft(table) + const Offset(40, -5),
const Offset(600, 0),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Subscription URL')).dx, lessThan(headerPosition.dx));
}
expect(tester.getRect(delete), deleteRect);
expect(tester.getTopLeft(find.byKey(const ValueKey('tracker-subscriptions-actions-header'))), actionsHeader);
expect(find.byIcon(Icons.edit_outlined), findsNothing);
if (width < 672) {
final rightInset = tester.getRect(table).right - deleteRect.right;
tester.view.physicalSize = const Size(768, 900);
await tester.pumpAndSettle();
expect(delete.hitTestable(), findsOneWidget);
expect(tester.getRect(table).right - tester.getRect(delete).right, closeTo(rightInset, 1));
}
await tester.tap(delete);
expect(deletions, [49]);
expect(tester.takeException(), isNull);
});
}
}
+69 -116
View File
@@ -1,14 +1,12 @@
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart';
import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/util/arch/arch.dart';
import 'package:gopeed/util/github_mirror.dart';
import 'package:gopeed/util/package_info.dart' as app_package;
import 'package:gopeed/util/updater.dart';
import 'package:gopeed/core/network/candidate_download.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
@@ -24,101 +22,29 @@ void main() {
);
});
group('version comparison', () {
test('uses semantic version precedence', () {
expect(isNewerVersion('1.10.0', '1.9.9'), isTrue);
expect(isNewerVersion('2.0.0-beta.2', '2.0.0-beta.1'), isTrue);
expect(isNewerVersion('2.0.0-rc.1', '2.0.0-beta.2'), isTrue);
expect(isNewerVersion('2.0.0', '2.0.0-rc.1'), isTrue);
expect(isNewerVersion('1.2.3', '1.2.3'), isFalse);
expect(isNewerVersion('1.2.2', '1.2.3'), isFalse);
expect(isNewerVersion('invalid', '1.2.3'), isFalse);
});
});
group('release selection', () {
Map<String, dynamic> release(String version, {bool prerelease = false, bool draft = false}) => {
'tag_name': 'v$version',
'prerelease': prerelease,
'draft': draft,
'body': '$version notes',
'html_url': 'https://example.com/$version',
};
test('stable builds ignore preview releases', () {
final selected = selectUpdateRelease([release('2.1.0-beta.1', prerelease: true), release('2.0.1')], '2.0.0');
expect(selected?.version, '2.0.1');
});
test('preview builds advance within the same preview line', () {
final selected = selectUpdateRelease([
release('2.0.0-beta.2', prerelease: true),
release('1.9.9'),
release('2.1.0-beta.1', prerelease: true),
], '2.0.0-beta.1');
expect(selected?.version, '2.0.0-beta.2');
});
test('four-part platform beta selects the next GitHub prerelease and matching asset', () {
final currentVersion = app_package.normalizeAppVersion('2.0.0.1');
final selected = selectUpdateRelease([release('2.0.0-beta.2', prerelease: true)], currentVersion);
expect(selected?.version, '2.0.0-beta.2');
expect(
updateAssetName(selected!.version, channel: UpdateChannel.macosDmg, architecture: Architecture.arm64),
'Gopeed-v2.0.0-beta.2-macos-arm64.dmg',
);
});
test('preview builds graduate to the final stable release', () {
final selected = selectUpdateRelease([
release('2.0.0-beta.2', prerelease: true),
release('2.0.0'),
], '2.0.0-beta.1');
expect(selected?.version, '2.0.0');
});
test('selects the greatest eligible version and ignores invalid releases', () {
final selected = selectUpdateRelease([
release('2.0.0-beta.3', prerelease: true),
release('2.0.0-beta.4', prerelease: true, draft: true),
{'tag_name': 'not-a-version'},
release('2.0.0-beta.2', prerelease: true),
], '2.0.0-beta.1');
expect(selected?.version, '2.0.0-beta.3');
});
});
group('release assets', () {
test('uses the release naming convention for desktop packages', () {
expect(
updateAssetName('1.8.0', channel: UpdateChannel.windowsPortable, architecture: Architecture.x64),
'Gopeed-v1.8.0-windows-amd64-portable.zip',
);
expect(
updateAssetName('1.8.0', channel: UpdateChannel.macosDmg, architecture: Architecture.arm64),
'Gopeed-v1.8.0-macos-arm64.dmg',
);
expect(
updateAssetName('1.8.0', channel: UpdateChannel.linuxDeb, architecture: Architecture.ia32),
'Gopeed-v1.8.0-linux-386.deb',
);
});
test('maps Android ABI names', () {
expect(
updateAssetName('1.8.0', channel: UpdateChannel.androidApk, architecture: Architecture.arm),
'Gopeed-v1.8.0-android-armeabi-v7a.apk',
);
expect(
updateAssetName('1.8.0', channel: UpdateChannel.androidApk, architecture: Architecture.arm64),
'Gopeed-v1.8.0-android-arm64-v8a.apk',
);
});
test('package names use the client channel and architecture', () {
expect(appUpdateAssetName('2.0.0', UpdateChannel.windowsInstaller, 'ia32'), 'Gopeed-v2.0.0-windows-386.zip');
expect(
appUpdateAssetName('2.0.0-beta.3', UpdateChannel.windowsPortable, 'x64'),
'Gopeed-v2.0.0-beta.3-windows-amd64-portable.zip',
);
expect(appUpdateAssetName('2.0.0', UpdateChannel.macosDmg, 'arm64'), 'Gopeed-v2.0.0-macos-arm64.dmg');
expect(appUpdateAssetName('2.0.0', UpdateChannel.linuxDeb, 'x64'), 'Gopeed-v2.0.0-linux-amd64.deb');
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'arm'), 'Gopeed-v2.0.0-android-armeabi-v7a.apk');
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'arm64'), 'Gopeed-v2.0.0-android-arm64-v8a.apk');
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'x64'), 'Gopeed-v2.0.0-android-x86_64.apk');
expect(appUpdateAssetName('2.0.0', UpdateChannel.androidApk, 'riscv64'), 'Gopeed-v2.0.0-android.apk');
for (final channel in [
null,
UpdateChannel.linuxFlathub,
UpdateChannel.linuxSnap,
UpdateChannel.linuxAppImage,
UpdateChannel.linuxRpm,
UpdateChannel.iosIpa,
UpdateChannel.docker,
]) {
expect(appUpdateAssetName('2.0.0', channel, 'arm64'), isEmpty);
}
});
test('selects the matching bilingual release notes', () {
@@ -127,23 +53,6 @@ void main() {
expect(localizedReleaseNotes(notes, 'zh-CN'), '# 更新日志\n\n- 中文更新');
});
test('builds enabled GitHub proxy URLs and ignores deleted mirrors', () {
final config = ExtraConfigGithubMirror(
mirrors: [
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://mirror.example'),
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://deleted.example', isDeleted: true),
],
);
expect(
githubMirrorUrls(
'https://github.com/GopeedLab/gopeed/releases/download/v1.8.0/app.zip',
MirrorType.githubRelease,
config: config,
),
['https://mirror.example/https://github.com/GopeedLab/gopeed/releases/download/v1.8.0/app.zip'],
);
});
testWidgets('update dialog shows beta versions without tag prefixes', (tester) async {
tester.view.physicalSize = const Size(390, 760);
tester.view.devicePixelRatio = 1;
@@ -194,7 +103,7 @@ void main() {
expect(find.text('Faster downloads'), findsOneWidget);
expect(find.text('下载更快'), findsNothing);
expect(find.text('Later'), findsOneWidget);
expect(find.text('Update Now'), findsOneWidget);
expect(find.text('Update'), findsOneWidget);
expect(find.text("What's New"), findsNothing);
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
expect(versionText.style?.decoration, TextDecoration.none);
@@ -207,6 +116,50 @@ void main() {
expect(tester.takeException(), isNull);
});
for (final width in [390.0, 1024.0]) {
testWidgets('checksum failures prevent success and show a localized error at $width', (tester) async {
tester.view.physicalSize = Size(width, 760);
tester.view.devicePixelRatio = 1;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
var attempts = 0;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.SecondaryButton(
onPressed: () => showAppUpdateDialog(
context,
versionInfo: const VersionInfo(
version: '2.0.0',
changeLog: '',
releaseUrl: 'https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0',
),
onUpdate: (_, progress) async {
attempts++;
progress(100, 100);
throw const DownloadChecksumException();
},
),
child: const Text('Open update'),
),
),
),
);
await tester.tap(find.text('Open update'));
await tester.pumpAndSettle();
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(attempts, 1);
expect(find.text('Update verification failed. Installation stopped.'), findsOneWidget);
expect(find.text('Update failed. Please try again later'), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async {
await tester.pumpWidget(
shad.ShadcnApp(
+29 -33
View File
@@ -2274,10 +2274,8 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('tracker subscriptions support selecting and clearing all entries', (WidgetTester tester) async {
testWidgets('tracker subscriptions allow adding and deleting a custom URL', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 900));
tester.platformDispatcher.textScaleFactorTestValue = 1.2;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final settingsController = FakeSettingsController();
await tester.pumpWidget(
ProviderScope(
@@ -2292,35 +2290,31 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Downloads'));
await tester.pumpAndSettle();
final selectAll = find.byKey(const ValueKey('tracker-select-all'));
expect(selectAll, findsOneWidget);
expect(find.descendant(of: selectAll, matching: find.text('Select All')), findsOneWidget);
await tester.ensureVisible(selectAll);
final add = find.byKey(const ValueKey('add-tracker-subscription'));
await tester.ensureVisible(add);
await tester.pumpAndSettle();
final horizontalScrollbar = tester.widget<Scrollbar>(find.byKey(const ValueKey('tracker-horizontal-scrollbar')));
expect(horizontalScrollbar.thumbVisibility, isTrue);
expect(horizontalScrollbar.controller?.hasClients, isTrue);
expect(horizontalScrollbar.controller!.position.maxScrollExtent, greaterThan(0));
final verticalScrollbar = tester.widget<Scrollbar>(find.byKey(const ValueKey('tracker-vertical-scrollbar')));
expect(verticalScrollbar.controller?.hasClients, isTrue);
expect(verticalScrollbar.controller!.position.maxScrollExtent, greaterThan(0));
final scrollbarRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scrollbar')));
final scrollViewRect = tester.getRect(find.byKey(const ValueKey('tracker-horizontal-scroll-view')));
expect(scrollbarRect.bottom - scrollViewRect.bottom, AppDesignTokens.space12);
expect(find.text(allTrackerSubscribeUrls.first), findsOneWidget);
horizontalScrollbar.controller!.jumpTo(horizontalScrollbar.controller!.position.maxScrollExtent);
await tester.pump();
expect(horizontalScrollbar.controller!.offset, greaterThan(0));
await tester.tap(selectAll);
await tester.tap(add);
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, allTrackerSubscribeUrls);
await tester.tap(selectAll);
final dialog = find.byType(shad.AlertDialog);
await tester.enterText(
find.descendant(of: dialog, matching: find.byType(AppTextField)),
'https://my.example/trackers.txt',
);
await tester.tap(find.descendant(of: dialog, matching: find.text('Save')));
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.extra.bt.trackerSubscribeUrls, isEmpty);
expect(settingsController.state.requireValue.config.protocolConfig.bt.trackerSubscribeUrls, [
'https://my.example/trackers.txt',
]);
expect(find.text('https://my.example/trackers.txt'), findsOneWidget);
expect(find.byKey(const ValueKey('edit-settings-list-https://my.example/trackers.txt')), findsNothing);
final delete = find.byKey(const ValueKey('delete-settings-list-https://my.example/trackers.txt'));
await tester.ensureVisible(delete);
await tester.pumpAndSettle();
await tester.tap(delete);
await tester.pumpAndSettle();
await tester.tap(find.descendant(of: find.byType(shad.AlertDialog), matching: find.text('Delete')));
await tester.pumpAndSettle();
expect(settingsController.state.requireValue.config.protocolConfig.bt.trackerSubscribeUrls, isEmpty);
expect(tester.takeException(), isNull);
});
@@ -2399,7 +2393,9 @@ void main() {
expect(find.descendant(of: button, matching: find.byType(shad.CircularProgressIndicator)), findsOneWidget);
});
testWidgets('failed tracker update does not reload or reset the settings page', (WidgetTester tester) async {
testWidgets('failed tracker update reloads subscription statuses without resetting the settings page', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1024, 900));
final runtimeController = FailingTrackerRuntimeController();
final settingsController = StaticSettingsController();
@@ -2428,7 +2424,7 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(settingsController.reloadCalls, 0);
expect(settingsController.reloadCalls, 1);
expect(tester.state<ScrollableState>(sectionScrollable).position.pixels, closeTo(offsetBeforeUpdate, 0.01));
expect(find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator)), findsNothing);
expect(find.byKey(const ValueKey('app-toast-content')), findsOneWidget);
@@ -5967,14 +5963,14 @@ class PendingTrackerRuntimeController extends FakeRuntimeController {
final _trackerUpdateCompleter = Completer<void>();
@override
Future<void> updateTrackers(DownloaderConfig config) => _trackerUpdateCompleter.future;
Future<void> updateTrackers() => _trackerUpdateCompleter.future;
void completeTrackerUpdate() => _trackerUpdateCompleter.complete();
}
class FailingTrackerRuntimeController extends FakeRuntimeController {
@override
Future<void> updateTrackers(DownloaderConfig config) async {
Future<void> updateTrackers() async {
throw StateError('tracker update failed');
}
}