Files
pangolin/client/test/bridge/desktop_vpn_bridge_m4m5_test.dart
T
wangjia 83dd5369ab feat(M4/M5): URLTest 自动选线 + Kill-switch + 弹性重连 [tsk_xAQhC1xuCd8x]
M4 URLTest 自动选线:
- ClashApiClient.getGroupDelay() — GET /group/<name>/delay 触发按需测速
- ClashApiClient.extractUrltestResults() — 从 /proxies 响应解析 URLTest
  组成员最新延迟,填充 stats 帧的 urltestResults 字段
- DesktopKernelProcess._startStatsPoll() — 每秒并行拉取连接统计 + URLTest
  延迟(_clashApi.getProxies()),URLTest 失败不影响主统计
- DesktopVpnBridge.selectOutbound(tag) — Clash API PUT /proxies/proxy,
  tag 可传节点 tag 或 urltest 组 tag(如 "auto-select")恢复自动
- DesktopVpnBridge.getActiveOutbound() — GET /proxies 读 proxy.now
- app/kernel/poc/reality_client.config.json.tmpl — 增加 urltest("auto-select")
  + selector("proxy") 出口组,route.final 改为 "proxy"

M5 Kill-switch + 弹性重连:
- DesktopVpnBridge.setKillSwitch({required bool on}) — 将 _killSwitchEnabled
  写入 TUN inbound strict_route 字段(applyKillSwitchToConfig);内核运行中
  触发静默重载(kill → start(lastConfig))
- DesktopVpnBridge.applyKillSwitchToConfig() — static,killSwitch=true ↔
  TUN strict_route=true,killSwitch=false ↔ strict_route=false
- 自动退避重连:kernel 崩溃推 error → _scheduleReconnect() 退避序列
  1s/2s/4s/8s/16s/30s(上限),stop() 取消,重连成功后重置计数器
- DesktopKernelProcess 意外退出清理 _clashApi(避免下次 spawn 残留)
- app/pangolin/test/killswitch_checklist.md — 三端故障注入验收清单
  (Desktop ,iOS/Android 欠账说明)

新增测试(client/test/bridge/desktop_vpn_bridge_m4m5_test.dart):
- M4: extractUrltestResults 解析/空/无 history 边界
- M4: getGroupDelay HTTP 请求格式验证
- M4: selectOutbound / getActiveOutbound Clash API 调用断言
- M5: applyKillSwitchToConfig 字段覆盖 + 无 TUN 边界
- M5: setKillSwitch 状态注入 + 同值幂等
- M5: 崩溃→error 不崩 / 首次重连在退避延迟内触发 / stop 后不再重连

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:11:09 +08:00

