chore(agents,tool): rebuild agent config, lint gates, CI, and release tooling
Rework .agents/, Claude/Codex config, CI workflows, lint gates and lint-rule tests, and the release/changelog tooling. The 5% comment density gate stays diff-scoped inside git hooks, where GIT_DIR outranks the -C the gate uses to locate the repository. The tray icon generator writes multi-size ICO files from the SVG sources into the per-platform tray asset directories. The custom code-review subagents are gone: the built-in /code-review never dispatched them, and the subagent model now lives in a user-level setting outside the repository. The Makefile, build_config.yaml and CI hand the Core and Helper builds to the setup build hook, so the buildkit launchers go, and setup.dart packages AppImage and rpm on arm64 with flutter_distributor pinned to the fork tag that normalizes packaging permissions. Changelog: skip
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _entryPoints = ['lib/main.dart'];
|
||||
|
||||
/// Types, and the top level names a file publishes alongside them — the
|
||||
/// `final appPath = AppPath()` singleton pattern is how most of `lib/common`
|
||||
/// is actually consumed.
|
||||
final _declaration = RegExp(
|
||||
r'^(?:abstract |sealed |final |base |mixin )*'
|
||||
r'(?:(?:class|enum|mixin)\s+([A-Za-z]\w*)'
|
||||
r'|(?:final|const)\s+(?:[\w<>,\s\[\]?]+\s+)?([a-z]\w*)\s*=)',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
Iterable<File> _dartFiles({required bool includeGenerated}) sync* {
|
||||
for (final root in ['lib', 'test', 'tool', 'plugins']) {
|
||||
final directory = Directory(root);
|
||||
if (!directory.existsSync()) {
|
||||
fail('$root no longer exists; update this test.');
|
||||
}
|
||||
for (final entity in directory.listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||
final generated =
|
||||
entity.path.endsWith('.g.dart') ||
|
||||
entity.path.endsWith('.freezed.dart') ||
|
||||
entity.path.contains('/generated/');
|
||||
if (generated && !includeGenerated) continue;
|
||||
yield entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isBarrel(String source) {
|
||||
final lines = source
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.where((line) => line.isNotEmpty && !line.startsWith('//'));
|
||||
return lines.isNotEmpty &&
|
||||
lines.every(
|
||||
(line) => line.startsWith('export ') || line.startsWith('import '),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('every file under lib declares something used outside itself', () {
|
||||
// Generated code counts as a consumer but never as a declarer: a riverpod
|
||||
// notifier is reached through the provider its annotation generates.
|
||||
final consumers = {
|
||||
for (final file in _dartFiles(includeGenerated: true))
|
||||
file.path: file.readAsStringSync(),
|
||||
};
|
||||
final sources = {
|
||||
for (final file in _dartFiles(includeGenerated: false))
|
||||
file.path: consumers[file.path]!,
|
||||
};
|
||||
final barrels = {
|
||||
for (final MapEntry(key: path, value: source) in sources.entries)
|
||||
if (_isBarrel(source)) path,
|
||||
};
|
||||
|
||||
final orphans = <String>[];
|
||||
|
||||
for (final MapEntry(key: path, value: source) in sources.entries) {
|
||||
if (!path.startsWith('lib/')) continue;
|
||||
if (barrels.contains(path) || _entryPoints.contains(path)) continue;
|
||||
if (path.startsWith('lib/l10n/')) continue;
|
||||
|
||||
// Extensions and typedefs are reached through the types they attach to,
|
||||
// never by name, so a file that publishes only those cannot be measured
|
||||
// this way.
|
||||
final names = _declaration
|
||||
.allMatches(source)
|
||||
.map((match) => match.group(1) ?? match.group(2)!)
|
||||
.toSet();
|
||||
if (names.isEmpty) continue;
|
||||
|
||||
final used = consumers.entries.any((entry) {
|
||||
if (entry.key == path || barrels.contains(entry.key)) return false;
|
||||
return names.any((name) => RegExp('\\b$name\\b').hasMatch(entry.value));
|
||||
});
|
||||
|
||||
if (!used) {
|
||||
orphans.add(
|
||||
'$path — publishes ${names.join(', ')}, none of which is referenced '
|
||||
'anywhere else. A barrel export keeps it compiling; it is still dead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(orphans, isEmpty, reason: orphans.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _roots = ['lib', 'test', 'tool', 'plugins'];
|
||||
|
||||
const _forbiddenImports = <String, String>{
|
||||
'package:cupertino_ui/':
|
||||
'the app is Material only; take widgets-layer types from '
|
||||
'package:flutter/widgets.dart instead',
|
||||
'package:flutter/material.dart':
|
||||
'Material comes from package:material_ui/material_ui.dart',
|
||||
'package:flutter/cupertino.dart':
|
||||
'the app is Material only; take widgets-layer types from '
|
||||
'package:flutter/widgets.dart instead',
|
||||
};
|
||||
|
||||
const _generatedL10n = ['lib/l10n/l10n.dart', 'lib/l10n/intl/'];
|
||||
|
||||
bool _isGenerated(String path) {
|
||||
return path.contains('/generated/') ||
|
||||
path.endsWith('.g.dart') ||
|
||||
path.endsWith('.freezed.dart') ||
|
||||
_generatedL10n.any(path.startsWith);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('material_ui is the only design library the project imports', () {
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final root in _roots) {
|
||||
final directory = Directory(root);
|
||||
if (!directory.existsSync()) {
|
||||
fail('$root no longer exists; update _roots.');
|
||||
}
|
||||
for (final entity in directory.listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) {
|
||||
continue;
|
||||
}
|
||||
final relative = p.relative(entity.path);
|
||||
if (_isGenerated(relative)) {
|
||||
continue;
|
||||
}
|
||||
final lines = entity.readAsLinesSync();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i].trimLeft();
|
||||
if (!line.startsWith('import ') && !line.startsWith('export ')) {
|
||||
continue;
|
||||
}
|
||||
for (final entry in _forbiddenImports.entries) {
|
||||
if (line.contains("'${entry.key}") ||
|
||||
line.contains('"${entry.key}')) {
|
||||
offenders.add('$relative:${i + 1} — ${entry.value}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, isEmpty, reason: offenders.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _disposableTypes = {
|
||||
'AnimationController',
|
||||
'FocusNode',
|
||||
'PageController',
|
||||
'ScrollController',
|
||||
'StreamController',
|
||||
'TabController',
|
||||
'TextEditingController',
|
||||
'ValueNotifier',
|
||||
};
|
||||
|
||||
/// Fields the analyzer would flag that are deliberately never released, keyed by
|
||||
/// `<path>#<field>`. Everything here has to outlive the process, not a widget.
|
||||
const _allowed = {
|
||||
// `CoreEventManager` is a private-constructor singleton that fans core events
|
||||
// out for the whole run; closing its controller would end event delivery.
|
||||
'lib/core/event.dart#_controller',
|
||||
};
|
||||
|
||||
final _declaration = RegExp(
|
||||
r'^\s+(?:late\s+)?final\s+(?:[A-Za-z][\w<>,\s?]*\s+)?(_?[A-Za-z]\w*)\s*=\s*'
|
||||
r'(?:[A-Za-z]\w*)?'
|
||||
'(${_disposableTypes.join('|')})'
|
||||
r'\b',
|
||||
);
|
||||
|
||||
/// A `late` field declared without an initialiser, which the pattern above
|
||||
/// cannot see because there is no constructor call on the line. These are
|
||||
/// built in `initState` and owned just the same — `pages/editor.dart` kept a
|
||||
/// `late TextEditingController` alive for the process this way.
|
||||
final _lateDeclaration = RegExp(
|
||||
'^\\s+late\\s+(?:final\\s+)?(${_disposableTypes.join('|')})'
|
||||
r'(?:<[\w<>,\s?]*>)?\??\s+(_?[A-Za-z]\w*)\s*;',
|
||||
);
|
||||
|
||||
bool _isGenerated(String path) {
|
||||
return path.contains('/generated/') ||
|
||||
path.endsWith('.g.dart') ||
|
||||
path.endsWith('.freezed.dart');
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('every self-created disposable field is released in the same file', () {
|
||||
final root = Directory('lib');
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final entity in root.listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) {
|
||||
continue;
|
||||
}
|
||||
final relative = p.relative(entity.path);
|
||||
if (_isGenerated(relative)) {
|
||||
continue;
|
||||
}
|
||||
final source = entity.readAsStringSync();
|
||||
final lines = source.split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final match = _declaration.firstMatch(lines[i]);
|
||||
final lateMatch = match == null
|
||||
? _lateDeclaration.firstMatch(lines[i])
|
||||
: null;
|
||||
if (match == null && lateMatch == null) {
|
||||
continue;
|
||||
}
|
||||
final field = match?.group(1) ?? lateMatch!.group(2)!;
|
||||
final type = match?.group(2) ?? lateMatch!.group(1)!;
|
||||
if (_allowed.contains('$relative#$field')) {
|
||||
continue;
|
||||
}
|
||||
final released =
|
||||
source.contains('$field.dispose()') ||
|
||||
source.contains('$field.close()');
|
||||
if (!released) {
|
||||
offenders.add('$relative:${i + 1} $field ($type)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
isEmpty,
|
||||
reason:
|
||||
'These fields are constructed by their owner but never disposed or '
|
||||
'closed. Release them in `dispose()`, or add the declaration to '
|
||||
'`_allowed` with the reason it outlives its owner.\n'
|
||||
'${offenders.join('\n')}',
|
||||
);
|
||||
});
|
||||
|
||||
test('the allow list has no stale entries', () {
|
||||
for (final entry in _allowed) {
|
||||
final parts = entry.split('#');
|
||||
final file = File(parts.first);
|
||||
expect(
|
||||
file.existsSync(),
|
||||
isTrue,
|
||||
reason: '$entry names a file that no longer exists',
|
||||
);
|
||||
expect(
|
||||
file.readAsStringSync(),
|
||||
contains(parts.last),
|
||||
reason: '$entry names a field that no longer exists',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/l10n_labels.dart';
|
||||
import 'package:fl_clash/l10n/l10n.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _arbDir = 'arb';
|
||||
const _libDir = 'lib';
|
||||
|
||||
Map<String, Set<String>> _arbKeys() {
|
||||
final result = <String, Set<String>>{};
|
||||
for (final entity in Directory(_arbDir).listSync()) {
|
||||
if (entity is! File || !entity.path.endsWith('.arb')) continue;
|
||||
final decoded =
|
||||
jsonDecode(entity.readAsStringSync()) as Map<String, Object?>;
|
||||
result[entity.uri.pathSegments.last] = decoded.keys
|
||||
.where((key) => !key.startsWith('@'))
|
||||
.toSet();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('every locale carries the same keys', () {
|
||||
final byFile = _arbKeys();
|
||||
expect(byFile, isNotEmpty, reason: 'no .arb files were read from $_arbDir');
|
||||
|
||||
final reference = byFile['intl_en.arb'];
|
||||
expect(reference, isNotNull, reason: 'arb/intl_en.arb is the reference');
|
||||
|
||||
for (final entry in byFile.entries) {
|
||||
expect(
|
||||
entry.value,
|
||||
reference,
|
||||
reason:
|
||||
'${entry.key} does not carry the same keys as intl_en.arb. A key '
|
||||
'present in only one locale renders as the raw key elsewhere.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no Intl.message call builds a key at runtime', () {
|
||||
final offenders = <String>[];
|
||||
for (final entity in Directory(_libDir).listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||
final path = entity.path;
|
||||
if (path.contains('/generated/') || path.contains('/l10n/')) continue;
|
||||
if (entity.readAsStringSync().contains('Intl.message(')) {
|
||||
offenders.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
isEmpty,
|
||||
reason:
|
||||
'Intl.message(<runtime string>) is invisible to the analyzer and '
|
||||
'returns its argument unchanged when the lookup fails, shipping the '
|
||||
'raw key to the UI. Add an exhaustive-switch label to '
|
||||
'lib/common/l10n_labels.dart instead:\n ${offenders.join('\n ')}',
|
||||
);
|
||||
});
|
||||
|
||||
test('every supported locale has a translated display name', () async {
|
||||
for (final locale in AppLocalizations.delegate.supportedLocales) {
|
||||
await AppLocalizations.load(locale);
|
||||
expect(
|
||||
locale.label,
|
||||
isNot(locale.toString()),
|
||||
reason:
|
||||
'LocaleL10n.label fell back to the raw locale code for $locale. '
|
||||
'Add the locale to the switch in lib/common/l10n_labels.dart and '
|
||||
'a display-name key to every .arb file.',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// The one button that carries its label on an enclosing [Tooltip] instead of
|
||||
/// its own `tooltip:`. Nesting a second tooltip inside would fight it.
|
||||
const _wrappedInTooltip = 'lib/views/dashboard/widgets/core_status_button.dart';
|
||||
|
||||
final _iconButton = RegExp(
|
||||
r'\bIconButton(?:\.(?:filled|filledTonal|outlined))?\(',
|
||||
);
|
||||
|
||||
Iterable<File> _dartFilesIn(String root) sync* {
|
||||
final directory = Directory(root);
|
||||
if (!directory.existsSync()) {
|
||||
fail('$root no longer exists; update this test.');
|
||||
}
|
||||
for (final entity in directory.listSync(recursive: true)) {
|
||||
if (entity is File &&
|
||||
entity.path.endsWith('.dart') &&
|
||||
!entity.path.endsWith('.g.dart') &&
|
||||
!entity.path.endsWith('.freezed.dart') &&
|
||||
!entity.path.contains('/generated/')) {
|
||||
yield entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _arguments(String source, int start) {
|
||||
var depth = 1;
|
||||
var index = start;
|
||||
while (index < source.length && depth > 0) {
|
||||
if (source[index] == '(') depth++;
|
||||
if (source[index] == ')') depth--;
|
||||
index++;
|
||||
}
|
||||
return source.substring(start, index - 1);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('every icon-only IconButton carries a tooltip', () {
|
||||
final unlabelled = <String>[];
|
||||
|
||||
for (final file in _dartFilesIn('lib')) {
|
||||
final source = file.readAsStringSync();
|
||||
if (file.path == _wrappedInTooltip) continue;
|
||||
|
||||
for (final match in _iconButton.allMatches(source)) {
|
||||
final arguments = _arguments(source, match.end);
|
||||
if (RegExp(r'\btooltip\s*:').hasMatch(arguments)) continue;
|
||||
// An icon slot holding text is already its own visible label.
|
||||
if (RegExp(r'icon:\s*Text\(').hasMatch(arguments)) continue;
|
||||
|
||||
final line = '\n'.allMatches(source.substring(0, match.start)).length;
|
||||
unlabelled.add(
|
||||
'${file.path}:${line + 1} — an icon has no accessible name, so '
|
||||
'TalkBack and VoiceOver announce nothing and the desktop build shows '
|
||||
'no hover hint.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(unlabelled, isEmpty, reason: unlabelled.join('\n'));
|
||||
});
|
||||
|
||||
test('the exempted button still gets its label from an enclosing Tooltip', () {
|
||||
final source = File(_wrappedInTooltip).readAsStringSync();
|
||||
|
||||
expect(
|
||||
source,
|
||||
contains('Tooltip('),
|
||||
reason:
|
||||
'$_wrappedInTooltip is exempted from the tooltip rule because an '
|
||||
'enclosing Tooltip labels it. That wrapper is gone; either restore it '
|
||||
'or give the button its own tooltip and drop the exemption.',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _platformPackages = [
|
||||
'tray',
|
||||
'window_manager',
|
||||
'launch_at_startup',
|
||||
'screen_retriever',
|
||||
];
|
||||
|
||||
const _platformModules = [
|
||||
'lib/common/tray.dart',
|
||||
'lib/common/window.dart',
|
||||
'lib/common/launch.dart',
|
||||
'lib/common/system_dns.dart',
|
||||
'lib/common/permission.dart',
|
||||
];
|
||||
|
||||
final _platformImport = RegExp(
|
||||
"^import 'package:(${_platformPackages.join('|')})/",
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
Set<String> _closureOfCommonBarrel() {
|
||||
String resolve(String uri, String from) {
|
||||
if (uri.startsWith('package:fl_clash/')) {
|
||||
return 'lib/${uri.substring('package:fl_clash/'.length)}';
|
||||
}
|
||||
if (uri.startsWith('dart:') || uri.startsWith('package:')) return uri;
|
||||
return p.normalize(p.join(p.dirname(from), uri));
|
||||
}
|
||||
|
||||
final directive = RegExp(
|
||||
r'''^\s*(?:import|export|part)\s+['"]([^'"]+)['"]''',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
final reached = <String>{'lib/common/common.dart'};
|
||||
final queue = <String>[...reached];
|
||||
|
||||
while (queue.isNotEmpty) {
|
||||
final current = queue.removeLast();
|
||||
final file = File(current);
|
||||
if (!file.existsSync()) continue;
|
||||
for (final match in directive.allMatches(file.readAsStringSync())) {
|
||||
final next = resolve(match.group(1)!, current);
|
||||
if (!reached.add(next)) continue;
|
||||
queue.add(next);
|
||||
}
|
||||
}
|
||||
|
||||
return reached;
|
||||
}
|
||||
|
||||
Iterable<File> _dartFilesIn(String root) sync* {
|
||||
final directory = Directory(root);
|
||||
if (!directory.existsSync()) {
|
||||
fail('$root no longer exists; update this test.');
|
||||
}
|
||||
for (final entity in directory.listSync(recursive: true)) {
|
||||
if (entity is File &&
|
||||
entity.path.endsWith('.dart') &&
|
||||
!entity.path.contains('/generated/')) {
|
||||
yield entity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('the common barrel never re-exports a platform module', () {
|
||||
final exports = File('lib/common/common.dart').readAsLinesSync();
|
||||
final leaked = <String>[];
|
||||
|
||||
for (final module in _platformModules) {
|
||||
final line = "export '${p.basename(module)}';";
|
||||
if (exports.contains(line)) {
|
||||
leaked.add(
|
||||
'$line — importing the barrel would pull the platform package into '
|
||||
'every one of its consumers',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(leaked, isEmpty, reason: leaked.join('\n'));
|
||||
});
|
||||
|
||||
test('only the designated modules reach a desktop platform package', () {
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final root in ['lib/common', 'lib/enum', 'lib/models']) {
|
||||
for (final file in _dartFilesIn(root)) {
|
||||
final relative = p.relative(file.path);
|
||||
if (_platformModules.contains(relative)) {
|
||||
continue;
|
||||
}
|
||||
final match = _platformImport.firstMatch(file.readAsStringSync());
|
||||
if (match != null) {
|
||||
offenders.add('$relative imports ${match.group(1)}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, isEmpty, reason: offenders.join('\n'));
|
||||
});
|
||||
|
||||
test('nothing the common barrel reaches imports a platform package', () {
|
||||
final reached = _closureOfCommonBarrel();
|
||||
final offenders = [
|
||||
for (final package in _platformPackages)
|
||||
if (reached.any((uri) => uri.startsWith('package:$package/'))) package,
|
||||
];
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
isEmpty,
|
||||
reason:
|
||||
'A file the common barrel transitively reaches pulls a desktop '
|
||||
'platform package into all ${reached.where((f) => f.startsWith('lib/')).length} '
|
||||
'of its dependencies:\n${offenders.join('\n')}',
|
||||
);
|
||||
});
|
||||
|
||||
test('the common barrel never reaches the view tree', () {
|
||||
final reached = _closureOfCommonBarrel();
|
||||
final views =
|
||||
reached.where((file) => file.startsWith('lib/views/')).toList()..sort();
|
||||
|
||||
expect(
|
||||
views,
|
||||
isEmpty,
|
||||
reason:
|
||||
'A widget is reachable from `lib/common/common.dart`, so every file '
|
||||
'that imports the barrel for a string helper now compiles the UI. '
|
||||
'This is how `common/navigation.dart` and the widget on '
|
||||
'`DashboardWidget` each put ~60 view files in the closure. Data the '
|
||||
'provider layer needs is passed through a port in '
|
||||
'`common/app_ports.dart`; the layer that owns the widgets binds '
|
||||
'it:\n${views.join('\n')}',
|
||||
);
|
||||
});
|
||||
|
||||
test('common never depends on the manager barrel', () {
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final file in _dartFilesIn('lib/common')) {
|
||||
if (file.readAsStringSync().contains(
|
||||
"import 'package:fl_clash/manager/manager.dart';",
|
||||
)) {
|
||||
offenders.add(
|
||||
'${p.relative(file.path)} — import the one manager it needs, not the '
|
||||
'barrel that reaches every platform manager',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, isEmpty, reason: offenders.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
const _uiRoots = ['lib/views', 'lib/widgets', 'lib/pages', 'lib/features'];
|
||||
|
||||
const _forbidden = <String, String>{
|
||||
r'globalState\.container':
|
||||
'read providers through a WidgetRef, not the global container',
|
||||
r'(?<![\w.])coreController\b':
|
||||
'reach Core through coreHandlerProvider, not the global singleton',
|
||||
};
|
||||
|
||||
bool _isGenerated(String path) {
|
||||
return path.contains('/generated/') ||
|
||||
path.endsWith('.g.dart') ||
|
||||
path.endsWith('.freezed.dart');
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('the UI layer never reaches a process-wide singleton directly', () {
|
||||
final offenders = <String>[];
|
||||
|
||||
for (final root in _uiRoots) {
|
||||
final directory = Directory(root);
|
||||
if (!directory.existsSync()) {
|
||||
fail('$root no longer exists; update _uiRoots.');
|
||||
}
|
||||
for (final entity in directory.listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) {
|
||||
continue;
|
||||
}
|
||||
final relative = p.relative(entity.path);
|
||||
if (_isGenerated(relative)) {
|
||||
continue;
|
||||
}
|
||||
final lines = entity.readAsLinesSync();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
if (line.trimLeft().startsWith('//')) {
|
||||
continue;
|
||||
}
|
||||
for (final entry in _forbidden.entries) {
|
||||
if (RegExp(entry.key).hasMatch(line)) {
|
||||
offenders.add('$relative:${i + 1} — ${entry.value}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders, isEmpty, reason: offenders.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -46,5 +46,30 @@ void main() {
|
||||
'split-per-abi',
|
||||
]);
|
||||
});
|
||||
|
||||
test('refuses to package while a native build hook is skipped', () {
|
||||
const pubspec = '''
|
||||
hooks:
|
||||
user_defines:
|
||||
setup:
|
||||
build_assets: false
|
||||
rust_api:
|
||||
build_assets: true
|
||||
''';
|
||||
|
||||
expect(setup.packagesNotBuildingAssets(pubspec), ['setup']);
|
||||
expect(setup.packagesNotBuildingAssets('name: x\n'), isEmpty);
|
||||
});
|
||||
|
||||
test('packages every Linux format on every architecture', () {
|
||||
expect(setup.createPackageTargets('linux', null), 'deb,appimage,rpm');
|
||||
expect(setup.createPackageTargets('linux', 'deb'), 'deb');
|
||||
expect(setup.createPackageTargets('macos', null), 'dmg');
|
||||
});
|
||||
|
||||
test('downloads the appimagetool build matching the host', () {
|
||||
expect(setup.appImageToolArch('arm64'), 'aarch64');
|
||||
expect(setup.appImageToolArch('amd64'), 'x86_64');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../tool/src/changelog/builder.dart';
|
||||
import '../../tool/src/changelog/git.dart';
|
||||
import '../../tool/src/changelog/models.dart';
|
||||
import '../../tool/src/changelog/render.dart';
|
||||
|
||||
void main() {
|
||||
late Directory repo;
|
||||
|
||||
void git(List<String> arguments) {
|
||||
final result = Process.runSync(
|
||||
'git',
|
||||
arguments,
|
||||
workingDirectory: repo.path,
|
||||
environment: const {
|
||||
'GIT_AUTHOR_DATE': '2026-01-02T00:00:00Z',
|
||||
'GIT_COMMITTER_DATE': '2026-01-02T00:00:00Z',
|
||||
},
|
||||
);
|
||||
if (result.exitCode != 0) {
|
||||
fail('git ${arguments.join(' ')} failed: ${result.stderr}');
|
||||
}
|
||||
}
|
||||
|
||||
void commit(String message) {
|
||||
git(['commit', '--allow-empty', '--quiet', '--message', message]);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
repo = Directory.systemTemp.createTempSync('flclash-changelog-');
|
||||
git(['init', '--quiet', '--initial-branch=main']);
|
||||
git(['config', 'user.email', 'changelog-test@example.com']);
|
||||
git(['config', 'user.name', 'Changelog test']);
|
||||
|
||||
commit('feat: initial release');
|
||||
git(['tag', 'v1.0.0']);
|
||||
|
||||
commit('feat(a): first feature');
|
||||
commit('fix(b): first fix');
|
||||
git(['tag', 'backup-pre-squash-deadbee']);
|
||||
git(['tag', 'v1.1.0-pre.1']);
|
||||
|
||||
commit('feat(c)!: second feature\n\nBREAKING CHANGE: The old flag is gone');
|
||||
git(['tag', 'v1.1.0']);
|
||||
|
||||
commit('chore: bump build number');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
repo.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
ChangelogBuildResult build({PendingVersion? pending}) => ChangelogBuilder(
|
||||
Git(workingDirectory: repo.path),
|
||||
boundary: 'v1.0.0',
|
||||
).build(pending: pending);
|
||||
|
||||
test('collects one section per stable tag above the boundary', () {
|
||||
final versions = build().changelog.versions;
|
||||
|
||||
expect(versions.map((version) => version.tag), ['v1.1.0']);
|
||||
expect(versions.single.date, '2026-01-02');
|
||||
});
|
||||
|
||||
test('preserves frozen history before the first structured release', () {
|
||||
git(['checkout', '--quiet', '--orphan', 'frozen-history']);
|
||||
commit('chore: optimize commented policy');
|
||||
git(['tag', 'v0.8.96']);
|
||||
final builder = ChangelogBuilder(Git(workingDirectory: repo.path));
|
||||
expect(builder.build().changelog.versions, isEmpty);
|
||||
|
||||
commit('fix: new release fix');
|
||||
final next = builder
|
||||
.build(
|
||||
pending: const PendingVersion(version: '0.8.97', date: '2026-09-10'),
|
||||
)
|
||||
.changelog;
|
||||
expect(next.versions.single.tag, 'v0.8.97');
|
||||
expect(
|
||||
next.versions.single.groups.single.entries.single.text,
|
||||
'New release fix',
|
||||
);
|
||||
|
||||
const history =
|
||||
'## v0.8.96 (2026-08-17)\n\n'
|
||||
'- Optimize commented policy\n'
|
||||
'- Fix whole group delay test failing on Windows\n'
|
||||
'- Optimize package icon loading and connections polling\n';
|
||||
final original = '${renderMarkdown(builder.build().changelog)}$history';
|
||||
final merged = mergeMarkdown(renderMarkdown(next), original);
|
||||
expect(merged.substring(merged.indexOf('## v0.8.96')), history);
|
||||
});
|
||||
|
||||
test('a prerelease tag does not split the stable range', () {
|
||||
final groups = build().changelog.versions.single.groups;
|
||||
final features = groups
|
||||
.firstWhere((group) => group.type == ChangelogType.feat)
|
||||
.entries
|
||||
.map((entry) => entry.text);
|
||||
|
||||
expect(features, ['Second feature', 'First feature']);
|
||||
});
|
||||
|
||||
test('ignores tags that are not release tags', () {
|
||||
final tags = Git(workingDirectory: repo.path).versionTags();
|
||||
|
||||
expect(tags.map((tag) => tag.name), ['v1.1.0', 'v1.1.0-pre.1', 'v1.0.0']);
|
||||
});
|
||||
|
||||
test('lifts breaking changes into their own group', () {
|
||||
final groups = build().changelog.versions.single.groups;
|
||||
|
||||
expect(groups.first.type, ChangelogType.breaking);
|
||||
expect(groups.first.entries.single.text, 'The old flag is gone');
|
||||
});
|
||||
|
||||
test('a pending version covers the commits after the newest stable tag', () {
|
||||
final versions = build(
|
||||
pending: const PendingVersion(version: '1.2.0', date: '2026-02-03'),
|
||||
).changelog.versions;
|
||||
|
||||
expect(versions.map((version) => version.tag), ['v1.2.0', 'v1.1.0']);
|
||||
expect(versions.first.date, '2026-02-03');
|
||||
expect(versions.first.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('a pending version is skipped once its tag exists', () {
|
||||
final versions = build(
|
||||
pending: const PendingVersion(version: '1.1.0', date: '2026-02-03'),
|
||||
).changelog.versions;
|
||||
|
||||
expect(versions.map((version) => version.tag), ['v1.1.0']);
|
||||
expect(versions.single.date, '2026-01-02');
|
||||
});
|
||||
|
||||
test('a release tag on another branch is not reachable from this one', () {
|
||||
git(['checkout', '--quiet', '-b', 'feature', 'v1.0.0']);
|
||||
commit('feat(d): work started before the release');
|
||||
|
||||
final scoped = Git(workingDirectory: repo.path);
|
||||
|
||||
expect(
|
||||
scoped.tagExists('v1.1.0'),
|
||||
isTrue,
|
||||
reason: 'the tag is still in the repository',
|
||||
);
|
||||
expect(
|
||||
scoped.tagIsReachable('v1.1.0'),
|
||||
isFalse,
|
||||
reason:
|
||||
'verify must skip versions the builder cannot derive here, or every '
|
||||
'branch forked before the newest release fails on an unrelated tag.',
|
||||
);
|
||||
expect(
|
||||
scoped.versionTags().map((tag) => tag.name),
|
||||
isNot(contains('v1.1.0')),
|
||||
reason: 'tagIsReachable must agree with the tags the builder walks.',
|
||||
);
|
||||
expect(
|
||||
scoped.tagIsReachable('v1.0.0'),
|
||||
isTrue,
|
||||
reason: 'a tag this branch was cut from is still checked.',
|
||||
);
|
||||
});
|
||||
|
||||
test('a release tag on this branch stays checked', () {
|
||||
final scoped = Git(workingDirectory: repo.path);
|
||||
|
||||
expect(scoped.tagIsReachable('v1.1.0'), isTrue);
|
||||
expect(
|
||||
scoped.versionTags().map((tag) => tag.name),
|
||||
contains('v1.1.0'),
|
||||
reason:
|
||||
'the skip must be narrow; on the release branch verify still '
|
||||
'compares every derivable version.',
|
||||
);
|
||||
});
|
||||
|
||||
test('a tag with no version in changelog.json is reported', () {
|
||||
final derived = build().changelog;
|
||||
final recorded = Changelog(
|
||||
versions: derived.versions
|
||||
.where((version) => version.tag != 'v1.1.0')
|
||||
.toList(),
|
||||
);
|
||||
|
||||
expect(missingReleaseTags(recorded, derived), ['v1.1.0']);
|
||||
expect(missingReleaseTags(derived, derived), isEmpty);
|
||||
});
|
||||
|
||||
test('is deterministic across runs', () {
|
||||
expect(
|
||||
encodeChangelog(build().changelog),
|
||||
encodeChangelog(build().changelog),
|
||||
);
|
||||
});
|
||||
|
||||
test('reports commits that are not conventional', () {
|
||||
commit('Optimize core service');
|
||||
|
||||
final warnings = build(
|
||||
pending: const PendingVersion(version: '1.2.0', date: '2026-02-03'),
|
||||
).warnings;
|
||||
|
||||
expect(warnings.single, contains('not a conventional commit'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../tool/src/changelog/models.dart';
|
||||
import '../../tool/src/changelog/parser.dart';
|
||||
|
||||
RawCommit commit(String subject, [String body = '']) =>
|
||||
RawCommit(hash: 'abcdef1234567890', subject: subject, body: body);
|
||||
|
||||
void main() {
|
||||
group('ChangelogParser', () {
|
||||
test('uses the Changelog trailer as the user facing text', () {
|
||||
final parser = ChangelogParser();
|
||||
final items = parser.parse(
|
||||
commit('feat(profiles): support per-profile override script', '''
|
||||
Adds a Dart-side hook.
|
||||
|
||||
Changelog: Per-profile override scripts
|
||||
'''),
|
||||
);
|
||||
|
||||
expect(items, hasLength(1));
|
||||
expect(items.single.type, ChangelogType.feat);
|
||||
expect(items.single.entry.scope, 'profiles');
|
||||
expect(items.single.entry.id, 'abcdef1');
|
||||
expect(items.single.entry.text, 'Per-profile override scripts');
|
||||
expect(parser.warnings, isEmpty);
|
||||
});
|
||||
|
||||
test('falls back to the capitalized subject description', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('fix(windows): keep the TUN adapter alive'),
|
||||
);
|
||||
|
||||
expect(items.single.type, ChangelogType.fix);
|
||||
expect(items.single.entry.text, 'Keep the TUN adapter alive');
|
||||
});
|
||||
|
||||
test('drops commits marked Changelog: skip', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('feat(core): internal only', 'Changelog: skip'),
|
||||
);
|
||||
|
||||
expect(items, isEmpty);
|
||||
});
|
||||
|
||||
test('drops non user facing types without a trailer', () {
|
||||
final parser = ChangelogParser();
|
||||
|
||||
expect(parser.parse(commit('refactor(lib): restructure state')), isEmpty);
|
||||
expect(parser.parse(commit('ci: enforce lint')), isEmpty);
|
||||
expect(parser.parse(commit('test: cover lifecycle')), isEmpty);
|
||||
expect(parser.warnings, isEmpty);
|
||||
});
|
||||
|
||||
test('keeps a non user facing type when it carries a trailer', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('refactor(tray): replace the fork', 'Changelog: Rebuilt tray'),
|
||||
);
|
||||
|
||||
expect(items.single.type, ChangelogType.feat);
|
||||
expect(items.single.entry.text, 'Rebuilt tray');
|
||||
});
|
||||
|
||||
test('honours Changelog-Type over the commit type', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('chore(core): bump core', '''
|
||||
Changelog: Update core
|
||||
Changelog-Type: perf
|
||||
'''),
|
||||
);
|
||||
|
||||
expect(items.single.type, ChangelogType.perf);
|
||||
});
|
||||
|
||||
test('warns and drops an unknown Changelog-Type', () {
|
||||
final parser = ChangelogParser();
|
||||
final items = parser.parse(
|
||||
commit('chore(core): bump core', '''
|
||||
Changelog: Update core
|
||||
Changelog-Type: unknown-type
|
||||
'''),
|
||||
);
|
||||
|
||||
expect(items, isEmpty);
|
||||
expect(parser.warnings.single, contains('unknown Changelog-Type'));
|
||||
});
|
||||
|
||||
test('emits a breaking entry next to the regular one', () {
|
||||
final parser = ChangelogParser();
|
||||
final items = parser.parse(
|
||||
commit('feat(backup)!: new archive layout', '''
|
||||
Changelog: Faster backups
|
||||
|
||||
BREAKING CHANGE: Archives from 0.8.95 and earlier need re-import
|
||||
'''),
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type), [
|
||||
ChangelogType.breaking,
|
||||
ChangelogType.feat,
|
||||
]);
|
||||
expect(
|
||||
items.first.entry.text,
|
||||
'Archives from 0.8.95 and earlier need re-import',
|
||||
);
|
||||
expect(items.last.entry.text, 'Faster backups');
|
||||
expect(parser.warnings, isEmpty);
|
||||
});
|
||||
|
||||
test('warns when a breaking commit has no footer', () {
|
||||
final parser = ChangelogParser();
|
||||
final items = parser.parse(commit('fix(core)!: drop the legacy socket'));
|
||||
|
||||
expect(items.first.type, ChangelogType.breaking);
|
||||
expect(items.first.entry.text, 'Drop the legacy socket');
|
||||
expect(parser.warnings.single, contains('BREAKING CHANGE'));
|
||||
});
|
||||
|
||||
test('treats a BREAKING CHANGE footer alone as breaking', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit(
|
||||
'fix(core): tighten the handshake',
|
||||
'BREAKING CHANGE: Old cores',
|
||||
),
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type), [
|
||||
ChangelogType.breaking,
|
||||
ChangelogType.fix,
|
||||
]);
|
||||
});
|
||||
|
||||
test('joins continuation lines of a trailer', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('feat: multi line', '''
|
||||
Changelog: A long entry
|
||||
that wraps onto a second line
|
||||
|
||||
Some other paragraph.
|
||||
'''),
|
||||
);
|
||||
|
||||
expect(
|
||||
items.single.entry.text,
|
||||
'A long entry that wraps onto a second line',
|
||||
);
|
||||
});
|
||||
|
||||
test('stops a trailer at the next unrelated git trailer', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('feat: multi line', '''
|
||||
Changelog: A long entry
|
||||
Co-authored-by: Someone <someone@example.com>
|
||||
'''),
|
||||
);
|
||||
|
||||
expect(items.single.entry.text, 'A long entry');
|
||||
});
|
||||
|
||||
test('stops a trailer at a git trailer with no space after the colon', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('feat: multi line', '''
|
||||
Changelog: A long entry
|
||||
Claude-Session:https://claude.ai/code/session_abc
|
||||
'''),
|
||||
);
|
||||
|
||||
expect(items.single.entry.text, 'A long entry');
|
||||
});
|
||||
|
||||
test('joins a Note: line as a continuation, not a trailer', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('feat: multi line', '''
|
||||
Changelog: A long entry
|
||||
Note: the old handle still works
|
||||
'''),
|
||||
);
|
||||
|
||||
expect(
|
||||
items.single.entry.text,
|
||||
'A long entry Note: the old handle still works',
|
||||
);
|
||||
});
|
||||
|
||||
test('warns about a translation trailer left in a commit', () {
|
||||
final parser = ChangelogParser();
|
||||
final items = parser.parse(commit('feat: x', 'Changelog-zh-CN: 新功能'));
|
||||
|
||||
expect(items.single.entry.text, 'X');
|
||||
expect(
|
||||
parser.warnings.single,
|
||||
contains('unsupported changelog trailer: Changelog-zh-CN'),
|
||||
);
|
||||
});
|
||||
|
||||
test('warns about a breaking translation trailer', () {
|
||||
final parser = ChangelogParser();
|
||||
parser.parse(commit('feat: x', 'Breaking-zh-CN: 变更'));
|
||||
|
||||
expect(
|
||||
parser.warnings.single,
|
||||
contains('unsupported changelog trailer: Breaking-zh-CN'),
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the subject when Changelog: carries no text', () {
|
||||
final items = ChangelogParser().parse(
|
||||
commit('fix(core): keep the socket alive', 'Changelog:'),
|
||||
);
|
||||
|
||||
expect(items.single.entry.text, 'Keep the socket alive');
|
||||
});
|
||||
|
||||
test('warns about a commit that is not conventional', () {
|
||||
final parser = ChangelogParser();
|
||||
|
||||
expect(parser.parse(commit('Optimize core service')), isEmpty);
|
||||
expect(parser.warnings.single, contains('not a conventional commit'));
|
||||
});
|
||||
|
||||
test('handles a missing scope', () {
|
||||
final items = ChangelogParser().parse(commit('feat: add a thing'));
|
||||
|
||||
expect(items.single.entry.scope, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('groupItems', () {
|
||||
test('orders groups by declaration and drops empty ones', () {
|
||||
final items = ChangelogParser().parseAll([
|
||||
commit('fix: b'),
|
||||
commit('feat: a'),
|
||||
commit('perf: c'),
|
||||
commit('feat: d'),
|
||||
]);
|
||||
|
||||
final groups = groupItems(items);
|
||||
|
||||
expect(groups.map((group) => group.type), [
|
||||
ChangelogType.feat,
|
||||
ChangelogType.fix,
|
||||
ChangelogType.perf,
|
||||
]);
|
||||
expect(groups.first.entries.map((entry) => entry.text), ['A', 'D']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../tool/src/changelog/builder.dart';
|
||||
import '../../tool/src/changelog/models.dart';
|
||||
import '../../tool/src/changelog/render.dart';
|
||||
|
||||
ChangelogVersion version({
|
||||
String tag = 'v0.8.96',
|
||||
String date = '2026-08-16',
|
||||
bool prerelease = false,
|
||||
List<ChangelogGroup> groups = const [],
|
||||
}) => ChangelogVersion(
|
||||
version: tag.substring(1),
|
||||
tag: tag,
|
||||
date: date,
|
||||
prerelease: prerelease,
|
||||
groups: groups,
|
||||
);
|
||||
|
||||
ChangelogGroup entryGroup(
|
||||
ChangelogType type,
|
||||
List<(String, String?, String)> entries,
|
||||
) => ChangelogGroup(
|
||||
type: type,
|
||||
entries: [
|
||||
for (final (id, scope, text) in entries)
|
||||
ChangelogEntry(id: id, scope: scope, text: text),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('renderMarkdown', () {
|
||||
test('renders versions, groups and the frozen marker', () {
|
||||
final markdown = renderMarkdown(
|
||||
Changelog(
|
||||
versions: [
|
||||
version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.breaking, [
|
||||
('af20769', 'lib', 'Backups need re-import'),
|
||||
]),
|
||||
entryGroup(ChangelogType.feat, [
|
||||
('1a2b3c4', 'profiles', 'Per-profile override scripts'),
|
||||
('2b3c4d5', null, 'Scopeless entry'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(markdown, startsWith('# Changelog\n'));
|
||||
expect(markdown, contains('## v0.8.96 (2026-08-16)'));
|
||||
expect(markdown, contains('**Breaking Changes**'));
|
||||
expect(markdown, contains('- **lib** Backups need re-import (af20769)'));
|
||||
expect(markdown, contains('- Scopeless entry (2b3c4d5)'));
|
||||
expect(markdown, contains(changelogFrozenMarker));
|
||||
expect(markdown.indexOf('**Features**'), greaterThan(0));
|
||||
expect(markdown, isNot(contains('### ')));
|
||||
});
|
||||
});
|
||||
|
||||
group('mergeMarkdown', () {
|
||||
const legacy = '## v0.8.95\n\n- Optimize core service\n';
|
||||
|
||||
test('keeps a legacy file below the frozen marker', () {
|
||||
final merged = mergeMarkdown(
|
||||
renderMarkdown(const Changelog(versions: [])),
|
||||
legacy,
|
||||
);
|
||||
|
||||
expect(merged, contains(changelogFrozenMarker));
|
||||
expect(merged, endsWith(legacy));
|
||||
expect(
|
||||
merged.indexOf(legacy),
|
||||
greaterThan(merged.indexOf(changelogFrozenMarker)),
|
||||
);
|
||||
});
|
||||
|
||||
test('is idempotent once the marker exists', () {
|
||||
final rendered = renderMarkdown(const Changelog(versions: []));
|
||||
final once = mergeMarkdown(rendered, legacy);
|
||||
final twice = mergeMarkdown(rendered, once);
|
||||
|
||||
expect(twice, once);
|
||||
});
|
||||
|
||||
test('replaces only the generated head on a rerun', () {
|
||||
final first = mergeMarkdown(
|
||||
renderMarkdown(const Changelog(versions: [])),
|
||||
legacy,
|
||||
);
|
||||
final second = mergeMarkdown(
|
||||
renderMarkdown(
|
||||
Changelog(
|
||||
versions: [
|
||||
version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.fix, [
|
||||
('5d6e7f8', 'windows', 'Fix TUN'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
first,
|
||||
);
|
||||
|
||||
expect(second, contains('- **windows** Fix TUN (5d6e7f8)'));
|
||||
expect(second, endsWith(legacy));
|
||||
});
|
||||
});
|
||||
|
||||
group('markdownHead', () {
|
||||
test('survives a reworded frozen note', () {
|
||||
const rendered =
|
||||
'# Changelog\n\n$changelogFrozenMarker\n<!-- an older wording -->\n\n';
|
||||
final merged = mergeMarkdown(rendered, '## v0.8.95\n\n- Old\n');
|
||||
|
||||
expect(merged, isNot(contains('an older wording\n<!--')));
|
||||
expect(merged.split('<!--').length - 1, 2);
|
||||
expect(merged, endsWith('- Old\n'));
|
||||
});
|
||||
|
||||
test('cuts at the frozen note', () {
|
||||
final rendered = renderMarkdown(const Changelog(versions: []));
|
||||
final merged = mergeMarkdown(rendered, '## v0.8.95\n\n- Old\n');
|
||||
|
||||
expect(markdownHead(merged), rendered.trimRight());
|
||||
expect(markdownHead(merged), isNot(contains('Old')));
|
||||
});
|
||||
});
|
||||
|
||||
group('renderRelease', () {
|
||||
test('wraps entries in the app readable markers', () {
|
||||
final body = renderRelease(
|
||||
version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.feat, [
|
||||
('1a2b3c4', 'profiles', 'Scripts'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(body, startsWith(releaseBeginMarker));
|
||||
expect(body, contains(releaseEndMarker));
|
||||
expect(body, contains('- **profiles** Scripts'));
|
||||
});
|
||||
|
||||
test('escapes the characters the caption parse mode would read', () {
|
||||
final text = renderTelegram(
|
||||
version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.fix, [
|
||||
('5d6e7f8', 'core', 'Reject <script> & "quoted" input'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
moreUrl: 'https://example.com',
|
||||
);
|
||||
|
||||
expect(
|
||||
text,
|
||||
'<b>Bug Fixes</b>\n• Reject <script> & "quoted" input',
|
||||
);
|
||||
});
|
||||
|
||||
test('says so when a version has no user facing changes', () {
|
||||
expect(renderRelease(version()), contains(emptyVersionNote));
|
||||
});
|
||||
|
||||
test('carries the translated payload the app reads', () {
|
||||
final body = renderRelease(
|
||||
version(
|
||||
groups: [
|
||||
const ChangelogGroup(
|
||||
type: ChangelogType.feat,
|
||||
entries: [ChangelogEntry(id: '1a2b3c4', text: 'Scripts')],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final payload = _payloadOf(body);
|
||||
final decoded = decodeChangelog(payload);
|
||||
expect(decoded.versions.single.tag, 'v0.8.96');
|
||||
expect(
|
||||
decoded.versions.single.groups.single.entries.single.text,
|
||||
'Scripts',
|
||||
);
|
||||
expect(body.trimRight(), endsWith(releaseJsonEndMarker));
|
||||
});
|
||||
|
||||
test('escapes > so an entry cannot close the comment early', () {
|
||||
final body = renderRelease(
|
||||
version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.fix, [
|
||||
('1a2b3c4', null, 'Handle a --> sequence'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final payload = _payloadOf(body);
|
||||
expect(payload, isNot(contains(releaseJsonEndMarker)));
|
||||
expect(
|
||||
decodeChangelog(
|
||||
payload,
|
||||
).versions.single.groups.single.entries.single.text,
|
||||
'Handle a --> sequence',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('renderTelegram', () {
|
||||
test('renders bullets without markdown scopes', () {
|
||||
final text = renderTelegram(
|
||||
version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.fix, [('5d6e7f8', 'windows', 'Fix TUN')]),
|
||||
],
|
||||
),
|
||||
moreUrl: 'https://example.com',
|
||||
);
|
||||
|
||||
expect(text, '<b>Bug Fixes</b>\n• Fix TUN');
|
||||
});
|
||||
|
||||
test('says so when a version has no user facing changes', () {
|
||||
expect(
|
||||
renderTelegram(version(), moreUrl: 'https://example.com'),
|
||||
'• $emptyVersionNote',
|
||||
);
|
||||
});
|
||||
|
||||
test('truncates past the caption limit and links out', () {
|
||||
final text = renderTelegram(
|
||||
version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.feat, [
|
||||
for (var index = 0; index < 200; index++)
|
||||
('hash$index', null, 'A reasonably long changelog entry'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
moreUrl: 'https://example.com',
|
||||
);
|
||||
|
||||
expect(text.length, lessThan(telegramLimit + 40));
|
||||
expect(text, endsWith('https://example.com'));
|
||||
});
|
||||
});
|
||||
|
||||
group('encodeChangelog', () {
|
||||
test('round trips through json', () {
|
||||
final changelog = Changelog(
|
||||
versions: [
|
||||
version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.feat, [
|
||||
('1a2b3c4', 'profiles', 'Scripts'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final decoded = decodeChangelog(encodeChangelog(changelog));
|
||||
|
||||
expect(decoded.versions.single.tag, 'v0.8.96');
|
||||
expect(decoded.versions.single.groups.single.type, ChangelogType.feat);
|
||||
expect(
|
||||
decoded.versions.single.groups.single.entries.single.scope,
|
||||
'profiles',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a future schema version', () {
|
||||
final decoded = decodeChangelog('{"schemaVersion": 99, "versions": []}');
|
||||
|
||||
expect(decoded.versions, isEmpty);
|
||||
});
|
||||
|
||||
test('ends with a newline so the file is diff friendly', () {
|
||||
expect(encodeChangelog(const Changelog(versions: [])), endsWith('\n'));
|
||||
});
|
||||
});
|
||||
|
||||
group('readPubspecVersion', () {
|
||||
test('reads the app version without the build number', () {
|
||||
expect(
|
||||
readPubspecVersion('name: fl_clash\nversion: 0.8.96+2026081501\n'),
|
||||
'0.8.96',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String _payloadOf(String body) {
|
||||
final start =
|
||||
body.indexOf(releaseJsonBeginMarker) + releaseJsonBeginMarker.length;
|
||||
return body.substring(start, body.indexOf(releaseJsonEndMarker, start));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../tool/src/icons/ico.dart';
|
||||
|
||||
Uint8List _png(int fill, int length) =>
|
||||
Uint8List.fromList(List.filled(length, fill));
|
||||
|
||||
int _u16(Uint8List bytes, int offset) =>
|
||||
bytes[offset] | (bytes[offset + 1] << 8);
|
||||
|
||||
int _u32(Uint8List bytes, int offset) =>
|
||||
_u16(bytes, offset) | (_u16(bytes, offset + 2) << 16);
|
||||
|
||||
void main() {
|
||||
test('writes one directory entry per size, sorted, with correct offsets', () {
|
||||
final ico = buildIco([
|
||||
IcoEntry(size: 256, png: _png(3, 7)),
|
||||
IcoEntry(size: 16, png: _png(1, 5)),
|
||||
IcoEntry(size: 32, png: _png(2, 6)),
|
||||
]);
|
||||
|
||||
expect(_u16(ico, 0), 0);
|
||||
expect(_u16(ico, 2), 1);
|
||||
expect(_u16(ico, 4), 3);
|
||||
|
||||
const firstImage = 6 + 16 * 3;
|
||||
expect([ico[6], ico[7]], [16, 16]);
|
||||
expect(_u32(ico, 6 + 8), 5);
|
||||
expect(_u32(ico, 6 + 12), firstImage);
|
||||
expect([ico[22], ico[23]], [32, 32]);
|
||||
expect(_u32(ico, 22 + 12), firstImage + 5);
|
||||
expect([ico[38], ico[39]], [0, 0]);
|
||||
expect(_u32(ico, 38 + 12), firstImage + 11);
|
||||
expect(ico.sublist(firstImage, firstImage + 5), everyElement(1));
|
||||
expect(ico.sublist(firstImage + 11), everyElement(3));
|
||||
});
|
||||
|
||||
test('rejects sizes outside the ICO range and empty input', () {
|
||||
expect(() => buildIco([]), throwsArgumentError);
|
||||
expect(
|
||||
() => buildIco([IcoEntry(size: 512, png: _png(0, 1))]),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('tray sizes stop at the 2x small-icon ceiling', () {
|
||||
expect(trayIcoSizes.last, 64);
|
||||
expect(icoSizes, containsAll(trayIcoSizes));
|
||||
expect(icoSizes.last, 256);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
void main() {
|
||||
group('Linux packaging teardown', () {
|
||||
for (final format in ['deb', 'rpm']) {
|
||||
test('$format removes the Helper unit only on a real uninstall', () {
|
||||
final config =
|
||||
loadYaml(
|
||||
File(
|
||||
'linux/packaging/$format/make_config.yaml',
|
||||
).readAsStringSync(),
|
||||
)
|
||||
as YamlMap;
|
||||
final scripts = (config['postuninstall_scripts'] as YamlList)
|
||||
.cast<String>();
|
||||
|
||||
expect(scripts.first, contains('exit 0'));
|
||||
expect(
|
||||
scripts,
|
||||
contains('rm -f /etc/systemd/system/flclash-helper.service'),
|
||||
);
|
||||
expect(
|
||||
scripts.any((script) => script.contains('systemctl daemon-reload')),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('rpm keeps the Core bytes the Helper was built against', () {
|
||||
final config =
|
||||
loadYaml(
|
||||
File('linux/packaging/rpm/make_config.yaml').readAsStringSync(),
|
||||
)
|
||||
as YamlMap;
|
||||
final macros = (config['spec_macros'] as YamlList).cast<String>();
|
||||
|
||||
expect(macros, contains('%global debug_package %{nil}'));
|
||||
expect(macros, contains('%global __os_install_post %{nil}'));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user