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:
chen08209
2026-09-05 20:09:26 +08:00
parent 62addf738a
commit 96fcb9ab5e
60 changed files with 5482 additions and 1010 deletions
+94
View File
@@ -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'));
});
}
+63
View File
@@ -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'));
});
}
+111
View File
@@ -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',
);
}
});
}
+80
View File
@@ -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.',
);
}
});
}
+78
View 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.',
);
});
}
+160
View File
@@ -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'));
});
}
+55
View File
@@ -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'));
});
}
+25
View File
@@ -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');
});
});
}
+210
View File
@@ -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'));
});
}
+247
View File
@@ -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']);
});
});
}
+306
View File
@@ -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 &lt;script&gt; &amp; "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));
}
+53
View File
@@ -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);
});
}
+44
View File
@@ -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}'));
});
}