665 lines
23 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.
// desktop_vpn_bridge_m4m5_test.dart — M4/M5 功能单元测试
//
// 验收条件(tsk_xAQhC1xuCd8x):
// M4 URLTest
// 1. ClashApiClient.extractUrltestResults 正确解析 /proxies 响应
// 2. extractUrltestResults 在无 URLTest 组时返回空列表
// 3. extractUrltestResults 跳过无 history 的成员
// 4. ClashApiClient.getGroupDelay 发送正确 GET 请求并解析结果
// 5. selectOutbound 通过 Clash API PUT /proxies/proxy 切换出口
// 6. getActiveOutbound 从 /proxies 读取 proxy.now
// 7. getActiveOutbound 在内核未运行时返回 'auto'
// M5 Kill-switch
// 8. applyKillSwitchToConfig(true) 设置 TUN strict_route=true
// 9. applyKillSwitchToConfig(false) 设置 TUN strict_route=false
// 10. 无 TUN inbound 时 applyKillSwitchToConfig 原样返回
// 11. setKillSwitch 保存 _killSwitchEnabled 并在 start 时注入 config
// M5 退避重连:
// 12. 内核崩溃推 error → UI 不崩(error 事件流通)
// 13. 自动重连调度:error 后 _scheduleReconnect 在 1s 内触发(首次延迟)
// 14. stop() 取消自动重连(不再触发重连)
// 15. 重连成功后 reconnectAttempt 重置为 0
// 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(复用自 kernel_process_test.dart,支持崩溃模拟)
// ══════════════════════════════════════════════════════════════════
class FakeKernelProcess implements KernelProcess {
FakeKernelProcess({
this.shouldFailSpawn = false,
this.crashAfterMs,
});
final bool shouldFailSpawn;
final int? crashAfterMs; // 若非 null,spawn 成功后在此延迟后模拟崩溃
bool _running = false;
String? lastConfigPath;
int spawnCount = 0;
int killCount = 0;
ClashApiClient? _fakeClashApi;
final _statusCtrl = StreamController<VpnStatus>.broadcast();
final _logCtrl = StreamController<String>.broadcast();
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
@override
bool get isRunning => _running;
@override
ClashApiClient get clashApiClient =>
_fakeClashApi ?? (throw StateError('FakeKernelProcess.clashApiClient: set fakeClashApi first'));
set fakeClashApi(ClashApiClient c) => _fakeClashApi = c;
@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;
spawnCount++;
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 (crashAfterMs != null) {
Future<void>.delayed(Duration(milliseconds: crashAfterMs!)).then((_) {
if (_running) {
_running = false;
_emitStatus(VpnStatus.error);
}
});
}
}
@override
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)}) async {
killCount++;
if (_running) {
_running = false;
_emitStatus(VpnStatus.off);
}
}
void emitStats(VpnStatsEvent e) {
if (!_statsCtrl.isClosed) _statsCtrl.add(e);
}
/// 手动触发崩溃(error 状态)
void simulateCrash() {
if (_running) {
_running = false;
_emitStatus(VpnStatus.error);
}
}
void disposeFake() {
_statusCtrl.close();
_logCtrl.close();
_statsCtrl.close();
}
void _emitStatus(VpnStatus s) {
if (!_statusCtrl.isClosed) _statusCtrl.add(s);
}
}
// ══════════════════════════════════════════════════════════════════
// TestBridge: 覆盖 writeConfig,避免写真实文件系统
// ══════════════════════════════════════════════════════════════════
class _TestBridge extends DesktopVpnBridge {
_TestBridge(this.fakeKernel) : super(kernel: fakeKernel);
final FakeKernelProcess fakeKernel;
String? lastWrittenConfig;
List<String> allWrittenConfigs = [];
@override
Future<String> writeConfig(String configJson) async {
lastWrittenConfig = configJson;
allWrittenConfigs.add(configJson);
final tmp = Directory.systemTemp.createTempSync('pangolin_m4m5_test_');
final f = File('${tmp.path}/config.json')..writeAsStringSync(configJson);
return f.path;
}
@override
void dispose() {
fakeKernel.disposeFake();
super.dispose();
}
}
// ══════════════════════════════════════════════════════════════════
// 测试套件
// ══════════════════════════════════════════════════════════════════
void main() {
// ── M4: ClashApiClient.extractUrltestResults ──────────────────
group('M4 ClashApiClient.extractUrltestResults', () {
test('1. 正确解析单 URLTest 组的成员延迟', () {
final proxiesResp = {
'proxies': {
'auto-select': {
'type': 'URLTest',
'now': 'hk-1',
'all': ['hk-1', 'sg-1'],
},
'hk-1': {
'type': 'VMess',
'alive': true,
'history': [
{'time': '2024-01-01T00:00:00Z', 'delay': 18},
],
},
'sg-1': {
'type': 'VMess',
'alive': true,
'history': [
{'time': '2024-01-01T00:00:00Z', 'delay': 54},
],
},
},
};
final results = ClashApiClient.extractUrltestResults(proxiesResp);
expect(results.length, 2);
final tags = results.map((r) => r.tag).toList();
expect(tags, containsAll(['hk-1', 'sg-1']));
final hk = results.firstWhere((r) => r.tag == 'hk-1');
expect(hk.delayMs, 18);
final sg = results.firstWhere((r) => r.tag == 'sg-1');
expect(sg.delayMs, 54);
});
test('2. 无 URLTest 组时返回空列表', () {
final proxiesResp = {
'proxies': {
'GLOBAL': {
'type': 'Selector',
'all': ['DIRECT'],
},
'DIRECT': {'type': 'Direct'},
},
};
final results = ClashApiClient.extractUrltestResults(proxiesResp);
expect(results, isEmpty);
});
test('3. 跳过无 history 的成员(不崩溃)', () {
final proxiesResp = {
'proxies': {
'auto-select': {
'type': 'URLTest',
'all': ['ok-node', 'no-history-node'],
},
'ok-node': {
'type': 'VMess',
'history': [
{'time': '2024-01-01T00:00:00Z', 'delay': 45},
],
},
'no-history-node': {
'type': 'VMess',
'history': <Map<String, dynamic>>[],
},
},
};
final results = ClashApiClient.extractUrltestResults(proxiesResp);
// 只有 ok-node 有 history
expect(results.length, 1);
expect(results.first.tag, 'ok-node');
expect(results.first.delayMs, 45);
});
test('4. proxies 为空 Map 时返回空列表', () {
final results =
ClashApiClient.extractUrltestResults({'proxies': <String, dynamic>{}});
expect(results, isEmpty);
});
test('4b. 顶层无 proxies key 时返回空列表', () {
final results = ClashApiClient.extractUrltestResults({});
expect(results, isEmpty);
});
});
// ── M4: ClashApiClient.getGroupDelay ─────────────────────────
group('M4 ClashApiClient.getGroupDelay', () {
test('5. 发送正确 GET 请求并解析 tag→delayMs', () async {
http.Request? capturedReq;
final mock = MockClient((req) async {
capturedReq = req;
return http.Response(
jsonEncode({'hk-1': 18, 'sg-1': 54}),
200,
);
});
final client = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
final result = await client.getGroupDelay('auto-select');
expect(capturedReq?.method, 'GET');
expect(capturedReq?.url.path, '/group/auto-select/delay');
expect(capturedReq?.url.queryParameters['url'],
'http://www.gstatic.com/generate_204');
expect(result['hk-1'], 18);
expect(result['sg-1'], 54);
client.dispose();
});
test('5b. 自定义 testUrl 和 timeoutMs', () async {
http.Request? capturedReq;
final mock = MockClient((req) async {
capturedReq = req;
return http.Response(jsonEncode(<String, dynamic>{}), 200);
});
final client = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
await client.getGroupDelay('auto-select',
testUrl: 'http://example.com', timeoutMs: 5000);
expect(capturedReq?.url.queryParameters['url'], 'http://example.com');
expect(capturedReq?.url.queryParameters['timeout'], '5000');
client.dispose();
});
test('5c. 非200状态码抛出 HttpException', () async {
final mock =
MockClient((_) async => http.Response('Not Found', 404));
final client = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
await expectLater(
client.getGroupDelay('auto-select'),
throwsA(isA<HttpException>()),
);
client.dispose();
});
});
// ── M4: selectOutbound & getActiveOutbound ───────────────────
group('M4 selectOutbound & getActiveOutbound', () {
test('6. selectOutbound 调用 PUT /proxies/proxy', () async {
Map<String, dynamic>? sentBody;
String? capturedTag;
final fake = FakeKernelProcess();
final mock = MockClient((req) async {
if (req.method == 'PUT') {
capturedTag = req.url.pathSegments.last;
sentBody = jsonDecode(req.body) as Map<String, dynamic>;
return http.Response('', 204);
}
// GET /connections (readiness)
return http.Response(
jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}),
200);
});
fake.fakeClashApi = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
final bridge = _TestBridge(fake);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 30));
await bridge.selectOutbound('sg-1');
expect(capturedTag, 'proxy');
expect(sentBody?['name'], 'sg-1');
bridge.dispose();
});
test('6b. selectOutbound 用 auto-select 可切回 URLTest 自动模式', () async {
String? selectedProxy;
final fake = FakeKernelProcess();
final mock = MockClient((req) async {
if (req.method == 'PUT') {
final body = jsonDecode(req.body) as Map<String, dynamic>;
selectedProxy = body['name'] as String?;
return http.Response('', 204);
}
return http.Response(
jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}),
200);
});
fake.fakeClashApi = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
final bridge = _TestBridge(fake);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 30));
await bridge.selectOutbound('auto-select'); // 切回自动
expect(selectedProxy, 'auto-select');
bridge.dispose();
});
test('7. getActiveOutbound 在内核未运行时返回 "auto"', () async {
final fake = FakeKernelProcess();
final bridge = _TestBridge(fake);
final active = await bridge.getActiveOutbound();
expect(active, 'auto');
bridge.dispose();
});
test('7b. getActiveOutbound 从 proxies[proxy][now] 读取', () async {
final fake = FakeKernelProcess();
final mock = MockClient((req) async {
if (req.url.path == '/proxies') {
return http.Response(
jsonEncode({
'proxies': {
'proxy': {'type': 'Selector', 'now': 'sg-1'},
},
}),
200,
);
}
return http.Response(
jsonEncode({'downloadTotal': 0, 'uploadTotal': 0, 'connections': []}),
200);
});
fake.fakeClashApi = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
final bridge = _TestBridge(fake);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 30));
final active = await bridge.getActiveOutbound();
expect(active, 'sg-1');
bridge.dispose();
});
});
// ── M5: applyKillSwitchToConfig ──────────────────────────────
group('M5 applyKillSwitchToConfig', () {
const configWithTun = '''{
"inbounds": [
{"type": "tun", "tag": "tun-in", "strict_route": false}
],
"outbounds": [{"type": "direct", "tag": "direct"}]
}''';
test('8. killSwitch=true 设置 TUN strict_route=true', () {
final result =
DesktopVpnBridge.applyKillSwitchToConfig(configWithTun, true);
final decoded = jsonDecode(result) as Map<String, dynamic>;
final inbounds = decoded['inbounds'] as List;
final tun = inbounds.firstWhere((e) => e['type'] == 'tun') as Map;
expect(tun['strict_route'], true);
});
test('9. killSwitch=false 设置 TUN strict_route=false', () {
const configWithKsOn = '''{
"inbounds": [{"type": "tun", "strict_route": true}]
}''';
final result =
DesktopVpnBridge.applyKillSwitchToConfig(configWithKsOn, false);
final decoded = jsonDecode(result) as Map<String, dynamic>;
final inbounds = decoded['inbounds'] as List;
final tun = inbounds.first as Map;
expect(tun['strict_route'], false);
});
test('10. 无 TUN inbound 时原样返回(不抛异常)', () {
const noTun = '{"inbounds": [{"type": "http", "listen_port": 7890}]}';
final result = DesktopVpnBridge.applyKillSwitchToConfig(noTun, true);
// 无 TUN,原样返回
expect(jsonDecode(result)['inbounds'][0]['type'], 'http');
});
test('10b. 无效 JSON 原样返回(不抛异常)', () {
const invalid = 'not-json';
expect(() => DesktopVpnBridge.applyKillSwitchToConfig(invalid, true),
returnsNormally);
});
test('10c. 保留非 TUN inbound 及其他 config 字段', () {
const multi = '''{
"log": {"level": "info"},
"inbounds": [
{"type": "mixed", "listen_port": 7890},
{"type": "tun", "strict_route": false}
]
}''';
final result = DesktopVpnBridge.applyKillSwitchToConfig(multi, true);
final decoded = jsonDecode(result) as Map<String, dynamic>;
expect((decoded['log'] as Map)['level'], 'info');
final inbounds = decoded['inbounds'] as List;
expect(inbounds.length, 2);
expect(inbounds[0]['type'], 'mixed'); // 非 TUN 保留不变
expect(inbounds[1]['strict_route'], true);
});
});
// ── M5: setKillSwitch 注入行为 ───────────────────────────────
group('M5 setKillSwitch config injection', () {
test('11. start 时默认 killSwitch=false 保持原 strict_route 值', () async {
final fake = FakeKernelProcess();
final bridge = _TestBridge(fake);
const cfg = '{"inbounds":[{"type":"tun","strict_route":true}]}';
await bridge.start(cfg);
await Future<void>.delayed(const Duration(milliseconds: 30));
// killSwitch=false → strict_route=false
final written = jsonDecode(bridge.lastWrittenConfig!) as Map<String, dynamic>;
final tun = (written['inbounds'] as List)
.firstWhere((e) => e['type'] == 'tun') as Map;
expect(tun['strict_route'], false);
bridge.dispose();
});
test('11b. setKillSwitch(on: true) 后 start 注入 strict_route=true', () async {
final fake = FakeKernelProcess();
final bridge = _TestBridge(fake);
await bridge.setKillSwitch(on: true);
const cfg = '{"inbounds":[{"type":"tun","strict_route":false}]}';
await bridge.start(cfg);
await Future<void>.delayed(const Duration(milliseconds: 30));
final written = jsonDecode(bridge.lastWrittenConfig!) as Map<String, dynamic>;
final tun = (written['inbounds'] as List)
.firstWhere((e) => e['type'] == 'tun') as Map;
expect(tun['strict_route'], true);
bridge.dispose();
});
test('11c. setKillSwitch(on: X) 相同值时不重复重载', () async {
final fake = FakeKernelProcess();
final bridge = _TestBridge(fake);
const cfg = '{"inbounds":[{"type":"tun","strict_route":false}]}';
await bridge.start(cfg);
await Future<void>.delayed(const Duration(milliseconds: 30));
final spawnsBefore = fake.spawnCount;
await bridge.setKillSwitch(on: false); // 已经是 false,不触发重载
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(fake.spawnCount, spawnsBefore); // spawn 次数不变
bridge.dispose();
});
});
// ── M5: 自动退避重连 ─────────────────────────────────────────
group('M5 auto-reconnect with backoff', () {
test('12. 内核崩溃推 error → UI 不崩,error 事件可订阅', () async {
final fake = FakeKernelProcess();
final bridge = _TestBridge(fake);
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 30));
fake.simulateCrash(); // 模拟内核崩溃
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(events, contains(VpnStatus.error));
expect(() => bridge.dispose(), returnsNormally);
await sub.cancel();
});
test('13. error 后在首次退避延迟内触发重连(spawn 次数 +1)', () async {
final fake = FakeKernelProcess(crashAfterMs: 50);
final bridge = _TestBridge(fake);
await bridge.start('{"log":{}}');
final spawnsAfterStart = fake.spawnCount;
// 等待崩溃(50ms+ 首次退避(1s+ buffer
await Future<void>.delayed(const Duration(milliseconds: 1200));
expect(fake.spawnCount, greaterThan(spawnsAfterStart),
reason: '应该触发至少一次自动重连');
bridge.dispose();
});
test('14. stop() 之后不再触发自动重连', () async {
final fake = FakeKernelProcess();
final bridge = _TestBridge(fake);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 30));
await bridge.stop(); // 先停止
final spawnsAfterStop = fake.spawnCount;
fake.simulateCrash(); // 此时已 stop,不应触发重连
await Future<void>.delayed(const Duration(milliseconds: 1200));
expect(fake.spawnCount, equals(spawnsAfterStop),
reason: 'stop 后崩溃不应再触发自动重连');
bridge.dispose();
});
test('15. stats 流在 on 状态下转发内核事件', () async {
final fake = FakeKernelProcess();
final bridge = _TestBridge(fake);
final statsReceived = <VpnStatsEvent>[];
final sub = bridge.statsStream.listen(statsReceived.add);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 30));
const frame = VpnStatsEvent(
uploadBytes: 2048,
downloadBytes: 8192,
uploadSpeed: 1024.0,
downloadSpeed: 4096.0,
urltestResults: [
UrltestResult(tag: 'hk-1', delayMs: 18),
UrltestResult(tag: 'sg-1', delayMs: 54),
],
);
fake.emitStats(frame);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(statsReceived, hasLength(1));
expect(statsReceived[0].urltestResults, hasLength(2));
expect(statsReceived[0].urltestResults[0].tag, 'hk-1');
expect(statsReceived[0].urltestResults[0].delayMs, 18);
await sub.cancel();
bridge.dispose();
});
});
// ── M4: UrltestResult / VpnStatsEvent 模型 ───────────────────
group('M4 UrltestResult model', () {
test('fromMap / toMap 往返', () {
const r = UrltestResult(tag: 'hk-01', delayMs: 18);
final map = r.toMap();
final r2 = UrltestResult.fromMap(map);
expect(r2.tag, 'hk-01');
expect(r2.delayMs, 18);
});
test('VpnStatsEvent.fromMap 解析 urltestResults 列表', () {
final map = {
'uploadBytes': 100,
'downloadBytes': 200,
'uploadSpeed': 10.0,
'downloadSpeed': 20.0,
'urltestResults': [
{'tag': 'hk-1', 'delayMs': 18},
{'tag': 'sg-1', 'delayMs': 54},
],
};
final event = VpnStatsEvent.fromMap(map);
expect(event.urltestResults.length, 2);
expect(event.urltestResults[0].tag, 'hk-1');
expect(event.urltestResults[1].delayMs, 54);
});
test('VpnStatsEvent.fromNativeMap 处理 Map<Object?, Object?>', () {
final rawMap = <Object?, Object?>{
'uploadBytes': 1024,
'downloadBytes': 2048,
'uploadSpeed': 512.0,
'downloadSpeed': 1024.0,
'urltestResults': <Object?>[
<Object?, Object?>{'tag': 'jp-1', 'delayMs': 32},
],
};
final event = VpnStatsEvent.fromNativeMap(rawMap);
expect(event.urltestResults.length, 1);
expect(event.urltestResults[0].tag, 'jp-1');
});
});
}