Files
pangolin/client/test/bridge/kernel_process_test.dart
T
wangjia 4f9d2d2cf3 fix(client): 补提交 stats-overhaul 漏掉的 account_api/kernel_process WIP
同 device_usage:account_api.dart(deviceUsage/usage 方法)、kernel_process.dart
及对应测试一直未提交,本地有→analyze/test 过,但不在 git→Windows 构建报
「deviceUsage isn't defined」。补齐使提交树自洽可构建。
孤儿组件 device_stat_row/metric_card 已无人引用,不提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:53:56 +08:00

513 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<VpnStatus>.broadcast();
final _logCtrl = StreamController<String>.broadcast();
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
@override
bool get isRunning => _running;
@override
ClashApiClient get clashApiClient =>
throw UnimplementedError('FakeKernelProcess.clashApiClient not needed in tests');
@override
Stream<String> get logStream => _logCtrl.stream;
@override
Stream<VpnStatus> get statusStream => _statusCtrl.stream;
@override
Stream<VpnStatsEvent> get statsStream => _statsCtrl.stream;
@override
Future<void> spawn(String configPath) async {
lastConfigPath = configPath;
if (shouldFailSpawn) {
_emitStatus(VpnStatus.error);
throw Exception('fake spawn failure');
}
_running = true;
_emitStatus(VpnStatus.connecting);
await Future<void>.delayed(const Duration(milliseconds: 10));
_emitStatus(VpnStatus.on);
if (simulateUnexpectedExit) {
Future<void>.delayed(const Duration(milliseconds: 60)).then((_) {
if (_running) {
_running = false;
_emitStatus(VpnStatus.error);
}
});
}
}
@override
Future<void> 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<String> 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 = <http.Request>[];
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<HttpException>()));
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<String, dynamic>? body;
final mock = MockClient((req) async {
expect(req.method, 'PUT');
expect(req.url.path, '/proxies/my-group');
body = jsonDecode(req.body) as Map<String, dynamic>;
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 = <String, dynamic>{
'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 4915265535', () {
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<String, dynamic>;
final exp = decoded['experimental'] as Map<String, dynamic>;
final api = exp['clash_api'] as Map<String, dynamic>;
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<String>());
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<String, dynamic>;
expect((decoded['log'] as Map)['level'], 'info');
expect(decoded['dns'], isA<Map>());
});
test('throws FormatException on invalid JSON', () {
expect(
() => DesktopVpnBridge.injectClashApi('not json'),
throwsA(isA<FormatException>()),
);
});
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<String, dynamic>;
final exp = decoded['experimental'] as Map<String, dynamic>;
// 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 = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{"level":"info"}}');
await Future<void>.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 = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{}}');
await Future<void>.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<void>.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 = <VpnStatsEvent>[];
final sub = bridge.statsStream.listen(stats.add);
await bridge.start('{"log":{}}');
await Future<void>.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<void>.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 = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{}}');
await Future<void>.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 = <VpnStatus>[];
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<String, dynamic>;
final exp = written['experimental'] as Map<String, dynamic>?;
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<Stream<String>>());
expect(kp.statusStream, isA<Stream<VpnStatus>>());
expect(kp.statsStream, isA<Stream<VpnStatsEvent>>());
});
});
}