test: run the suite from a few shared entrypoints
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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,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(() {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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 => '');
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user