4f9d2d2cf3
同 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>
513 lines
18 KiB
Dart
513 lines
18 KiB
Dart
// 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 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<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>>());
|
||
});
|
||
});
|
||
}
|