feat(providers): assemble custom profiles and import configs
Changelog: DNS, Hosts and ports apply without a restart
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 [],
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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, (_, _) {});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user