diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e3d6dd6e..cc158433 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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 diff --git a/lib/common/future.dart b/lib/common/future.dart index 465ed656..4eb9a949 100644 --- a/lib/common/future.dart +++ b/lib/common/future.dart @@ -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 on Future { Future withTimeout({ @@ -37,6 +37,24 @@ extension FutureExt on Future { } } +/// 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 { + final Future Function() _resolve; + Future? _pending; + ({T value})? _resolved; + + ResolveOnce(this._resolve); + + Future get value async { + if (_resolved case (:final value)) return value; + final value = await (_pending ??= _resolve()); + _resolved = (value: value); + return value; + } +} + extension CompleterExt on Completer { void safeCompleter(T value) { if (isCompleted) { diff --git a/lib/common/path.dart b/lib/common/path.dart index 4af5b800..1cf2f0c8 100644 --- a/lib/common/path.dart +++ b/lib/common/path.dart @@ -8,11 +8,10 @@ import 'package:path_provider/path_provider.dart'; class AppPath { static AppPath? _instance; - Completer dataDir = Completer(); - late final Future _downloadDir = downloadDirectory(); - Completer tempDir = Completer(); + late ResolveOnce _dataDir; + late ResolveOnce _downloadDir; + late ResolveOnce _tempDir; Completer cacheDir = Completer(); - late String appDirPath; @visibleForTesting static Future 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 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 get downloadDirPath async { - final directory = await _downloadDir; + final directory = await _downloadDir.value; return directory?.path ?? await homeDirPath; } Future get homeDirPath async { - final directory = await dataDir.future; + final directory = await _dataDir.value; return directory.path; } @@ -81,7 +85,7 @@ class AppPath { } Future 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 get sharedPreferencesPath async { - final directory = await dataDir.future; + final directory = await _dataDir.value; return join(directory.path, 'shared_preferences.json'); } Future 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 get tempPath async { - final directory = await tempDir.future; + final directory = await _tempDir.value; return directory.path; } } diff --git a/lib/common/preferences.dart b/lib/common/preferences.dart index 8dd4593a..045fbb25 100644 --- a/lib/common/preferences.dart +++ b/lib/common/preferences.dart @@ -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 sharedPreferencesCompleter = Completer(); + var _store = ResolveOnce(_open); - Future get isInit async => - await sharedPreferencesCompleter.future != null; + Future get isInit async => await _store.value != null; - Preferences._internal() { - SharedPreferences.getInstance() - .then((value) => sharedPreferencesCompleter.complete(value)) - .onError((_, _) => sharedPreferencesCompleter.complete(null)); + Preferences._internal(); + + static Future _open() async { + try { + return await SharedPreferences.getInstance(); + } catch (_) { + return null; + } + } + + @visibleForTesting + Future rebind() async { + _store = ResolveOnce(_open); + await _store.value; } factory Preferences() { @@ -27,23 +37,23 @@ class Preferences { } Future getVersion() async { - final preferences = await sharedPreferencesCompleter.future; + final preferences = await _store.value; return preferences?.getInt('version') ?? 0; } Future setVersion(int version) async { - final preferences = await sharedPreferencesCompleter.future; + final preferences = await _store.value; await preferences?.setInt('version', version); } Future saveShareState(SharedState shareState) async { - final preferences = await sharedPreferencesCompleter.future; + final preferences = await _store.value; await preferences?.setString('sharedState', json.encode(shareState)); } Future?> 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? configMap = json.decode(configString); @@ -59,7 +69,7 @@ class Preferences { Future?> 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 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 saveConfig(Config config) async { - final preferences = await sharedPreferencesCompleter.future; + final preferences = await _store.value; return preferences?.setString(configKey, json.encode(config)) ?? false; } Future 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 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 clearSystemDnsRecord() async { - final sharedPreferencesIns = await sharedPreferencesCompleter.future; + final sharedPreferencesIns = await _store.value; await sharedPreferencesIns?.remove(systemDnsRecordKey); } Future 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 saveBootRecord(BootRecord record) async { - final sharedPreferencesIns = await sharedPreferencesCompleter.future; + final sharedPreferencesIns = await _store.value; await sharedPreferencesIns?.setString(bootRecordKey, json.encode(record)); } Future clearPreferences() async { - final sharedPreferencesIns = await sharedPreferencesCompleter.future; + final sharedPreferencesIns = await _store.value; await sharedPreferencesIns?.clear(); } } diff --git a/pubspec.lock b/pubspec.lock index b4d299da..6bd83007 100755 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 6481e431..a325a317 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/common/app_data_test.dart b/test/common/app_data_test.dart index f4974447..96140c60 100644 --- a/test/common/app_data_test.dart +++ b/test/common/app_data_test.dart @@ -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(); diff --git a/test/common/backup_task_test.dart b/test/common/backup_task_test.dart index d098426d..75db90f2 100644 --- a/test/common/backup_task_test.dart +++ b/test/common/backup_task_test.dart @@ -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 getTemporaryPath() async => root; - - @override - Future getApplicationSupportPath() async => root; - - @override - Future 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(() { diff --git a/test/common/dav_client_test.dart b/test/common/dav_client_test.dart index cbcd0b96..78825c37 100644 --- a/test/common/dav_client_test.dart +++ b/test/common/dav_client_test.dart @@ -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 getTemporaryPath() async => root; - - @override - Future getApplicationSupportPath() async => root; - - @override - Future 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(() { diff --git a/test/common/path_test.dart b/test/common/path_test.dart index 2df35fde..e31e9c1b 100644 --- a/test/common/path_test.dart +++ b/test/common/path_test.dart @@ -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 getTemporaryPath() async => root; - - @override - Future getApplicationSupportPath() async => root; - - @override - Future 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(() { diff --git a/test/common/preferences_test.dart b/test/common/preferences_test.dart index 290f0f49..4148d56a 100644 --- a/test/common/preferences_test.dart +++ b/test/common/preferences_test.dart @@ -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', () { diff --git a/test/common/system_test.dart b/test/common/system_test.dart index d1df74f4..6bc680ed 100644 --- a/test/common/system_test.dart +++ b/test/common/system_test.dart @@ -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 getTemporaryPath() async => root; - - @override - Future getApplicationSupportPath() async => root; - - @override - Future 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(() { diff --git a/test/common/tray_menu_test.dart b/test/common/tray_menu_test.dart index 0a098205..4848d3d0 100644 --- a/test/common/tray_menu_test.dart +++ b/test/common/tray_menu_test.dart @@ -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 getTemporaryPath() async => root; - - @override - Future getApplicationSupportPath() async => root; - - @override - Future 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')); }); diff --git a/test/helpers/app_paths.dart b/test/helpers/app_paths.dart new file mode 100644 index 00000000..fd62a024 --- /dev/null +++ b/test/helpers/app_paths.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +import 'package:fl_clash/common/path.dart'; + +/// Resolves every directory [appPath] hands out to [root]. +Future useAppPaths(Directory root) { + Future resolve() async => root; + AppPath.supportDirectory = resolve; + AppPath.temporaryDirectory = resolve; + AppPath.cacheDirectory = resolve; + AppPath.downloadDirectory = resolve; + return appPath.rebind(); +} diff --git a/test/helpers/preferences.dart b/test/helpers/preferences.dart new file mode 100644 index 00000000..263625ae --- /dev/null +++ b/test/helpers/preferences.dart @@ -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 mockPreferences([Map values = const {}]) { + SharedPreferences.setMockInitialValues(values); + return preferences.rebind(); +} diff --git a/test/manager/android_manager_test.dart b/test/manager/android_manager_test.dart index 55086757..76f4f538 100644 --- a/test/manager/android_manager_test.dart +++ b/test/manager/android_manager_test.dart @@ -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: [ diff --git a/test/manager/core_manager_test.dart b/test/manager/core_manager_test.dart index f9eac1f9..92093b8b 100644 --- a/test/manager/core_manager_test.dart +++ b/test/manager/core_manager_test.dart @@ -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 getTemporaryPath() async => root; - - @override - Future getApplicationSupportPath() async => root; - - @override - Future getApplicationCachePath() async => root; -} - const _nullProfileSetupState = SetupState( profileId: null, profileLastUpdateDate: null, @@ -118,7 +102,6 @@ Future _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(() { diff --git a/test/models/profile_save_test.dart b/test/models/profile_save_test.dart index cecf6e85..0095479d 100644 --- a/test/models/profile_save_test.dart +++ b/test/models/profile_save_test.dart @@ -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 getTemporaryPath() async => root; - - @override - Future getApplicationSupportPath() async => root; - - @override - Future 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(() { diff --git a/test/providers/backup_action_test.dart b/test/providers/backup_action_test.dart index 4e1f4ccd..d2220802 100644 --- a/test/providers/backup_action_test.dart +++ b/test/providers/backup_action_test.dart @@ -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(() { diff --git a/test/providers/clash_providers_action_test.dart b/test/providers/clash_providers_action_test.dart index 7bed1b0c..803c8681 100644 --- a/test/providers/clash_providers_action_test.dart +++ b/test/providers/clash_providers_action_test.dart @@ -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? 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(() { diff --git a/test/providers/database_test.dart b/test/providers/database_test.dart index 00c3bfad..662244cc 100644 --- a/test/providers/database_test.dart +++ b/test/providers/database_test.dart @@ -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 captureWriteFailure(FutureOr Function() body) { - final completer = Completer(); - 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 captureWriteFailure(void Function() body) { + final reported = Completer(); + 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 diff --git a/test/providers/icon_sets_action_test.dart b/test/providers/icon_sets_action_test.dart index 9c34331b..3bca1a4f 100644 --- a/test/providers/icon_sets_action_test.dart +++ b/test/providers/icon_sets_action_test.dart @@ -23,8 +23,8 @@ String _iconSetJson(String name, List 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; diff --git a/test/providers/profiles_action_test.dart b/test/providers/profiles_action_test.dart index f2835b49..a9681310 100644 --- a/test/providers/profiles_action_test.dart +++ b/test/providers/profiles_action_test.dart @@ -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(() { diff --git a/test/providers/store_action_test.dart b/test/providers/store_action_test.dart index 506ac0e0..55ab09bd 100644 --- a/test/providers/store_action_test.dart +++ b/test/providers/store_action_test.dart @@ -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 => ''); diff --git a/test/providers/system_action_test.dart b/test/providers/system_action_test.dart index 4be700e1..a74dc939 100644 --- a/test/providers/system_action_test.dart +++ b/test/providers/system_action_test.dart @@ -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 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(), diff --git a/test/views/clash_providers_view_test.dart b/test/views/clash_providers_view_test.dart index 2bea8f7c..fb305a49 100644 --- a/test/views/clash_providers_view_test.dart +++ b/test/views/clash_providers_view_test.dart @@ -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(() { diff --git a/test/views/resources_view_test.dart b/test/views/resources_view_test.dart index 6d2c4679..32c122f3 100644 --- a/test/views/resources_view_test.dart +++ b/test/views/resources_view_test.dart @@ -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); }); diff --git a/tool/test.dart b/tool/test.dart new file mode 100644 index 00000000..650ed6ee --- /dev/null +++ b/tool/test.dart @@ -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 = []; + var size = 0; +} + +Future main(List arguments) async { + final split = arguments.indexOf('--'); + final paths = split < 0 ? arguments : arguments.sublist(0, split); + final flutterArguments = split < 0 + ? const [] + : 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 _flutterTest(List arguments) async { + final process = await Process.start( + 'flutter', + ['test', ...arguments], + mode: ProcessStartMode.inheritStdio, + runInShell: Platform.isWindows, + ); + return process.exitCode; +} + +List _rerunArguments(List arguments) { + final kept = []; + 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 _collect(List paths) { + final files = {}; + for (final path in paths) { + switch (FileSystemEntity.typeSync(path)) { + case FileSystemEntityType.directory: + files.addAll( + Directory(path) + .listSync(recursive: true) + .whereType() + .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 files})> _writeBundles(List 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 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 files, bool failsOutsideBundles}) _failures( + List<({String path, List 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 = {}; + final tests = {}; + final passed = {}; + final failed = {}; + 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 [] + : 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; + switch (event['type']) { + case 'suite': + final suite = event['suite'] as Map; + suites[suite['id'] as int] = suite['path'] as String; + case 'testStart': + final test = event['test'] as Map; + 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, + ); +}