Files
pangolin/client/test/bridge/kernel_process_test.dart
T
wangjia 3c6a8a517a feat: 桌面端 PoC M1 — sing-box 子进程 TUN 接线 [tsk_SLCsjNgtmng3]
实现桌面端(macOS PoC)内核子进程管理,打通 sing-box TUN 模式全链路:

### client/lib/bridge/kernel_process.dart(完整实现,替换原有 stub)
- ClashApiClient: HTTP 客户端,支持 /connections / /proxies / /traffic(SSE) / /traffic(plain)
  - Bearer Token 鉴权;getConnections 用于就绪探测与流量统计;getTraffic SSE 读首帧后断开
- KernelProcess 接口: 新增 statusStream / statsStream 至接口定义
- DesktopKernelProcess:
  - spawn(configPath): 解析 Clash API 端口/secret → sudo sing-box run(macOS PoC)→
    轮询 /connections 等待就绪(20s 超时)→ emit connecting→on
  - kill(): SIGTERM + 等待 gracePeriod(5s) → SIGKILL → emit off
  - 意外退出: emit error(UI 可一键重连,不崩溃)
  - 统计轮询: 每秒 GET /connections,差分算 uploadSpeed/downloadSpeed
  - 二进制解析: ENV > exe同目录 > macOS Bundle Resources > 开发目录 > /usr/local/bin
- 辅助函数: generateClashApiPort(高位随机)、generateClashApiSecret(32B hex)

### client/lib/bridge/desktop_vpn_bridge.dart(新文件)
- DesktopVpnBridge implements VpnBridge:
  - start(configJson): injectClashApi(注入随机端口+secret)→ writeConfig(0600)→ kernel.spawn
  - stop(): kernel.kill(5s)
  - statusStream / statsStream: 代理 KernelProcess 事件流
  - selectOutbound: Clash API PUT /proxies/{group}
  - getActiveOutbound: 从 /proxies 读 now 字段
  - configDirOverride: 测试注入支持
  - injectClashApi: 静态方法,尊重已有 clash_api 配置,合并保留 experimental 其他字段

### client/test/bridge/kernel_process_test.dart(新文件)
- ClashApiClient 完整测试: headers / 解析 / PUT body / DELETE / 非 200 抛 HttpException
- generateClashApiPort / generateClashApiSecret 生成范围和格式测试
- DesktopVpnBridge.injectClashApi: 注入 / 尊重已有 / 保留字段 / 保留其他 experimental / 非法 JSON
- DesktopVpnBridge + FakeKernelProcess 集成: connecting→on / stop→off / statsStream / 意外退出 / spawn失败

### app/kernel/poc/(新目录)
- reality_client.config.json.tmpl: VLESS+REALITY+TUN 客户端配置模板
  - TUN inbound: auto_route + strict_route(macOS kill-switch 基础保护)
  - DNS: 防泄露(remote via VPN + cn 直连)
  - experimental.clash_api: 随机端口 + secret 占位符
- gen-poc-config.sh: 渲染模板为可用 JSON(从环境变量读 REALITY 参数)
- README.md: 完整 PoC 接线与 M1 验收步骤

提权说明(macOS PoC):
  · sudo 提权(开发机需配 /etc/sudoers.d/pangolin-singbox 或有 sudo 缓存)
  · 正式版: SMJobBless Helper + 公证(BACKLOG-11D-HELPER)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:53:50 +08:00

466 lines
16 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();
});
});
// ── 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>>());
});
});
}