feat(providers): assemble custom profiles and import configs

Changelog: DNS, Hosts and ports apply without a restart
This commit is contained in:
chen08209
2026-10-08 17:58:43 +08:00
parent 16770249f1
commit 532ebf07e9
102 changed files with 9850 additions and 3448 deletions
+53
View File
@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:fl_clash/common/app_data.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart' as db;
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory home;
setUpAll(() {
home = Directory.systemTemp.createTempSync('flclash-app-data-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
});
tearDownAll(() {
if (home.existsSync()) home.deleteSync(recursive: true);
});
test('clears what a startup failure left behind without the Core', () async {
SharedPreferences.setMockInitialValues({});
await preferences.setVersion(7);
final databaseFile = File(await appPath.databasePath)
..writeAsStringSync('not a database');
final profilesDir = Directory(join(await appPath.profilesPath, '1'))
..createSync(recursive: true);
final broken = db.Database(
LazyDatabase(() async => throw const FileSystemException('corrupt')),
);
db.database = broken;
await expectLater(broken.profilesDao.query().get(), throwsA(anything));
await clearAppData();
expect(await preferences.getVersion(), 0);
expect(
databaseFile.existsSync(),
isFalse,
reason:
'a database that failed to open rethrows from close, and it is '
'the file most likely to be why startup failed',
);
expect(profilesDir.parent.existsSync(), isFalse);
});
}
+56 -24
View File
@@ -148,8 +148,7 @@ void main() {
test('reports a provider cache file no row names any more', () {
final cache = makeDir('provider_cache');
writeFile(join(cache.path, 'proxies', 'live'), 'keep');
writeFile(join(cache.path, 'proxies', 'gone'), 'drop');
writeFile(join(cache.path, 'rules', 'live'), 'keep');
writeFile(join(cache.path, 'rules', 'stale'), 'drop');
final orphans = shakeOrphanFiles(
@@ -162,13 +161,7 @@ void main() {
scriptsDirPath: join(root.path, 'missing'),
);
expect(
orphans,
unorderedEquals([
join(cache.path, 'proxies', 'gone'),
join(cache.path, 'rules', 'stale'),
]),
);
expect(orphans, [join(cache.path, 'rules', 'stale')]);
});
test('ignores directories that do not exist', () {
@@ -310,14 +303,13 @@ void main() {
final profile = data.profiles.single;
final rule = data.rules.single;
expect(rule.content, 'example.com');
final link = data.links.single;
expect(link.profileId, profile.id);
expect(link.ruleId, rule.id);
expect(link.scene, RuleScene.added);
expect(rule.profileId, profile.id);
expect(profile.type, ProfileType.file);
expect(data.disabledRules, isEmpty);
});
test(
'keeps a disabled link only for a rule id it has already seen',
'keeps a disabled rule only for a rule id it has already seen',
() async {
writeFile(join(source.path, 'profiles', 'p1.yaml'), '');
@@ -343,14 +335,35 @@ void main() {
targetPath: target.path,
);
final disabled = data.links
.where((link) => link.scene == RuleScene.disabled)
.toList();
expect(disabled, hasLength(1));
expect(disabled.single.ruleId, data.rules.single.id);
expect(data.disabledRules, [
DisabledRule(
profileId: data.profiles.single.id,
ruleId: data.rules.single.id,
),
]);
},
);
test('gives a profile with a url the url type', () async {
writeFile(join(source.path, 'profiles', 'p1.yaml'), '');
final data = await migrateLegacyConfig(
configMap: legacyConfig(
profiles: [
{
'id': 'p1',
'url': 'https://example.com/sub',
'autoUpdateDuration': 0,
},
],
),
sourcePath: source.path,
targetPath: target.path,
);
expect(data.profiles.single.type, ProfileType.url);
});
test('points the profile at its remapped script id', () async {
writeFile(join(source.path, 'profiles', 'p1.yaml'), '');
@@ -375,7 +388,27 @@ void main() {
);
expect(data.profiles.single.scriptId, data.scripts.single.id);
expect(data.profiles.single.overwriteType, OverwriteType.script);
expect(data.profiles.single.extendType, ExtendType.script);
});
test('falls a custom overwrite back to a standard one', () async {
writeFile(join(source.path, 'profiles', 'p1.yaml'), '');
final data = await migrateLegacyConfig(
configMap: legacyConfig(
profiles: [
{
'id': 'p1',
'autoUpdateDuration': 0,
'overwrite': {'type': 'custom'},
},
],
),
sourcePath: source.path,
targetPath: target.path,
);
expect(data.profiles.single.extendType, ExtendType.standard);
});
test('remaps currentProfileId and nulls it when there is none', () async {
@@ -449,7 +482,6 @@ void main() {
group('backup and restore', () {
const localProvider = ClashProvider(
id: 4,
kind: ProviderKind.rule,
label: 'Local rules',
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.yaml,
@@ -476,9 +508,9 @@ void main() {
await database.scriptsDao.setAll([
Script(id: 2, label: 'Script', lastUpdateTime: DateTime(2026)),
]);
await database.rulesDao.putProfileAddedRule(
1,
await database.rulesDao.putRule(
const Rule(id: 3, content: 'example.com', order: 'a'),
profileId: 1,
);
await database.clashProvidersDao.putAll([localProvider.toCompanion()]);
await database.close();
@@ -566,7 +598,7 @@ void main() {
expect(data.profiles.single.label, 'Backed up');
expect(data.scripts.single.label, 'Script');
expect(data.rules.single.content, 'example.com');
expect(data.links.single.ruleId, 3);
expect(data.rules.single.profileId, 1);
expect(data.clashProviders.single.label, 'Local rules');
expect(data.configMap?['version'], 1);
expect(
+100
View File
@@ -0,0 +1,100 @@
import 'package:fl_clash/common/cidr.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('cuts a block out of a range as the fewest covering blocks', () {
expect(subtractCidrs(['0.0.0.0/0'], ['10.0.0.0/8']), [
'0.0.0.0/5',
'8.0.0.0/7',
'11.0.0.0/8',
'12.0.0.0/6',
'16.0.0.0/4',
'32.0.0.0/3',
'64.0.0.0/2',
'128.0.0.0/1',
]);
});
test('a family without an included block counts as whole on request', () {
expect(
subtractCidrs(['10.0.0.0/8'], ['10.1.0.0/16']),
isNot(contains('::/0')),
);
expect(subtractCidrs(['10.0.0.0/8'], const [], wholeWhenEmpty: true), [
'10.0.0.0/8',
'::/0',
]);
});
test('a CIDR is read as strictly as the core reads a prefix', () {
for (final value in [
'192.168.1.5/24',
'0.0.0.0/0',
'fd00::/8',
'::ffff:1.2.3.4/96',
]) {
expect(isCidr(value), isTrue, reason: value);
}
for (final value in [
'192.168.1.5',
'192.168.1.0/33',
'192.168.01.0/24',
'192.168.1.0/024',
' 10.0.0.0/8',
'10.0.0.0/8 ',
'10.0.0/8',
'fe80::1%eth0/64',
'fd00::/129',
]) {
expect(isCidr(value), isFalse, reason: value);
}
});
test('merges overlapping blocks and ignores what does not parse', () {
expect(subtractCidrs(['10.0.0.0/8', 'bad', '10.1.0.0/16'], const []), [
'10.0.0.0/8',
]);
});
test('bypassing private ranges covers exactly the rest of both families', () {
final routes = subtractCidrs(
const [],
privateRouteAddress,
wholeWhenEmpty: true,
);
expect(routes, hasLength(82));
expect(subtractCidrs(privateRouteAddress, routes), privateRouteAddress);
expect(
subtractCidrs(
const ['0.0.0.0/0', '::/0'],
[...routes, ...privateRouteAddress],
),
isEmpty,
);
});
test('an include list wholly excluded leaves that family unrouted', () async {
final routes = await const Tun(
routeAddress: ['192.168.1.0/24'],
).vpnRouteAddress(bypassPrivateRoute: true);
expect(routes, isNotEmpty);
expect(routes.where((route) => !route.contains(':')), isEmpty);
expect(
await const Tun().vpnRouteAddress(bypassPrivateRoute: false),
isEmpty,
);
});
test('the task subtracts as the function does', () async {
const include = ['10.0.0.0/8', '2001:db8::/32'];
const exclude = ['10.1.0.0/16', '2001:db8:1::/48'];
expect(
await subtractCidrsTask(include, exclude, wholeWhenEmpty: true),
subtractCidrs(include, exclude, wholeWhenEmpty: true),
);
});
}
+37 -5
View File
@@ -168,6 +168,33 @@ void main() {
});
});
group('windowControlsOverPage', () {
test('a wide macOS window meets the traffic lights at its top left', () {
expect(
windowControlsOverPage(isMacOS: true, version: 15, isMobileView: false),
macOSTrafficLightsArea,
);
});
test('a window header or a native title bar keeps them off the page', () {
for (final (isMacOS, version, isMobileView) in [
(true, 15, true),
(true, 10, false),
(false, 11, false),
]) {
expect(
windowControlsOverPage(
isMacOS: isMacOS,
version: version,
isMobileView: isMobileView,
),
Size.zero,
reason: 'macOS=$isMacOS $version mobileView=$isMobileView',
);
}
});
});
group('getProxiesColumns', () {
test('minimum 2 columns', () {
expect(getProxiesColumns(100, ProxiesLayout.standard), 2);
@@ -197,17 +224,22 @@ void main() {
});
test('scales with width', () {
expect(getProfilesColumns(270), 1);
expect(getProfilesColumns(240), 1);
expect(getProfilesColumns(700), 2);
expect(getProfilesColumns(1300), 4);
expect(getProfilesColumns(960), 3);
});
test('caps at three columns', () {
expect(getProfilesColumns(1300), 3);
expect(getProfilesColumns(2400, spacing: 14), 3);
});
test('spacing costs a column at the breakpoint', () {
expect(getProfilesColumns(540), 2);
expect(getProfilesColumns(540, spacing: 14), 1);
expect(getProfilesColumns(480), 2);
expect(getProfilesColumns(480, spacing: 14), 1);
});
test('fits two padded cards near 600px', () {
test('fits two padded cards near 600 dp', () {
expect(getProfilesColumns(568, spacing: 14), 2);
});
+58
View File
@@ -60,6 +60,64 @@ void main() {
});
});
group('parseUrlInput', () {
bool isHttp(String url) => url.startsWith('https://');
test('splits on lines and any whitespace, stripping YAML markup', () {
final parsed = parseUrlInput(
"https://a.com/x?q=1,2 https://b.com\r\n\n- 'https://c.com'\n",
isValid: isHttp,
);
expect(parsed.entries, [
'https://a.com/x?q=1,2',
'https://b.com',
'https://c.com',
]);
expect(parsed.isValid, isTrue);
});
test('keeps a share link line whole', () {
final parsed = parseUrlInput(
'trojan://pass@example.com:443#Hong Kong 01\nhttps://a.com https://b.com',
isValid: (_) => true,
);
expect(parsed.entries, [
'trojan://pass@example.com:443#Hong Kong 01',
'https://a.com',
'https://b.com',
]);
});
test('merges duplicates and skips existing urls', () {
final parsed = parseUrlInput(
'https://a.com\nhttps://b.com\nhttps://a.com\nhttps://c.com',
isValid: isHttp,
existing: {'https://b.com'},
);
expect(parsed.entries, ['https://a.com', 'https://c.com']);
expect(parsed.skippedExisting, 1);
});
test('reports invalid urls with their line', () {
final parsed = parseUrlInput(
'https://a.com\nftp://b.com https://c.com',
isValid: isHttp,
);
expect(parsed.entries, ['https://a.com', 'https://c.com']);
expect(parsed.issues, [
const InputIssue(
line: 2,
raw: 'ftp://b.com',
kind: InputIssueKind.invalidUrl,
),
]);
});
});
group('parseMapInput', () {
test('splits on the first whitespace and keeps colons', () {
final parsed = parseMapInput(
+19 -1
View File
@@ -47,6 +47,15 @@ void main() {
expect(received, isEmpty);
});
test('an install-config link with a blank url is ignored', () async {
await listen();
await emit('flclash://install-config?url=');
await emit('flclash://install-config?url=%20');
expect(received, isEmpty);
});
test('a link for another host is ignored', () async {
await listen();
@@ -132,7 +141,6 @@ void main() {
null,
'',
'plain text',
'vmess://eyJhZGQiOiIxLjIuMy40In0=',
'https://',
'clash://install-config?url=not-a-url',
'clash://open-profile?url=https://example.com/a.yaml',
@@ -142,6 +150,16 @@ void main() {
);
});
test('accepts a proxy share link', () {
expect(
profileUrlFromQrCodes([
'plain text',
'vmess://eyJhZGQiOiIxLjIuMy40In0=',
]),
'vmess://eyJhZGQiOiIxLjIuMy40In0=',
);
});
test('returns the first code that carries a profile url', () {
expect(
profileUrlFromQrCodes([
+16
View File
@@ -183,6 +183,22 @@ void main() {
]);
});
test('keeps what it can read of a damaged current config', () async {
final configMap = _createConfigMap()
..['currentProfileId'] = 7
..['excludeSSIDs'] = 'home';
final store = _FakeMigrationStore(
configMap: configMap,
version: Migration.currentVersion,
);
final config = await Migration(store: store).run();
expect(config.currentProfileId, 7);
expect(config.excludeSSIDs, isEmpty);
expect(store.events, ['getConfigMap', 'getVersion']);
});
test('keeps the current version when password obfuscation fails', () async {
final configMap = _createConfigMap(
davProps: const DAVProps(uri: 'https://example.com/dav', user: 'user'),
-6
View File
@@ -63,8 +63,6 @@ void main() {
},
},
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -112,8 +110,6 @@ void main() {
},
},
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -150,8 +146,6 @@ void main() {
},
},
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
+3
View File
@@ -20,6 +20,9 @@ base class _LocalPlatformFile extends PlatformFile {
@override
XFile get xFile => XFile(_file.path);
@override
int? lengthSync() => null;
@override
Future<int> length() => _file.length();
+161
View File
@@ -1,7 +1,43 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/common/request.dart';
import 'package:flutter_test/flutter_test.dart';
class _StalledHttpClient extends Fake implements HttpClient {
@override
Duration? connectionTimeout;
@override
String Function(Uri url)? findProxy;
@override
Future<HttpClientRequest> openUrl(String method, Uri url) =>
Completer<HttpClientRequest>().future;
}
class _HostAdapter implements HttpClientAdapter {
_HostAdapter(this.byHost);
final Map<String, ResponseBody Function()> byHost;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
return byHost[options.uri.host]!();
}
@override
void close({bool force = false}) {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -32,4 +68,129 @@ void main() {
),
);
});
group('latestRelease', () {
Request requestAnswering(Map<String, ResponseBody Function()> byHost) {
return Request()..dio.httpClientAdapter = _HostAdapter(byHost);
}
test('reads the release from the API', () async {
final request = requestAnswering({
'api.github.com': () => ResponseBody.fromString(
'{"tag_name":"v1.2.3","body":"notes"}',
200,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
),
});
expect(await request.latestRelease(), {
'tag_name': 'v1.2.3',
'body': 'notes',
});
});
test(
'falls back to the release page when the API is rate limited',
() async {
final request = requestAnswering({
'api.github.com': () => ResponseBody.fromString('{}', 403),
'github.com': () => ResponseBody.fromString(
'',
302,
headers: {
HttpHeaders.locationHeader: [
'https://github.com/owner/repo/releases/tag/v1.2.4',
],
},
),
});
expect(await request.latestRelease(), {'tag_name': 'v1.2.4'});
},
);
test('fails instead of answering when neither endpoint does', () async {
final request = requestAnswering({
'api.github.com': () => ResponseBody.fromString('{}', 403),
'github.com': () => ResponseBody.fromString('', 503),
});
await expectLater(request.latestRelease(), throwsA(isA<DioException>()));
});
});
group('downloadTarget', () {
test('sends a URL without credentials unchanged', () {
final target = downloadTarget('https://example.com/a.yaml?x=1');
expect(target.uri.toString(), 'https://example.com/a.yaml?x=1');
expect(target.headers, isEmpty);
});
test('moves decoded credentials into the Authorization header', () {
for (final url in [
'https://me@mail.com:p:w@dav.example.com/dav/a.yaml',
'https://me%40mail.com:p%3Aw@dav.example.com/dav/a.yaml',
]) {
final target = downloadTarget(url);
expect(target.uri.toString(), 'https://dav.example.com/dav/a.yaml');
expect(
target.headers[HttpHeaders.authorizationHeader],
'Basic ${base64.encode(utf8.encode('me@mail.com:p:w'))}',
);
}
});
});
group('decompressUnlabeled', () {
final yaml = utf8.encode('proxies: []\n');
test('inflates a gzip or zlib body that arrived unlabeled', () {
for (final codec in [gzip, zlib]) {
final body = Uint8List.fromList(codec.encode(yaml));
expect(decompressUnlabeled(body), yaml);
}
});
test('leaves a plain body untouched', () {
final body = Uint8List.fromList(yaml);
expect(decompressUnlabeled(body), same(body));
});
test('keeps a body that only starts like gzip', () {
final body = Uint8List.fromList([0x1f, 0x8b, 1, 2, 3]);
expect(decompressUnlabeled(body), same(body));
});
});
testWidgets('a stalled download fails at the shared download deadline', (
tester,
) async {
late final Future<Response<String>> pending;
HttpOverrides.runZoned(() {
pending = Request().getTextResponseForUrl('http://127.0.0.1/stalled');
}, createHttpClient: (_) => _StalledHttpClient());
var settled = false;
final outcome = expectLater(
pending.whenComplete(() => settled = true),
throwsA(
isA<DioException>().having(
(e) => e.type,
'type',
DioExceptionType.receiveTimeout,
),
),
);
await tester.pump(downloadTimeoutDuration - const Duration(seconds: 1));
expect(settled, isFalse);
await tester.pump(const Duration(seconds: 1));
await outcome;
});
}
+46
View File
@@ -26,6 +26,45 @@ void main() {
test('plain text', () {
expect('not a url'.isUrl, isFalse);
});
test('accepts an e-mail user name typed with a bare @', () {
final uri = 'https://me@mail.com:p@ss@dav.example.com/dav/a.yaml'.webUri;
expect(uri?.host, 'dav.example.com');
expect(uri?.userInfo, 'me%40mail.com:p%40ss');
expect(uri?.path, '/dav/a.yaml');
});
test('leaves an @ after the authority alone', () {
final uri = 'https://example.com/a@b?to=c@d'.webUri;
expect(uri?.host, 'example.com');
expect(uri?.userInfo, isEmpty);
expect(uri?.path, '/a@b');
});
});
group('StringExtension.isShareLink', () {
test('a link whose scheme mihomo converts', () {
expect('vmess://eyJhZGQiOiIxLjIuMy40In0='.isShareLink, isTrue);
expect('Hy2://pass@example.com:443#HK'.isShareLink, isTrue);
});
test('a subscription url', () {
expect('https://example.com/sub'.isShareLink, isFalse);
});
test('a scheme with nothing after it', () {
expect('ss://'.isShareLink, isFalse);
});
test('several links', () {
expect('ss://a@b:1\nss://c@d:2'.isShareLink, isFalse);
});
test('an unknown scheme', () {
expect('wireguard://key@example.com:51820'.isShareLink, isFalse);
});
});
group('StringExtension.splitByMultipleSeparators', () {
@@ -125,6 +164,13 @@ void main() {
expect('icon.PNG'.isSvg, isFalse);
expect('icon.svg.bak'.isSvg, isFalse);
});
test('reads the path of a url and the type of a data uri', () {
expect('https://example.com/icon.SVG?raw=true'.isSvg, isTrue);
expect('https://example.com/icon.png#.svg'.isSvg, isFalse);
expect('data:image/svg+xml;base64,PHN2Zy8+'.isSvg, isTrue);
expect('data:image/png;base64,iVBORw0KGgo='.isSvg, isFalse);
});
});
group('StringExtension.isRegex', () {
+30 -26
View File
@@ -14,32 +14,31 @@ const _profileDns = <String, dynamic>{
},
};
const _patchConfig = PatchClashConfig(
dns: Dns(
listen: ':1053',
ipv6: true,
nameserver: ['1.1.1.1'],
fallbackFilter: FallbackFilter(geoip: true),
),
dnsOverrideKeys: {
DnsOverrideKey.nameserver,
DnsOverrideKey.fallbackFilterGeoip,
DnsOverrideKey.ipv6,
},
const _dns = Dns(
listen: ':1053',
ipv6: true,
nameserver: ['1.1.1.1'],
fallbackFilter: FallbackFilter(geoip: true),
);
const _keys = {
DnsOverrideKey.nameserver,
DnsOverrideKey.fallbackFilterGeoip,
DnsOverrideKey.ipv6,
};
Future<YamlMap> _dnsOf({
required Map<String, dynamic> rawConfig,
required bool overrideDns,
Dns dns = _dns,
Set<DnsOverrideKey> keys = _keys,
}) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 1,
rawConfig: rawConfig,
realPatchConfig: _patchConfig,
overrideDns: overrideDns,
overrideNtp: false,
realPatchConfig: const PatchClashConfig(),
overrides: ProfileOverrides(dns: dns, dnsOverrideKeys: keys),
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -53,10 +52,7 @@ Future<YamlMap> _dnsOf({
void main() {
test('overrides only the selected DNS keys of an enabled profile', () async {
final dns = await _dnsOf(
rawConfig: {'dns': _profileDns},
overrideDns: true,
);
final dns = await _dnsOf(rawConfig: {'dns': _profileDns});
expect(dns['nameserver'], ['1.1.1.1']);
expect(dns['ipv6'], true);
@@ -67,11 +63,8 @@ void main() {
});
});
test('leaves an enabled profile alone while the override is off', () async {
final dns = await _dnsOf(
rawConfig: {'dns': _profileDns},
overrideDns: false,
);
test('leaves an enabled profile alone without picked keys', () async {
final dns = await _dnsOf(rawConfig: {'dns': _profileDns}, keys: {});
expect(dns['nameserver'], ['9.9.9.9']);
expect(dns.containsKey('ipv6'), isFalse);
@@ -84,7 +77,7 @@ void main() {
test(
'fills the minimal defaults plus overrides for a profile without DNS',
() async {
final dns = await _dnsOf(rawConfig: {}, overrideDns: false);
final dns = await _dnsOf(rawConfig: {});
expect(dns, {
'enable': true,
@@ -95,4 +88,15 @@ void main() {
});
},
);
test('a key picked without a value writes nothing', () async {
final dns = await _dnsOf(
rawConfig: {'dns': _profileDns},
dns: const Dns(nameserver: [], listen: ''),
keys: {DnsOverrideKey.nameserver, DnsOverrideKey.listen},
);
expect(dns['nameserver'], ['9.9.9.9']);
expect(dns['listen'], '0.0.0.0:53');
});
}
+12 -13
View File
@@ -11,23 +11,22 @@ const _profileNtp = <String, dynamic>{
'write-to-system': true,
};
const _patchConfig = PatchClashConfig(
const _overrides = ProfileOverrides(
ntp: Ntp(server: 'time.cloudflare.com', interval: 60),
ntpOverrideKeys: {NtpOverrideKey.server, NtpOverrideKey.interval},
);
Future<YamlMap?> _ntpOf({
required Map<String, dynamic> rawConfig,
required bool overrideNtp,
ProfileOverrides overrides = _overrides,
}) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 1,
rawConfig: rawConfig,
realPatchConfig: _patchConfig,
overrideDns: false,
overrideNtp: overrideNtp,
realPatchConfig: const PatchClashConfig(),
overrides: overrides,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -41,10 +40,7 @@ Future<YamlMap?> _ntpOf({
void main() {
test('overrides only the selected NTP keys of a profile', () async {
final ntp = await _ntpOf(
rawConfig: {'ntp': _profileNtp},
overrideNtp: true,
);
final ntp = await _ntpOf(rawConfig: {'ntp': _profileNtp});
expect(ntp!['server'], 'time.cloudflare.com');
expect(ntp['interval'], 60);
@@ -53,10 +49,10 @@ void main() {
expect(ntp['write-to-system'], true);
});
test('leaves the profile alone while the override is off', () async {
test('leaves the profile alone without picked keys', () async {
final ntp = await _ntpOf(
rawConfig: {'ntp': _profileNtp},
overrideNtp: false,
overrides: const ProfileOverrides(),
);
expect(ntp!['server'], 'ntp.aliyun.com');
@@ -64,11 +60,14 @@ void main() {
});
test('writes nothing for a profile without NTP', () async {
expect(await _ntpOf(rawConfig: {}, overrideNtp: false), isNull);
expect(
await _ntpOf(rawConfig: {}, overrides: const ProfileOverrides()),
isNull,
);
});
test('a profile without NTP gets only the selected keys', () async {
final ntp = await _ntpOf(rawConfig: {}, overrideNtp: true);
final ntp = await _ntpOf(rawConfig: {});
expect(ntp, {'server': 'time.cloudflare.com', 'interval': 60});
});
+126
View File
@@ -0,0 +1,126 @@
import 'dart:convert';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
Map<String, dynamic> _profileSniffer() => jsonDecode('''
{
"enable": false,
"override-destination": false,
"skip-domain": ["keep.me"],
"sniff": {"tls": {"ports": [443]}, "HTTP": {"ports": [80]}}
}
''');
const _overrides = ProfileOverrides(
sniffer: Sniffer(
enable: true,
forceDomain: ['+.example.com'],
sniff: {
'TLS': SnifferConfig(ports: ['443', '8443']),
},
),
snifferOverrideKeys: {
SnifferOverrideKey.enable,
SnifferOverrideKey.forceDomain,
SnifferOverrideKey.sniffTls,
},
);
Future<YamlMap?> _snifferOf({
required Map<String, dynamic> rawConfig,
ProfileOverrides overrides = _overrides,
}) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 1,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
overrides: overrides,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
return config['sniffer'] as YamlMap?;
}
void main() {
test('overrides only the selected sniffer keys of a profile', () async {
final sniffer = await _snifferOf(rawConfig: {'sniffer': _profileSniffer()});
expect(sniffer!['enable'], true);
expect(sniffer['force-domain'], ['+.example.com']);
expect(sniffer['override-destination'], false);
expect(sniffer['skip-domain'], ['keep.me']);
});
test(
'an overridden protocol replaces the profile entry of any case',
() async {
final sniffer = await _snifferOf(
rawConfig: {'sniffer': _profileSniffer()},
);
expect(sniffer!['sniff'], {
'HTTP': {
'ports': ['80'],
},
'TLS': {
'ports': ['443', '8443'],
},
});
},
);
test('keeps the protocols a profile lists in the deprecated form', () async {
final sniffer = await _snifferOf(
rawConfig: {
'sniffer': jsonDecode('''
{"enable": true, "sniffing": ["tls", "http"], "port-whitelist": [443, "8000-9000"]}
'''),
},
);
expect(sniffer!['sniff'], {
'HTTP': {
'ports': ['443', '8000-9000'],
},
'TLS': {
'ports': ['443', '8443'],
},
});
});
test('leaves the profile alone without picked keys', () async {
final sniffer = await _snifferOf(
rawConfig: {'sniffer': _profileSniffer()},
overrides: const ProfileOverrides(),
);
expect(sniffer!['enable'], false);
expect(sniffer.containsKey('force-domain'), isFalse);
expect((sniffer['sniff'] as YamlMap).keys, ['tls', 'HTTP']);
});
test('a profile without a sniffer gets only the selected keys', () async {
final sniffer = await _snifferOf(rawConfig: {});
expect(sniffer, {
'enable': true,
'force-domain': ['+.example.com'],
'sniff': {
'TLS': {
'ports': ['443', '8443'],
},
},
});
});
}
+78 -31
View File
@@ -23,6 +23,26 @@ void main() {
expect(await encodeMD5Task('abc'), '900150983cd24fb0d6963f7d28e17f72');
});
test('a proxy name holding control characters stays readable YAML', () {
const name = 'HK \x1b[31m\x00\x7f\r
';
final encoded = yaml.encode({
'proxies': [
{'name': name},
],
'proxy-groups': [
{
'name': 'G',
'proxies': [name],
},
],
});
expect(encoded, isNot(contains('\x1b')));
final config = loadYaml(encoded) as YamlMap;
expect(config['proxies'].first['name'], name);
expect(config['proxy-groups'].first['proxies'], [name]);
});
test('toGroupsTask converts, selects, and sorts core proxy data', () async {
final proxies = <String, dynamic>{
'Selector': {
@@ -191,8 +211,6 @@ void main() {
ipv6: true,
hosts: {'router.local': '192.168.1.1,192.168.1.2'},
),
overrideDns: false,
overrideNtp: false,
appendSystemDns: true,
proxyGroups: const [],
rules: const [],
@@ -249,8 +267,6 @@ void main() {
profileId: 7,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -303,8 +319,6 @@ void main() {
geoAutoUpdate: false,
geoUpdateInterval: 48,
),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -332,8 +346,6 @@ void main() {
profileId: 12,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -362,15 +374,7 @@ void main() {
profileId: 9,
rawConfig: {},
realPatchConfig: PatchClashConfig(),
overrideDns: true,
overrideNtp: false,
appendSystemDns: false,
proxies: [
CustomProxy(
id: 2,
definition: {'name': 'Node', 'type': 'socks5', 'port': 1080},
),
],
proxyGroups: [
ProxyGroup(
id: 1,
@@ -394,9 +398,6 @@ void main() {
expect(config['dns']['enable'], true);
expect(config['dns']['nameserver'], isNot(contains('system://')));
expect(config['proxies'], [
{'name': 'Node', 'type': 'socks5', 'port': 1080},
]);
expect(config['proxy-groups'], hasLength(1));
expect(config['proxy-groups'][0].keys, ['name', 'type', 'proxies']);
expect(config['proxy-groups'][0]['type'], 'select');
@@ -427,8 +428,6 @@ void main() {
profileId: 13,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -460,8 +459,6 @@ void main() {
'ntp': {'enable': true, 'write-to-system': true},
},
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -499,8 +496,6 @@ void main() {
'external-controller-pipe': r'\\.\pipe\mihomo',
},
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -530,8 +525,6 @@ void main() {
profileId: 14,
rawConfig: {'secret': 'from-the-profile'},
realPatchConfig: realPatchConfig,
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -685,8 +678,6 @@ void main() {
profileId: 13,
rawConfig: rawConfig,
realPatchConfig: realPatchConfig,
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
@@ -725,6 +716,64 @@ void main() {
});
});
group('makeRealProfileTask network settings', () {
Future<YamlMap> runWith(
PatchClashConfig realPatchConfig,
Map<String, dynamic> rawConfig,
) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 14,
rawConfig: await decodeJSONTask<Map<String, dynamic>>(
await encodeJSONTask(rawConfig),
),
realPatchConfig: realPatchConfig,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
return loadYaml(result.yaml) as YamlMap;
}
test('tun settings replace the profile ones and leave the rest', () async {
final config = await runWith(
const PatchClashConfig(tun: Tun(mtu: 1400, strictRoute: true)),
{
'tun': {'mtu': 9000, 'strict-route': false, 'udp-timeout': 60},
},
);
final tun = config['tun'] as YamlMap;
expect(tun['mtu'], 1400);
expect(tun['strict-route'], true);
expect(tun['congestion-controller'], 'cubic');
expect(tun['udp-timeout'], 60);
});
test('outbound settings start at the core defaults', () async {
final config = await runWith(const PatchClashConfig(), {
'routing-mark': 1,
});
expect(config['keep-alive-idle'], 15);
expect(config['routing-mark'], 0);
});
test('set outbound values replace the profile ones', () async {
final config = await runWith(
const PatchClashConfig(routingMark: 6666, disableKeepAlive: true),
{'routing-mark': 1, 'disable-keep-alive': false},
);
expect(config['routing-mark'], 6666);
expect(config['disable-keep-alive'], true);
});
});
group('makeRealProfileTask legacy provider file migration', () {
late Directory tempDir;
const url = 'https://example.com/proxy.yaml';
@@ -760,8 +809,6 @@ void main() {
},
},
realPatchConfig: const PatchClashConfig(),
overrideDns: false,
overrideNtp: false,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
+75
View File
@@ -0,0 +1,75 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
const _overrides = ProfileOverrides(
tun: ProfileTun(disableIcmpForwarding: true, excludeInterface: ['docker0']),
tunOverrideKeys: {
TunOverrideKey.disableIcmpForwarding,
TunOverrideKey.excludeInterface,
},
);
Future<YamlMap> _tunOf({
required Map<String, dynamic> rawConfig,
ProfileOverrides overrides = _overrides,
}) async {
final result = await makeRealProfileTask(
MakeRealProfileState(
profilesPath: '/profiles',
profileId: 1,
rawConfig: rawConfig,
realPatchConfig: const PatchClashConfig(tun: Tun(mtu: 1400)),
overrides: overrides,
appendSystemDns: false,
proxyGroups: const [],
rules: const [],
addedRules: const [],
defaultUA: 'FlClash-Test',
),
);
final config = loadYaml(result.yaml) as YamlMap;
return config['tun'] as YamlMap;
}
void main() {
test('writes the selected keys beside the network settings', () async {
final tun = await _tunOf(rawConfig: {});
expect(tun['disable-icmp-forwarding'], true);
expect(tun['exclude-interface'], ['docker0']);
expect(tun['mtu'], 1400);
});
test('replaces the same keys of the profile and keeps the rest', () async {
final tun = await _tunOf(
rawConfig: {
'tun': {
'exclude-interface': ['eth1'],
'include-uid': [1000],
},
},
);
expect(tun['exclude-interface'], ['docker0']);
expect(tun['include-uid'], [1000]);
});
test('leaves the profile alone without picked keys', () async {
final tun = await _tunOf(
rawConfig: {
'tun': {
'exclude-interface': ['eth1'],
},
},
overrides: const ProfileOverrides(
tun: ProfileTun(disableIcmpForwarding: true),
),
);
expect(tun['exclude-interface'], ['eth1']);
expect(tun.containsKey('disable-icmp-forwarding'), isFalse);
});
}
+18
View File
@@ -18,3 +18,21 @@ class TestGlobalRules extends GlobalRules {
@override
Stream<List<Rule>> build() => Stream.value(initial);
}
class TestIconSets extends IconSets {
final List<IconSet> initial;
TestIconSets([this.initial = const []]);
@override
Stream<List<IconSet>> build() => Stream.value(initial);
}
class TestClashProviders extends ClashProviders {
final List<ClashProvider> initial;
TestClashProviders([this.initial = const []]);
@override
Stream<List<ClashProvider>> build() => Stream.value(initial);
}
+1 -1
View File
@@ -10,7 +10,7 @@ class TestProfiles extends Profiles {
List<Profile> build() => initial;
@override
void put(Profile profile, {Iterable<int> renameIn = const []}) {
void put(Profile profile) {
final next = List<Profile>.from(state);
final index = next.indexWhere((item) => item.id == profile.id);
if (index == -1) {
+2
View File
@@ -6,6 +6,7 @@ import 'package:fl_clash/plugins/service.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -48,6 +49,7 @@ void main() {
tearDown(() => container.dispose());
Future<void> pumpAndroidManager(WidgetTester tester) async {
await container.read(vpnOptionsProvider.future);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
+43 -11
View File
@@ -57,6 +57,11 @@ void main() {
await tester.pump();
}
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump();
}
Future<void> outlastTip(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 7));
await tester.pump(const Duration(milliseconds: 500));
@@ -68,9 +73,9 @@ void main() {
}
void startService() {
container.read(runningVpnOptionsProvider.notifier).value = container.read(
vpnOptionsProvider,
);
container.read(runningVpnOptionsProvider.notifier).value = container
.read(vpnOptionsProvider)
.requireValue;
}
void updateVpn(VpnProps Function(VpnProps state) update) {
@@ -86,7 +91,7 @@ void main() {
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
await settle(tester);
expect(tip(), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
@@ -105,17 +110,44 @@ void main() {
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mixedPort: state.mixedPort + 1));
await tester.pump();
await settle(tester);
expect(tip(), findsOneWidget);
await drainTimers(tester);
});
testWidgets('a route change asks once its routes are computed', (
tester,
) async {
await pumpVpnManager(tester);
startService();
container
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(
tun: state.tun.copyWith(routeAddress: const ['10.0.0.0/8']),
),
);
await tester.pump();
expect(container.read(vpnOptionsProvider).isLoading, isTrue);
expect(tip(), findsNothing);
for (var i = 0; i < 100 && tip().evaluate().isEmpty; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
await tester.pump();
}
expect(tip(), findsOneWidget);
await drainTimers(tester);
});
testWidgets('no tip while no service is running', (tester) async {
await pumpVpnManager(tester);
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
await settle(tester);
expect(tip(), findsNothing);
await drainTimers(tester);
@@ -133,7 +165,7 @@ void main() {
),
),
);
await tester.pump();
await settle(tester);
expect(tip(), findsNothing);
await drainTimers(tester);
@@ -146,13 +178,13 @@ void main() {
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
await settle(tester);
expect(tip(), findsOneWidget);
await outlastTip(tester);
expect(tip(), findsNothing);
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
await settle(tester);
expect(tip(), findsNothing);
await drainTimers(tester);
@@ -164,13 +196,13 @@ void main() {
await pumpVpnManager(tester);
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
await settle(tester);
await outlastTip(tester);
expect(tip(), findsNothing);
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
await settle(tester);
expect(tip(), findsOneWidget);
await drainTimers(tester);
+20 -2
View File
@@ -46,7 +46,7 @@ void main() {
final bytes = Uint8List.fromList(utf8.encode('bad: ['));
await expectLater(
profile.saveFile(bytes, validate: (_) async => 'invalid config'),
profile.saveFile(bytes, validate: (_, _) async => 'invalid config'),
throwsA(
isA<MessageException>().having(
(e) => e.message,
@@ -64,11 +64,29 @@ void main() {
final profile = Profile.normal(label: 'p');
final bytes = Uint8List.fromList(utf8.encode('proxies: []'));
final saved = await profile.saveFile(bytes, validate: (_) async => '');
final saved = await profile.saveFile(bytes, validate: (_, _) async => '');
expect(saved.lastUpdateDate, isNotNull);
final savedFile = await profile.file;
expect(await savedFile.readAsString(), 'proxies: []');
});
test('validates with the profile age key', () async {
final profile = Profile.normal(
label: 'p',
).copyWith(ageSecretKey: 'AGE-SECRET-KEY-1A');
final bytes = Uint8List.fromList(utf8.encode('proxies: []'));
String? ageSecretKey;
await profile.saveFile(
bytes,
validate: (_, key) async {
ageSecretKey = key;
return '';
},
);
expect(ageSecretKey, 'AGE-SECRET-KEY-1A');
});
});
}
+23
View File
@@ -93,6 +93,29 @@ void main() {
expect(urlProfile.type, ProfileType.url);
expect(urlProfile.realAutoUpdate, true);
expect(urlProfile.realLabel, 'Remote');
expect(urlProfile.copyWith(label: '').realLabel, 'example.com');
});
test('an unnamed profile falls back to its url host, then its id', () {
final profiles = [
const Profile(
id: 1,
label: 'example.com',
autoUpdateDuration: defaultUpdateDuration,
),
];
const remote = Profile(
id: 2,
type: ProfileType.url,
url: 'https://example.com:8443/api/subscribe?token=1',
autoUpdateDuration: defaultUpdateDuration,
);
const local = Profile(id: 3, autoUpdateDuration: defaultUpdateDuration);
expect(remote.defaultLabel, 'example.com');
expect(local.defaultLabel, '3');
expect(profiles.optimizeLabel(remote).label, 'example.com(1)');
expect(profiles.optimizeLabel(local).label, '3');
});
});
+3 -3
View File
@@ -155,7 +155,7 @@ void main() {
test('putScript re-applies only a current profile running that script', () {
final profile = Profile.normal().copyWith(
overwriteType: OverwriteType.script,
extendType: ExtendType.script,
scriptId: 1,
);
final setupAction = _ApplyRecordingSetupAction();
@@ -174,7 +174,7 @@ void main() {
container
.read(profilesProvider.notifier)
.put(profile.copyWith(overwriteType: OverwriteType.standard));
.put(profile.copyWith(extendType: ExtendType.standard));
action.putScript(script(1));
expect(setupAction.applyCount, 1);
});
@@ -183,7 +183,7 @@ void main() {
'updateScript releases the updating key when the download fails',
() async {
final profile = Profile.normal().copyWith(
overwriteType: OverwriteType.script,
extendType: ExtendType.script,
scriptId: 1,
);
final setupAction = _ApplyRecordingSetupAction();
-9
View File
@@ -165,15 +165,6 @@ void main() {
});
});
group('viewHeight provider (derived)', () {
test('derives from viewSize height', () {
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(800, 600));
expect(container.read(viewHeightProvider), 600);
});
});
group('Init provider', () {
test('default is false', () {
expect(container.read(initProvider), false);
+77 -32
View File
@@ -19,7 +19,7 @@ Profile _profile(int id, String label) => Profile(
id: id,
label: label,
autoUpdateDuration: Duration.zero,
overwriteType: OverwriteType.standard,
extendType: ExtendType.standard,
);
void main() {
@@ -235,20 +235,21 @@ void main() {
test('a restore drops the cache of a provider it replaced', () async {
const dropped = ClashProvider(
id: 5,
kind: ProviderKind.rule,
label: 'Dropped',
url: 'https://example.com/dropped.yaml',
);
const kept = ClashProvider(
id: 6,
kind: ProviderKind.proxy,
label: 'Kept',
);
const kept = ClashProvider(id: 6, label: 'Kept');
await testDatabase.clashProvidersDao.putAll(
[dropped, kept].map((item) => item.toCompanion()),
);
await dropped.saveContent('payload: []'.codeUnits);
await kept.saveContent('proxies: []'.codeUnits);
for (final path in [
await dropped.path,
await dropped.compiledPath,
await kept.path,
await kept.compiledPath,
]) {
File(path).createSync(recursive: true);
}
final container = buildContainer();
container
.read(appSettingProvider.notifier)
@@ -263,7 +264,13 @@ void main() {
);
expect(File(await dropped.path).existsSync(), isFalse);
expect(File(await dropped.compiledPath).existsSync(), isFalse);
expect(File(await kept.path).existsSync(), isTrue);
expect(
File(await kept.compiledPath).existsSync(),
isFalse,
reason: 'the restored source is compiled again on first use',
);
});
test('rows that fail to restore take their files back out', () async {
@@ -337,8 +344,6 @@ void main() {
source
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mixedPort: 7899));
source.read(overrideDnsProvider.notifier).value = true;
source.read(overrideNtpProvider.notifier).value = true;
final configMap = configMapOf(source);
final target = buildContainer();
@@ -349,8 +354,6 @@ void main() {
expect(target.read(currentProfileIdProvider), 42);
expect(target.read(appSettingProvider).autoLaunch, isTrue);
expect(target.read(patchClashConfigProvider).mixedPort, 7899);
expect(target.read(overrideDnsProvider), isTrue);
expect(target.read(overrideNtpProvider), isTrue);
});
test('keeps the bound WebDAV account when the backup has none', () async {
@@ -366,6 +369,53 @@ void main() {
expect(target.read(davSettingProvider), dav);
});
test('keeps the picked apps when the backup lists none', () async {
final configMap = configMapOf(buildContainer());
const picked = AccessControlProps(
enable: true,
mode: AccessControlMode.acceptSelected,
acceptList: ['com.example.browser'],
);
final target = buildContainer();
target
.read(vpnSettingProvider.notifier)
.update((state) => state.copyWith(accessControlProps: picked));
await actionOf(
target,
).applyRestore(MigrationData(configMap: configMap), RestoreOption.all);
expect(target.read(vpnSettingProvider).accessControlProps, picked);
});
test('takes the apps a backup lists from another phone', () async {
const listed = AccessControlProps(
enable: true,
rejectList: ['com.example.bank'],
);
final source = buildContainer();
source
.read(vpnSettingProvider.notifier)
.update((state) => state.copyWith(accessControlProps: listed));
final configMap = configMapOf(source);
final target = buildContainer();
target
.read(vpnSettingProvider.notifier)
.update(
(state) => state.copyWith(
accessControlProps: const AccessControlProps(
acceptList: ['com.example.browser'],
),
),
);
await actionOf(
target,
).applyRestore(MigrationData(configMap: configMap), RestoreOption.all);
expect(target.read(vpnSettingProvider).accessControlProps, listed);
});
test('leaves the settings untouched for an onlyProfiles restore', () async {
final source = buildContainer();
source.read(currentProfileIdProvider.notifier).value = 42;
@@ -397,12 +447,13 @@ void main() {
});
});
group('a malformed config aborts before the database is touched', () {
test('leaves the existing profiles in place', () async {
group('a config with unreadable values', () {
test('restores the rest without keeping a dropped profile', () async {
final container = buildContainer();
await testDatabase.profilesDao.putAll([
_profile(9, 'Pre-existing').toCompanion(0),
]);
container.read(currentProfileIdProvider.notifier).value = 9;
container
.read(appSettingProvider.notifier)
.update(
@@ -410,27 +461,21 @@ void main() {
state.copyWith(restoreStrategy: RestoreStrategy.override),
);
await expectLater(
actionOf(container).applyRestore(
MigrationData(
configMap: const {'currentProfileId': 'not an int'},
profiles: [_profile(1, 'From backup')],
),
RestoreOption.all,
await actionOf(container).applyRestore(
MigrationData(
configMap: const {
'currentProfileId': 'not an int',
'appSettingProps': {'hideIp': true},
},
profiles: [_profile(1, 'From backup')],
),
throwsA(isA<TypeError>()),
RestoreOption.all,
);
final stored = await testDatabase.profilesDao.query().get();
expect(
stored.map((item) => item.label),
['Pre-existing'],
reason:
'an override restore deletes every profile the backup omits, so a '
'config that cannot be parsed must abort before the batch runs; '
'otherwise the profiles are replaced and currentProfileId still '
'points at a row that was just deleted',
);
expect(stored.map((item) => item.label), ['From backup']);
expect(container.read(currentProfileIdProvider), isNull);
expect(container.read(appSettingProvider).hideIp, true);
});
});
}
+482 -214
View File
@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/native.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/database/database.dart' as db;
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
@@ -14,7 +16,6 @@ import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' show basenameWithoutExtension;
import 'package:riverpod/riverpod.dart';
import '../helpers/test_profiles.dart';
@@ -22,29 +23,25 @@ import '../helpers/test_profiles.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
class _RecordingSetupAction extends SetupAction {
int applies = 0;
final forced = <bool>[];
int get applies => forced.length;
@override
void applyProfileDebounce({bool silence = false, bool force = false}) {
applies++;
forced.add(force);
}
}
/// Only a change the current custom overwrite can see is worth a reapply.
/// Only a change the current custom profile can see is worth a reapply.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const profileId = 1;
const standardProfileId = 2;
const proxies = ClashProvider(
id: 10,
kind: ProviderKind.proxy,
label: 'Shared nodes',
url: 'https://example.com/nodes.yaml',
);
const otherCustomId = 4;
const rules = ClashProvider(
id: 11,
kind: ProviderKind.rule,
label: 'Ad block',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.text,
@@ -54,7 +51,10 @@ void main() {
late db.Database testDatabase;
late ProviderContainer container;
late _RecordingSetupAction setup;
late Map<int, Map<String, dynamic>> subscriptions;
late RuleSetInfo compiled;
late Exception? compileError;
late Completer<void>? compileGate;
late List<({String name, String url})> compiles;
setUpAll(() {
home = Directory.systemTemp.createTempSync('flclash-providers-action-');
@@ -75,26 +75,59 @@ void main() {
const profile = Profile(
id: profileId,
label: 'Custom',
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
overwriteType: OverwriteType.custom,
);
const standardProfile = Profile(
id: standardProfileId,
label: 'Standard',
autoUpdateDuration: Duration.zero,
);
await testDatabase.profiles.put(profile.toCompanion());
await testDatabase.profiles.put(standardProfile.toCompanion());
subscriptions = {};
const otherCustom = Profile(
id: otherCustomId,
label: 'Other custom',
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
);
for (final item in [profile, standardProfile, otherCustom]) {
await testDatabase.profiles.put(item.toCompanion());
}
compiled = (
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.text,
);
compileError = null;
compileGate = null;
compiles = [];
final cacheRoot = Directory(await appPath.providerCacheRootPath);
if (cacheRoot.existsSync()) cacheRoot.deleteSync(recursive: true);
final core = _MockCoreHandlerInterface();
when(() => core.getConfig(any())).thenAnswer((invocation) async {
final path = invocation.positionalArguments.single as String;
return subscriptions[int.parse(basenameWithoutExtension(path))] ?? {};
when(() => core.compileRuleSet(any(), url: any(named: 'url'))).thenAnswer((
invocation,
) async {
final name = invocation.positionalArguments.single as String;
final url = invocation.namedArguments[#url] as String;
await compileGate?.future;
compiles.add((name: name, url: url));
if (compileError case final error?) throw error;
final source = File(await appPath.getProviderCachePath(name));
if (url.isNotEmpty) {
source
..createSync(recursive: true)
..writeAsStringSync('downloaded');
}
if (compiled.behavior != RuleProviderBehavior.classical &&
compiled.format != RuleProviderFormat.mrs) {
File(
'${source.path}.mrs',
).writeAsStringSync('mrs of ${source.readAsStringSync()}');
}
return compiled;
});
container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(
() => TestProfiles([profile, standardProfile]),
() => TestProfiles([profile, standardProfile, otherCustom]),
),
setupActionProvider.overrideWith(() => setup),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
@@ -102,8 +135,7 @@ void main() {
);
container.listen(currentProfileProvider, (_, _) {});
container.read(currentProfileIdProvider.notifier).value = profileId;
container.listen(clashProvidersProvider(ProviderKind.proxy), (_, _) {});
container.listen(clashProvidersProvider(ProviderKind.rule), (_, _) {});
container.listen(clashProvidersProvider, (_, _) {});
await pumpEventQueue();
});
@@ -128,8 +160,7 @@ void main() {
}
Future<void> ruleSetIn(int owner, String name, {required int id}) async {
await testDatabase.rulesDao.putProfileCustomRule(
owner,
await testDatabase.rulesDao.putRule(
Rule(
id: id,
ruleAction: RuleAction.RULE_SET,
@@ -137,137 +168,179 @@ void main() {
ruleTarget: 'DIRECT',
order: 'a',
),
profileId: owner,
);
}
Future<List<String>?> useOf(int owner) async =>
(await testDatabase.proxyGroupsDao.query(owner).get()).single.use;
Future<String?> ruleSetOf(int owner) async =>
(await testDatabase.rulesDao.queryProfileCustomRules(owner).get())
(await testDatabase.rulesDao.queryProfileRules(owner).get())
.single
.ruleProvider;
test('a proxy provider no group uses does not reapply', () async {
await action().putProvider(proxies);
test('a rule set a rule references reapplies', () async {
await ruleSetIn(profileId, 'Ad block', id: 30);
await action().putProvider(rules);
await pumpEventQueue();
expect(setup.applies, 0);
expect(setup.forced, [false]);
});
test('a proxy provider a group uses reapplies', () async {
await testDatabase.proxyGroups.put(
const ProxyGroup(
id: 20,
name: 'Auto',
type: GroupType.URLTest,
use: ['Shared nodes'],
order: 'a',
).toCompanion(profileId),
);
test('new content for a used rule set forces a reapply', () async {
await ruleSetIn(profileId, 'Ad block', id: 30);
await action().putProvider(proxies);
await action().putProvider(rules, content: 'payload: []'.codeUnits);
await pumpEventQueue();
expect(setup.applies, 1);
expect(setup.forced, [true]);
});
test('renaming a used provider reapplies under its old name', () async {
await testDatabase.proxyGroups.put(
const ProxyGroup(
id: 20,
name: 'Auto',
type: GroupType.URLTest,
use: ['Shared nodes'],
order: 'a',
).toCompanion(profileId),
);
test('renaming a used rule set reapplies under its old name', () async {
await ruleSetIn(profileId, 'Ad block', id: 30);
await action().putProvider(
proxies.copyWith(label: 'Renamed'),
previous: proxies,
rules.copyWith(label: 'Renamed'),
previous: rules,
);
await pumpEventQueue();
expect(setup.applies, 1);
});
test('a rule set a rule still references is kept', () async {
await testDatabase.rulesDao.putProfileCustomRule(
profileId,
const Rule(
id: 30,
ruleAction: RuleAction.RULE_SET,
ruleProvider: 'Ad block',
ruleTarget: 'DIRECT',
order: 'a',
),
);
test('a rule set a rule still references is deleted', () async {
await ruleSetIn(profileId, 'Ad block', id: 30);
await testDatabase.clashProvidersDao.putAll([rules.toCompanion()]);
await pumpEventQueue();
final users = await action().delProvider(rules);
action().delProvider(rules);
await pumpEventQueue();
expect(users.map((profile) => profile.id), [profileId]);
expect(
(await testDatabase.clashProvidersDao.query(ProviderKind.rule).get()).map(
(provider) => provider.id,
),
[rules.id],
);
expect(await testDatabase.clashProvidersDao.query().get(), isEmpty);
expect(setup.applies, 0);
});
test('a provider names every profile whose groups still use it', () async {
for (final (id, owner) in [(20, profileId), (21, standardProfileId)]) {
await testDatabase.proxyGroups.put(
ProxyGroup(
id: id,
name: 'Auto',
type: GroupType.URLTest,
use: const ['Shared nodes'],
order: 'a',
).toCompanion(owner),
);
}
test('a rule set names every custom profile whose rules use it', () async {
await ruleSetIn(profileId, 'Ad block', id: 30);
await ruleSetIn(standardProfileId, 'Ad block', id: 31);
await ruleSetIn(otherCustomId, 'Ad block', id: 32);
expect(
(await action().profilesUsing(proxies)).map((profile) => profile.id),
[profileId, standardProfileId],
);
expect(await action().profilesUsing(rules), isEmpty);
expect((await action().profilesUsing(rules)).map((profile) => profile.id), [
profileId,
otherCustomId,
]);
});
test('a proxy provider a profile of its name stands in for can go', () async {
await testDatabase.proxyGroups.put(
const ProxyGroup(
id: 20,
name: 'Auto',
type: GroupType.URLTest,
use: ['Shared nodes'],
order: 'a',
).toCompanion(profileId),
);
await testDatabase.clashProvidersDao.putAll([proxies.toCompanion()]);
test('a rule set only a custom profile\'s DNS names is in use, and its '
'changes reapply', () async {
container
.read(profilesProvider.notifier)
.put(
const Profile(
id: 3,
label: 'Shared nodes',
autoUpdateDuration: Duration.zero,
.updateProfile(
profileId,
(profile) => profile.copyWith(
overrides: const ProfileOverrides(
dns: Dns(nameserverPolicy: {'rule-set:Ad block': '223.5.5.5'}),
dnsOverrideKeys: {DnsOverrideKey.nameserverPolicy},
),
),
);
expect((await action().profilesUsing(rules)).map((profile) => profile.id), [
profileId,
]);
await action().putProvider(rules);
await pumpEventQueue();
expect(setup.forced, [false]);
});
test('renaming a rule set renames it in every custom profile', () async {
await ruleSetIn(profileId, 'Ad block', id: 30);
await ruleSetIn(otherCustomId, 'Ad block', id: 31);
await ruleSetIn(standardProfileId, 'Ad block', id: 32);
container
.read(profilesProvider.notifier)
.updateProfile(
otherCustomId,
(profile) => profile.copyWith(
overrides: const ProfileOverrides(
sniffer: Sniffer(forceDomain: ['rule-set:Ad block']),
snifferOverrideKeys: {SnifferOverrideKey.forceDomain},
),
),
);
await testDatabase.clashProvidersDao.putAll([rules.toCompanion()]);
await pumpEventQueue();
expect(await action().delProvider(proxies), isEmpty);
await pumpEventQueue();
expect(
await testDatabase.clashProvidersDao.query(ProviderKind.proxy).get(),
isEmpty,
await action().putProvider(
rules.copyWith(label: 'Renamed'),
previous: rules,
);
await pumpEventQueue();
expect(await ruleSetOf(profileId), 'Renamed');
expect(await ruleSetOf(otherCustomId), 'Renamed');
expect(await ruleSetOf(standardProfileId), 'Ad block');
expect(
container
.read(profilesProvider)
.getProfile(otherCustomId)!
.overrides
.sniffer
.forceDomain,
['rule-set:Renamed'],
);
});
group('what a delete names', () {
ProfilesAction profiles() =>
container.read(profilesActionProvider.notifier);
Future<void> targetIn(int owner, String target, {required int id}) =>
testDatabase.rulesDao.putRule(
Rule(id: id, content: 'x', ruleTarget: target, order: 'a'),
profileId: owner,
);
test('a local proxy is used by the custom profiles naming it where no '
'group of theirs takes the name', () async {
await targetIn(profileId, 'HK', id: 30);
await targetIn(standardProfileId, 'HK', id: 31);
await targetIn(otherCustomId, 'HK', id: 32);
await testDatabase.proxyGroups.put(
const ProxyGroup(
id: 40,
name: 'HK',
type: GroupType.Selector,
proxies: ['DIRECT'],
).toCompanion(otherCustomId),
);
expect(
(await profiles().proxyUsers({'HK'})).map((profile) => profile.id),
[profileId],
);
expect(await profiles().proxyUsers({'US'}), isEmpty);
});
test('a group is in use while the rest of its profile names it', () async {
Future<void> group(int id, String name, List<String> proxies) =>
testDatabase.proxyGroups.put(
ProxyGroup(
id: id,
name: name,
type: GroupType.Selector,
proxies: proxies,
).toCompanion(profileId),
);
await group(40, 'Auto', ['DIRECT']);
await group(41, 'Parent', ['Auto']);
await group(42, 'Ruled', ['DIRECT']);
await targetIn(profileId, 'Ruled', id: 30);
expect(await profiles().groupsInUse(profileId, {40}), {'Auto'});
expect(await profiles().groupsInUse(profileId, {40, 41}), isEmpty);
expect(await profiles().groupsInUse(profileId, {42}), {'Ruled'});
expect(await profiles().groupsInUse(otherCustomId, {40}), isEmpty);
});
});
test('a rule set no rule references does not reapply', () async {
@@ -277,94 +350,304 @@ void main() {
expect(setup.applies, 0);
});
test('a standard overwrite never reapplies for a provider', () async {
container.read(currentProfileIdProvider.notifier).value = null;
test('a subscription profile never reapplies for a provider', () async {
await ruleSetIn(standardProfileId, 'Ad block', id: 30);
container.read(currentProfileIdProvider.notifier).value = standardProfileId;
await action().putProvider(proxies);
await action().putProvider(rules);
await pumpEventQueue();
expect(setup.applies, 0);
});
test(
'a subscription with the name keeps its profile off the users',
() async {
await useIn(profileId, 'Shared nodes', id: 20);
await useIn(standardProfileId, 'Shared nodes', id: 21);
await ruleSetIn(standardProfileId, 'Ad block', id: 30);
subscriptions[standardProfileId] = {
'proxy-providers': {'Shared nodes': <String, dynamic>{}},
'rule-providers': {'Ad block': <String, dynamic>{}},
};
expect(
(await action().profilesUsing(proxies)).map((profile) => profile.id),
[profileId],
);
expect(await action().profilesUsing(rules), isEmpty);
},
);
test('a rename follows only the references that reach the app one', () async {
await testDatabase.clashProvidersDao.putAll([
proxies.toCompanion(),
rules.toCompanion(),
]);
await useIn(profileId, 'Shared nodes', id: 20);
await useIn(standardProfileId, 'Shared nodes', id: 21);
test('a rename follows the custom profiles and leaves a standard extension '
'naming its own set', () async {
await testDatabase.clashProvidersDao.putAll([rules.toCompanion()]);
await ruleSetIn(profileId, 'Ad block', id: 30);
await ruleSetIn(standardProfileId, 'Ad block', id: 31);
subscriptions[standardProfileId] = {
'proxy-providers': {'Shared nodes': <String, dynamic>{}},
'rule-providers': {'Ad block': <String, dynamic>{}},
};
await pumpEventQueue();
expect(
await action().putProvider(
proxies.copyWith(label: 'Renamed "nodes"'),
previous: proxies,
),
isEmpty,
);
expect(
await action().putProvider(
rules.copyWith(label: 'Renamed rules'),
previous: rules,
),
isEmpty,
final saved = await action().putProvider(
rules.copyWith(label: 'Renamed "rules"'),
previous: rules,
);
await pumpEventQueue();
expect(await useOf(profileId), ['Renamed "nodes"']);
expect(await useOf(standardProfileId), ['Shared nodes']);
expect(await ruleSetOf(profileId), 'Renamed rules');
expect(saved.label, 'Renamed "rules"');
expect(await ruleSetOf(profileId), 'Renamed "rules"');
expect(await ruleSetOf(standardProfileId), 'Ad block');
});
test('a rename onto a name a user subscription has is refused', () async {
await testDatabase.clashProvidersDao.putAll([proxies.toCompanion()]);
await useIn(profileId, 'Shared nodes', id: 20);
subscriptions[profileId] = {
'proxy-providers': {'Taken': <String, dynamic>{}},
};
Future<ClashProvider> stored(int id) async =>
(await testDatabase.clashProvidersDao.query().get()).singleWhere(
(provider) => provider.id == id,
);
Future<String?> read(Future<String> path) async {
final file = File(await path);
return file.existsSync() ? file.readAsStringSync() : null;
}
test('saved content takes the behavior and format the Core reads', () async {
compiled = (
behavior: RuleProviderBehavior.ipcidr,
format: RuleProviderFormat.yaml,
);
const local = ClashProvider(id: 40, label: 'Local');
await action().putProvider(local, content: 'IP-CIDR,10.0.0.0/8'.codeUnits);
await pumpEventQueue();
final conflicts = await action().putProvider(
proxies.copyWith(label: 'Taken'),
previous: proxies,
expect(compiles.single, (name: '${local.fileName}.new', url: ''));
final saved = await stored(local.id);
expect(saved.behavior, RuleProviderBehavior.ipcidr);
expect(saved.format, RuleProviderFormat.yaml);
expect(await read(saved.path), 'IP-CIDR,10.0.0.0/8');
expect(await read(saved.compiledPath), 'mrs of IP-CIDR,10.0.0.0/8');
final staged = await appPath.getProviderCachePath('${local.fileName}.new');
expect(File(staged).existsSync(), isFalse);
expect(File('$staged.mrs').existsSync(), isFalse);
});
test('content the Core rejects leaves the saved set as it was', () async {
await ruleSetIn(profileId, 'Ad block', id: 30);
File(await rules.path)
..createSync(recursive: true)
..writeAsStringSync('example.com');
compileError = const CoreMethodException(
code: 'rule_set_mixed',
message: 'mixed',
);
await expectLater(
action().putProvider(rules, content: 'example.com\n10.0.0.0/8'.codeUnits),
throwsA(isA<CoreMethodException>()),
);
await pumpEventQueue();
expect(conflicts.map((profile) => profile.id), [profileId]);
expect(
(await testDatabase.clashProvidersDao.query(ProviderKind.proxy).get())
.single
.label,
'Shared nodes',
);
expect(await useOf(profileId), ['Shared nodes']);
expect(await read(rules.path), 'example.com');
expect(await testDatabase.clashProvidersDao.query().get(), isEmpty);
expect(setup.applies, 0);
final staged = await appPath.getProviderCachePath('${rules.fileName}.new');
expect(File(staged).existsSync(), isFalse);
});
test('a set that turns classical drops the mrs it had', () async {
File(await rules.compiledPath)
..createSync(recursive: true)
..writeAsStringSync('old mrs');
compiled = (
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.text,
);
await action().putProvider(
rules,
previous: rules,
content: 'DOMAIN-KEYWORD,ad'.codeUnits,
);
expect(await read(rules.path), 'DOMAIN-KEYWORD,ad');
expect(File(await rules.compiledPath).existsSync(), isFalse);
});
test('a new remote set downloads, and a rename alone does not', () async {
const remote = ClashProvider(
id: 41,
label: 'Remote',
url: 'https://example.com/r.list',
);
await ruleSetIn(profileId, 'Remote', id: 30);
await action().putProvider(remote);
await pumpEventQueue();
expect(compiles.single, (name: remote.fileName, url: remote.url));
expect(setup.forced, [true]);
final saved = await stored(remote.id);
expect(saved.behavior, RuleProviderBehavior.domain);
expect(await read(saved.compiledPath), 'mrs of downloaded');
await action().putProvider(
saved.copyWith(label: 'Renamed'),
previous: saved,
);
await pumpEventQueue();
expect(compiles, hasLength(1));
expect(setup.forced, [true, false]);
});
group('a set changed while it downloads', () {
const remote = ClashProvider(
id: 41,
label: 'Remote',
url: 'https://example.com/r.list',
);
late ClashProvider saved;
late Future<ClashProvider> updating;
setUp(() async {
await ruleSetIn(profileId, 'Remote', id: 30);
await action().putProvider(remote);
await pumpEventQueue();
saved = await stored(remote.id);
compileGate = Completer();
updating = action().putProvider(saved, previous: saved, refresh: true);
await pumpEventQueue();
});
Future<void> download() async {
compileGate!.complete();
await updating;
await pumpEventQueue();
}
test('stays deleted, its cache too', () async {
action().delProvider(saved);
await pumpEventQueue();
await download();
expect(await testDatabase.clashProvidersDao.query().get(), isEmpty);
expect(File(await saved.path).existsSync(), isFalse);
expect(setup.forced, [true]);
});
test('keeps its new name', () async {
container
.read(clashProvidersProvider.notifier)
.put(saved.copyWith(label: 'Renamed'));
await pumpEventQueue();
await download();
expect((await stored(remote.id)).label, 'Renamed');
expect(await ruleSetOf(profileId), 'Renamed');
});
test('drops the download of the url it no longer has', () async {
final moved = saved.copyWith(url: 'https://example.com/moved.list');
container.read(clashProvidersProvider.notifier).put(moved);
await pumpEventQueue();
await download();
expect((await stored(remote.id)).url, moved.url);
expect(File(await saved.path).existsSync(), isFalse);
});
});
test('prepare builds a missing mrs and keeps what the Core found', () async {
await testDatabase.clashProvidersDao.putAll([rules.toCompanion()]);
File(await rules.path)
..createSync(recursive: true)
..writeAsStringSync('10.0.0.0/8');
compiled = (
behavior: RuleProviderBehavior.ipcidr,
format: RuleProviderFormat.text,
);
final prepared = await action().prepare(rules);
expect(compiles.single, (name: rules.fileName, url: ''));
expect(prepared.behavior, RuleProviderBehavior.ipcidr);
expect((await stored(rules.id)).behavior, RuleProviderBehavior.ipcidr);
expect(await read(prepared.compiledPath), 'mrs of 10.0.0.0/8');
expect(await action().prepare(prepared), prepared);
expect(compiles, hasLength(1));
});
test('prepare downloads a remote set whose cache is gone', () async {
const remote = ClashProvider(
id: 42,
label: 'Remote',
url: 'https://example.com/r.list',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.text,
);
final prepared = await action().prepare(remote);
expect(compiles.single, (name: remote.fileName, url: remote.url));
expect(prepared, remote);
expect(await read(remote.corePath), 'mrs of downloaded');
});
test('prepare leaves a set the Core cannot build to the Core', () async {
compileError = const CoreMethodException(code: 'core_error', message: '');
expect(await action().prepare(rules), rules);
});
test('a remote sync reloads in place unless the set changed shape', () async {
const remote = ClashProvider(
id: 43,
label: 'Remote',
url: 'https://example.com/r.list',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.text,
);
await testDatabase.clashProvidersDao.putAll([remote.toCompanion()]);
await ruleSetIn(profileId, 'Remote', id: 30);
expect(await action().syncRemote(remote), isTrue);
await pumpEventQueue();
expect(setup.applies, 0);
compiled = (
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.text,
);
expect(await action().syncRemote(remote), isFalse);
await pumpEventQueue();
expect(setup.forced, [true]);
expect((await stored(remote.id)).behavior, RuleProviderBehavior.classical);
});
test('only an app-level remote set is found by the file it loads', () async {
const remote = ClashProvider(
id: 44,
label: 'Remote',
url: 'https://example.com/r.list',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.text,
);
await testDatabase.clashProvidersDao.putAll([
remote.toCompanion(),
rules.toCompanion(),
]);
Future<ExternalProvider> loaded(
Future<String> path, {
String type = 'Rule',
String vehicleType = 'File',
}) async => ExternalProvider(
name: 'Remote',
type: type,
path: await path,
count: 1,
vehicleType: vehicleType,
updateAt: DateTime(2026),
);
expect(
await action().remoteProviderOf(await loaded(remote.corePath)),
remote,
);
expect(await action().remoteProviderOf(await loaded(remote.path)), isNull);
expect(
await action().remoteProviderOf(await loaded(rules.corePath)),
isNull,
);
expect(
await action().remoteProviderOf(
await loaded(remote.corePath, vehicleType: 'HTTP'),
),
isNull,
);
});
group('a profile as a provider', () {
@@ -377,52 +660,37 @@ void main() {
setUp(() async {
container.read(profilesProvider.notifier).put(home);
await useIn(profileId, 'Home', id: 20);
await useIn(standardProfileId, 'Home', id: 21);
subscriptions[standardProfileId] = {
'proxy-providers': {'Home': <String, dynamic>{}},
};
await useIn(otherCustomId, 'Home', id: 21);
});
tearDown(() => feature = const Feature());
ProfilesAction profiles() =>
container.read(profilesActionProvider.notifier);
test('has no users while custom providers are off', () async {
expect(await profiles().providerUsers(home), isEmpty);
final rename = await profiles().providerRename(
home,
home.copyWith(label: 'Away'),
);
expect(rename.renameIn, isEmpty);
});
test('is used by the profiles its label reaches', () async {
feature = const Feature(customProviders: true);
test('is used by the custom profiles its label reaches', () async {
expect(
(await profiles().providerUsers(home)).map((profile) => profile.id),
[profileId],
[profileId, otherCustomId],
);
final rename = await profiles().providerRename(
home,
home.copyWith(label: 'Away'),
);
expect(rename.renameIn.map((profile) => profile.id), [profileId]);
expect(rename.conflicts, isEmpty);
});
test('cannot take a label a user subscription has', () async {
feature = const Feature(customProviders: true);
subscriptions[profileId] = {
'proxy-providers': {'Away': <String, dynamic>{}},
};
test('a custom profile is never one, even under a label in use', () async {
final custom = container
.read(profilesProvider)
.singleWhere((profile) => profile.id == profileId);
await useIn(otherCustomId, custom.realLabel, id: 22);
final rename = await profiles().providerRename(
home,
home.copyWith(label: 'Away'),
);
expect(rename.conflicts.map((profile) => profile.id), [profileId]);
expect(await profiles().providerUsers(custom), isEmpty);
});
test('forces a reapply of the profile whose groups use it', () async {
await action().applyIfReferenced(ProviderKind.proxy, {
'Home',
}, force: true);
await action().applyIfReferenced(ProviderKind.proxy, {
'Elsewhere',
}, force: true);
expect(setup.forced, [true]);
});
});
}
+2 -23
View File
@@ -125,17 +125,6 @@ void main() {
});
});
group('OverrideDns provider', () {
test('default is false', () {
expect(container.read(overrideDnsProvider), false);
});
test('can toggle on', () {
container.read(overrideDnsProvider.notifier).update((_) => true);
expect(container.read(overrideDnsProvider), true);
});
});
group('ExcludeSSIDs provider', () {
test('reorders with final insertion index semantics', () {
container
@@ -202,7 +191,6 @@ void main() {
expect(config.vpnProps.enable, true);
expect(config.networkProps.systemProxy, true);
expect(config.currentProfileId, null);
expect(config.overrideDns, false);
expect(config.hotKeyActions, isEmpty);
expect(config.patchClashConfig, const PatchClashConfig());
expect(config.excludeSSIDs, isEmpty);
@@ -210,7 +198,6 @@ void main() {
test('reflects updated sub-provider values', () {
container.read(currentProfileIdProvider.notifier).update((_) => 99);
container.read(overrideDnsProvider.notifier).update((_) => true);
container
.read(patchClashConfigProvider.notifier)
.update((_) => const PatchClashConfig(mixedPort: 7890));
@@ -220,7 +207,6 @@ void main() {
final config = container.read(configProvider);
expect(config.currentProfileId, 99);
expect(config.overrideDns, true);
expect(config.patchClashConfig.mixedPort, 7890);
expect(config.excludeSSIDs, ['Office Wi-Fi']);
});
@@ -246,21 +232,14 @@ void main() {
group('buildConfigOverrides', () {
test('produces correct overrides', () {
const config = Config(
themeProps: ThemeProps(),
currentProfileId: 7,
overrideDns: true,
overrideNtp: true,
);
const config = Config(themeProps: ThemeProps(), currentProfileId: 7);
final overrides = buildConfigOverrides(config);
expect(overrides.length, 13);
expect(overrides.length, 11);
final overrideContainer = ProviderContainer(overrides: overrides);
addTearDown(overrideContainer.dispose);
expect(overrideContainer.read(currentProfileIdProvider), 7);
expect(overrideContainer.read(overrideDnsProvider), true);
expect(overrideContainer.read(overrideNtpProvider), true);
expect(
overrideContainer.read(patchClashConfigProvider),
config.patchClashConfig,
+748
View File
@@ -0,0 +1,748 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/native.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/database/database.dart' as db;
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod/riverpod.dart';
import 'package:yaml/yaml.dart';
import '../helpers/test_profiles.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
CustomImportApp _app({
Map<String, String> profiles = const {},
Set<String> profileLabels = const {},
Map<String, String> ruleProviders = const {},
Set<String> ruleProviderLabels = const {},
List<Map<String, dynamic>> proxies = const [],
Set<String> groupNames = const {},
}) => (
profileLabels: {...profileLabels, ...profiles.values},
profileUrls: profiles,
ruleProviderLabels: {...ruleProviderLabels, ...ruleProviders.values},
ruleProviderUrls: ruleProviders,
proxies: proxies,
groupNames: groupNames,
);
CustomProfileImport _read(String config, [CustomImportApp? app]) =>
readCustomProfileImport((bytes: utf8.encode(config), app: app ?? _app()));
Matcher _issue(CustomImportIssue issue, {String? item}) =>
isA<CustomImportException>()
.having((error) => error.issue, 'issue', issue)
.having((error) => error.item, 'item', item ?? anything);
void main() {
group('proxy providers', () {
test('uses a provider the app already has under its label', () {
final config = _read('''
proxy-providers:
sub:
type: http
url: https://example.com/sub
proxy-groups:
- {name: Proxy, type: select, use: [sub]}
''', _app(profiles: {'https://example.com/sub': 'My sub'}));
expect(config.proxyProviders, isEmpty);
expect(config.proxyGroups.single.use, ['My sub']);
});
test('moves a new provider off a label the app uses', () {
final config = _read('''
proxy-providers:
sub:
type: http
url: https://example.com/new
health-check: {enable: true, url: https://cp.example.com, interval: 300}
override: {additional-prefix: '[sub] '}
proxy-groups:
- {name: Proxy, type: select, use: [sub, other]}
''', _app(profileLabels: {'sub'}));
expect(config.proxyProviders, [
(label: 'sub(1)', url: 'https://example.com/new', content: ''),
]);
expect(config.proxyGroups.single.use, ['sub(1)', 'other']);
expect(config.overrides.proxyProviders, {
'sub(1)': const ProxyProviderOptions(
healthCheck: ProviderHealthCheck(
url: 'https://cp.example.com',
interval: 300,
),
proxyOverride: ProviderOverride(additionalPrefix: '[sub] '),
),
});
});
test('adds file providers empty and inline ones with their proxies', () {
final config = _read('''
proxy-providers:
local: {type: file, path: ./local.yaml}
inline:
type: inline
payload:
- {name: A, type: ss, server: a.example.com}
''');
expect(config.proxyProviders.first, (
label: 'local',
url: '',
content: '',
));
final inline = config.proxyProviders.last;
expect(inline.label, 'inline');
expect(inline.url, isEmpty);
expect(loadYaml(inline.content), {
'proxies': [
{'name': 'A', 'type': 'ss', 'server': 'a.example.com'},
],
});
});
test('adds one provider for two names sharing a url', () {
final config = _read('''
proxy-providers:
a: {type: http, url: https://example.com/sub}
b: {type: http, url: https://example.com/sub}
proxy-groups:
- {name: Proxy, type: select, use: [a, b]}
''');
expect(config.proxyProviders.single.label, 'a');
expect(config.proxyGroups.single.use, ['a']);
});
test('applies merge keys', () {
final config = _read('''
p: &p
type: http
interval: 3600
proxy-providers:
sub:
<<: *p
url: https://example.com/sub
''');
expect(config.proxyProviders.single.url, 'https://example.com/sub');
});
});
group('rule providers', () {
test('renames every reference to a moved rule set', () {
final config = _read(
'''
rule-providers:
ads: {type: http, url: https://example.com/ads.yaml, behavior: domain}
cn: {type: http, url: https://example.com/cn.yaml, behavior: domain}
rules:
- RULE-SET,ads,REJECT
- AND,((RULE-SET,ads),(NETWORK,UDP)),REJECT
- RULE-SET,cn,DIRECT
- MATCH,DIRECT
dns:
nameserver-policy:
rule-set:ads,cn: https://dns.example.com/dns-query
fake-ip-filter: ['rule-set:ads', '+.lan']
sniffer:
skip-domain: ['rule-set:cn']
''',
_app(
ruleProviders: {'https://example.com/cn.yaml': 'China'},
ruleProviderLabels: {'ads'},
),
);
expect(config.ruleProviders, [
(label: 'ads(1)', url: 'https://example.com/ads.yaml', content: ''),
]);
expect(
[for (final rule in config.rules) rule.rawValue],
[
'RULE-SET,ads(1),REJECT',
'AND,((RULE-SET,ads(1)),(NETWORK,UDP)),REJECT',
'RULE-SET,China,DIRECT',
'MATCH,DIRECT',
],
);
expect(config.overrides.dns.nameserverPolicy.keys, [
'rule-set:ads(1),China',
]);
expect(config.overrides.dns.fakeIpFilter, ['rule-set:ads(1)', '+.lan']);
expect(config.overrides.sniffer.skipDomain, ['rule-set:China']);
expect(config.overrides.ruleSets, {'ads(1)', 'China'});
});
test('adds an inline set with its payload', () {
final config = _read('''
rule-providers:
local: {type: file, path: ./local.yaml, behavior: classical}
inline: {type: inline, behavior: domain, payload: [+.example.com]}
''');
expect(config.ruleProviders.first.content, isEmpty);
expect(loadYaml(config.ruleProviders.last.content), {
'payload': ['+.example.com'],
});
});
});
group('proxies and groups', () {
test('keeps a proxy the app holds as it is under the app name', () {
final config = _read(
'''
proxies:
- {name: Hong Kong, type: ss, server: hk.example.com, port: 443}
proxy-groups:
- {name: Proxy, type: select, proxies: [Hong Kong, DIRECT]}
rules:
- MATCH,Proxy
''',
_app(
proxies: [
{
'name': 'HK',
'type': 'ss',
'server': 'hk.example.com',
'port': 443,
},
],
),
);
expect(config.proxies, isEmpty);
expect(config.proxyGroups.single.proxies, ['HK', 'DIRECT']);
});
test('reads the port a share link writes as text as a number', () {
final config = _read(
'''
proxies:
- {name: Hong Kong, type: ss, server: hk.example.com, port: 443}
''',
_app(
proxies: [
{
'name': 'HK',
'type': 'ss',
'server': 'hk.example.com',
'port': '443',
},
],
),
);
expect(config.proxies, isEmpty);
});
test('takes a proxy keeping an app proxy name, server and credentials '
'as that proxy, whatever else a share link changed', () {
final config = _read(
'''
proxies:
- name: HK
type: trojan
server: hk.example.com
port: 443
password: secret
sni: hk.example.com
- {name: SG, type: trojan, server: sg.example.com, port: 443, password: new}
proxy-groups:
- {name: Proxy, type: select, proxies: [HK, SG]}
''',
_app(
proxies: [
{
'name': 'HK',
'type': 'trojan',
'server': 'hk.example.com',
'port': '443',
'password': 'secret',
'udp': true,
'skip-cert-verify': false,
},
{
'name': 'SG',
'type': 'trojan',
'server': 'sg.example.com',
'port': '443',
'password': 'old',
},
],
),
);
expect([for (final proxy in config.proxies) proxy['name']], ['SG-01']);
expect(config.proxyGroups.single.proxies, ['HK', 'SG-01']);
});
test('gives the dialers proxies carry to the profile', () {
final config = _read(
'''
proxies:
- {name: Hong Kong, type: ss, server: hk.example.com, dialer-proxy: Relay}
- {name: Relay, type: ss, server: relay.example.com, dialer-proxy: Chain}
proxy-groups:
- {name: Chain, type: select, proxies: [DIRECT]}
''',
_app(
proxies: [
{
'name': 'HK',
'type': 'ss',
'server': 'hk.example.com',
'dialer-proxy': 'Old',
},
],
),
);
expect(config.proxies, [
{'name': 'Relay', 'type': 'ss', 'server': 'relay.example.com'},
]);
expect(config.dialers, {'HK': 'Relay', 'Relay': 'Chain'});
});
test('moves a proxy and a group off the names of app proxies', () {
final config = _read(
'''
proxies:
- {name: HK, type: ss, server: new.example.com, port: 443}
- {name: SG, type: ss, server: sg.example.com, dialer-proxy: Auto}
proxy-groups:
- {name: Auto, type: url-test, proxies: [HK]}
- {name: Proxy, type: select, proxies: [Auto, SG]}
rules:
- DOMAIN,example.com,Auto
ntp: {enable: true, dialer-proxy: Auto}
''',
_app(
proxies: [
{'name': 'HK', 'type': 'ss', 'server': 'old.example.com'},
{'name': 'Auto', 'type': 'ss', 'server': 'auto.example.com'},
],
),
);
expect(
[for (final proxy in config.proxies) proxy['name']],
['HK-01', 'SG'],
);
expect(config.proxies.last, isNot(contains('dialer-proxy')));
expect(config.dialers, {'SG': 'Auto-01'});
expect(
[for (final group in config.proxyGroups) group.name],
['Auto-01', 'Proxy'],
);
expect(
[for (final group in config.proxyGroups) group.proxies],
[
['HK-01'],
['Auto-01', 'SG'],
],
);
expect(config.rules.single.ruleTarget, 'Auto-01');
expect(config.overrides.ntp.dialerProxy, 'Auto-01');
});
test('moves a proxy, not a group, off another profile\'s groups', () {
final config = _read('''
proxies:
- {name: Auto, type: ss, server: new.example.com}
proxy-groups:
- {name: Proxy, type: select, proxies: [Auto]}
rules:
- MATCH,Proxy
''', _app(groupNames: {'Auto', 'Proxy'}));
expect([for (final proxy in config.proxies) proxy['name']], ['Auto-01']);
expect(config.proxyGroups.single.name, 'Proxy');
expect(config.proxyGroups.single.proxies, ['Auto-01']);
});
test('moves a name the file repeats off its first holder', () {
final config = _read('''
proxies:
- {name: A, type: ss, server: a.example.com, dialer-proxy: G}
- {name: A, type: ss, server: b.example.com}
proxy-groups:
- {name: G, type: select, proxies: [A]}
- {name: G, type: select, proxies: [DIRECT]}
rules:
- MATCH,G
''');
expect(
[for (final proxy in config.proxies) proxy['name']],
['A', 'A-01'],
);
expect(
[for (final group in config.proxyGroups) group.name],
['G', 'G-01'],
);
expect(config.proxyGroups.first.proxies, ['A']);
expect(config.dialers, {'A': 'G'});
expect(config.rules.single.ruleTarget, 'G');
});
test(
'drops include-all-providers and keeps the proxies of include-all',
() {
final config = _read('''
proxy-groups:
- {name: All, type: select, include-all: true}
- {name: Providers, type: select, include-all-providers: true}
''');
final [all, providers] = config.proxyGroups;
expect(all.includeAll, isNull);
expect(all.includeAllProxies, isTrue);
expect(all.includeAllProviders, isNull);
expect(providers.includeAllProviders, isNull);
expect(providers.includeAllProxies, isNull);
},
);
test('renames the proxies DNS servers dial through', () {
final config = _read(
'''
proxies:
- {name: Auto, type: ss, server: new.example.com}
- {name: TS, type: tailscale}
dns:
nameserver:
- https://dns.example.com/dns-query#Auto
- tls://1.1.1.1#Auto&h3=true
- ts://TS
- https://dns.example.com/dns-query#DIRECT
nameserver-policy:
'+.example.com': https://dns.example.com/dns-query#Auto
''',
_app(
proxies: [
{'name': 'Auto', 'type': 'ss', 'server': 'old.example.com'},
{'name': 'TS', 'type': 'ss', 'server': 'ts.example.com'},
],
),
);
final dns = config.overrides.dns;
expect(dns.nameserver, [
'https://dns.example.com/dns-query#Auto-01',
'tls://1.1.1.1#Auto-01&h3=true',
'ts://TS-01',
'https://dns.example.com/dns-query#DIRECT',
]);
expect(dns.nameserverPolicy, {
'+.example.com': 'https://dns.example.com/dns-query#Auto-01',
});
});
test('leaves ids for the caller to assign', () {
final config = _read('''
proxy-groups:
- {name: Proxy, type: select, proxies: [DIRECT]}
rules:
- MATCH,Proxy
''');
expect(config.proxyGroups.single.id, 0);
expect(config.rules.single.id, 0);
});
});
group('overrides', () {
test('takes the sections the profile can hold', () {
final config = _read('''
dns:
enable: true
enhanced-mode: fake-ip
nameserver: [https://dns.example.com/dns-query]
unknown-key: 1
sniffer:
enable: true
sniff:
tls: {ports: [443]}
tun:
enable: true
exclude-interface: [docker0]
''');
final overrides = config.overrides;
expect(overrides.dnsOverrideKeys, {
DnsOverrideKey.enable,
DnsOverrideKey.enhancedMode,
DnsOverrideKey.nameserver,
});
expect(overrides.dns.nameserver, ['https://dns.example.com/dns-query']);
expect(overrides.snifferOverrideKeys, {
SnifferOverrideKey.enable,
SnifferOverrideKey.sniffTls,
});
expect(overrides.sniffer.sniff['TLS']?.ports, ['443']);
expect(overrides.tunOverrideKeys, {TunOverrideKey.excludeInterface});
expect(overrides.tun.excludeInterface, ['docker0']);
expect(overrides.ntpOverrideKeys, isEmpty);
});
});
group('refuses', () {
test('a file that is not a config', () {
expect(() => _read('- a\n- b\n'), throwsA(_issue(.unreadable)));
expect(() => _read('name: value\n'), throwsA(_issue(.unreadable)));
expect(() => _read('proxies: [\n'), throwsA(_issue(.unreadable)));
});
test('a rule of a type the app does not know', () {
expect(
() => _read('rules:\n - UNKNOWN,a,DIRECT\n'),
throwsA(_issue(.ruleType, item: 'UNKNOWN,a,DIRECT')),
);
});
test('a group the app cannot hold, naming it', () {
expect(
() => _read('proxy-groups:\n - {name: Smart, type: smart}\n'),
throwsA(_issue(.entry, item: 'Smart')),
);
});
});
group('importing into a custom profile', () {
const profileId = 1;
const custom = Profile(
id: profileId,
label: 'Custom',
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
);
const hongKong = CustomProxy(
id: 5,
definition: {'name': 'HK', 'type': 'ss', 'server': 'hk.example.com'},
);
late Directory home;
late db.Database testDatabase;
late ProviderContainer container;
late List<String> compiledUrls;
setUp(() async {
await AppLocalizations.load(const Locale('en'));
home = Directory.systemTemp.createTempSync('flclash-custom-import-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
testDatabase = db.Database(NativeDatabase.memory());
db.database = testDatabase;
await testDatabase.profiles.put(custom.toCompanion());
await testDatabase.customProxies.put(hongKong.toCompanion('a0'));
await testDatabase.proxyGroups.put(
const ProxyGroup(
id: 7,
name: 'Old',
type: GroupType.Selector,
proxies: ['HK'],
order: 'a0',
).toCompanion(profileId),
);
await testDatabase.rulesDao.putRule(
const Rule(id: 8, ruleAction: RuleAction.MATCH, ruleTarget: 'Old'),
profileId: profileId,
);
await testDatabase.proxyDialers.put(
const ProxyDialer(
profileId: profileId,
proxyId: 5,
target: 'Old',
).toCompanion(),
);
compiledUrls = [];
final core = _MockCoreHandlerInterface();
when(() => core.validateConfig(any())).thenAnswer((_) async => '');
when(() => core.compileRuleSet(any(), url: any(named: 'url'))).thenAnswer(
(invocation) async {
compiledUrls.add(invocation.namedArguments[#url] as String);
return (
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.text,
);
},
);
container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([custom])),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
],
);
globalState.container = container;
container
..listen(customProxiesProvider, (_, _) {})
..listen(clashProvidersProvider, (_, _) {})
..listen(proxyGroupsProvider(profileId), (_, _) {})
..listen(profileRulesProvider(profileId), (_, _) {})
..listen(proxyDialersProvider(profileId), (_, _) {});
await pumpEventQueue();
});
tearDown(() async {
container.dispose();
await testDatabase.close();
home.deleteSync(recursive: true);
});
test('replaces the profile and adds what it brings to the app', () async {
final action = container.read(profilesActionProvider.notifier);
final config = await action.readCustomImport(
profileId,
utf8.encode('''
proxy-providers:
local: {type: file, path: ./local.yaml}
rule-providers:
ads: {type: http, url: https://example.com/ads.txt}
proxies:
- {name: SG, type: ss, server: sg.example.com, dialer-proxy: HK}
proxy-groups:
- {name: Proxy, type: select, proxies: [SG, HK], use: [local]}
rules:
- RULE-SET,ads,REJECT
- MATCH,Proxy
dns:
enable: true
'''),
);
await action.importCustomProfile(profileId, config);
await pumpEventQueue();
final local = container
.read(profilesProvider)
.singleWhere((profile) => profile.label == 'local');
expect(local.type, ProfileType.file);
expect(local.lastUpdateDate, isNotNull);
expect(await (await local.file).readAsString(), isEmpty);
expect(
[
for (final provider
in await testDatabase.clashProvidersDao.query().get())
(provider.label, provider.url),
],
[('ads', 'https://example.com/ads.txt')],
);
expect(compiledUrls, ['https://example.com/ads.txt']);
final proxies = await testDatabase.customProxiesDao.query().get();
expect([for (final proxy in proxies) proxy.name], ['HK', 'SG']);
final groups = await testDatabase.proxyGroupsDao.query(profileId).get();
expect([for (final group in groups) group.name], ['Proxy']);
expect(groups.single.proxies, ['SG', 'HK']);
expect(groups.single.use, ['local']);
expect(
[
for (final rule
in await testDatabase.rulesDao.queryProfileRules(profileId).get())
rule.rawValue,
],
['RULE-SET,ads,REJECT', 'MATCH,Proxy'],
);
expect(await testDatabase.proxyDialersDao.query(profileId).get(), [
ProxyDialer(
profileId: profileId,
proxyId: proxies.last.id,
target: 'HK',
),
]);
expect(
container.read(profileProvider(profileId))?.overrides.dnsOverrideKeys,
{DnsOverrideKey.enable},
);
});
test('takes an app proxy carrying a dialer for the same proxy and gives '
'the dialer to the profile', () async {
await testDatabase.customProxies.put(
hongKong
.copyWith(
definition: {...hongKong.definition, 'dialer-proxy': 'Old'},
)
.toCompanion('a0'),
);
await pumpEventQueue();
final action = container.read(profilesActionProvider.notifier);
final config = await action.readCustomImport(
profileId,
utf8.encode('''
proxies:
- {name: Hong Kong, type: ss, server: hk.example.com, dialer-proxy: Relay}
proxy-groups:
- {name: Relay, type: select, proxies: [DIRECT]}
- {name: Proxy, type: select, proxies: [Hong Kong]}
'''),
);
await action.importCustomProfile(profileId, config);
await pumpEventQueue();
expect(
[
for (final proxy in await testDatabase.customProxiesDao.query().get())
proxy.id,
],
[hongKong.id],
);
expect(await testDatabase.proxyDialersDao.query(profileId).get(), [
ProxyDialer(
profileId: profileId,
proxyId: hongKong.id,
target: 'Relay',
),
]);
});
test('reports what it cannot read in the app language', () async {
final action = container.read(profilesActionProvider.notifier);
await expectLater(
action.readCustomImport(
profileId,
utf8.encode('rules:\n - UNKNOWN,a,DIRECT\n'),
),
throwsA(
isA<MessageException>().having(
(error) => error.message,
'message',
currentAppLocalizations.failedItem(
'UNKNOWN,a,DIRECT',
currentAppLocalizations.ruleTextInvalid,
),
),
),
);
await expectLater(
action.readCustomImport(profileId, utf8.encode('name: value\n')),
throwsA(
isA<MessageException>().having(
(error) => error.message,
'message',
currentAppLocalizations.importConfigInvalid,
),
),
);
});
});
}
+955
View File
@@ -0,0 +1,955 @@
import 'dart:async';
import 'dart:io';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/test_profiles.dart';
class _TestClashProviders extends ClashProviders {
final List<ClashProvider> initial;
_TestClashProviders(this.initial);
@override
Stream<List<ClashProvider>> build() => Stream.value(initial);
}
class _TestCustomProxies extends CustomProxies {
final Stream<List<CustomProxy>> proxies;
_TestCustomProxies([List<CustomProxy> initial = const []])
: proxies = Stream.value(initial);
_TestCustomProxies.pending(Future<List<CustomProxy>> initial)
: proxies = Stream.fromFuture(initial);
@override
Stream<List<CustomProxy>> build() => proxies;
}
class _TestProxyDialers extends ProxyDialers {
final Map<int, String> initial;
_TestProxyDialers([this.initial = const {}]);
@override
Stream<Map<int, String>> build(int profileId) => Stream.value(initial);
}
class _TestProfileRules extends ProfileRules {
@override
Stream<List<Rule>> build(int profileId) => Stream.value(const []);
}
class _TestProxyGroups extends ProxyGroups {
final Stream<List<ProxyGroup>> groups;
_TestProxyGroups(List<ProxyGroup> initial) : groups = Stream.value(initial);
_TestProxyGroups.pending(Future<List<ProxyGroup>> initial)
: groups = Stream.fromFuture(initial);
@override
Stream<List<ProxyGroup>> build(int profileId) => groups;
@override
void order(int oldIndex, int newIndex) {}
}
const profileId = 1;
Set<int> _invalidGroupIds(ProviderContainer container) => container
.read(customProfileIssuesProvider(profileId))
.proxyGroups
.keys
.toSet();
Future<void> _loadSources(ProviderContainer container) async {
await container.read(proxyGroupsProvider(profileId).future);
await container.read(customProxiesProvider.future);
await container.read(proxyDialersProvider(profileId).future);
await container.read(clashProvidersProvider.future);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const proxyGroup = ProxyGroup(
id: 7,
profileId: profileId,
name: 'Group',
type: GroupType.Selector,
proxies: ['Known'],
use: ['provider'],
);
test('validity stays quiet until the groups load', () async {
final groups = Completer<List<ProxyGroup>>();
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
clashProvidersProvider.overrideWith(() => _TestClashProviders([])),
profileRulesProvider.overrideWith2((_) => _TestProfileRules()),
customProxiesProvider.overrideWith(_TestCustomProxies.new),
proxyDialersProvider.overrideWith2((_) => _TestProxyDialers()),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups.pending(groups.future),
),
],
);
addTearDown(container.dispose);
container.listen(customProfileDataProvider(profileId), (_, _) {});
expect(container.read(customProfileDataProvider(profileId)), isNull);
expect(_invalidGroupIds(container), isEmpty);
expect(
container.read(customProfileTargetIsValidProvider(profileId, 'Gone')),
true,
);
groups.complete([proxyGroup]);
await _loadSources(container);
expect(container.read(customProfileDataProvider(profileId)), isNotNull);
expect(_invalidGroupIds(container), {7});
expect(
container.read(customProfileTargetIsValidProvider(profileId, 'Group')),
true,
);
expect(
container.read(customProfileTargetIsValidProvider(profileId, 'Gone')),
false,
);
});
test('validity stays quiet until the app proxies load', () async {
final proxies = Completer<List<CustomProxy>>();
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
clashProvidersProvider.overrideWith(() => _TestClashProviders([])),
profileRulesProvider.overrideWith2((_) => _TestProfileRules()),
customProxiesProvider.overrideWith(
() => _TestCustomProxies.pending(proxies.future),
),
proxyDialersProvider.overrideWith2((_) => _TestProxyDialers()),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups([proxyGroup.copyWith(use: null)]),
),
],
);
addTearDown(container.dispose);
container.listen(customProfileDataProvider(profileId), (_, _) {});
await container.read(proxyGroupsProvider(profileId).future);
await container.read(clashProvidersProvider.future);
expect(container.read(customProfileDataProvider(profileId)), isNull);
expect(_invalidGroupIds(container), isEmpty);
proxies.complete([
const CustomProxy(id: 1, definition: {'name': 'Known', 'type': 'socks5'}),
]);
await container.read(customProxiesProvider.future);
expect(container.read(customProfileDataProvider(profileId)), isNotNull);
expect(_invalidGroupIds(container), isEmpty);
});
test('an app proxy is a valid member and a subscription profile a valid '
'provider, a custom profile is neither', () async {
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(
() => TestProfiles([
Profile.normal(label: 'Subscription'),
Profile.custom(label: 'Mine'),
]),
),
clashProvidersProvider.overrideWith(() => _TestClashProviders([])),
profileRulesProvider.overrideWith2((_) => _TestProfileRules()),
customProxiesProvider.overrideWith(
() => _TestCustomProxies([
const CustomProxy(
id: 1,
definition: {'name': 'Home', 'type': 'socks5'},
),
]),
),
proxyDialersProvider.overrideWith2((_) => _TestProxyDialers()),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups([
proxyGroup.copyWith(proxies: ['Home'], use: ['Subscription']),
]),
),
],
);
addTearDown(container.dispose);
container.listen(customProfileDataProvider(profileId), (_, _) {});
await container.read(proxyGroupsProvider(profileId).future);
await container.read(clashProvidersProvider.future);
await container.read(customProxiesProvider.future);
expect(_invalidGroupIds(container), isEmpty);
expect(
container.read(
customProfileProxyProviderIsValidProvider(profileId, 'Home'),
),
false,
);
expect(
proxyGroupIssues(
proxyGroup.copyWith(proxies: ['Mine']),
container.read(customProfileDataProvider(profileId))!,
),
[
const CustomIssue.missingProxies(['Mine']),
const CustomIssue.missingProviders(['provider']),
],
);
expect(
proxyGroupIssues(
proxyGroup.copyWith(proxies: null, use: ['Subscription']),
container.read(customProfileDataProvider(profileId))!,
),
isEmpty,
);
expect(
proxyGroupIssues(
proxyGroup.copyWith(proxies: null, use: ['Mine']),
container.read(customProfileDataProvider(profileId))!,
),
[
const CustomIssue.missingProviders(['Mine']),
],
);
expect(
container.read(appProviderNamesProvider(ProviderKind.rule)),
isEmpty,
);
});
test('the app proxies report the names they share', () async {
CustomProxy proxy(int id, String name) =>
CustomProxy(id: id, definition: {'name': name, 'type': 'socks5'});
final container = ProviderContainer(
overrides: [
customProxiesProvider.overrideWith(
() => _TestCustomProxies([
proxy(1, 'Same'),
proxy(2, 'Same'),
proxy(3, 'Other'),
proxy(4, 'Group'),
]),
),
customProxyCoreErrorsProvider.overrideWith((_) async => {}),
customGroupNamesProvider.overrideWith(
(_) => Stream.value(const {'Group'}),
),
],
);
addTearDown(container.dispose);
container.listen(customProxyListIssuesProvider, (_, _) {});
await container.read(customProxiesProvider.future);
await container.read(customProxyCoreErrorsProvider.future);
await container.read(customGroupNamesProvider.future);
expect(container.read(customProxyListIssuesProvider).keys, {1, 2, 4});
});
test(
'loaded groups report the groups that reference missing names',
() async {
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
clashProvidersProvider.overrideWith(() => _TestClashProviders([])),
profileRulesProvider.overrideWith2((_) => _TestProfileRules()),
customProxiesProvider.overrideWith(_TestCustomProxies.new),
proxyDialersProvider.overrideWith2((_) => _TestProxyDialers()),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups([proxyGroup]),
),
],
);
addTearDown(container.dispose);
container.listen(customProfileDataProvider(profileId), (_, _) {});
await _loadSources(container);
expect(container.read(customProfileDataProvider(profileId)), isNotNull);
expect(_invalidGroupIds(container), {7});
expect(
container.read(
customProfileProxyProviderIsValidProvider(profileId, 'provider'),
),
false,
);
},
);
test('DNS servers and the NTP dialer name a proxy the profile has, while '
'their sections are on', () async {
const overrides = ProfileOverrides(
dns: Dns(
nameserver: ['1.1.1.1#Group', '8.8.8.8#Gone'],
nameserverPolicy: {'+.lan': '1.1.1.1#DIRECT, 9.9.9.9#Away'},
),
dnsOverrideKeys: {
DnsOverrideKey.nameserver,
DnsOverrideKey.nameserverPolicy,
},
ntp: Ntp(enable: true, dialerProxy: 'Gone'),
ntpOverrideKeys: {NtpOverrideKey.enable, NtpOverrideKey.dialerProxy},
);
ProviderContainer containerFor(ProfileOverrides overrides) {
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(
() => TestProfiles([
Profile(
id: profileId,
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
overrides: overrides,
),
]),
),
clashProvidersProvider.overrideWith(() => _TestClashProviders([])),
profileRulesProvider.overrideWith2((_) => _TestProfileRules()),
customProxiesProvider.overrideWith(_TestCustomProxies.new),
proxyDialersProvider.overrideWith2((_) => _TestProxyDialers()),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups([proxyGroup]),
),
],
);
addTearDown(container.dispose);
container.listen(customProfileIssuesProvider(profileId), (_, _) {});
return container;
}
var container = containerFor(overrides);
await _loadSources(container);
var issues = container.read(customProfileIssuesProvider(profileId));
expect(issues.dns, [
const CustomIssue.missingProxies(['Gone', 'Away']),
]);
expect(issues.ntp, [const CustomIssue.missingDialer('Gone')]);
container = containerFor(
overrides.copyWith(
dns: overrides.dns.copyWith(enable: false),
dnsOverrideKeys: {...overrides.dnsOverrideKeys, DnsOverrideKey.enable},
ntp: overrides.ntp.copyWith(enable: false),
),
);
await _loadSources(container);
issues = container.read(customProfileIssuesProvider(profileId));
expect(issues.dns, isEmpty);
expect(issues.ntp, isEmpty);
});
test('dialers resolve per profile and count only for the app proxies a group '
'names', () async {
CustomProxy proxy(int id, String name) =>
CustomProxy(id: id, definition: {'name': name, 'type': 'ss'});
const landing = ProxyGroup(
id: 7,
profileId: profileId,
name: 'Landing',
type: GroupType.Selector,
proxies: ['Home', 'Work', 'Loop'],
);
const relay = ProxyGroup(
id: 8,
profileId: profileId,
name: 'Relay',
type: GroupType.Selector,
proxies: ['DIRECT'],
);
Future<ProviderContainer> build(List<ProxyGroup> groups) async {
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
clashProvidersProvider.overrideWith(() => _TestClashProviders([])),
profileRulesProvider.overrideWith2((_) => _TestProfileRules()),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups(groups)),
customProxiesProvider.overrideWith(
() => _TestCustomProxies([
proxy(1, 'Home'),
proxy(2, 'Work'),
proxy(3, 'Loop'),
]),
),
proxyDialersProvider.overrideWith2(
(_) => _TestProxyDialers({1: 'Gone', 2: 'Relay', 3: 'Landing'}),
),
],
);
addTearDown(container.dispose);
container.listen(customProfileIssuesProvider(profileId), (_, _) {});
await _loadSources(container);
return container;
}
final using = await build([landing, relay]);
expect(using.read(customProfileDataProvider(profileId))!.dialers, {
'Home': 'Gone',
'Work': 'Relay',
'Loop': 'Landing',
});
expect(using.read(customProfileIssuesProvider(profileId)).dialers, {
1: [const CustomIssue.missingDialer('Gone')],
3: [const CustomIssue.dialerLoop('Loop', 'Landing')],
});
final unused = await build([
landing.copyWith(proxies: ['DIRECT']),
relay,
]);
expect(
unused.read(customProfileIssuesProvider(profileId)).dialers,
isEmpty,
);
});
group('dialers', () {
const landing = ProxyGroup(
id: 3,
name: 'Landing',
type: GroupType.Selector,
proxies: ['Home'],
);
const relay = ProxyGroup(
id: 4,
name: 'Relay',
type: GroupType.Selector,
proxies: ['Node'],
);
const outer = ProxyGroup(
id: 5,
name: 'Outer',
type: GroupType.Selector,
proxies: ['Relay', 'Landing'],
);
const home = CustomProxy(id: 9, definition: {'name': 'Home', 'type': 'ss'});
const profileData = CustomProfileData(
proxyGroups: [landing, relay],
ruleTargets: {'DIRECT', 'Node', 'Landing', 'Relay'},
proxies: {'Home', 'Work', 'Loop'},
dialers: {'Home': 'Relay'},
);
test('a dial loops only through groups that reach the proxy', () {
const groups = [landing, relay, outer];
Set<String> loop(String target) =>
dialerLoop(target, groups, proxy: 'Home');
expect(loop('Relay'), isEmpty);
expect(loop('Node'), isEmpty);
expect(loop('Landing'), {'Landing'});
expect(loop('Outer'), {'Outer', 'Landing'});
expect(dialerLoop('Landing', groups, proxy: 'Work'), isEmpty);
});
test('a dial loops through the dialers of the proxies on its way', () {
const groups = [
ProxyGroup(
id: 6,
name: 'Exit',
type: GroupType.Selector,
proxies: ['Home', 'Work', 'Edge'],
),
];
Set<String> loop(String target, Map<String, String> dialers) =>
dialerLoop(target, groups, proxy: 'Home', dialers: dialers);
expect(loop('Home', const {}), {'Home'});
expect(loop('Work', const {'Work': 'Edge'}), isEmpty);
expect(loop('Work', const {'Work': 'Home'}), {'Work'});
expect(loop('Work', const {'Work': 'Edge', 'Edge': 'Exit'}), {
'Work',
'Edge',
'Exit',
});
expect(
loop('Work', const {'Work': 'Home', 'Home': 'Edge', 'Gone': 'Home'}),
{'Work'},
);
expect(loop('Work', const {'Work': 'Away', 'Away': 'Home'}), isEmpty);
});
test('include-all reaches a proxy only once a group declares it', () {
const all = ProxyGroup(
id: 6,
name: 'All',
type: GroupType.Selector,
includeAll: true,
);
expect(dialerLoop('All', [all], proxy: 'Home'), isEmpty);
expect(dialerLoop('All', [all, landing], proxy: 'Home'), {'All'});
});
test('what a filter picks is left to the core', () {
const all = ProxyGroup(
id: 6,
name: 'All',
type: GroupType.Selector,
includeAll: true,
);
for (final group in [
all.copyWith(filter: '(?i)hk'),
all.copyWith(excludeFilter: 'Home'),
all.copyWith(excludeType: 'Shadowsocks'),
]) {
expect(dialerLoop('All', [group, landing], proxy: 'Home'), isEmpty);
}
expect(
dialerLoop('Landing', [
landing.copyWith(excludeFilter: 'Home'),
], proxy: 'Home'),
{'Landing'},
);
});
test('a dialer must exist and must not lead back', () {
expect(proxyDialerIssues(home, 'Relay', profileData), isEmpty);
expect(
proxyDialerIssues(
home,
'Work',
profileData.copyWith(
proxyGroups: [
landing.copyWith(proxies: ['Home', 'Work']),
],
),
),
isEmpty,
);
expect(proxyDialerIssues(home, 'Work', profileData), [
const CustomIssue.missingDialer('Work'),
]);
expect(proxyDialerIssues(home, 'Gone', profileData), [
const CustomIssue.missingDialer('Gone'),
]);
expect(proxyDialerIssues(home, 'Landing', profileData), [
const CustomIssue.dialerLoop('Home', 'Landing'),
]);
});
test('a group edit that closes a dialer loop is flagged on the group', () {
expect(proxyGroupIssues(relay, profileData), isEmpty);
expect(
proxyGroupIssues(
relay.copyWith(proxies: ['Node', 'Landing']),
profileData,
),
[const CustomIssue.dialerLoop('Home', 'Relay')],
);
});
test('groups sharing a name each loop through their own members', () {
const looping = ProxyGroup(
id: 6,
name: 'Twin',
type: GroupType.Selector,
proxies: ['Home'],
);
const plain = ProxyGroup(
id: 7,
name: 'Twin',
type: GroupType.Selector,
proxies: ['DIRECT'],
);
final data = profileData.copyWith(
proxyGroups: [looping, plain],
dialers: {'Home': 'Twin'},
);
const issue = CustomIssue.dialerLoop('Home', 'Twin');
expect(proxyGroupIssues(looping, data), contains(issue));
expect(proxyGroupIssues(plain, data), isNot(contains(issue)));
});
test('the payload declares the named proxies with the profile dialers only '
'and drops a looping one', () {
const work = CustomProxy(
id: 10,
definition: {'name': 'Work', 'type': 'ss', 'dialer-proxy': 'Node'},
);
const unnamed = CustomProxy(
id: 12,
definition: {'name': 'Unnamed', 'type': 'ss'},
);
final payload = appProxiesPayload(
[home, work, unnamed],
dialers: {9: 'Landing'},
groups: [
landing.copyWith(proxies: ['Home', 'Work']),
relay,
],
);
expect(payload, [
home.definition,
{'name': 'Work', 'type': 'ss'},
]);
expect(
appProxiesPayload(
[home, work],
dialers: {9: 'Work', 10: 'Home'},
groups: [
landing.copyWith(proxies: ['Home', 'Work']),
],
),
[
home.definition,
{'name': 'Work', 'type': 'ss'},
],
);
expect(
appProxiesPayload(
[home],
dialers: {9: 'Relay'},
groups: const [landing, relay],
),
[
{'name': 'Home', 'type': 'ss', 'dialer-proxy': 'Relay'},
],
);
});
});
group('issues', () {
const profileData = CustomProfileData(
proxyGroups: [
ProxyGroup(id: 1, name: 'A', type: GroupType.Selector, proxies: ['B']),
ProxyGroup(id: 2, name: 'B', type: GroupType.Selector, proxies: ['A']),
],
ruleTargets: {'DIRECT', 'REJECT', 'Node', 'A', 'B'},
);
test('a group that reaches itself names the loop', () {
expect(proxyGroupIssues(profileData.proxyGroups.first, profileData), [
const CustomIssue.groupLoop(['A', 'B', 'A']),
]);
});
test('a group without members or providers is refused', () {
const group = ProxyGroup(id: 3, name: 'C', type: GroupType.Selector);
expect(proxyGroupIssues(group, profileData), [
const CustomIssue.noProxySource(),
]);
expect(
proxyGroupIssues(group.copyWith(includeAllProxies: true), profileData),
isEmpty,
);
});
test('a group cannot take a local proxy\'s name, named here or not', () {
const data = CustomProfileData(
ruleTargets: {'DIRECT'},
proxies: {'Home'},
);
const group = ProxyGroup(
id: 3,
name: 'Home',
type: GroupType.Selector,
proxies: ['DIRECT'],
);
expect(proxyGroupIssues(group, data), [
const CustomIssue.duplicateName('Home'),
]);
expect(proxyGroupIssues(group.copyWith(name: 'Away'), data), isEmpty);
});
test('a group cannot take another group\'s or a built-in name', () {
const group = ProxyGroup(
id: 3,
name: 'A',
type: GroupType.Selector,
proxies: ['DIRECT', 'Gone'],
);
expect(proxyGroupIssues(group, profileData), [
const CustomIssue.duplicateName('A'),
const CustomIssue.missingProxies(['Gone']),
]);
expect(
proxyGroupIssues(group.copyWith(name: 'DIRECT'), profileData).first,
const CustomIssue.reservedName('DIRECT'),
);
});
test('empty-fallback takes only a built-in', () {
const group = ProxyGroup(
id: 3,
name: 'C',
type: GroupType.Selector,
proxies: ['Node'],
);
for (final name in ['COMPATIBLE', 'PASS']) {
expect(
proxyGroupIssues(group.copyWith(emptyFallback: name), profileData),
isEmpty,
reason: name,
);
}
for (final name in ['A', 'Node', 'Gone']) {
expect(
proxyGroupIssues(group.copyWith(emptyFallback: name), profileData),
[CustomIssue.invalidEmptyFallback(name)],
reason: name,
);
}
});
test('a logic rule needs every rule set it nests', () {
final data = profileData.copyWith(ruleProviders: const {'Ads'});
final rule = Rule.parse(
'OR,((RULE-SET,Ads),(AND,((RULE-SET,Gone),(NETWORK,UDP)))),DIRECT',
id: 1,
);
expect(customRuleIssues(rule, data), [
const CustomIssue.missingRuleSet('Gone'),
]);
expect(
customRuleIssues(rule, data.copyWith(ruleProviders: {'Ads', 'Gone'})),
isEmpty,
);
});
test('a SUB-RULE has no sub-rules to point at', () {
expect(
customRuleIssues(
Rule.parse('SUB-RULE,(NETWORK,tcp),nested', id: 1),
profileData,
),
[const CustomIssue.missingSubRule('nested')],
);
});
test('a rule may target a local proxy once a group declares it', () {
const data = CustomProfileData(
proxyGroups: [
ProxyGroup(
id: 1,
name: 'G',
type: GroupType.Selector,
proxies: ['Home'],
),
],
ruleTargets: {'DIRECT', 'G'},
proxies: {'Home', 'Spare'},
);
expect(customRuleIssues(Rule.parse('MATCH,Home', id: 1), data), isEmpty);
expect(customRuleIssues(Rule.parse('MATCH,Spare', id: 2), data), [
const CustomIssue.missingTarget('Spare'),
]);
});
test('custom proxies report names and core errors together', () {
const first = CustomProxy(id: 1, definition: {'name': 'X'});
const second = CustomProxy(id: 2, definition: {'name': 'X'});
expect(
customProxyIssues(
first,
proxies: const [first, second],
groupNames: const {},
coreError: 'missing type',
),
[
const CustomIssue.duplicateName('X'),
const CustomIssue.coreRejected('missing type'),
],
);
});
});
test('customProxyTypes lists every type the core parses', () {
final source = File('core/Clash.Meta/adapter/parser.go').readAsStringSync();
expect(customProxyTypes, [
for (final match in RegExp(
r'^\tcase "([^"]+)":',
multiLine: true,
).allMatches(source))
match.group(1)!,
]);
});
test('credentials match the keys each option struct of the core reads', () {
const root = 'core/Clash.Meta/adapter';
final structs = {
for (final file in Directory(
'$root/outbound',
).listSync().whereType<File>())
if (!file.path.endsWith('_test.go'))
for (final match in RegExp(
r'^type (\w+Option) struct \{\n([\s\S]*?)\n\}',
multiLine: true,
).allMatches(file.readAsStringSync()))
match.group(1)!: match.group(2)!,
};
final cases = RegExp(
r'^\tcase "([^"]+)":\n\t\t\w+ := &outbound\.(\w+)\{',
multiLine: true,
).allMatches(File('$root/parser.go').readAsStringSync()).toList();
final keyPattern = RegExp(r'proxy:"(username|password|uuid)[,"]');
expect(cases, hasLength(customProxyTypes.length));
for (final match in cases) {
final type = match.group(1)!;
expect(
CustomProxy(
id: 0,
definition: {'type': type},
).credentials.map((credential) => credential.name).toSet(),
keyPattern
.allMatches(structs[match.group(2)]!)
.map((key) => key.group(1))
.toSet(),
reason: type,
);
}
});
test('imported links take names no proxy holds yet', () {
final proxies = importedCustomProxies(
[
{'name': 'Home', 'type': 'ss', 'server': 'a.example'},
{'name': '', 'type': 'vless', 'server': 'b.example'},
{'name': '-01', 'type': 'vless', 'server': 'b.example'},
{'name': 'DIRECT', 'type': 'trojan', 'server': 'c.example'},
],
existing: const [
CustomProxy(id: 1, definition: {'name': 'Home', 'type': 'ss'}),
],
groupNames: const {'b.example'},
);
expect(proxies.map((proxy) => proxy.name), [
'Home-01',
'b.example-01',
'b.example-02',
'DIRECT-01',
]);
expect(proxies.map((proxy) => proxy.server), [
'a.example',
'b.example',
'b.example',
'c.example',
]);
expect(proxies.map((proxy) => proxy.id).toSet(), hasLength(4));
});
group('share link edit', () {
const a = CustomProxy(
id: 1,
definition: {'name': 'A', 'type': 'ss', 'server': 'a'},
);
const w = CustomProxy(
id: 2,
definition: {'name': 'W', 'type': 'wireguard'},
);
const b = CustomProxy(
id: 3,
definition: {'name': 'B', 'type': 'trojan', 'server': 'b'},
);
const previous = [a, w, b];
const links = ['ss://a#A', '', 'trojan://b#B'];
ShareLinkEdit read(String text) =>
ShareLinkEdit.read(text, previous: previous, links: links);
test('an untouched text keeps every proxy as it is', () {
final edit = read(ShareLinkEdit.textOf(links));
expect(ShareLinkEdit.textOf(links), 'ss://a#A\ntrojan://b#B');
expect(edit.pending, isEmpty);
expect(edit.apply(const {}, groupNames: const {}), previous);
});
test('edited lines land in text order, a proxy with no link after the '
'proxy it followed', () {
final edit = read('trojan://b#B\n\nss://a2#A\nss://c\ntrojan://b#B');
expect(edit.pending.map((line) => line.line), [3, 4, 5]);
final proxies = edit.apply(groupNames: const {}, {
3: [
{'name': 'A', 'type': 'ss', 'server': 'a2'},
],
4: [
{'name': '', 'type': 'ss', 'server': 'c'},
],
5: [
{'name': 'B', 'type': 'trojan', 'server': 'b'},
],
});
expect(proxies.map((proxy) => proxy.name), ['W', 'B', 'A', 'c', 'B-01']);
expect(proxies.take(3).map((proxy) => proxy.id), [2, 3, 1]);
expect(proxies[2].server, 'a2');
expect(proxies.skip(3).map((proxy) => proxy.id), everyElement(isNot(1)));
});
test('a new line takes no group\'s name', () {
final proxies = read('ss://a#A\ntrojan://b#B\nss://g#G').apply(
{
3: [
{'name': 'G', 'type': 'ss', 'server': 'g'},
],
},
groupNames: const {'G'},
);
expect(proxies.map((proxy) => proxy.name), ['A', 'W', 'B', 'G-01']);
});
test('a removed line drops its proxy and leaves the rest', () {
expect(read('trojan://b#B').apply(const {}, groupNames: const {}), [
w,
b,
]);
});
});
test('proxy providers are the subscription profiles and rule providers the '
'app ones', () async {
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(
() => TestProfiles([
Profile.normal(label: 'Home'),
Profile.normal(label: 'Shared'),
Profile.custom(label: 'Mine'),
]),
),
clashProvidersProvider.overrideWith(
() => _TestClashProviders(const [
ClashProvider(
id: 11,
label: 'Home',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.text,
),
]),
),
],
);
addTearDown(container.dispose);
for (final kind in ProviderKind.values) {
container.listen(appProviderNamesProvider(kind), (_, _) {});
}
await container.read(clashProvidersProvider.future);
expect(container.read(appProviderNamesProvider(ProviderKind.proxy)), {
'Home',
'Shared',
});
expect(container.read(appProviderNamesProvider(ProviderKind.rule)), {
'Home',
});
});
}
+554 -130
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:drift/native.dart';
import 'package:fl_clash/common/feature.dart';
// `Profiles`, `Scripts` and `ProxyGroups` name both a drift table and a
// notifier, so the schema side is imported behind a prefix.
import 'package:fl_clash/database/database.dart' as db;
@@ -133,36 +132,30 @@ void main() {
},
);
Future<void> putAppProxyProvider(String label) async {
await testDatabase.clashProvidersDao.putAll([
ClashProvider(
id: 9,
kind: ProviderKind.proxy,
label: label,
url: 'https://example.com/nodes.yaml',
).toCompanion(),
]);
await keepAlive(clashProvidersProvider(ProviderKind.proxy));
}
test('put steps past a label an app-level proxy provider holds', () async {
feature = const Feature(customProviders: true);
addTearDown(() => feature = const Feature());
await putAppProxyProvider('Shared nodes');
notifier.put(profile(1, label: 'Shared nodes'));
test('a read-back during a write keeps the optimistic profile', () async {
final rows = StreamController<List<Profile>>();
addTearDown(rows.close);
final scoped = ProviderContainer(
overrides: [profilesStreamProvider.overrideWith((_) => rows.stream)],
);
addTearDown(scoped.dispose);
scoped.listen(profilesProvider, (_, _) {});
rows.add(const []);
await pumpEventQueue();
final landing = Completer<void>();
final held = testDatabase.transaction(() => landing.future);
expect(read().single.label, 'Shared nodes(1)');
});
test('put ignores app-level providers while they are off', () async {
await putAppProxyProvider('Shared nodes');
notifier.put(profile(1, label: 'Shared nodes'));
scoped.read(profilesProvider.notifier).put(profile(1, label: 'Mine'));
rows.add([profile(2, label: 'Theirs')]);
await pumpEventQueue();
expect(scoped.read(profilesProvider).map((item) => item.id), [1]);
expect(read().single.label, 'Shared nodes');
landing.complete();
await held;
await pumpEventQueue();
rows.add([profile(1, label: 'Mine'), profile(2, label: 'Theirs')]);
await pumpEventQueue();
expect(scoped.read(profilesProvider).map((item) => item.id), [1, 2]);
});
test('put falls back to the id when the profile has no label', () async {
@@ -189,29 +182,84 @@ void main() {
},
);
test('put carries a new label into the groups it is told of', () async {
for (final (id, label) in [(1, 'Home'), (2, 'Work'), (3, 'Other')]) {
notifier.put(profile(id, label: label));
}
await pumpEventQueue();
Profile custom(int id) =>
profile(id, label: 'Custom $id').copyWith(type: ProfileType.custom);
test(
'a new label follows into the groups of every custom profile',
() async {
notifier.put(profile(1, label: 'Home'));
for (final id in [2, 3]) {
notifier.put(custom(id));
}
await pumpEventQueue();
for (final id in [2, 3]) {
await testDatabase.proxyGroups.put(
ProxyGroup(
id: id,
name: 'Group',
type: GroupType.Selector,
use: const ['Home', 'Other'],
).toCompanion(id),
);
}
notifier.put(profile(1, label: 'Away'));
await pumpEventQueue();
Future<List<String>?> use(int id) async =>
(await testDatabase.proxyGroupsDao.query(id).get()).single.use;
expect(await use(2), ['Away', 'Other']);
expect(await use(3), ['Away', 'Other']);
},
);
test('a new label follows into the provider options of every custom '
'profile', () async {
const options = ProxyProviderOptions(filter: 'HK');
notifier.put(profile(1, label: 'Home'));
for (final id in [2, 3]) {
await testDatabase.proxyGroups.put(
ProxyGroup(
id: id,
name: 'Group',
type: GroupType.Selector,
use: const ['Home'],
).toCompanion(id),
notifier.put(
custom(id).copyWith(
overrides: const ProfileOverrides(
proxyProviders: {'Home': options},
),
),
);
}
notifier.put(profile(1, label: 'Away'), renameIn: const [2]);
await pumpEventQueue();
Future<List<String>?> use(int id) async =>
(await testDatabase.proxyGroupsDao.query(id).get()).single.use;
expect(await use(2), ['Away']);
expect(await use(3), ['Home']);
notifier.put(profile(1, label: 'Away'));
await pumpEventQueue();
Map<String, ProxyProviderOptions> optionsOf(int id) =>
read().firstWhere((item) => item.id == id).overrides.proxyProviders;
expect(optionsOf(2), {'Away': options});
expect(optionsOf(3), {'Away': options});
final rows = await testDatabase.profilesDao.query().get();
expect(rows.firstWhere((item) => item.id == 3).overrides.proxyProviders, {
'Away': options,
});
});
test('a custom profile is never a provider, so its label renames '
'nothing', () async {
notifier.put(custom(2));
await pumpEventQueue();
await testDatabase.proxyGroups.put(
const ProxyGroup(
id: 9,
name: 'Group',
type: GroupType.Selector,
use: ['Custom 2'],
).toCompanion(2),
);
notifier.put(custom(2).copyWith(label: 'Renamed'));
await pumpEventQueue();
final group = (await testDatabase.proxyGroupsDao.query(2).get()).single;
expect(group.use, ['Custom 2']);
});
test('put restores the previous list when the write fails', () async {
@@ -290,23 +338,10 @@ void main() {
},
);
test('setAndReorder replaces the whole list', () async {
notifier.put(profile(1, label: 'One'));
await pumpEventQueue();
notifier.setAndReorder([profile(2, label: 'Two', order: 0)]);
expect(read().map((item) => item.id), [2]);
await pumpEventQueue();
final rows = await testDatabase.profilesDao.query().get();
expect(rows.map((item) => item.id), [2]);
});
test('reorder only writes the rows whose order actually changed', () async {
notifier.setAndReorder([
profile(1, label: 'One', order: 0),
profile(2, label: 'Two', order: 1),
]);
notifier
..put(profile(1, label: 'One', order: 0))
..put(profile(2, label: 'Two', order: 1));
await pumpEventQueue();
notifier.reorder([
@@ -321,10 +356,9 @@ void main() {
});
test('reorder restores the previous order when the write fails', () async {
notifier.setAndReorder([
profile(1, label: 'One', order: 0),
profile(2, label: 'Two', order: 1),
]);
notifier
..put(profile(1, label: 'One', order: 0))
..put(profile(2, label: 'Two', order: 1));
await pumpEventQueue();
await breakTable('profiles');
@@ -371,6 +405,21 @@ void main() {
expect(rows.map((item) => item.order), [0, 1, 2]);
});
test('a row written around the notifier during a write is read back '
'once the write settles', () async {
final landing = Completer<void>();
final write = notifier.optimisticAsync(read(), () => landing.future);
await testDatabase.scripts.put(script(1, 'Direct').toCompanion());
await pumpEventQueue();
expect(read(), isEmpty);
landing.complete();
await write;
await pumpEventQueue();
expect(read().map((item) => item.label), ['Direct']);
});
test('put appends a new script and replaces an existing one', () async {
notifier.put(script(1, 'First'));
await pumpEventQueue();
@@ -462,7 +511,7 @@ void main() {
await pumpEventQueue();
expect(read().single.order, isNotNull);
final rows = await testDatabase.rulesDao.queryGlobalAddedRules().get();
final rows = await testDatabase.rulesDao.queryGlobalRules().get();
expect(rows.single.order, isNotNull);
});
@@ -479,7 +528,7 @@ void main() {
notifier.put(const Rule(id: 2, content: 'second'));
await pumpEventQueue();
final rows = await testDatabase.rulesDao.queryGlobalAddedRules().get();
final rows = await testDatabase.rulesDao.queryGlobalRules().get();
expect(rows.map((item) => item.id), [2, 1]);
});
@@ -496,7 +545,7 @@ void main() {
expect(read().map((item) => item.id), [2, 3, 1], reason: 'optimistic');
await pumpEventQueue();
final rows = await testDatabase.rulesDao.queryGlobalAddedRules().get();
final rows = await testDatabase.rulesDao.queryGlobalRules().get();
expect(rows.map((item) => item.id), [2, 3, 1]);
},
);
@@ -511,10 +560,7 @@ void main() {
await pumpEventQueue();
expect(read(), isEmpty);
expect(
await testDatabase.rulesDao.queryGlobalAddedRules().get(),
isEmpty,
);
expect(await testDatabase.rulesDao.queryGlobalRules().get(), isEmpty);
});
test('delAll restores the previous list when the write fails', () async {
@@ -539,39 +585,39 @@ void main() {
notifier.order(0, 1);
await pumpEventQueue();
final rows = await testDatabase.rulesDao.queryGlobalAddedRules().get();
final rows = await testDatabase.rulesDao.queryGlobalRules().get();
expect(rows.map((item) => item.id), before.reversed);
});
});
group('ProfileCustomRules', () {
late ProfileCustomRules notifier;
group('ProfileRules', () {
late ProfileRules notifier;
setUp(() async {
await testDatabase.profilesDao.putAll([profile(profileId).toCompanion()]);
await keepAlive(profileCustomRulesProvider(profileId));
notifier = container.read(profileCustomRulesProvider(profileId).notifier);
await keepAlive(profileRulesProvider(profileId));
notifier = container.read(profileRulesProvider(profileId).notifier);
});
List<Rule> read() => notifier.value;
test('put scopes the rule to its profile', () async {
notifier.put(const Rule(id: 1, content: 'custom'));
notifier.put(const Rule(id: 1, content: 'own'));
await pumpEventQueue();
expect(read().single.id, 1);
final scoped = await testDatabase.rulesDao
.queryProfileCustomRules(profileId)
.queryProfileRules(profileId)
.get();
expect(scoped.single.id, 1);
expect(
await testDatabase.rulesDao.queryGlobalAddedRules().get(),
await testDatabase.rulesDao.queryGlobalRules().get(),
isEmpty,
reason: 'a custom rule must not leak into the global scene',
reason: 'a profile rule must not leak into the global rules',
);
});
test('delAll and order round-trip through the profile scene', () async {
test('delAll and order round-trip through the profile rules', () async {
notifier.put(const Rule(id: 1, content: 'one'));
await pumpEventQueue();
notifier.put(const Rule(id: 2, content: 'two'));
@@ -581,8 +627,9 @@ void main() {
notifier.order(0, 1);
await pumpEventQueue();
expect(
(await testDatabase.rulesDao.queryProfileCustomRules(profileId).get())
.map((item) => item.id),
(await testDatabase.rulesDao.queryProfileRules(profileId).get()).map(
(item) => item.id,
),
before.reversed,
);
@@ -604,49 +651,24 @@ void main() {
await failure;
expect(read().map((item) => item.id), [1], reason: 'rolled back');
});
});
group('ProfileAddedRules', () {
late ProfileAddedRules notifier;
setUp(() async {
await testDatabase.profilesDao.putAll([profile(profileId).toCompanion()]);
await keepAlive(profileAddedRulesProvider(profileId));
notifier = container.read(profileAddedRulesProvider(profileId).notifier);
});
List<Rule> read() => notifier.value;
test('put scopes the rule to its profile', () async {
notifier.put(const Rule(id: 1, content: 'added'));
await pumpEventQueue();
final scoped = await testDatabase.rulesDao
.queryProfileAddedRules(profileId)
.get();
expect(scoped.single.id, 1);
expect(
await testDatabase.rulesDao.queryProfileCustomRules(profileId).get(),
isEmpty,
reason: 'added and custom are separate scenes of the same table',
);
});
test('order rewrites the order key within the added scene', () async {
test('setAll replaces the list in the order given', () async {
notifier.put(const Rule(id: 1, content: 'one'));
await pumpEventQueue();
notifier.put(const Rule(id: 2, content: 'two'));
await pumpEventQueue();
final before = read().map((item) => item.id).toList();
notifier.order(0, 1);
notifier.setAll(const [
Rule(id: 3, content: 'three'),
Rule(id: 1, content: 'one'),
]);
expect(read().map((item) => item.id), [3, 1], reason: 'optimistic');
await pumpEventQueue();
expect(
(await testDatabase.rulesDao.queryProfileAddedRules(profileId).get())
.map((item) => item.id),
before.reversed,
);
final rows = await testDatabase.rulesDao
.queryProfileRules(profileId)
.get();
expect(rows.map((item) => item.id), [3, 1]);
});
test('delAll restores the previous list when the write fails', () async {
@@ -660,6 +682,350 @@ void main() {
await failure;
expect(read().map((item) => item.id), [1], reason: 'rolled back');
});
test('a read-back during a write keeps the optimistic rule', () async {
final gated = _GatedProfileRules();
final scoped = ProviderContainer(
overrides: [profileRulesProvider.overrideWith2((_) => gated)],
);
addTearDown(scoped.dispose);
scoped.listen(profileRulesProvider(profileId), (_, _) {});
await pumpEventQueue();
gated.put(const Rule(id: 1, content: 'mine'));
await testDatabase.rulesDao.putRule(
const Rule(id: 2, content: 'theirs'),
profileId: profileId,
);
await pumpEventQueue();
expect(gated.value.map((item) => item.id), [1]);
gated.landing.complete();
await pumpEventQueue();
expect(gated.value.map((item) => item.id), unorderedEquals([1, 2]));
});
});
group('CustomProxies', () {
late CustomProxies notifier;
setUp(() async {
await keepAlive(customProxiesProvider);
notifier = container.read(customProxiesProvider.notifier);
});
CustomProxy proxy(int id) => CustomProxy(
id: id,
definition: {'name': 'Proxy $id', 'type': 'socks5'},
);
test('setAll keeps the dialers of the proxies it keeps', () async {
await testDatabase.profilesDao.putAll([profile(profileId).toCompanion()]);
notifier.put(proxy(1));
await pumpEventQueue();
notifier.put(proxy(2));
await pumpEventQueue();
await testDatabase.proxyDialersDao.set(profileId, 1, 'Relay');
await testDatabase.proxyDialersDao.set(profileId, 2, 'Relay');
notifier.setAll([proxy(3), proxy(1)]);
expect(notifier.value.map((item) => item.id), [
3,
1,
], reason: 'optimistic');
await pumpEventQueue();
final rows = await testDatabase.customProxiesDao.query().get();
expect(rows.map((item) => item.id), [3, 1]);
final dialers = await testDatabase.proxyDialersDao.query(profileId).get();
expect(dialers.map((item) => item.proxyId), [1]);
});
test('renaming a proxy rewrites what custom profiles name it by', () async {
const standardId = 2;
const overrides = ProfileOverrides(
dns: Dns(
nameserver: [
'https://dns.example/dns-query#Proxy 1',
'tls://1.1.1.1#Proxy 2',
],
nameserverPolicy: {
'+.example.com': '1.1.1.1#Proxy%201, 8.8.8.8',
'+.other.com': '8.8.8.8;9.9.9.9',
},
),
ntp: Ntp(dialerProxy: 'Proxy 1'),
);
await testDatabase.profilesDao.putAll([
profile(profileId)
.copyWith(type: ProfileType.custom, overrides: overrides)
.toCompanion(),
profile(standardId).copyWith(overrides: overrides).toCompanion(),
]);
notifier.put(proxy(1));
await pumpEventQueue();
notifier.put(proxy(2));
await pumpEventQueue();
await testDatabase.proxyGroups.put(
const ProxyGroup(
id: 7,
name: 'Landing',
type: GroupType.Selector,
proxies: ['Proxy 1', 'Proxy 2'],
defaultSelected: 'Proxy 1',
order: 'a',
).toCompanion(profileId),
);
for (final (id, owner) in [(8, profileId), (9, standardId)]) {
await testDatabase.rulesDao.putRule(
Rule(id: id, content: 'x', ruleTarget: 'Proxy 1', order: 'a'),
profileId: owner,
);
}
await testDatabase.proxyDialersDao.set(profileId, 2, 'Proxy 1');
notifier.put(
proxy(1).copyWith(definition: {'name': 'Renamed', 'type': 'socks5'}),
);
await pumpEventQueue();
final group =
(await testDatabase.proxyGroupsDao.query(profileId).get()).single;
expect(group.proxies, ['Renamed', 'Proxy 2']);
expect(group.defaultSelected, 'Renamed');
Future<String?> ruleTarget(int owner) async =>
(await testDatabase.rulesDao.queryProfileRules(owner).get())
.single
.ruleTarget;
expect(await ruleTarget(profileId), 'Renamed');
expect(await ruleTarget(standardId), 'Proxy 1');
final dialers = await testDatabase.proxyDialersDao.query(profileId).get();
expect(dialers.single.target, 'Renamed');
final [custom, standard] = await testDatabase.profilesDao.query().get();
expect(custom.overrides.dns.nameserver, [
'https://dns.example/dns-query#Renamed',
'tls://1.1.1.1#Proxy 2',
]);
expect(custom.overrides.dns.nameserverPolicy, {
'+.example.com': '1.1.1.1#Renamed, 8.8.8.8',
'+.other.com': '8.8.8.8;9.9.9.9',
});
expect(custom.overrides.ntp.dialerProxy, 'Renamed');
expect(standard.overrides, overrides);
});
Profile custom(int id, {ProfileOverrides? overrides}) =>
profile(id, label: 'Custom $id').copyWith(
type: ProfileType.custom,
overrides: overrides ?? const ProfileOverrides(),
);
Future<void> putGroup(int owner, ProxyGroup group) =>
testDatabase.proxyGroups.put(group.toCompanion(owner));
test('renaming a proxy leaves the profiles where a group takes either '
'name', () async {
const ntp = ProfileOverrides(
ntp: Ntp(dialerProxy: 'Proxy 1'),
ntpOverrideKeys: {NtpOverrideKey.dialerProxy},
);
await testDatabase.profilesDao.putAll([
for (final id in [1, 2, 3]) custom(id, overrides: ntp).toCompanion(),
]);
notifier.put(proxy(1));
await pumpEventQueue();
await putGroup(
1,
const ProxyGroup(
id: 11,
name: 'Landing',
type: GroupType.Selector,
proxies: ['Proxy 1'],
),
);
await putGroup(
2,
const ProxyGroup(
id: 21,
name: 'Proxy 1',
type: GroupType.Selector,
proxies: ['DIRECT'],
),
);
await putGroup(
3,
const ProxyGroup(
id: 31,
name: 'Renamed',
type: GroupType.Selector,
proxies: ['DIRECT'],
),
);
for (final owner in [1, 2, 3]) {
await testDatabase.rulesDao.putRule(
Rule(
id: 100 + owner,
content: 'x',
ruleTarget: 'Proxy 1',
order: 'a',
),
profileId: owner,
);
}
notifier.put(
proxy(1).copyWith(definition: {'name': 'Renamed', 'type': 'socks5'}),
);
await pumpEventQueue();
Future<String?> ruleTarget(int owner) async =>
(await testDatabase.rulesDao.queryProfileRules(owner).get())
.single
.ruleTarget;
final rows = {
for (final row in await testDatabase.profilesDao.query().get())
row.id: row,
};
expect(await ruleTarget(1), 'Renamed');
expect(rows[1]!.overrides.ntp.dialerProxy, 'Renamed');
for (final owner in [2, 3]) {
expect(await ruleTarget(owner), 'Proxy 1', reason: '$owner');
expect(rows[owner]!.overrides, ntp, reason: '$owner');
}
});
test('renaming a proxy moves the picks of the groups listing it', () async {
await keepAlive(profilesProvider);
container
.read(profilesProvider.notifier)
.put(
custom(1).copyWith(
selectedMap: const {'Landing': 'Proxy 1', 'Pool': 'Proxy 1'},
),
);
await pumpEventQueue();
notifier.put(proxy(1));
await pumpEventQueue();
await putGroup(
1,
const ProxyGroup(
id: 11,
name: 'Landing',
type: GroupType.Selector,
proxies: ['Proxy 1'],
),
);
await putGroup(
1,
const ProxyGroup(
id: 12,
name: 'Pool',
type: GroupType.Selector,
use: ['Subscription'],
),
);
notifier.put(
proxy(1).copyWith(definition: {'name': 'Renamed', 'type': 'socks5'}),
);
await pumpEventQueue();
const picks = {'Landing': 'Renamed', 'Pool': 'Proxy 1'};
expect(container.read(profilesProvider).single.selectedMap, picks);
expect(
(await testDatabase.profilesDao.query().get()).single.selectedMap,
picks,
);
});
test(
'a profile written as a proxy is renamed keeps both changes',
() async {
const dns = ProfileOverrides(
dns: Dns(nameserver: ['1.1.1.1#Proxy 1']),
dnsOverrideKeys: {DnsOverrideKey.nameserver},
);
await keepAlive(profilesProvider);
final profiles = container.read(profilesProvider.notifier);
profiles.put(custom(1, overrides: dns));
await pumpEventQueue();
notifier.put(proxy(1));
await pumpEventQueue();
notifier.put(
proxy(1).copyWith(definition: {'name': 'Renamed', 'type': 'socks5'}),
);
profiles.put(
container
.read(profilesProvider)
.single
.copyWith(currentGroupName: 'Landing'),
);
await pumpEventQueue();
final row = (await testDatabase.profilesDao.query().get()).single;
expect(row.overrides.dns.nameserver, ['1.1.1.1#Renamed']);
expect(row.currentGroupName, 'Landing');
expect(container.read(profilesProvider).single, row);
},
);
});
group('ClashProviders', () {
test(
'renaming a set renames it wherever a custom profile names it',
() async {
const overrides = ProfileOverrides(
dns: Dns(
nameserverPolicy: {'rule-set:ads,cn': '223.5.5.5'},
fakeIpFilter: ['rule-set:ads'],
),
dnsOverrideKeys: {
DnsOverrideKey.nameserverPolicy,
DnsOverrideKey.fakeIpFilter,
},
sniffer: Sniffer(skipDomain: ['rule-set:ads']),
snifferOverrideKeys: {SnifferOverrideKey.skipDomain},
);
await keepAlive(profilesProvider);
await keepAlive(clashProvidersProvider);
container
.read(profilesProvider.notifier)
.put(
profile(
profileId,
label: 'Custom',
).copyWith(type: ProfileType.custom, overrides: overrides),
);
final providers = container.read(clashProvidersProvider.notifier);
providers.put(const ClashProvider(id: 5, label: 'ads'));
await pumpEventQueue();
await testDatabase.rulesDao.putRule(
const Rule(
id: 9,
ruleAction: RuleAction.RULE_SET,
ruleProvider: 'ads',
ruleTarget: 'REJECT',
order: 'a',
),
profileId: profileId,
);
providers.put(const ClashProvider(id: 5, label: 'block'));
await pumpEventQueue();
final rule =
(await testDatabase.rulesDao.queryProfileRules(profileId).get())
.single;
expect(rule.ruleProvider, 'block');
final row = (await testDatabase.profilesDao.query().get()).single;
expect(row.overrides.ruleSets, {'block', 'cn'});
expect(row.overrides.dns.nameserverPolicy.keys, ['rule-set:block,cn']);
expect(row.overrides.dns.fakeIpFilter, ['rule-set:block']);
expect(row.overrides.sniffer.skipDomain, ['rule-set:block']);
expect(container.read(profilesProvider).single, row);
},
);
});
group('ProfileDisabledRuleIds', () {
@@ -667,8 +1033,7 @@ void main() {
setUp(() async {
await testDatabase.profilesDao.putAll([profile(profileId).toCompanion()]);
await testDatabase.rulesDao.putProfileAddedRule(
profileId,
await testDatabase.rulesDao.putRule(
const Rule(id: 7, content: 'toggled', order: 'a'),
);
await keepAlive(profileDisabledRuleIdsProvider(profileId));
@@ -679,14 +1044,13 @@ void main() {
List<int> read() => notifier.value;
test('put links the rule and del unlinks it', () async {
test('put disables the rule and del enables it again', () async {
notifier.put(7);
expect(read(), [7]);
await pumpEventQueue();
expect(
(await testDatabase.rulesDao.queryProfileDisabledRules(profileId).get())
.map((item) => item.id),
await testDatabase.rulesDao.queryDisabledRuleIds(profileId).get(),
[7],
);
@@ -694,7 +1058,7 @@ void main() {
await pumpEventQueue();
expect(read(), isEmpty);
expect(
await testDatabase.rulesDao.queryProfileDisabledRules(profileId).get(),
await testDatabase.rulesDao.queryDisabledRuleIds(profileId).get(),
isEmpty,
);
});
@@ -709,7 +1073,7 @@ void main() {
});
test('put restores the previous ids when the write fails', () async {
await breakTable('profile_rule_mapping');
await breakTable('disabled_rules');
final failure = captureWriteFailure(() => notifier.put(7));
expect(read(), [7], reason: 'optimistic');
@@ -773,19 +1137,19 @@ void main() {
expect(read(), hasLength(1));
});
test('renaming a group rewrites the custom rules that target it', () async {
test('renaming a group rewrites the rules that target it', () async {
expect(notifier.put(group(1, 'Old')), isTrue);
await pumpEventQueue();
await testDatabase.rulesDao.putProfileCustomRule(
profileId,
await testDatabase.rulesDao.putRule(
const Rule(id: 9, content: 'x', ruleTarget: 'Old', order: 'a'),
profileId: profileId,
);
expect(notifier.put(group(1, 'New')), isTrue);
await pumpEventQueue();
final rules = await testDatabase.rulesDao
.queryProfileCustomRules(profileId)
.queryProfileRules(profileId)
.get();
expect(rules.single.ruleTarget, 'New');
});
@@ -804,6 +1168,56 @@ void main() {
expect(parent.proxies, ['New']);
});
test('renaming a group keeps every member list readable', () async {
expect(notifier.put(group(1, ',')), isTrue);
expect(notifier.put(group(2, 'Old')), isTrue);
expect(
notifier.put(group(3, 'Parent', proxies: ['x', 'Old', 'y'])),
isTrue,
);
await pumpEventQueue();
expect(notifier.put(group(1, 'Comma')), isTrue);
expect(notifier.put(group(2, 'Say "hi" \\o/')), isTrue);
expect(read().firstWhere((item) => item.id == 3).proxies, [
'x',
'Say "hi" \\o/',
'y',
], reason: 'optimistic');
await pumpEventQueue();
final rows = await testDatabase.proxyGroupsDao.query(profileId).get();
expect(rows.firstWhere((item) => item.id == 3).proxies, [
'x',
'Say "hi" \\o/',
'y',
]);
});
test('renaming a group moves its picks, tab and unfolding', () async {
await testDatabase.profilesDao.putAll([
profile(profileId)
.copyWith(
type: ProfileType.custom,
selectedMap: const {'Old': 'DIRECT', 'Parent': 'Old'},
currentGroupName: 'Old',
unfoldSet: const {'Old', 'Parent'},
)
.toCompanion(),
]);
expect(notifier.put(group(1, 'Old')), isTrue);
expect(notifier.put(group(2, 'Parent', proxies: ['Old'])), isTrue);
await pumpEventQueue();
expect(notifier.put(group(1, 'New')), isTrue);
await pumpEventQueue();
final row = (await testDatabase.profilesDao.query().get()).single;
expect(row.selectedMap, {'New': 'DIRECT', 'Parent': 'New'});
expect(row.currentGroupName, 'New');
expect(row.unfoldSet, {'New', 'Parent'});
});
test('put records the icon so it can be reused later', () async {
expect(
notifier.put(group(1, 'Iconic').copyWith(icon: 'https://icon.example')),
@@ -860,3 +1274,13 @@ void main() {
});
});
}
class _GatedProfileRules extends ProfileRules {
final landing = Completer<void>();
@override
Future<void> persistRule(Rule rule) async {
await landing.future;
await super.persistRule(rule);
}
}
@@ -148,6 +148,38 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a request the Core turns down leaves a running update be', (
tester,
) async {
final coreInterface = _MockCoreHandlerInterface();
var requests = 0;
when(() => coreInterface.updateGeoData('MMDB')).thenAnswer(
(_) async => requests++ == 0 ? '' : 'geo update already in progress',
);
final container = await _pumpGeoResourceAction(tester, coreInterface);
final action = container.read(geoResourceActionProvider.notifier);
final key = GeoResource.MMDB.updatingKey;
await action.updateGeoResource(GeoResource.MMDB);
await expectLater(
action.updateGeoResource(GeoResource.MMDB, announceSuccess: false),
throwsA(isA<MessageException>()),
);
expect(container.read(isUpdatingProvider(key)), isTrue);
action.handleCoreUpdate('MMDB', false, false, null);
await tester.pump();
expect(container.read(isUpdatingProvider(key)), isFalse);
expect(
find.text(currentAppLocalizations.geoUpdated(GeoResource.MMDB.name)),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a core disconnect drops the pending geo operation', (
tester,
) async {
@@ -219,6 +251,31 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('an update that announces no success still reports failure', (
tester,
) async {
final coreInterface = _MockCoreHandlerInterface();
when(() => coreInterface.updateGeoData(any())).thenAnswer((_) async => '');
final container = await _pumpGeoResourceAction(tester, coreInterface);
final action = container.read(geoResourceActionProvider.notifier);
final key = GeoResource.MMDB.updatingKey;
await action.updateGeoResource(GeoResource.MMDB, announceSuccess: false);
await action.updateGeoResource(GeoResource.ASN, announceSuccess: false);
action.handleCoreUpdate('MMDB', false, false, null);
action.handleCoreUpdate('ASN', false, false, 'download failed');
await tester.pump();
expect(container.read(isUpdatingProvider(key)), isFalse);
expect(
find.text(currentAppLocalizations.geoUpdated(GeoResource.MMDB.name)),
findsNothing,
);
expect(find.text('download failed'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('leaving the connected Core clears manual feedback', (
tester,
) async {
+184
View File
@@ -0,0 +1,184 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:drift/native.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart' as db;
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/widgets.dart' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:riverpod/riverpod.dart';
String _iconSetJson(String name, List<String> icons) => json.encode({
'name': name,
'icons': [
for (final icon in icons)
{'name': '$icon.png', 'url': 'https://example.com/$icon.png'},
],
});
/// Real downloads over loopback: the test binding would answer every request
/// with a 400, so this suite runs without it.
void main() {
late HttpServer server;
late String body;
Completer<void>? held;
late db.Database testDatabase;
late ProviderContainer container;
setUpAll(() async {
await AppLocalizations.load(const Locale('en'));
globalState.packageInfo = PackageInfo(
appName: 'FlClash',
packageName: 'com.follow.clash',
version: '1.0.0',
buildNumber: '1',
);
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) async {
await held?.future;
final response = request.response;
if (request.uri.path == '/missing') {
response.statusCode = HttpStatus.notFound;
} else {
response.write(body);
}
await response.close();
});
});
tearDownAll(() => server.close(force: true));
setUp(() {
held = null;
testDatabase = db.Database(NativeDatabase.memory());
db.database = testDatabase;
container = ProviderContainer();
globalState.container = container;
container.listen(iconSetsProvider, (_, _) {});
});
tearDown(() async {
container.dispose();
await testDatabase.close();
});
String urlOf(String path) => 'http://127.0.0.1:${server.port}/$path';
IconSetsAction action() => container.read(iconSetsActionProvider.notifier);
Future<List<IconSet>> stored() async {
await pumpEventQueue();
return testDatabase.iconSetsDao.query().get();
}
test('a url import takes the name of the set unless one is given', () async {
body = _iconSetJson('Qure', ['Proxy']);
await action().importUrl(urlOf('qure.json'));
await action().importUrl(urlOf('mine.json'), name: 'Mine');
final iconSets = await stored();
expect(iconSets.map((iconSet) => iconSet.name), ['Qure', 'Mine']);
expect(iconSets.first.url, urlOf('qure.json'));
expect(iconSets.first.icons.single.name, 'Proxy.png');
});
test('importing a url again syncs that set instead of adding one', () async {
body = _iconSetJson('Qure', ['Proxy']);
await action().importUrl(urlOf('qure.json'));
await stored();
body = _iconSetJson('Renamed upstream', ['Proxy', 'Direct']);
await action().importUrl(urlOf('qure.json'));
final iconSets = await stored();
expect(iconSets.single.name, 'Qure');
expect(iconSets.single.icons.map((icon) => icon.name), [
'Proxy.png',
'Direct.png',
]);
});
group('a set changed while it downloads', () {
late IconSet saved;
late Future<IconSet> syncing;
setUp(() async {
body = _iconSetJson('Qure', ['Proxy']);
await action().importUrl(urlOf('qure.json'));
saved = (await stored()).single;
body = _iconSetJson('Qure', ['Proxy', 'Direct']);
held = Completer();
syncing = action().sync(saved);
await pumpEventQueue();
});
Future<void> download() async {
held!.complete();
await syncing;
}
test('stays deleted', () async {
container.read(iconSetsProvider.notifier).del(saved.id);
await pumpEventQueue();
await download();
expect(await stored(), isEmpty);
});
test('keeps its new name and takes the new icons', () async {
container
.read(iconSetsProvider.notifier)
.put(saved.copyWith(name: 'Renamed'));
await pumpEventQueue();
await download();
final iconSet = (await stored()).single;
expect(iconSet.name, 'Renamed');
expect(iconSet.icons, hasLength(2));
});
});
test(
'a failed download or a document that is no icon set saves nothing',
() async {
body = '{"name": "x"}';
await expectLater(
action().importUrl(urlOf('missing')),
throwsA(anything),
);
await expectLater(
action().importUrl(urlOf('set.json')),
throwsA(
isA<MessageException>().having(
(e) => e.message,
'message',
'Not a valid icon set',
),
),
);
expect(await stored(), isEmpty);
},
);
test('a file import falls back to the file name', () async {
action().importContent(
'My icons.json',
'{"icons": [{"url": "https://example.com/a.png"}]}',
);
final iconSets = await stored();
expect(iconSets.single.name, 'My icons');
expect(iconSets.single.isRemote, isFalse);
});
}
-539
View File
@@ -1,539 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:fl_clash/common/feature.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/test_profiles.dart';
class _TestClashProviders extends ClashProviders {
final List<ClashProvider> initial;
_TestClashProviders(this.initial);
@override
Stream<List<ClashProvider>> build(ProviderKind kind) =>
Stream.value(initial.where((item) => item.kind == kind).toList());
}
class _TestCustomProxies extends CustomProxies {
final List<CustomProxy> initial;
_TestCustomProxies([this.initial = const []]);
@override
Stream<List<CustomProxy>> build(int profileId) => Stream.value(initial);
}
class _TestCustomRules extends ProfileCustomRules {
@override
Stream<List<Rule>> build(int profileId) => Stream.value(const []);
}
class _TestProxyGroups extends ProxyGroups {
final List<ProxyGroup> initial;
_TestProxyGroups(this.initial);
@override
Stream<List<ProxyGroup>> build(int profileId) => Stream.value(initial);
@override
void order(int oldIndex, int newIndex) {}
}
const profileId = 1;
Set<int> _invalidGroupIds(ProviderContainer container) => container
.read(customOverwriteIssuesProvider(profileId))
.proxyGroups
.keys
.toSet();
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(
() => feature = const Feature(customProviders: true, customProxies: true),
);
tearDown(() => feature = const Feature());
const proxyGroup = ProxyGroup(
id: 7,
profileId: profileId,
name: 'Group',
type: GroupType.Selector,
proxies: ['Known'],
use: ['provider'],
);
test('validity stays quiet until the profile config resolves', () async {
final config = Completer<ClashConfig>();
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
clashProvidersProvider.overrideWith2((_) => _TestClashProviders([])),
customProxiesProvider.overrideWith2((_) => _TestCustomProxies()),
profileCustomRulesProvider.overrideWith2((_) => _TestCustomRules()),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups([proxyGroup]),
),
clashConfigProvider(profileId).overrideWith((_) => config.future),
],
);
addTearDown(container.dispose);
container.listen(customOverwriteDateProvider(profileId), (_, _) {});
await container.read(proxyGroupsProvider(profileId).future);
await container.read(customProxiesProvider(profileId).future);
expect(
container.read(customOverwriteDateProvider(profileId)).loaded,
false,
);
expect(_invalidGroupIds(container), isEmpty);
expect(
container.read(customOverwriteTargetIsValidProvider(profileId, 'Known')),
true,
);
config.complete(
const ClashConfig(
proxies: [Proxy(name: 'Known', type: 'ss')],
proxyProviders: ['provider'],
),
);
await container.read(clashConfigProvider(profileId).future);
final overwrite = container.read(customOverwriteDateProvider(profileId));
expect(overwrite.loaded, true);
expect(overwrite.proxyNames, ['Known']);
expect(overwrite.proxyTypes, {'Known': 'ss'});
expect(_invalidGroupIds(container), isEmpty);
expect(
container.read(customOverwriteTargetIsValidProvider(profileId, 'Gone')),
false,
);
});
test('an app-level provider and a profile count as valid names', () async {
const appProvider = ClashProvider(
id: 9,
kind: ProviderKind.proxy,
label: 'provider',
url: 'https://example.com/nodes.yaml',
);
final profile = Profile.normal(label: 'Subscription');
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
clashProvidersProvider.overrideWith2(
(_) => _TestClashProviders([appProvider]),
),
customProxiesProvider.overrideWith2((_) => _TestCustomProxies()),
profileCustomRulesProvider.overrideWith2((_) => _TestCustomRules()),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups([
proxyGroup.copyWith(proxies: null, use: ['provider']),
]),
),
clashConfigProvider(profileId).overrideWith((_) => const ClashConfig()),
],
);
addTearDown(container.dispose);
container.listen(customOverwriteDateProvider(profileId), (_, _) {});
await container.read(proxyGroupsProvider(profileId).future);
await container.read(customProxiesProvider(profileId).future);
await container.read(clashConfigProvider(profileId).future);
await container.read(clashProvidersProvider(ProviderKind.proxy).future);
expect(_invalidGroupIds(container), isEmpty);
expect(
container.read(
customOverwriteProxyProviderIsValidProvider(profileId, 'provider'),
),
true,
);
expect(
proxyGroupIssues(
proxyGroup.copyWith(proxies: null, use: ['Subscription']),
container.read(customOverwriteDateProvider(profileId)),
),
isEmpty,
);
expect(
container.read(appProviderNamesProvider(ProviderKind.rule)),
isEmpty,
);
});
test(
'a resolved config reports the groups that reference missing names',
() async {
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
clashProvidersProvider.overrideWith2((_) => _TestClashProviders([])),
customProxiesProvider.overrideWith2((_) => _TestCustomProxies()),
profileCustomRulesProvider.overrideWith2((_) => _TestCustomRules()),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups([proxyGroup]),
),
clashConfigProvider(
profileId,
).overrideWith((_) => const ClashConfig()),
],
);
addTearDown(container.dispose);
container.listen(customOverwriteDateProvider(profileId), (_, _) {});
await container.read(proxyGroupsProvider(profileId).future);
await container.read(customProxiesProvider(profileId).future);
await container.read(clashConfigProvider(profileId).future);
expect(
container.read(customOverwriteDateProvider(profileId)).loaded,
true,
);
expect(_invalidGroupIds(container), {7});
expect(
container.read(
customOverwriteProxyProviderIsValidProvider(profileId, 'provider'),
),
false,
);
},
);
group('issues', () {
const overwrite = CustomOverwriteDate(
loaded: true,
proxyNames: ['Node'],
proxyGroups: [
ProxyGroup(id: 1, name: 'A', type: GroupType.Selector, proxies: ['B']),
ProxyGroup(id: 2, name: 'B', type: GroupType.Selector, proxies: ['A']),
],
ruleTargets: {'DIRECT', 'REJECT', 'Node', 'A', 'B'},
);
test('a group that reaches itself names the loop', () {
expect(proxyGroupIssues(overwrite.proxyGroups.first, overwrite), [
const OverwriteIssue.groupLoop(['A', 'B', 'A']),
]);
});
test('a group without members or providers is refused', () {
const group = ProxyGroup(id: 3, name: 'C', type: GroupType.Selector);
expect(proxyGroupIssues(group, overwrite), [
const OverwriteIssue.noProxySource(),
]);
expect(
proxyGroupIssues(group.copyWith(includeAllProxies: true), overwrite),
isEmpty,
);
});
test('a group cannot take a proxy or built-in name', () {
const group = ProxyGroup(
id: 3,
name: 'Node',
type: GroupType.Selector,
proxies: ['DIRECT', 'Gone'],
);
expect(proxyGroupIssues(group, overwrite), [
const OverwriteIssue.duplicateName('Node'),
const OverwriteIssue.missingProxies(['Gone']),
]);
expect(
proxyGroupIssues(group.copyWith(name: 'DIRECT'), overwrite).first,
const OverwriteIssue.reservedName('DIRECT'),
);
});
test('custom proxies report names and core errors together', () {
const first = CustomProxy(id: 1, definition: {'name': 'X'});
const second = CustomProxy(id: 2, definition: {'name': 'X'});
expect(
customProxyIssues(
first,
proxies: const [first, second],
proxyGroups: const [],
coreError: 'missing type',
),
[
const OverwriteIssue.duplicateName('X'),
const OverwriteIssue.coreRejected('missing type'),
],
);
expect(
customProxyIssues(
const CustomProxy(id: 3, definition: {'name': 'A'}),
proxies: const [],
proxyGroups: overwrite.proxyGroups,
),
[const OverwriteIssue.duplicateName('A')],
);
});
test('the profile groups kept beside custom proxies name what is gone', () {
const groups = [
ProxyGroup(
id: 1,
name: 'Auto',
type: GroupType.URLTest,
proxies: ['Mine', 'Gone'],
),
ProxyGroup(
id: 2,
name: 'Select',
type: GroupType.Selector,
proxies: ['Auto', 'DIRECT'],
),
];
final targets = {...RuleTarget.baseTargets, 'Mine'};
expect(
subscriptionGroupsIssue(groups, targets: targets),
const OverwriteIssue.subscriptionGroupMissingProxies(['Gone']),
);
expect(
subscriptionGroupsIssue(groups, targets: {...targets, 'Gone'}),
null,
);
});
test(
'custom proxies replace the profile names they are checked against',
() async {
const proxy = CustomProxy(
id: 5,
definition: {'name': 'Mine', 'type': 'socks5'},
);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
clashProvidersProvider.overrideWith2(
(_) => _TestClashProviders([]),
),
customProxiesProvider.overrideWith2(
(_) => _TestCustomProxies([proxy]),
),
profileCustomRulesProvider.overrideWith2((_) => _TestCustomRules()),
customProxyCoreErrorsProvider(
profileId,
).overrideWith((_) async => {proxy.id: 'bad cipher'}),
proxyGroupsProvider.overrideWith2(
(_) => _TestProxyGroups([proxyGroup]),
),
clashConfigProvider(profileId).overrideWith(
(_) => const ClashConfig(
proxies: [Proxy(name: 'Known', type: 'ss')],
proxyProviders: ['provider'],
),
),
],
);
addTearDown(container.dispose);
container.listen(customOverwriteIssuesProvider(profileId), (_, _) {});
await container.read(proxyGroupsProvider(profileId).future);
await container.read(customProxiesProvider(profileId).future);
await container.read(profileCustomRulesProvider(profileId).future);
await container.read(clashConfigProvider(profileId).future);
await container.read(customProxyCoreErrorsProvider(profileId).future);
final overwrite = container.read(
customOverwriteDateProvider(profileId),
);
expect(overwrite.proxyNames, ['Mine']);
final issues = container.read(customOverwriteIssuesProvider(profileId));
expect(issues.proxies, {
proxy.id: [const OverwriteIssue.coreRejected('bad cipher')],
});
expect(issues.proxyGroups, {
proxyGroup.id: [
const OverwriteIssue.missingProxies(['Known']),
],
});
},
);
test('a custom proxy cannot take a kept profile group name', () async {
const proxy = CustomProxy(
id: 5,
definition: {'name': 'Auto', 'type': 'socks5'},
);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
clashProvidersProvider.overrideWith2((_) => _TestClashProviders([])),
customProxiesProvider.overrideWith2(
(_) => _TestCustomProxies([proxy]),
),
profileCustomRulesProvider.overrideWith2((_) => _TestCustomRules()),
customProxyCoreErrorsProvider(
profileId,
).overrideWith((_) async => const {}),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups([])),
clashConfigProvider(profileId).overrideWith(
(_) => const ClashConfig(
proxyGroups: [
ProxyGroup(
id: 1,
name: 'Auto',
type: GroupType.URLTest,
proxies: ['DIRECT'],
),
],
),
),
],
);
addTearDown(container.dispose);
container.listen(customOverwriteIssuesProvider(profileId), (_, _) {});
await container.read(proxyGroupsProvider(profileId).future);
await container.read(customProxiesProvider(profileId).future);
await container.read(profileCustomRulesProvider(profileId).future);
await container.read(clashConfigProvider(profileId).future);
await container.read(customProxyCoreErrorsProvider(profileId).future);
expect(container.read(customOverwriteIssuesProvider(profileId)).proxies, {
proxy.id: [const OverwriteIssue.duplicateName('Auto')],
});
});
});
test('customProxyTypes lists every type the core parses', () {
final source = File('core/Clash.Meta/adapter/parser.go').readAsStringSync();
expect(customProxyTypes, [
for (final match in RegExp(
r'^\tcase "([^"]+)":',
multiLine: true,
).allMatches(source))
match.group(1)!,
]);
});
test(
'switched-off features leave app providers and custom proxies out',
() async {
feature = const Feature();
const appProvider = ClashProvider(
id: 9,
kind: ProviderKind.proxy,
label: 'provider',
url: 'https://example.com/nodes.yaml',
);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(
() => TestProfiles([Profile.normal(label: 'Subscription')]),
),
clashProvidersProvider.overrideWith2(
(_) => _TestClashProviders([appProvider]),
),
customProxiesProvider.overrideWith2(
(_) => _TestCustomProxies([
const CustomProxy(id: 5, definition: {'name': 'Mine'}),
]),
),
profileCustomRulesProvider.overrideWith2((_) => _TestCustomRules()),
proxyGroupsProvider.overrideWith2((_) => _TestProxyGroups([])),
clashConfigProvider(profileId).overrideWith(
(_) => const ClashConfig(
proxies: [Proxy(name: 'Known', type: 'ss')],
),
),
],
);
addTearDown(container.dispose);
container.listen(customOverwriteIssuesProvider(profileId), (_, _) {});
await container.read(proxyGroupsProvider(profileId).future);
await container.read(clashConfigProvider(profileId).future);
expect(
container.read(appProviderNamesProvider(ProviderKind.proxy)),
isEmpty,
);
expect(
container.read(appProviderLabelsProvider(ProviderKind.proxy)),
isEmpty,
);
final overwrite = container.read(customOverwriteDateProvider(profileId));
expect(overwrite.loaded, true);
expect(overwrite.proxyNames, ['Known']);
expect(
container.read(customOverwriteIssuesProvider(profileId)).proxies,
isEmpty,
);
},
);
test('a provider name resolves to the subscription, a profile, then the '
'app', () async {
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(
() => TestProfiles([Profile.normal(label: 'Home')]),
),
clashProvidersProvider.overrideWith2(
(_) => _TestClashProviders(const [
ClashProvider(
id: 9,
kind: ProviderKind.proxy,
label: 'Shared',
url: 'https://example.com/shared.yaml',
),
ClashProvider(
id: 10,
kind: ProviderKind.proxy,
label: 'Only app',
url: 'https://example.com/app.yaml',
),
ClashProvider(
id: 11,
kind: ProviderKind.rule,
label: 'Home',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.text,
),
]),
),
clashConfigProvider(profileId).overrideWith(
(_) => const ClashConfig(
proxyProviders: ['Shared'],
ruleProviders: ['Own rules'],
),
),
],
);
addTearDown(container.dispose);
for (final kind in ProviderKind.values) {
container.listen(providerSourcesProvider(profileId, kind), (_, _) {});
await container.read(clashProvidersProvider(kind).future);
}
await container.read(clashConfigProvider(profileId).future);
expect(
container.read(providerSourcesProvider(profileId, ProviderKind.proxy)),
{
'Shared': ProviderSource.subscription,
'Only app': ProviderSource.app,
'Home': ProviderSource.profile,
},
);
expect(
container.read(providerSourcesProvider(profileId, ProviderKind.rule)),
{'Own rules': ProviderSource.subscription, 'Home': ProviderSource.app},
);
feature = const Feature();
container.invalidate(providerSourcesProvider);
expect(
container.read(providerSourcesProvider(profileId, ProviderKind.proxy)),
isEmpty,
);
});
}
+224
View File
@@ -0,0 +1,224 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:mocktail/mocktail.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yaml/yaml.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory home;
late _MockCoreHandlerInterface core;
late ProviderContainer container;
setUpAll(() async {
await AppLocalizations.load(const Locale('en'));
home = Directory.systemTemp.createTempSync('flclash-profiles-action-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
});
tearDownAll(() {
if (home.existsSync()) home.deleteSync(recursive: true);
});
setUp(() {
core = _MockCoreHandlerInterface();
when(() => core.validateConfig(any())).thenAnswer((_) async => '');
container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
],
);
addTearDown(container.dispose);
globalState.container = container;
});
void decodesTo(String link, List<Map<String, dynamic>> proxies) {
when(
() => core.decodeShareLinks([link]),
).thenAnswer((_) async => [proxies]);
}
Future<void> pumpHost(WidgetTester tester) async {
const size = Size(1200, 1000);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
container.read(viewSizeProvider.notifier).value = size;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const TestApp(withStatusManager: true, child: SizedBox()),
),
);
}
Future<List<Map>> savedProxies(Profile profile) async {
final config = loadYaml(await (await profile.file).readAsString()) as Map;
return [for (final proxy in config['proxies'] as List) proxy as Map];
}
group('addProfileFromLink with a share link', () {
test('saves a file profile named after the proxy it holds', () async {
const link = 'vless://id@hk.example.com:443#HK';
decodesTo(link, [
{'name': 'HK', 'type': 'vless', 'server': 'hk.example.com'},
]);
await container
.read(profilesActionProvider.notifier)
.addProfileFromLink(link);
final profile = container.read(profilesProvider).single;
expect(profile.type, ProfileType.file);
expect(profile.url, isEmpty);
expect(profile.label, 'HK');
expect(await savedProxies(profile), [
{'name': 'HK', 'type': 'vless', 'server': 'hk.example.com'},
]);
verify(() => core.validateConfig(any())).called(1);
});
test('names an unnamed proxy after its server', () async {
const link = 'ss://YWVzLTEyOC1nY206cGFzcw@ss.example.com:8388';
decodesTo(link, [
{'name': '', 'type': 'ss', 'server': 'ss.example.com'},
]);
await container
.read(profilesActionProvider.notifier)
.addProfileFromLink(link, label: 'Mine');
final profile = container.read(profilesProvider).single;
expect(profile.label, 'Mine');
expect((await savedProxies(profile)).single['name'], 'ss.example.com');
});
test('moves a proxy off a name mihomo reserves', () async {
const link = 'trojan://pass@t.example.com:443#DIRECT';
decodesTo(link, [
{'name': 'DIRECT', 'type': 'trojan', 'server': 't.example.com'},
]);
await container
.read(profilesActionProvider.notifier)
.addProfileFromLink(link);
final profile = container.read(profilesProvider).single;
expect((await savedProxies(profile)).single['name'], 'DIRECT-01');
});
testWidgets('reports a link no proxy can be read from and adds nothing', (
tester,
) async {
const link = 'vmess://not-base64';
decodesTo(link, const []);
await pumpHost(tester);
await container
.read(profilesActionProvider.notifier)
.addProfileFromLink(link);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text(currentAppLocalizations.shareLinksInvalid), findsOne);
expect(container.read(profilesProvider), isEmpty);
verifyNever(() => core.validateConfig(any()));
});
});
group('addProfilesFromLinks with share links', () {
const links = [
'vless://id@hk.example.com:443#HK',
'trojan://pass@hk2.example.com:443#HK',
'ss://YWVzLTEyOC1nY206cGFzcw@ss.example.com:8388',
];
test('makes one profile of all their proxies', () async {
when(() => core.decodeShareLinks(links)).thenAnswer(
(_) async => [
[
{'name': 'HK', 'type': 'vless', 'server': 'hk.example.com'},
],
[
{'name': 'HK', 'type': 'trojan', 'server': 'hk2.example.com'},
],
[
{'name': '', 'type': 'ss', 'server': 'ss.example.com'},
],
],
);
await container
.read(profilesActionProvider.notifier)
.addProfilesFromLinks(links);
final profile = container.read(profilesProvider).single;
expect(profile.type, ProfileType.file);
expect(
profile.label,
currentAppLocalizations.proxiesProfileLabel('HK', 3),
);
expect(
[for (final proxy in await savedProxies(profile)) proxy['name']],
['HK', 'HK-01', 'ss.example.com'],
);
verify(() => core.decodeShareLinks(links)).called(1);
});
testWidgets('names the link no proxy can be read from and adds nothing', (
tester,
) async {
when(() => core.decodeShareLinks(links)).thenAnswer(
(_) async => [
[
{'name': 'HK', 'type': 'vless', 'server': 'hk.example.com'},
],
const [],
[
{'name': '', 'type': 'ss', 'server': 'ss.example.com'},
],
],
);
await pumpHost(tester);
await container
.read(profilesActionProvider.notifier)
.addProfilesFromLinks(links);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
final l10n = currentAppLocalizations;
expect(
find.text(
l10n.failedItem(links.first, l10n.shareLinkUnreadable(links[1])),
),
findsOne,
);
expect(container.read(profilesProvider), isEmpty);
verifyNever(() => core.validateConfig(any()));
});
});
}
+33
View File
@@ -579,6 +579,39 @@ void main() {
},
);
test(
'a group tested away from the proxies page ignores its search',
() async {
when(() => core.asyncTestDelay(_testUrl, any())).thenAnswer(
(invocation) async => Delay(
name: invocation.positionalArguments[1] as String,
url: _testUrl,
value: 10,
),
);
final container = _delayContainer(buildContainer);
container
.read(groupsProvider.notifier)
.update(
(_) => [
_group('Proxy', const [
_proxy,
Proxy(name: 'JP-01', type: 'ss'),
]),
],
);
container.listen(queryProvider(QueryTag.proxies), (_, _) {});
container.read(queryProvider(QueryTag.proxies).notifier).value = 'HK';
await actionOf(
container,
).delayTestPageGroup('Proxy', matchSearch: false);
verify(() => core.asyncTestDelay(_testUrl, 'HK-01')).called(1);
verify(() => core.asyncTestDelay(_testUrl, 'JP-01')).called(1);
},
);
test('a page group holds its testing mark until the run settles', () async {
final release = Completer<Delay?>();
when(
+63
View File
@@ -0,0 +1,63 @@
import 'package:drift/native.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/database/database.dart' as db;
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:flutter/widgets.dart' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod/riverpod.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late db.Database testDatabase;
late ProviderContainer container;
late Map<String, int> requests;
setUpAll(() => AppLocalizations.load(const Locale('en')));
setUp(() {
testDatabase = db.Database(NativeDatabase.memory());
db.database = testDatabase;
requests = {};
final core = _MockCoreHandlerInterface();
when(() => core.updateGeoData(any())).thenAnswer((invocation) async {
final name = invocation.positionalArguments.single as String;
final count = requests[name] = (requests[name] ?? 0) + 1;
return count == 1 ? '' : 'geo update already in progress';
});
container = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
],
);
});
tearDown(() async {
container.dispose();
await testDatabase.close();
});
test(
'an update all that overlaps one running skips what that one runs',
() async {
final action = container.read(resourcesActionProvider.notifier);
final failures = await Future.wait([
action.updateAll(),
action.updateAll(),
]);
expect(failures.expand((items) => items), isEmpty);
expect(requests, {
for (final geoResource in GeoResource.values) geoResource.name: 1,
});
},
);
}
+50 -2
View File
@@ -8,12 +8,31 @@ import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/route_state.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod/riverpod.dart';
class _MockCore extends Mock implements CoreHandlerInterface {}
class _RunStateReader extends ConsumerStatefulWidget {
const _RunStateReader();
@override
ConsumerState<_RunStateReader> createState() => _RunStateReaderState();
}
class _RunStateReaderState extends ConsumerState<_RunStateReader> {
@override
void initState() {
super.initState();
ref.read(isStartProvider);
}
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
const _picks = {'Proxy': 'Nested', 'Nested': 'HK-01', 'Auto': 'JP-02'};
const _state = RouteState(
@@ -108,6 +127,7 @@ void main() {
bool init = true,
CoreStatus status = CoreStatus.connected,
bool running = true,
bool watched = true,
}) {
final container = ProviderContainer(
overrides: [
@@ -119,7 +139,9 @@ void main() {
],
);
addTearDown(container.dispose);
container.listen(routeTrackerProvider, (_, _) {});
if (watched) {
container.listen(routeTrackerProvider, (_, _) {});
}
return container;
}
@@ -139,6 +161,32 @@ void main() {
expect(stateOf(container).proxied, isTrue);
});
testWidgets(
'a widget reading a run state the unwatched tracker missed mounts cleanly',
(tester) async {
final container = build(running: false, watched: false);
trackerOf(container);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const SizedBox.shrink(),
),
);
container.read(runTimeProvider.notifier).value = 1;
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const _RunStateReader(),
),
);
expect(tester.takeException(), isNull);
await tester.pump();
expect(stateOf(container).proxied, isTrue);
},
);
test(
'reads the Core once something watches and lets go when nothing does',
() async {
+303 -49
View File
@@ -14,6 +14,7 @@ import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
@@ -80,6 +81,7 @@ class TestSetupAction extends SetupAction {
final List<Completer<void>> pendingCoreCalls = [];
int trafficResets = 0;
int applyProfileCalls = 0;
final List<bool> appliedForce = [];
bool blockCoreCalls = false;
Error? coreRunningError;
int authorizeCalls = 0;
@@ -116,6 +118,7 @@ class TestSetupAction extends SetupAction {
Future<void> Function()? preloadInvoke,
}) async {
applyProfileCalls++;
appliedForce.add(force);
await preloadInvoke?.call();
return true;
}
@@ -392,6 +395,51 @@ void main() {
await container.read(setupActionProvider.notifier).setRunning(true);
expect(action.coreRunningCalls, isEmpty);
expect(container.read(runningVpnOptionsProvider), isNull);
});
test('an excluded SSID parks a run and leaving it resumes', () async {
container.dispose();
action = TestSetupAction();
container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
setupActionProvider.overrideWith(() => action),
commonActionProvider.overrideWith(TestCommonAction.new),
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
],
);
globalState.container = container;
container.read(initProvider.notifier).value = true;
final setup = container.read(setupActionProvider.notifier);
await setup.setRunning(true);
action.coreRunningCalls.clear();
container.read(currentSSIDProvider.notifier).value = 'Office Wi-Fi';
setup.syncSuspend();
await Future<void>.delayed(const Duration(milliseconds: 700));
expect(action.coreRunningCalls, [false]);
expect(container.read(isStartProvider), isTrue);
expect(container.read(runningVpnOptionsProvider), isNull);
container.read(currentSSIDProvider.notifier).value = 'Home';
setup.syncSuspend();
await Future<void>.delayed(const Duration(milliseconds: 700));
expect(action.coreRunningCalls, [false, true]);
expect(
container.read(runningVpnOptionsProvider),
await container.read(vpnOptionsProvider.future),
);
});
test('an SSID change leaves a stopped run alone', () async {
markInitialized();
container.read(setupActionProvider.notifier).syncSuspend();
await Future<void>.delayed(const Duration(milliseconds: 700));
expect(action.coreRunningCalls, isEmpty);
});
@@ -415,6 +463,64 @@ void main() {
});
});
group('running vpn options', () {
test('a start keeps what the service starts with until a stop', () async {
markInitialized();
final setup = container.read(setupActionProvider.notifier);
final started = await container.read(vpnOptionsProvider.future);
await setup.setRunning(true);
container
.read(vpnSettingProvider.notifier)
.update((state) => state.copyWith(ipv6: !state.ipv6));
expect(container.read(runningVpnOptionsProvider), started);
await setup.setRunning(false);
expect(container.read(runningVpnOptionsProvider), isNull);
});
test('a start the core rejects keeps nothing', () async {
markInitialized();
action.coreRunningError = StateError('listener refused');
await expectLater(
container.read(setupActionProvider.notifier).setRunning(true),
throwsStateError,
);
expect(container.read(runningVpnOptionsProvider), isNull);
});
test('a restart stops and starts with the current options', () async {
markInitialized();
final setup = container.read(setupActionProvider.notifier);
await setup.setRunning(true);
container
.read(vpnSettingProvider.notifier)
.update((state) => state.copyWith(ipv6: !state.ipv6));
await setup.restartVpn();
expect(action.coreRunningCalls, [true, false, true]);
expect(container.read(isStartProvider), isTrue);
expect(
container.read(runningVpnOptionsProvider),
await container.read(vpnOptionsProvider.future),
);
});
test('a restart never starts a stopped run', () async {
markInitialized();
await container.read(setupActionProvider.notifier).restartVpn();
expect(action.coreRunningCalls, isEmpty);
expect(container.read(isStartProvider), isFalse);
});
});
group('latest-intent arbitration', () {
test('a superseded stop does not run the post-stop cleanup', () async {
markInitialized();
@@ -780,6 +886,18 @@ void main() {
expect(action.applyProfileCalls, 1);
});
test('a forced apply stays forced when a plain one follows', () async {
final notifier = container.read(setupActionProvider.notifier);
notifier.applyProfileDebounce(force: true);
notifier.applyProfileDebounce();
await Future<void>.delayed(const Duration(milliseconds: 800));
notifier.applyProfileDebounce();
await Future<void>.delayed(const Duration(milliseconds: 800));
expect(action.appliedForce, [true, false]);
});
test('a stop cancels a pending apply', () async {
markInitialized();
final notifier = container.read(setupActionProvider.notifier);
@@ -789,6 +907,10 @@ void main() {
await Future<void>.delayed(const Duration(milliseconds: 800));
expect(action.applyProfileCalls, 0);
notifier.applyProfileDebounce();
await Future<void>.delayed(const Duration(milliseconds: 800));
expect(action.appliedForce, [false]);
});
});
@@ -824,14 +946,13 @@ void main() {
const nullProfileSetupState = SetupState(
profileId: null,
profileLastUpdateDate: null,
overwriteType: OverwriteType.standard,
profileType: ProfileType.file,
extendType: ExtendType.standard,
rules: [],
proxyGroups: [],
addedRules: [],
script: null,
overrideDns: false,
dns: Dns(),
dnsOverrideKeys: {},
overrides: ProfileOverrides(),
);
test(
@@ -984,60 +1105,106 @@ void main() {
},
);
test('a custom overwrite injects only the providers it names', () async {
test('leaving an editor rebuilds the profile only once its inputs '
'differ from the applied config', () async {
final profile = Profile.normal(label: 'p');
final core = _MockCoreHandlerInterface();
when(() => core.getConfig(any())).thenAnswer(
(_) async => {
'proxy-providers': {
'bundled': {'type': 'http', 'url': 'https://example.com/b.yaml'},
},
},
when(() => core.getConfig(any())).thenAnswer((_) async => {});
when(() => core.setupConfig(any())).thenAnswer((_) async => '');
var setupState = nullProfileSetupState.copyWith(profileId: profile.id);
final scoped = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile])),
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
setupStateProvider.overrideWith((_, _) => setupState),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
setupActionProvider.overrideWith(SetupAction.new),
],
);
const appProxy = ClashProvider(
addTearDown(scoped.dispose);
final setupAction = scoped.read(setupActionProvider.notifier);
Future<void> leaveEditor() async {
setupAction.autoApplyProfile();
SchedulerBinding.instance
..handleBeginFrame(null)
..handleDrawFrame();
await pumpEventQueue();
}
await setupAction.applyProfile(force: true);
verify(() => core.getConfig(any())).called(1);
await leaveEditor();
verifyNever(() => core.getConfig(any()));
setupState = setupState.copyWith(matchTarget: 'DIRECT');
scoped.invalidate(setupStateProvider);
await leaveEditor();
verify(() => core.getConfig(any())).called(1);
});
test('a custom profile declares only the proxies and providers it names, '
'without a profile file', () async {
final profile = Profile.custom(label: 'p');
final core = _MockCoreHandlerInterface();
const appProxy = CustomProxy(
id: 11,
kind: ProviderKind.proxy,
label: 'appProxies',
url: 'https://example.com/p.yaml',
definition: {'name': 'mine', 'type': 'socks5', 'server': '::1'},
);
const unusedProxy = CustomProxy(
id: 15,
definition: {'name': 'spare', 'type': 'socks5', 'server': '::1'},
);
const appRule = ClashProvider(
id: 12,
kind: ProviderKind.rule,
label: 'appRules',
url: 'https://example.com/r.yaml',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.mrs,
format: RuleProviderFormat.yaml,
);
const unusedProxy = ClashProvider(
final compiled = File(await appRule.compiledPath)
..createSync(recursive: true);
addTearDown(compiled.deleteSync);
const unusedRule = ClashProvider(
id: 13,
kind: ProviderKind.proxy,
label: 'unused',
url: 'https://example.com/u.yaml',
);
const localRule = ClashProvider(
id: 14,
kind: ProviderKind.rule,
label: 'localRules',
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.yaml,
);
const nestedRule = ClashProvider(
id: 16,
label: 'nestedRules',
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.yaml,
);
final setupState = nullProfileSetupState.copyWith(
profileId: profile.id,
overwriteType: OverwriteType.custom,
profileType: ProfileType.custom,
proxyGroups: [
const ProxyGroup(
id: 1,
name: 'g',
type: GroupType.Selector,
use: ['appProxies', 'sub'],
proxies: ['mine'],
use: ['sub'],
),
],
rules: [
Rule.parse('RULE-SET,appRules,DIRECT', id: 2),
Rule.parse('RULE-SET,localRules,DIRECT', id: 3),
Rule.parse(
'AND,((RULE-SET,nestedRules),(NETWORK,UDP)),REJECT',
id: 4,
),
],
clashProviders: const [appProxy, appRule, unusedProxy, localRule],
clashProviders: const [appRule, unusedRule, localRule, nestedRule],
profileProviders: const {'sub': 42},
appProxies: const [appProxy, unusedProxy],
);
final scoped = ProviderContainer(
overrides: [
@@ -1056,41 +1223,128 @@ void main() {
final config = loadYaml(res.yaml) as YamlMap;
final proxyProviders = config['proxy-providers'] as YamlMap;
expect(
proxyProviders['appProxies']['path'],
await appPath.getProviderCachePath(
ProviderKind.proxy,
appProxy.fileName,
),
);
verifyNever(() => core.getConfig(any()));
expect(proxyProviders.keys, {'sub'});
expect((config['proxies'] as YamlList).single['name'], 'mine');
expect(proxyProviders['sub']['type'], 'file');
expect(proxyProviders['sub']['path'], await appPath.getProfilePath('42'));
expect(proxyProviders.containsKey('unused'), isFalse);
expect(
proxyProviders['bundled']['path'],
startsWith(
await appPath.getProviderDirPath(
profile.id,
proxiesProviderDirectoryName,
),
),
);
expect(config['rule-providers']['appRules']['behavior'], 'domain');
expect(config['rule-providers']['appRules']['format'], 'mrs');
expect(
config['rule-providers']['appRules']['path'],
await appPath.getProviderCachePath(ProviderKind.rule, appRule.fileName),
);
expect((config['proxy-groups'] as YamlList).single['name'], 'g');
expect(config['rules'], [
'RULE-SET,appRules,DIRECT',
'RULE-SET,localRules,DIRECT',
'AND,((RULE-SET,nestedRules),(NETWORK,UDP)),REJECT',
]);
expect(config['rule-providers'].containsKey('nestedRules'), isTrue);
final remote = config['rule-providers']['appRules'] as YamlMap;
expect(remote['type'], 'file');
expect(remote.containsKey('url'), isFalse);
expect(remote['behavior'], 'domain');
expect(remote['format'], 'mrs');
expect(remote['path'], compiled.path);
expect(config['rule-providers'].containsKey('unused'), isFalse);
final local = config['rule-providers']['localRules'] as YamlMap;
expect(local['type'], 'file');
expect(local.containsKey('url'), isFalse);
expect(
local['path'],
await appPath.getProviderCachePath(
ProviderKind.rule,
localRule.fileName,
await appPath.getProviderCachePath(localRule.fileName),
);
});
test('a provider a custom profile names carries its options', () async {
final profile = Profile.custom(label: 'p');
final core = _MockCoreHandlerInterface();
final setupState = nullProfileSetupState.copyWith(
profileId: profile.id,
profileType: ProfileType.custom,
proxyGroups: [
const ProxyGroup(
id: 1,
name: 'g',
type: GroupType.Selector,
use: ['sub', 'plain'],
),
],
profileProviders: const {'sub': 42, 'plain': 43},
overrides: const ProfileOverrides(
proxyProviders: {
'sub': ProxyProviderOptions(
healthCheck: ProviderHealthCheck(interval: 600),
filter: 'HK',
excludeFilter: 'expire',
),
},
),
);
final scoped = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
setupActionProvider.overrideWith(SetupAction.new),
],
);
addTearDown(scoped.dispose);
final res = await scoped
.read(setupActionProvider.notifier)
.getProfile(
setupState: setupState,
patchConfig: const PatchClashConfig(),
);
final providers = (loadYaml(res.yaml) as YamlMap)['proxy-providers'];
expect(providers['sub']['filter'], 'HK');
expect(providers['sub']['exclude-filter'], 'expire');
expect(providers['sub']['health-check'], {
'enable': true,
'url': defaultTestUrl,
'interval': 600,
});
expect(providers['sub']['type'], 'file');
expect((providers['plain'] as YamlMap).keys, {'type', 'path'});
});
test('only a subscription profile runs its script extension', () async {
final engine = scriptEvaluator;
addTearDown(() => scriptEvaluator = engine);
final evaluated = <String>[];
scriptEvaluator =
({required String script, required String config}) async {
evaluated.add(script);
return config;
};
final script = await Script(
id: 77,
label: 'script',
lastUpdateTime: DateTime(2026),
).save('const main = (config) => config;');
final core = _MockCoreHandlerInterface();
when(() => core.getConfig(any())).thenAnswer((_) async => {});
final scoped = ProviderContainer(
overrides: [
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
setupActionProvider.overrideWith(SetupAction.new),
],
);
addTearDown(scoped.dispose);
Future<void> build(ProfileType type) => scoped
.read(setupActionProvider.notifier)
.getProfile(
setupState: nullProfileSetupState.copyWith(
profileId: 5,
profileType: type,
extendType: ExtendType.script,
script: script,
),
patchConfig: const PatchClashConfig(),
);
await build(ProfileType.custom);
expect(evaluated, isEmpty);
verifyNever(() => core.getConfig(any()));
await build(ProfileType.file);
expect(evaluated, hasLength(1));
verify(() => core.getConfig(any())).called(1);
});
test(
+100 -5
View File
@@ -1,5 +1,4 @@
import 'package:drift/native.dart';
import 'package:fl_clash/common/feature.dart';
import 'package:fl_clash/database/database.dart' as db;
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
@@ -17,11 +16,9 @@ void main() {
setUp(() {
testDatabase = db.Database(NativeDatabase.memory());
db.database = testDatabase;
feature = const Feature(customProviders: true, customProxies: true);
});
tearDown(() async {
feature = const Feature();
await testDatabase.close();
});
@@ -31,8 +28,62 @@ void main() {
const profile = Profile(
id: 1,
label: 'Home',
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
overwriteType: OverwriteType.custom,
);
const subscription = Profile(
id: 2,
label: 'Sub',
type: ProfileType.url,
url: 'https://example.com/sub',
autoUpdateDuration: Duration.zero,
);
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(
() => TestProfiles([profile, subscription]),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const SizedBox.shrink(),
),
);
final state = await container.read(setupStateProvider(profile.id).future);
expect(state.profileType, ProfileType.custom);
expect(state.profileProviders, {'Sub': subscription.id});
});
testWidgets('a custom setup leaves the extension of its profile out', (
tester,
) async {
const profile = Profile(
id: 1,
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
extendType: ExtendType.script,
scriptId: 5,
matchTarget: 'Proxy',
);
await testDatabase.profilesDao.putAll([profile.toCompanion()]);
await testDatabase.scripts.put(
Script(
id: 5,
label: 'Script',
lastUpdateTime: DateTime(2026),
).toCompanion(),
);
await testDatabase.rulesDao.putRule(
Rule.parse('DOMAIN,global.com,DIRECT', id: 10),
);
await testDatabase.rulesDao.putRule(
Rule.parse('DOMAIN,own.com,DIRECT', id: 11),
profileId: profile.id,
);
final container = ProviderContainer(
overrides: [
@@ -49,6 +100,50 @@ void main() {
final state = await container.read(setupStateProvider(profile.id).future);
expect(state.profileProviders, {'Home': profile.id});
expect(state.script, isNull);
expect(state.matchTarget, isNull);
expect(state.addedRules, isEmpty);
expect(state.rules.map((rule) => rule.id), [11]);
});
testWidgets('a custom setup carries the dialers of its own profile', (
tester,
) async {
const profile = Profile(
id: 1,
label: 'Home',
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
);
const other = Profile(
id: 2,
type: ProfileType.custom,
autoUpdateDuration: Duration.zero,
);
await testDatabase.profilesDao.putAll([
profile.toCompanion(),
other.toCompanion(),
]);
await testDatabase.customProxies.put(
const CustomProxy(id: 30, definition: {'name': 'Home'}).toCompanion(),
);
await testDatabase.proxyDialersDao.set(profile.id, 30, 'Relay');
await testDatabase.proxyDialersDao.set(other.id, 30, 'Other');
final container = ProviderContainer(
overrides: [
profilesProvider.overrideWith(() => TestProfiles([profile, other])),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const SizedBox.shrink(),
),
);
final state = await container.read(setupStateProvider(profile.id).future);
expect(state.proxyDialers, {30: 'Relay'});
});
}
+187 -19
View File
@@ -1,4 +1,5 @@
import 'package:fl_clash/common/app_ports.dart';
import 'package:fl_clash/common/cidr.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
@@ -39,7 +40,11 @@ void main() {
all: [Proxy(name: 'Selected', type: 'Direct', now: 'runtime')],
),
const Group(name: 'Hidden', type: GroupType.Selector, hidden: true),
Group(name: GroupName.GLOBAL.name, type: GroupType.Selector),
Group(
name: GroupName.GLOBAL.name,
type: GroupType.Selector,
hidden: true,
),
];
container.read(groupsProvider.notifier).update((_) => groups);
container
@@ -62,6 +67,31 @@ void main() {
expect(container.read(currentGroupsStateProvider).value, isEmpty);
});
test('a GLOBAL group of the profile\'s own shows in rule mode', () {
container
.read(groupsProvider.notifier)
.update(
(_) => [
Group(
name: GroupName.GLOBAL.name,
type: GroupType.Selector,
hidden: false,
),
],
);
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mode: Mode.rule));
expect(
container
.read(currentGroupsStateProvider)
.value
.map((group) => group.name),
[GroupName.GLOBAL.name],
);
});
test('group derivation ignores a change of selection only', () {
Group group(String now, String nestedNow) => Group(
name: 'Visible',
@@ -392,7 +422,7 @@ void main() {
expect(container.read(filterGroupsStateProvider('hk jp')).value, isEmpty);
});
test('runtime, VPN, tray, and DNS states follow live state', () {
test('runtime, VPN, tray, and DNS states follow live state', () async {
container
.read(runTimeProvider.notifier)
.update((_) => DateTime(2026).millisecondsSinceEpoch);
@@ -433,9 +463,10 @@ void main() {
expect(tray.tunEnable, isTrue);
expect(tray.isStart, isTrue);
final vpn = container.read(vpnStateProvider);
expect(vpn.stack, container.read(patchClashConfigProvider).tun.stack);
expect(vpn.vpnProps, container.read(vpnSettingProvider));
final vpn = await container.read(vpnOptionsProvider.future);
expect(vpn.stack, container.read(patchClashConfigProvider).tun.stack.name);
expect(vpn.port, 8899);
expect(vpn.bypassDomain, ['localhost']);
expect(container.read(shouldPatchSystemDnsProvider), isTrue);
@@ -508,6 +539,7 @@ void main() {
expect(tray.hotKeys.keys, [HotAction.view]);
expect(scoped.read(trayTitleStateProvider).showTrayTitle, isTrue);
expect(scoped.read(shouldPatchSystemDnsProvider), isFalse);
await scoped.read(vpnOptionsProvider.future);
final vpnOptions = scoped.read(sharedStateProvider).vpnOptions!;
expect(vpnOptions.enable, isFalse);
expect(vpnOptions.systemProxy, isFalse);
@@ -660,7 +692,26 @@ void main() {
);
});
test('package, hotkey, profile, and overwrite providers expose defaults', () {
test('a color scheme outlives the provider that generated it', () {
final provider = genColorSchemeProvider(
Brightness.dark,
color: Colors.teal,
);
final first = container.read(provider);
container.invalidate(provider);
expect(container.read(provider), same(first));
container
.read(themeSettingProvider.notifier)
.update(
(state) =>
state.copyWith(schemeVariant: DynamicSchemeVariant.tonalSpot),
);
expect(container.read(provider), isNot(same(first)));
});
test('package, hotkey, profile, and extend providers expose defaults', () {
const package = Package(
packageName: 'app.example',
label: 'Example',
@@ -688,16 +739,11 @@ void main() {
HotAction.tun,
);
final profile = Profile.normal().copyWith(
overwriteType: OverwriteType.custom,
);
final profile = Profile.normal().copyWith(extendType: ExtendType.script);
_profiles(container).replace([profile]);
expect(container.read(profileProvider(profile.id)), profile);
expect(
container.read(overwriteTypeProvider(profile.id)),
OverwriteType.custom,
);
expect(container.read(overwriteTypeProvider(-1)), OverwriteType.standard);
expect(container.read(extendTypeProvider(profile.id)), ExtendType.script);
expect(container.read(extendTypeProvider(-1)), ExtendType.standard);
expect(
container.read(accessControlStateProvider),
@@ -705,6 +751,25 @@ void main() {
);
});
test(
'a custom profile is no proxy provider and has no config file',
() async {
final subscription = Profile.normal(label: 'Sub');
final file = Profile.normal(label: 'File');
final custom = Profile.custom(label: 'Mine');
_profiles(container).replace([subscription, file, custom]);
expect(container.read(profileProvidersProvider), {
'Sub': subscription.id,
'File': file.id,
});
expect(
await container.read(clashConfigProvider(custom.id).future),
const ClashConfig(),
);
},
);
test('shared state hands the VPN service the resolved route list', () async {
await AppLocalizations.load(const Locale('en'));
container.listen(sharedStateProvider, (_, _) {});
@@ -715,26 +780,98 @@ void main() {
tun: state.tun.copyWith(routeAddress: const ['10.0.0.0/8']),
),
);
container
.read(networkSettingProvider.notifier)
.update((state) => state.copyWith(routeMode: RouteMode.config));
await container.read(vpnOptionsProvider.future);
expect(container.read(sharedStateProvider).vpnOptions?.routeAddress, [
'10.0.0.0/8',
'::/0',
]);
container
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(tun: state.tun.copyWith(routeAddress: [])),
);
container
.read(networkSettingProvider.notifier)
.update((state) => state.copyWith(routeMode: RouteMode.bypassPrivate));
.update((state) => state.copyWith(bypassPrivateRoute: true));
await container.read(vpnOptionsProvider.future);
expect(
container.read(sharedStateProvider).vpnOptions?.routeAddress,
defaultBypassPrivateRouteAddress,
subtractCidrs(const [], privateRouteAddress, wholeWhenEmpty: true),
);
});
test('only the route lists recompute the VPN routes', () async {
final computed = <List<String>?>[];
container.listen(
vpnRouteAddressProvider,
(_, next) => computed.add(next.value),
fireImmediately: true,
);
await container.read(vpnRouteAddressProvider.future);
computed.clear();
container
.read(vpnSettingProvider.notifier)
.update((state) => state.copyWith(ipv6: !state.ipv6));
container
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(
mixedPort: state.mixedPort + 1,
tun: state.tun.copyWith(mtu: state.tun.mtu - 1),
),
);
await container.read(vpnOptionsProvider.future);
expect(computed, isEmpty);
container
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(
tun: state.tun.copyWith(routeExcludeAddress: ['10.0.0.0/8']),
),
);
await container.read(vpnRouteAddressProvider.future);
expect(computed.last, isNot(contains('0.0.0.0/0')));
});
test('shared state keeps the last routes while new ones compute', () async {
await AppLocalizations.load(const Locale('en'));
container.listen(sharedStateProvider, (_, _) {});
container
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(
tun: state.tun.copyWith(routeAddress: const ['10.0.0.0/8']),
),
);
await container.read(vpnOptionsProvider.future);
container
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(
tun: state.tun.copyWith(routeAddress: const ['172.16.0.0/12']),
),
);
expect(container.read(sharedStateProvider).vpnOptions?.routeAddress, [
'10.0.0.0/8',
'::/0',
]);
await container.read(vpnOptionsProvider.future);
expect(container.read(sharedStateProvider).vpnOptions?.routeAddress, [
'172.16.0.0/12',
'::/0',
]);
});
// VpnService.setHttpProxy cannot carry credentials.
test('local authentication withholds the VPN system proxy', () async {
await AppLocalizations.load(const Locale('en'));
container.listen(sharedStateProvider, (_, _) {});
await container.read(vpnOptionsProvider.future);
expect(container.read(sharedStateProvider).vpnOptions?.systemProxy, true);
container
@@ -748,6 +885,7 @@ void main() {
),
),
);
await container.read(vpnOptionsProvider.future);
expect(container.read(sharedStateProvider).vpnOptions?.systemProxy, false);
expect(container.read(updateParamsProvider).authentication, ['user:pass']);
@@ -758,10 +896,40 @@ void main() {
authentication: const AuthenticationProps(enable: false),
),
);
await container.read(vpnOptionsProvider.future);
expect(container.read(sharedStateProvider).vpnOptions?.systemProxy, true);
expect(container.read(updateParamsProvider).authentication, isEmpty);
});
test('the setup patch changes only with what a re-apply carries', () {
container.listen(setupPatchProvider, (_, _) {});
final initial = container.read(setupPatchProvider);
container
.read(patchClashConfigProvider.notifier)
.update(
(state) => state.copyWith(
mode: Mode.global,
mixedPort: 8899,
tun: state.tun.copyWith(enable: true),
),
);
expect(container.read(setupPatchProvider), initial);
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(hosts: const {'a.test': '1.1.1.1'}));
final withHosts = container.read(setupPatchProvider);
expect(withHosts, isNot(initial));
container
.read(networkSettingProvider.notifier)
.update(
(state) => state.copyWith(appendSystemDns: !state.appendSystemDns),
);
expect(container.read(setupPatchProvider), isNot(withHosts));
});
test('shared state carries the notification stop action switch', () async {
await AppLocalizations.load(const Locale('en'));
container.listen(sharedStateProvider, (_, _) {});
+94
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -189,6 +191,98 @@ void main() {
});
});
group('batchRun', () {
testWidgets('caps the work in flight and reports failures in one message', (
tester,
) async {
await pumpHost(tester);
const tag = LoadingTag.profiles;
final gates = List.generate(5, (_) => Completer<void>());
var active = 0;
var peak = 0;
final run = globalState.batchRun<int>(
[0, 1, 2, 3, 4],
(index) async {
active++;
peak = active > peak ? active : peak;
try {
await gates[index].future;
if (index.isOdd) {
throw StateError('failed $index');
}
} finally {
active--;
}
},
label: (index) => 'item $index',
concurrency: 2,
tag: tag,
);
await tester.pump();
expect(active, 2);
expect(container.read(loadingProvider(tag)), isTrue);
for (final gate in gates) {
gate.complete();
await tester.pump();
expect(container.read(loadingProvider(tag)), isTrue);
}
await run;
await tester.pumpAndSettle();
expect(peak, 2);
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('2 items failed'), findsOneWidget);
await tester.tap(find.text('View'));
await tester.pumpAndSettle();
expect(find.text('2 items failed'), findsNothing);
expect(find.text('Error details'), findsOneWidget);
expect(find.text('item 1'), findsOneWidget);
expect(find.text('item 3'), findsOneWidget);
expect(find.textContaining('failed 3'), findsOneWidget);
expect(find.text('item 0'), findsNothing);
expect(
tester.getTopLeft(find.text('item 1')).dy,
lessThan(tester.getTopLeft(find.text('item 3')).dy),
);
await tester.pump(const Duration(milliseconds: 1100));
expect(container.read(loadingProvider(tag)), isFalse);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('names a lone failure and its reason in the message', (
tester,
) async {
await pumpHost(tester);
await globalState.batchRun<int>(
[0, 1],
(index) async {
if (index == 1) {
throw StateError('failed 1');
}
},
label: (index) => 'item $index',
concurrency: 2,
tag: null,
);
await tester.pumpAndSettle();
expect(
find.textContaining(RegExp(r'^item 1: .*failed 1')),
findsOneWidget,
);
expect(find.text('View'), findsNothing);
expect(find.byType(AlertDialog), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
});
});
group('canCrashCoreFor', () {
test('is allowed in debug builds and on the dev channel', () {
expect(