// kernel_process_test.dart — DesktopKernelProcess / DesktopVpnBridge 单元测试 // // 验收条件(tsk_SLCsjNgtmng3): // 1. ClashApiClient 构造参数正确存储 // 2. ClashApiClient.getConnections 请求正确(headers、解析) // 3. ClashApiClient.selectProxy 发送 PUT + 正确 body // 4. ClashApiClient.closeAllConnections 发送 DELETE // 5. generateClashApiPort 生成合法高位端口(49152-65535) // 6. generateClashApiSecret 生成 64 位 hex // 7. DesktopVpnBridge.injectClashApi 注入 Clash API 段(随机端口+secret) // 8. DesktopVpnBridge.injectClashApi 尊重已有 clash_api 配置 // 9. DesktopVpnBridge + FakeKernelProcess: start 触发 connecting→on 事件序列 // 10. DesktopVpnBridge: stop 触发 off // 11. DesktopVpnBridge: statsStream 转发 kernel 事件 // 12. 意外退出推 error 状态(UI 不崩) // 13. spawn 失败推 error 状态并抛异常 // ignore_for_file: avoid_print import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:pangolin_vpn/bridge/kernel_process.dart'; import 'package:pangolin_vpn/bridge/desktop_vpn_bridge.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; // ══════════════════════════════════════════════════════════════════ // FakeKernelProcess: implements KernelProcess,不依赖真实子进程 // ══════════════════════════════════════════════════════════════════ class FakeKernelProcess implements KernelProcess { FakeKernelProcess({ this.shouldFailSpawn = false, this.simulateUnexpectedExit = false, }); final bool shouldFailSpawn; final bool simulateUnexpectedExit; bool _running = false; String? lastConfigPath; int killCalls = 0; final _statusCtrl = StreamController.broadcast(); final _logCtrl = StreamController.broadcast(); final _statsCtrl = StreamController.broadcast(); @override bool get isRunning => _running; @override ClashApiClient get clashApiClient => throw UnimplementedError('FakeKernelProcess.clashApiClient not needed in tests'); @override Stream get logStream => _logCtrl.stream; @override Stream get statusStream => _statusCtrl.stream; @override Stream get statsStream => _statsCtrl.stream; @override Future spawn(String configPath) async { lastConfigPath = configPath; if (shouldFailSpawn) { _emitStatus(VpnStatus.error); throw Exception('fake spawn failure'); } _running = true; _emitStatus(VpnStatus.connecting); await Future.delayed(const Duration(milliseconds: 10)); _emitStatus(VpnStatus.on); if (simulateUnexpectedExit) { Future.delayed(const Duration(milliseconds: 60)).then((_) { if (_running) { _running = false; _emitStatus(VpnStatus.error); } }); } } @override Future kill({Duration gracePeriod = const Duration(seconds: 5)}) async { killCalls++; if (_running) { _running = false; _emitStatus(VpnStatus.off); } } // 测试辅助:向外注入统计帧 void emitStats(VpnStatsEvent e) { if (!_statsCtrl.isClosed) _statsCtrl.add(e); } void disposeFake() { _statusCtrl.close(); _logCtrl.close(); _statsCtrl.close(); } void _emitStatus(VpnStatus s) { if (!_statusCtrl.isClosed) _statusCtrl.add(s); } } // ══════════════════════════════════════════════════════════════════ // 测试用 DesktopVpnBridge:覆盖 writeConfig,避免写真实文件系统 // ══════════════════════════════════════════════════════════════════ class _TestBridge extends DesktopVpnBridge { _TestBridge(FakeKernelProcess kernel) : fake = kernel, super(kernel: kernel); final FakeKernelProcess fake; String? lastWrittenConfig; @override Future writeConfig(String configJson) async { lastWrittenConfig = configJson; // 写到 systemTemp,避免权限问题(测试后清理) final tmp = Directory.systemTemp.createTempSync('pangolin_test_'); final f = File('${tmp.path}/config.json') ..writeAsStringSync(configJson); return f.path; } @override void dispose() { fake.disposeFake(); } } // ══════════════════════════════════════════════════════════════════ // 单元测试 // ══════════════════════════════════════════════════════════════════ void main() { // ── 1. ClashApiClient ───────────────────────────────────────── group('ClashApiClient', () { test('stores baseUrl and secret', () { const url = 'http://127.0.0.1:51234'; const secret = 'my-secret'; final c = ClashApiClient(baseUrl: url, secret: secret); expect(c.baseUrl, url); expect(c.secret, secret); c.dispose(); }); test('getConnections sends Authorization header', () async { final requests = []; final mock = MockClient((req) async { requests.add(req); return http.Response( jsonEncode({'downloadTotal': 100, 'uploadTotal': 50, 'connections': []}), 200, ); }); final c = ClashApiClient( baseUrl: 'http://127.0.0.1:9090', secret: 'tok', httpClient: mock); await c.getConnections(); expect(requests, hasLength(1)); expect(requests[0].headers['Authorization'], 'Bearer tok'); expect(requests[0].url.path, '/connections'); c.dispose(); }); test('getConnections parses downloadTotal and uploadTotal', () async { final mock = MockClient((_) async => http.Response( jsonEncode({'downloadTotal': 12345, 'uploadTotal': 6789, 'connections': []}), 200, )); final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock); final data = await c.getConnections(); expect(data['downloadTotal'], 12345); expect(data['uploadTotal'], 6789); c.dispose(); }); test('getConnections throws HttpException on non-200', () async { final mock = MockClient((_) async => http.Response('Unauthorized', 401)); final c = ClashApiClient( baseUrl: 'http://127.0.0.1:9090', secret: 'bad', httpClient: mock); await expectLater(c.getConnections(), throwsA(isA())); c.dispose(); }); test('getProxies sends correct GET request', () async { final mock = MockClient((req) async { expect(req.method, 'GET'); expect(req.url.path, '/proxies'); return http.Response(jsonEncode({'proxies': {}}), 200); }); final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock); await c.getProxies(); c.dispose(); }); test('selectProxy sends PUT with correct body', () async { Map? body; final mock = MockClient((req) async { expect(req.method, 'PUT'); expect(req.url.path, '/proxies/my-group'); body = jsonDecode(req.body) as Map; return http.Response('', 204); }); final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock); await c.selectProxy('my-group', 'sg-01'); expect(body?['name'], 'sg-01'); c.dispose(); }); test('closeAllConnections sends DELETE', () async { final mock = MockClient((req) async { expect(req.method, 'DELETE'); expect(req.url.path, '/connections'); return http.Response('', 204); }); final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock); await c.closeAllConnections(); c.dispose(); }); }); // ── URLTest 延迟/组名提取 ───────────────────────────────────── group('URLTest extraction', () { final proxies = { 'proxies': { 'auto': { 'type': 'URLTest', 'all': ['reality-out', 'hy2-out'], }, 'reality-out': { 'type': 'Vless', 'history': [ {'time': 't1', 'delay': 30}, {'time': 't2', 'delay': 42}, ], }, 'hy2-out': { 'type': 'Hysteria2', 'history': [ {'time': 't1', 'delay': 88}, ], }, 'direct': {'type': 'Direct'}, }, }; test('extractUrltestGroups 返回 URLTest/Fallback 组名', () { expect(ClashApiClient.extractUrltestGroups(proxies), ['auto']); }); test('extractUrltestResults 取成员最新 history delay', () { final r = ClashApiClient.extractUrltestResults(proxies); final byTag = {for (final e in r) e.tag: e.delayMs}; expect(byTag['reality-out'], 42); // history.last expect(byTag['hy2-out'], 88); }); test('无 URLTest 组时返回空', () { final empty = { 'proxies': { 'direct': {'type': 'Direct'}, }, }; expect(ClashApiClient.extractUrltestGroups(empty), isEmpty); expect(ClashApiClient.extractUrltestResults(empty), isEmpty); }); }); // ── 2. 随机生成工具函数 ─────────────────────────────────────── group('Port and secret generators', () { test('generateClashApiPort is in range 49152–65535', () { for (var i = 0; i < 30; i++) { final p = generateClashApiPort(); expect(p, greaterThanOrEqualTo(49152)); expect(p, lessThanOrEqualTo(65535)); } }); test('generateClashApiSecret is 64 lowercase hex chars', () { final s = generateClashApiSecret(); expect(s.length, 64); expect(RegExp(r'^[0-9a-f]+$').hasMatch(s), isTrue); }); test('generateClashApiSecret produces unique values', () { final a = generateClashApiSecret(); final b = generateClashApiSecret(); expect(a, isNot(equals(b))); }); }); // ── 3. DesktopVpnBridge.injectClashApi ─────────────────────── group('DesktopVpnBridge.injectClashApi', () { test('injects clash_api when absent', () { const cfg = '{"log":{"level":"info"}}'; final (json, port, secret) = DesktopVpnBridge.injectClashApi(cfg); final decoded = jsonDecode(json) as Map; final exp = decoded['experimental'] as Map; final api = exp['clash_api'] as Map; final ctrl = api['external_controller'] as String; expect(ctrl, startsWith('127.0.0.1:')); expect(int.parse(ctrl.split(':')[1]), allOf(greaterThanOrEqualTo(49152), lessThanOrEqualTo(65535))); expect(api['secret'], isA()); expect((api['secret'] as String).length, 64); expect(port, greaterThanOrEqualTo(49152)); expect(secret.length, 64); }); test('respects existing clash_api config', () { const existing = '{' '"experimental":{"clash_api":{"external_controller":"127.0.0.1:12345","secret":"abc"}}' '}'; final (json, port, secret) = DesktopVpnBridge.injectClashApi(existing); // Should return original JSON unchanged expect(json, equals(existing)); expect(port, 12345); expect(secret, 'abc'); }); test('preserves other config fields', () { const cfg = '{"log":{"level":"info"},"dns":{"servers":[]}}'; final (json, _, _) = DesktopVpnBridge.injectClashApi(cfg); final decoded = jsonDecode(json) as Map; expect((decoded['log'] as Map)['level'], 'info'); expect(decoded['dns'], isA()); }); test('throws FormatException on invalid JSON', () { expect( () => DesktopVpnBridge.injectClashApi('not json'), throwsA(isA()), ); }); test('preserves existing experimental fields when injecting clash_api', () { const cfg = '{"experimental":{"cache_file":{"enabled":true}}}'; final (json, _, _) = DesktopVpnBridge.injectClashApi(cfg); final decoded = jsonDecode(json) as Map; final exp = decoded['experimental'] as Map; // cache_file should be preserved expect(exp.containsKey('cache_file'), isTrue); // clash_api should be injected expect(exp.containsKey('clash_api'), isTrue); }); }); // ── 4. DesktopVpnBridge + FakeKernelProcess ────────────────── group('DesktopVpnBridge with FakeKernelProcess', () { _TestBridge makeBridge({ bool failSpawn = false, bool unexpectedExit = false, }) { final fake = FakeKernelProcess( shouldFailSpawn: failSpawn, simulateUnexpectedExit: unexpectedExit, ); return _TestBridge(fake); } test('start emits connecting then on', () async { final bridge = makeBridge(); final events = []; final sub = bridge.statusStream.listen(events.add); await bridge.start('{"log":{"level":"info"}}'); await Future.delayed(const Duration(milliseconds: 30)); expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on])); await sub.cancel(); bridge.dispose(); }); test('stop emits off', () async { final bridge = makeBridge(); final events = []; final sub = bridge.statusStream.listen(events.add); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 20)); await bridge.stop(); expect(events.last, VpnStatus.off); expect((bridge.fake).killCalls, 1); await sub.cancel(); bridge.dispose(); }); test('getStatus returns on while running', () async { final bridge = makeBridge(); expect(await bridge.getStatus(), VpnStatus.off); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 20)); expect(await bridge.getStatus(), VpnStatus.on); await bridge.stop(); expect(await bridge.getStatus(), VpnStatus.off); bridge.dispose(); }); test('statsStream relays events from kernel', () async { final bridge = makeBridge(); final stats = []; final sub = bridge.statsStream.listen(stats.add); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 20)); const frame = VpnStatsEvent( uploadBytes: 1024, downloadBytes: 4096, uploadSpeed: 512.0, downloadSpeed: 2048.0, urltestResults: [], ); bridge.fake.emitStats(frame); await Future.delayed(const Duration(milliseconds: 10)); expect(stats, hasLength(1)); expect(stats[0].uploadBytes, 1024); expect(stats[0].downloadSpeed, 2048.0); await sub.cancel(); bridge.dispose(); }); test('unexpected kernel exit emits error, UI does not crash', () async { final bridge = makeBridge(unexpectedExit: true); final events = []; final sub = bridge.statusStream.listen(events.add); await bridge.start('{"log":{}}'); await Future.delayed(const Duration(milliseconds: 120)); expect(events, contains(VpnStatus.error)); // dispose must not throw expect(() => bridge.dispose(), returnsNormally); await sub.cancel(); }); test('failed spawn emits error and throws', () async { final bridge = makeBridge(failSpawn: true); final events = []; final sub = bridge.statusStream.listen(events.add); await expectLater( () => bridge.start('{"log":{}}'), throwsA(anything), ); expect(events, contains(VpnStatus.error)); await sub.cancel(); bridge.dispose(); }); test('injectClashApi runs during start, enriched JSON written', () async { final bridge = makeBridge(); await bridge.start('{"log":{"level":"info"}}'); final written = jsonDecode(bridge.lastWrittenConfig!) as Map; final exp = written['experimental'] as Map?; expect(exp, isNotNull); expect(exp!.containsKey('clash_api'), isTrue); bridge.dispose(); }); }); // ── 5. KernelProcess interface ─────────────────────────────── group('KernelProcess interface', () { test('FakeKernelProcess satisfies KernelProcess', () { // 静态类型检查:FakeKernelProcess implements KernelProcess final KernelProcess kp = FakeKernelProcess(); expect(kp.isRunning, isFalse); expect(kp.logStream, isA>()); expect(kp.statusStream, isA>()); expect(kp.statsStream, isA>()); }); }); }