test: run the suite from a few shared entrypoints

This commit is contained in:
chen08209
2026-10-13 17:01:19 +08:00
parent 03f9553824
commit f151824614
28 changed files with 459 additions and 231 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
run: dart run tool/changelog.dart verify
- name: Run tests
run: flutter test --reporter expanded --coverage
run: dart run tool/test.dart -- --reporter expanded --coverage
- name: Check coverage
run: dart run tool/check_coverage.dart coverage/lcov.info 75
+19 -1
View File
@@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:ui';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/ui/ui.dart';
extension FutureExt<T> on Future<T> {
Future<T> withTimeout({
@@ -37,6 +37,24 @@ extension FutureExt<T> on Future<T> {
}
}
/// Resolves once, then hands out the value rather than the future that
/// resolved it: a completed future still runs its listeners in the zone it was
/// created in, which under test may be a fake-async zone nothing drives.
class ResolveOnce<T> {
final Future<T> Function() _resolve;
Future<T>? _pending;
({T value})? _resolved;
ResolveOnce(this._resolve);
Future<T> get value async {
if (_resolved case (:final value)) return value;
final value = await (_pending ??= _resolve());
_resolved = (value: value);
return value;
}
}
extension CompleterExt<T> on Completer<T> {
void safeCompleter(T value) {
if (isCompleted) {
+24 -20
View File
@@ -8,11 +8,10 @@ import 'package:path_provider/path_provider.dart';
class AppPath {
static AppPath? _instance;
Completer<Directory> dataDir = Completer();
late final Future<Directory?> _downloadDir = downloadDirectory();
Completer<Directory> tempDir = Completer();
late ResolveOnce<Directory> _dataDir;
late ResolveOnce<Directory?> _downloadDir;
late ResolveOnce<Directory> _tempDir;
Completer<Directory> cacheDir = Completer();
late String appDirPath;
@visibleForTesting
static Future<Directory> Function() supportDirectory =
@@ -31,16 +30,21 @@ class AppPath {
getDownloadsDirectory;
AppPath._internal() {
appDirPath = join(dirname(Platform.resolvedExecutable));
supportDirectory().then((value) {
dataDir.complete(value);
});
temporaryDirectory().then((value) {
tempDir.complete(value);
});
cacheDirectory().then((value) {
cacheDir.complete(value);
});
_bind();
}
@visibleForTesting
Future<void> rebind() async {
cacheDir = Completer();
_bind();
await Future.wait([_dataDir.value, _downloadDir.value, _tempDir.value]);
}
void _bind() {
_dataDir = ResolveOnce(supportDirectory);
_downloadDir = ResolveOnce(downloadDirectory);
_tempDir = ResolveOnce(temporaryDirectory);
cacheDirectory().then(cacheDir.complete);
}
factory AppPath() {
@@ -66,12 +70,12 @@ class AppPath {
}
Future<String> get downloadDirPath async {
final directory = await _downloadDir;
final directory = await _downloadDir.value;
return directory?.path ?? await homeDirPath;
}
Future<String> get homeDirPath async {
final directory = await dataDir.future;
final directory = await _dataDir.value;
return directory.path;
}
@@ -81,7 +85,7 @@ class AppPath {
}
Future<String> get tempFilePath async {
final mTempDir = await tempDir.future;
final mTempDir = await _tempDir.value;
return join(mTempDir.path, 'temp$uniqueId');
}
@@ -96,12 +100,12 @@ class AppPath {
}
Future<String> get sharedPreferencesPath async {
final directory = await dataDir.future;
final directory = await _dataDir.value;
return join(directory.path, 'shared_preferences.json');
}
Future<String> get profilesPath async {
final directory = await dataDir.future;
final directory = await _dataDir.value;
return join(directory.path, profilesDirectoryName);
}
@@ -156,7 +160,7 @@ class AppPath {
}
Future<String> get tempPath async {
final directory = await tempDir.future;
final directory = await _tempDir.value;
return directory.path;
}
}
+30 -20
View File
@@ -6,19 +6,29 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/system_dns.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class Preferences {
static Preferences? _instance;
Completer<SharedPreferences?> sharedPreferencesCompleter = Completer();
var _store = ResolveOnce(_open);
Future<bool> get isInit async =>
await sharedPreferencesCompleter.future != null;
Future<bool> get isInit async => await _store.value != null;
Preferences._internal() {
SharedPreferences.getInstance()
.then((value) => sharedPreferencesCompleter.complete(value))
.onError((_, _) => sharedPreferencesCompleter.complete(null));
Preferences._internal();
static Future<SharedPreferences?> _open() async {
try {
return await SharedPreferences.getInstance();
} catch (_) {
return null;
}
}
@visibleForTesting
Future<void> rebind() async {
_store = ResolveOnce(_open);
await _store.value;
}
factory Preferences() {
@@ -27,23 +37,23 @@ class Preferences {
}
Future<int> getVersion() async {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
return preferences?.getInt('version') ?? 0;
}
Future<void> setVersion(int version) async {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
await preferences?.setInt('version', version);
}
Future<void> saveShareState(SharedState shareState) async {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
await preferences?.setString('sharedState', json.encode(shareState));
}
Future<Map<String, Object?>?> getConfigMap() async {
try {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
final configString = preferences?.getString(configKey);
if (configString == null) return null;
final Map<String, Object?>? configMap = json.decode(configString);
@@ -59,7 +69,7 @@ class Preferences {
Future<Map<String, Object?>?> getClashConfigMap() async {
try {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
final clashConfigString = preferences?.getString(clashConfigKey);
if (clashConfigString == null) return null;
return json.decode(clashConfigString);
@@ -74,7 +84,7 @@ class Preferences {
Future<void> clearClashConfig() async {
try {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
await preferences?.remove(clashConfigKey);
return;
} catch (e) {
@@ -95,13 +105,13 @@ class Preferences {
}
Future<bool> saveConfig(Config config) async {
final preferences = await sharedPreferencesCompleter.future;
final preferences = await _store.value;
return preferences?.setString(configKey, json.encode(config)) ?? false;
}
Future<SystemDnsRecord?> getSystemDnsRecord() async {
try {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
final raw = sharedPreferencesIns?.getString(systemDnsRecordKey);
if (raw == null) {
return null;
@@ -117,7 +127,7 @@ class Preferences {
}
Future<void> saveSystemDnsRecord(SystemDnsRecord record) async {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
await sharedPreferencesIns?.setString(
systemDnsRecordKey,
json.encode(record),
@@ -125,13 +135,13 @@ class Preferences {
}
Future<void> clearSystemDnsRecord() async {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
await sharedPreferencesIns?.remove(systemDnsRecordKey);
}
Future<BootRecord?> getBootRecord() async {
try {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
final raw = sharedPreferencesIns?.getString(bootRecordKey);
if (raw == null) {
return null;
@@ -147,12 +157,12 @@ class Preferences {
}
Future<void> saveBootRecord(BootRecord record) async {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
await sharedPreferencesIns?.setString(bootRecordKey, json.encode(record));
}
Future<void> clearPreferences() async {
final sharedPreferencesIns = await sharedPreferencesCompleter.future;
final sharedPreferencesIns = await _store.value;
await sharedPreferencesIns?.clear();
}
}
+1 -1
View File
@@ -1014,7 +1014,7 @@ packages:
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: "direct dev"
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
-1
View File
@@ -91,7 +91,6 @@ dev_dependencies:
sqlite3: ^3.7.0
cross_file: ^0.3.5+5
mocktail: ^1.0.5
path_provider_platform_interface: ^2.1.3
flutter:
uses-material-design: true
+8 -7
View File
@@ -6,19 +6,18 @@ 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';
import '../helpers/app_paths.dart';
import '../helpers/preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory home;
setUpAll(() {
setUpAll(() async {
home = Directory.systemTemp.createTempSync('flclash-app-data-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
await useAppPaths(home);
});
tearDownAll(() {
@@ -26,7 +25,7 @@ void main() {
});
test('clears what a startup failure left behind without the Core', () async {
SharedPreferences.setMockInitialValues({});
await mockPreferences();
await preferences.setVersion(7);
final databaseFile = File(await appPath.databasePath)
..writeAsStringSync('not a database');
@@ -35,7 +34,9 @@ void main() {
final broken = db.Database(
LazyDatabase(() async => throw const FileSystemException('corrupt')),
);
final previous = db.database;
db.database = broken;
addTearDown(() => db.database = previous);
await expectLater(broken.profilesDao.query().get(), throwsA(anything));
await clearAppData();
+2 -16
View File
@@ -10,22 +10,8 @@ import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
class _FakePathProvider extends PathProviderPlatform {
final String root;
_FakePathProvider(this.root);
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
import '../helpers/app_paths.dart';
/// The public entry points hand their work to a background isolate, which
/// cannot reach the mocked platform channels, so the tests drive the worker
@@ -40,7 +26,7 @@ void main() {
// already-existing temp and cache dirs the platform hands out.
Directory(join(root.path, 'tmp')).createSync(recursive: true);
Directory(join(root.path, 'archive')).createSync(recursive: true);
PathProviderPlatform.instance = _FakePathProvider(root.path);
await useAppPaths(root);
});
tearDownAll(() {
+4 -20
View File
@@ -7,7 +7,8 @@ import 'package:fl_clash/common/dav_client.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import '../helpers/app_paths.dart';
class _FakeDAVClient extends DAVClient {
_FakeDAVClient(super.dav, this.result, [this.onPing]);
@@ -22,21 +23,6 @@ class _FakeDAVClient extends DAVClient {
}
}
class _FakePathProvider extends PathProviderPlatform {
final String root;
_FakePathProvider(this.root);
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
class _RecordedRequest {
_RecordedRequest(this.method, this.path, this.authorization, this.body);
@@ -213,11 +199,9 @@ void main() {
late Directory root;
HttpOverrides? testOverrides;
// appPath resolves the data directory once per process, so every test in
// this group has to share one root.
setUpAll(() {
setUpAll(() async {
root = Directory.systemTemp.createTempSync('dav_client_test');
PathProviderPlatform.instance = _FakePathProvider(root.path);
await useAppPaths(root);
});
tearDownAll(() {
+3 -17
View File
@@ -4,32 +4,18 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:yaml/yaml.dart';
class _FakePathProvider extends PathProviderPlatform {
_FakePathProvider(this.root);
final String root;
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
import '../helpers/app_paths.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory root;
setUpAll(() {
setUpAll(() async {
root = Directory.systemTemp.createTempSync('path_test');
PathProviderPlatform.instance = _FakePathProvider(root.path);
await useAppPaths(root);
});
tearDownAll(() {
+3 -2
View File
@@ -7,6 +7,8 @@ import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/preferences.dart';
const _sharedState = SharedState(
stopTip: 'stop',
startTip: 'start',
@@ -19,13 +21,12 @@ const _sharedState = SharedState(
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
late SharedPreferences store;
setUp(() async {
await mockPreferences();
store = await SharedPreferences.getInstance();
await store.clear();
});
group('version', () {
+3 -17
View File
@@ -3,22 +3,8 @@ import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
class _FakePathProvider extends PathProviderPlatform {
_FakePathProvider(this.root);
final String root;
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
import '../helpers/app_paths.dart';
class _RecordedRun {
const _RecordedRun(this.executable, this.arguments);
@@ -115,9 +101,9 @@ void main() {
late _FakeProcesses processes;
String? password;
setUpAll(() {
setUpAll(() async {
root = Directory.systemTemp.createTempSync('system_test');
PathProviderPlatform.instance = _FakePathProvider(root.path);
await useAppPaths(root);
});
tearDownAll(() {
+3 -17
View File
@@ -17,10 +17,11 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:riverpod/riverpod.dart';
import 'package:tray/tray.dart';
import '../helpers/app_paths.dart';
const _channel = MethodChannel('tray');
const _proxyGroup = Group(
@@ -41,21 +42,6 @@ HotKeyAction _bind(HotAction action, PhysicalKeyboardKey key) {
);
}
class _FakePathProvider extends PathProviderPlatform {
_FakePathProvider(this.root);
final String root;
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
TrayState _trayState({
bool isStart = false,
bool tunEnable = false,
@@ -106,7 +92,7 @@ void main() {
setUpAll(() async {
root = Directory.systemTemp.createTempSync('tray_menu_test');
PathProviderPlatform.instance = _FakePathProvider(root.path);
await useAppPaths(root);
await AppLocalizations.load(const Locale('en'));
});
+13
View File
@@ -0,0 +1,13 @@
import 'dart:io';
import 'package:fl_clash/common/path.dart';
/// Resolves every directory [appPath] hands out to [root].
Future<void> useAppPaths(Directory root) {
Future<Directory> resolve() async => root;
AppPath.supportDirectory = resolve;
AppPath.temporaryDirectory = resolve;
AppPath.cacheDirectory = resolve;
AppPath.downloadDirectory = resolve;
return appPath.rebind();
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:fl_clash/common/preferences.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Backs [preferences] with a fresh in-memory store holding [values].
Future<void> mockPreferences([Map<String, Object> values = const {}]) {
SharedPreferences.setMockInitialValues(values);
return preferences.rebind();
}
+2 -2
View File
@@ -13,6 +13,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/preferences.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
@@ -28,14 +29,13 @@ class _RecordingSetupAction extends SetupAction {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
late ProviderContainer container;
late SharedPreferences store;
setUp(() async {
await mockPreferences();
store = await SharedPreferences.getInstance();
await store.clear();
_RecordingSetupAction.requests.clear();
container = ProviderContainer(
overrides: [
+3 -22
View File
@@ -1,7 +1,5 @@
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/core/core.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -17,32 +15,18 @@ import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/providers/route_state.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/app_paths.dart';
import '../helpers/test_profiles.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
class _FakePathProvider extends PathProviderPlatform {
final String root;
_FakePathProvider(this.root);
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
const _nullProfileSetupState = SetupState(
profileId: null,
profileLastUpdateDate: null,
@@ -118,7 +102,6 @@ Future<ProviderContainer> _pumpCoreManager(
supportedLocales: AppLocalizations.delegate.supportedLocales,
builder: (context, child) {
globalState.measure = Measure.of(context, 1);
globalState.theme = CommonTheme.of(context, 1);
return StatusManager(child: child!);
},
home: const CoreManager(child: SizedBox()),
@@ -161,9 +144,7 @@ void main() {
profileSwitchTempDir = Directory.systemTemp.createTempSync(
'core_manager_test',
);
PathProviderPlatform.instance = _FakePathProvider(
profileSwitchTempDir.path,
);
await useAppPaths(profileSwitchTempDir);
});
tearDownAll(() {
+3 -17
View File
@@ -5,31 +5,17 @@ import 'dart:typed_data';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
class _FakePathProvider extends PathProviderPlatform {
final String root;
_FakePathProvider(this.root);
@override
Future<String?> getTemporaryPath() async => root;
@override
Future<String?> getApplicationSupportPath() async => root;
@override
Future<String?> getApplicationCachePath() async => root;
}
import '../helpers/app_paths.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUpAll(() {
setUpAll(() async {
tempDir = Directory.systemTemp.createTempSync('profile_save_test');
PathProviderPlatform.instance = _FakePathProvider(tempDir.path);
await useAppPaths(tempDir);
});
tearDownAll(() {
+5 -6
View File
@@ -13,7 +13,9 @@ import 'package:flutter/widgets.dart' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:riverpod/riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/app_paths.dart';
import '../helpers/preferences.dart';
Profile _profile(int id, String label) => Profile(
id: id,
@@ -29,13 +31,10 @@ void main() {
late Directory home;
setUpAll(() async {
SharedPreferences.setMockInitialValues({'version': 1});
await mockPreferences({'version': 1});
await AppLocalizations.load(const Locale('en'));
home = Directory.systemTemp.createTempSync('flclash-backup-action-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
await useAppPaths(home);
});
tearDownAll(() {
@@ -18,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:riverpod/riverpod.dart';
import '../helpers/app_paths.dart';
import '../helpers/test_profiles.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
@@ -56,12 +57,9 @@ void main() {
late Completer<void>? compileGate;
late List<({String name, String url})> compiles;
setUpAll(() {
setUpAll(() async {
home = Directory.systemTemp.createTempSync('flclash-providers-action-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
await useAppPaths(home);
});
tearDownAll(() {
+18 -8
View File
@@ -5,8 +5,10 @@ import 'package:drift/native.dart';
// notifier, so the schema side is imported behind a prefix.
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/database.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
import 'package:flutter_test/flutter_test.dart';
@@ -17,6 +19,7 @@ import 'package:flutter_test/flutter_test.dart';
/// fails. These tests drive the real drift schema on an in-memory executor so
/// both halves — the optimistic value and the rollback — are observable.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const profileId = 1;
late db.Database testDatabase;
@@ -29,6 +32,8 @@ void main() {
order: order,
);
setUpAll(() => AppLocalizations.load(const Locale('en')));
setUp(() async {
testDatabase = db.Database(NativeDatabase.memory());
db.database = testDatabase;
@@ -48,14 +53,19 @@ void main() {
}
/// Most call sites wrap the persist step in `unawaited(...)`, so the failure
/// never reaches the caller — it lands in the zone handler, and only after
/// the rollback has already restored the snapshot.
Future<Object> captureWriteFailure(FutureOr<void> Function() body) {
final completer = Completer<Object>();
runZonedGuarded(() async => body(), (error, _) {
if (!completer.isCompleted) completer.complete(error);
});
return completer.future.timeout(const Duration(seconds: 5));
/// never reaches the caller: the notifier logs it, and only after the
/// rollback has already restored the snapshot.
Future<void> captureWriteFailure(void Function() body) {
final reported = Completer<void>();
final print = debugPrint;
debugPrint = (message, {wrapWidth}) {
if (message?.contains('Optimistic database write failed') ?? false) {
if (!reported.isCompleted) reported.complete();
}
};
addTearDown(() => debugPrint = print);
body();
return reported.future.timeout(const Duration(seconds: 5));
}
/// `customStatement` does not raise a drift table-update notification, so the
+5 -2
View File
@@ -23,8 +23,8 @@ String _iconSetJson(String name, List<String> icons) => json.encode({
],
});
/// Real downloads over loopback: the test binding would answer every request
/// with a 400, so this suite runs without it.
/// Real downloads over loopback: the test binding answers every request with a
/// 400, so its override is lifted while a test runs.
void main() {
late HttpServer server;
late String body;
@@ -56,6 +56,9 @@ void main() {
tearDownAll(() => server.close(force: true));
setUp(() {
final bindingOverrides = HttpOverrides.current;
HttpOverrides.global = null;
addTearDown(() => HttpOverrides.global = bindingOverrides);
held = null;
testDatabase = db.Database(NativeDatabase.memory());
db.database = testDatabase;
+2 -4
View File
@@ -17,6 +17,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yaml/yaml.dart';
import '../helpers/app_paths.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
@@ -32,10 +33,7 @@ void main() {
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;
await useAppPaths(home);
});
tearDownAll(() {
+6 -7
View File
@@ -15,7 +15,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart';
import 'package:riverpod/riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/app_paths.dart';
import '../helpers/preferences.dart';
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
@@ -44,13 +46,10 @@ void main() {
late _MockCoreHandlerInterface core;
late db.Database testDatabase;
setUpAll(() {
setUpAll(() async {
registerFallbackValue(0);
home = Directory.systemTemp.createTempSync('flclash-store-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
await useAppPaths(home);
});
tearDownAll(() {
@@ -58,7 +57,7 @@ void main() {
});
setUp(() async {
SharedPreferences.setMockInitialValues({});
await mockPreferences();
await preferences.setVersion(7);
core = _MockCoreHandlerInterface();
when(() => core.clearEffect(any())).thenAnswer((_) async => '');
+4 -3
View File
@@ -10,7 +10,8 @@ import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/preferences.dart';
class TestSystemAction extends SystemAction {
final List<String> calls = [];
@@ -255,7 +256,7 @@ void main() {
test(
'saving preferences captures the latest normal window geometry',
() async {
SharedPreferences.setMockInitialValues({});
await mockPreferences();
const geometry = WindowProps(width: 1180, height: 760, left: 72, top: 48);
windowPort = _GeometryWindowPort(geometry);
final action = _PersistenceSystemAction();
@@ -279,7 +280,7 @@ void main() {
test(
'a geometry failure does not prevent saving the current config',
() async {
SharedPreferences.setMockInitialValues({});
await mockPreferences();
const current = WindowProps(width: 900, height: 640, left: 24, top: 16);
windowPort = _GeometryWindowPort(
const WindowProps(),
+3 -4
View File
@@ -14,6 +14,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../helpers/app_paths.dart';
import '../helpers/context_menu.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
@@ -89,11 +90,9 @@ ProviderContainer _containerFor(
void main() {
late Directory home;
setUpAll(() {
setUpAll(() async {
home = Directory.systemTemp.createTempSync('flclash-rule-sets-view-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
await useAppPaths(home);
});
tearDownAll(() {
+3 -11
View File
@@ -2,12 +2,12 @@ import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/views/resources.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/services.dart';
@@ -16,6 +16,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' hide context;
import '../helpers/app_paths.dart';
import '../helpers/context_menu.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
@@ -25,22 +26,13 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory home;
final supportDirectory = AppPath.supportDirectory;
final temporaryDirectory = AppPath.temporaryDirectory;
final cacheDirectory = AppPath.cacheDirectory;
setUpAll(() async {
home = Directory.systemTemp.createTempSync('flclash-resources-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
await appPath.homeDirPath;
await useAppPaths(home);
});
tearDownAll(() {
AppPath.supportDirectory = supportDirectory;
AppPath.temporaryDirectory = temporaryDirectory;
AppPath.cacheDirectory = cacheDirectory;
if (home.existsSync()) home.deleteSync(recursive: true);
});
+280
View File
@@ -0,0 +1,280 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:path/path.dart' as p;
const _usage =
'''
Runs the root package tests from a few generated entrypoints.
usage: dart run tool/test.dart [path ...] [-- flutter test arguments]
Every *_test.dart under the given paths (default: test) is imported into one of
$_bundleCount entrypoints under $_bundleDir, so `flutter test` compiles and
starts $_bundleCount programs rather than one per file. A file carrying a
library-level annotation such as @TestOn runs on its own.
Files in one entrypoint share an isolate. A file that fails there is run again
on its own, and that run decides the result; the files that pass alone are
listed, since they depend on or are broken by state another file left. A
failure in a file that already ran on its own, or one the report ties to no
file, fails the run whatever the rerun finds.''';
// Fixed, so a commit groups its files the same way on every machine.
const _bundleCount = 32;
const _bundleDir = '.dart_tool/test_bundles';
const _reportPath = '$_bundleDir/report.json';
final _annotation = RegExp(r'^@(TestOn|Tags|Timeout|Skip|OnPlatform)\b');
// Options of `flutter test` the rerun drops: it only decides pass or fail, and
// would otherwise overwrite the coverage of the whole run.
// Sharding its few files anew would skip the test that failed.
const _rerunDropsWithValue = {
'--coverage-path',
'--coverage-package',
'--file-reporter',
'--total-shards',
'--shard-index',
};
const _rerunDropsFlag = {'--coverage', '--merge-coverage', '--branch-coverage'};
class _Bundle {
final files = <String>[];
var size = 0;
}
Future<void> main(List<String> arguments) async {
final split = arguments.indexOf('--');
final paths = split < 0 ? arguments : arguments.sublist(0, split);
final flutterArguments = split < 0
? const <String>[]
: arguments.sublist(split + 1);
if (paths.contains('-h') || paths.contains('--help')) {
stdout.writeln(_usage);
return;
}
final files = _collect(paths.isEmpty ? const ['test'] : paths);
final standalone = files.where(_hasAnnotation).toList();
final bundles = _writeBundles(
files.where((file) => !standalone.contains(file)).toList(),
);
final setsConcurrency = flutterArguments.any(
(argument) =>
argument.startsWith('-j') || argument.startsWith('--concurrency'),
);
final reports = !flutterArguments.any(
(argument) => argument.startsWith('--file-reporter'),
);
final code = await _flutterTest([
if (!setsConcurrency)
'--concurrency=${min(_bundleCount, Platform.numberOfProcessors)}',
if (reports) '--file-reporter=json:$_reportPath',
...flutterArguments,
for (final bundle in bundles) bundle.path,
...standalone,
]);
if (code == 0 || !reports) exit(code);
final failures = _failures(bundles);
final failed = failures.files;
if (failed.isEmpty) exit(code);
stdout.writeln(
'\n${failed.length} file(s) failed in a shared isolate; '
'running them on their own:\n${failed.map((f) => ' $f').join('\n')}\n',
);
final rerunCode = await _flutterTest([
..._rerunArguments(flutterArguments),
...failed,
]);
if (rerunCode == 0) {
stdout.writeln(
'\nEvery file above passes on its own: each depends on, or is broken by, '
'state another file in its entrypoint left behind.',
);
}
if (failures.failsOutsideBundles) {
stdout.writeln(
'\nThe run still fails: other failures came from files that ran on '
'their own, or from no file the report names.',
);
exit(code);
}
exit(rerunCode);
}
Future<int> _flutterTest(List<String> arguments) async {
final process = await Process.start(
'flutter',
['test', ...arguments],
mode: ProcessStartMode.inheritStdio,
runInShell: Platform.isWindows,
);
return process.exitCode;
}
List<String> _rerunArguments(List<String> arguments) {
final kept = <String>[];
for (var i = 0; i < arguments.length; i++) {
final argument = arguments[i];
final name = argument.split('=').first;
if (_rerunDropsFlag.contains(name)) continue;
if (_rerunDropsWithValue.contains(name)) {
if (!argument.contains('=')) i++;
continue;
}
kept.add(argument);
}
return kept;
}
List<String> _collect(List<String> paths) {
final files = <String>{};
for (final path in paths) {
switch (FileSystemEntity.typeSync(path)) {
case FileSystemEntityType.directory:
files.addAll(
Directory(path)
.listSync(recursive: true)
.whereType<File>()
.map((file) => p.normalize(file.path))
.where((file) => file.endsWith('_test.dart')),
);
case FileSystemEntityType.file:
files.add(p.normalize(path));
default:
stderr.writeln('No such test path: $path');
exit(64);
}
}
if (files.isEmpty) {
stderr.writeln('No *_test.dart files under ${paths.join(' ')}');
exit(64);
}
return files.toList()..sort();
}
bool _hasAnnotation(String file) {
for (final line in File(file).readAsLinesSync()) {
if (_annotation.hasMatch(line)) return true;
if (line.startsWith('import ') || line.startsWith('void main')) break;
}
return false;
}
/// Hands each file, largest first, to the bundle with the least code so far;
/// size stands in for run time.
List<({String path, List<String> files})> _writeBundles(List<String> files) {
final directory = Directory(_bundleDir);
if (directory.existsSync()) directory.deleteSync(recursive: true);
directory.createSync(recursive: true);
final sizes = {for (final file in files) file: File(file).lengthSync()};
final bundles = List.generate(
min(_bundleCount, files.length),
(_) => _Bundle(),
);
final bySize = [...files]
..sort((a, b) {
final order = sizes[b]!.compareTo(sizes[a]!);
return order != 0 ? order : a.compareTo(b);
});
for (final file in bySize) {
final bundle = bundles.reduce((a, b) => b.size < a.size ? b : a);
bundle.files.add(file);
bundle.size += sizes[file]!;
}
return [
for (final (index, bundle) in bundles.indexed)
(
path: _writeBundle(directory, index, bundle.files..sort()),
files: bundle.files,
),
];
}
String _writeBundle(Directory directory, int index, List<String> files) {
final buffer = StringBuffer()
..writeln('// Generated by tool/test.dart.')
..writeln("import 'package:flutter_test/flutter_test.dart';");
for (final (i, file) in files.indexed) {
buffer.writeln(
"import '${_posix(p.relative(file, from: directory.path))}' as t$i;",
);
}
buffer
..writeln()
..writeln('void main() {');
for (final (i, file) in files.indexed) {
buffer.writeln(" group('${_posix(file)}', t$i.main);");
}
buffer.writeln('}');
final name = 'bundle_${index.toString().padLeft(2, '0')}_test.dart';
final path = p.join(directory.path, name);
File(path).writeAsStringSync(buffer.toString());
return path;
}
String _posix(String path) => p.posix.joinAll(p.split(path));
/// The files behind every failed test in the JSON report; a bundle that failed
/// to load stands for all of its files.
({List<String> files, bool failsOutsideBundles}) _failures(
List<({String path, List<String> files})> bundles,
) {
final report = File(_reportPath);
if (!report.existsSync()) {
return (files: const [], failsOutsideBundles: false);
}
final byPath = {
for (final bundle in bundles) p.canonicalize(bundle.path): bundle.files,
};
final suites = <int, String>{};
final tests = <int, ({int suite, String name})>{};
final passed = <int>{};
final failed = <String>{};
var failsOutsideBundles = false;
void fail(int id) {
final test = tests[id];
final suite = test == null ? null : suites[test.suite];
final files = suite == null ? null : byPath[p.canonicalize(suite)];
final owners = test == null || files == null
? const <String>[]
: test.name.startsWith('loading ')
? files
: files.where((file) => test.name.startsWith('${_posix(file)} '));
if (owners.isEmpty) failsOutsideBundles = true;
failed.addAll(owners);
}
for (final line in report.readAsLinesSync()) {
if (!line.startsWith('{')) continue;
final event = jsonDecode(line) as Map<String, dynamic>;
switch (event['type']) {
case 'suite':
final suite = event['suite'] as Map<String, dynamic>;
suites[suite['id'] as int] = suite['path'] as String;
case 'testStart':
final test = event['test'] as Map<String, dynamic>;
tests[test['id'] as int] = (
suite: test['suiteID'] as int,
name: test['name'] as String,
);
case 'error' when passed.contains(event['testID']):
fail(event['testID'] as int);
case 'testDone' when event['result'] == 'success':
passed.add(event['testID'] as int);
case 'testDone':
fail(event['testID'] as int);
}
}
return (
files: failed.toList()..sort(),
failsOutsideBundles: failsOutsideBundles,
);
}