fix(常驻采集): 探针不可用只报「不知道」,并把采集端实际读到的配置摆出来
## 一、探针不可用 ≠ 没在跑(把上一轮糊回去的洞补上) installer 在 ccb6c3f 里给 `process` 加了 `probe_available/probe_error`,把 「查不了」与「没在跑」分开了(人话输出已经用上);但 JSON 消费方没跟上: `ResidentStatus.fromJson` 只读 `running`/`pids`,于是 Windows(无 pgrep)上 `running=false + registered=true` → 卡片一本正经显示 **"已安装但没起来"(橙色告警)**, 而真相是"一次都没查过"。这比"没有卡片"糟得多:它看起来**很确定**,用户会 照着它去重装一个已经装好的东西。 - `fromJson` 读 `probe_available`,**只有明确 false 且没观测到进程**才降级为 `ResidentState.unknown`(中性灰)+ detail 写明原因与"这不等于没在运行" - `probe_available` 做成**三态**(`bool?`):缺字段(老二进制/老快照)→ 不看该判据。 若把"缺字段"当 false,所有老机器都会被判成"未知"——那是用另一种方式骗人 - 正面观测优先:`running=true`/有 pid 时仍判 running(探针元数据不压过证据) ## 二、采集端实际读到的配置:诚实标注,不做同步 macOS GUI 是真沙箱(容器内 `ConfigStore`),常驻采集端由 launchd 拉起、读真实 家目录 —— 磁盘上**两份 config 同时活着**:界面里改网关地址只改容器那份,采集端 看到的还是旧的,两边都不报错。三条同步路本轮都不通(沙箱无权写容器外 / 沙箱子 进程继承沙箱 / 让 agent 读容器会给主路径加上"必须装过 GUI"的依赖),故只做标注: - 卡片显示「采集端当前读到:<server_url> · <device_id>」(来自 `status --json`) - 与界面输入不一致时明说不一致,并给出下一步(点一次「重新安装并启动」) - 配置面板加一句:上面两项只对「界面内采集」生效,常驻采集读的是安装器那份 - 未安装/问不到时不显示这一行(没有"采集端读到的配置"这回事,不能凭空造) ## 三、`log.path` 的漂移种子写进注释 `/tmp/<label>.err.log` 在 Rust 的 `macos_launchagent_plist` 与 Dart 的 `statusSnapshot()` 各写一份,今天"逐字相同"只是碰巧。已在 `logPath` 的文档里 写明正解:让 `AutoStartPlan` 返回 `stderr_log_path`,Dart 只读不算。 ## 四、顺带补上 label 守卫(等 ccb6c3f 落地后才敢提) `⑥` 组:字面量只许出现在 `lib/host/autostart_label.dart`,两个消费者必须引用 `kDefaultAutostartLabel`。 ## 验证 - `flutter test` → **556/0**(新增 7 条:接缝 6 + 卡片 5,另含 label 守卫 2) - `dart analyze lib bin` → No issues found - 变异自证: · 把 `fromJson` 里 `probeAvailable == false` 分支短路 → `⑦` 两条 ★ 报红(其余仍绿),恢复即绿 · 把卡片的 `if (mismatch)` 短路 → 「与界面输入不一致」报红,恢复即绿
This commit is contained in:
1 parent
24e79b06a6
commit
d7147e67a7
5 files changed
+374
-3
No files matched your search
@@ -60,6 +60,10 @@ class ResidentStatus {
|
||||
this.logExists = false,
|
||||
this.agentPath = '',
|
||||
this.agentExists = false,
|
||||
this.probeAvailable,
|
||||
this.probeError = '',
|
||||
this.serverUrl = '',
|
||||
this.deviceId = '',
|
||||
this.detail,
|
||||
});
|
||||
|
||||
@@ -78,6 +82,17 @@ class ResidentStatus {
|
||||
final bool configExists;
|
||||
final String configPath;
|
||||
|
||||
/// 采集端错误日志路径(launchd 的 `StandardErrorPath`)。
|
||||
///
|
||||
/// ⚠️ **这条事实目前在 Rust 与 Dart 各写了一份**:`rust-core/src/keepalive.rs`
|
||||
/// 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成 `/tmp/<label>.err.log`,
|
||||
/// `bin/pmon_install.dart` 的 `statusSnapshot()` 又自己拼一遍。今天两边**逐字
|
||||
/// 相同**,但"两边碰巧一样"正是漂移的前兆 —— 改一处,status 就指向一个不存在
|
||||
/// 的日志文件,而两边都不会报错。
|
||||
///
|
||||
/// 正解(未做,要动 Rust 结构 + FFI 出口):让 `AutoStartPlan` 多返回
|
||||
/// `stderr_log_path`,Dart 侧**只读不算** —— 即改为读
|
||||
/// `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`**。
|
||||
final String logPath;
|
||||
final bool logExists;
|
||||
|
||||
@@ -85,6 +100,27 @@ class ResidentStatus {
|
||||
final String agentPath;
|
||||
final bool agentExists;
|
||||
|
||||
/// 采集端进程探针**是否可用**(`process.probe_available`)。
|
||||
///
|
||||
/// 三态而非布尔:`null` 表示这个 CLI 版本压根没报这个字段(老二进制/老快照)。
|
||||
/// 只有明确 `false` 才允许把结论降级为 [ResidentState.unknown] ——
|
||||
/// 老 CLI 没这个字段时若按"不可用"处理,会把所有老机器的正常状态打成"未知",
|
||||
/// 那就是用另一种方式骗人。
|
||||
final bool? probeAvailable;
|
||||
|
||||
/// 探针不可用的原因(`process.probe_error`),只在 [probeAvailable] 为 false 时有意义
|
||||
final String probeError;
|
||||
|
||||
/// 采集端**实际读到的**网关地址(`config.server_url`,即安装器那份配置)
|
||||
///
|
||||
/// 为什么不让界面拿自己那份配置显示:macOS GUI 是**真沙箱**,它读写的
|
||||
/// `ConfigStore` 在应用容器里;而常驻采集端由 launchd 拉起、读真实家目录。
|
||||
/// 两份都活着、各说各话 —— 显示"采集端实际读到什么"才是真的。
|
||||
final String serverUrl;
|
||||
|
||||
/// 采集端**实际读到的**设备标识(`config.device_id`)
|
||||
final String deviceId;
|
||||
|
||||
/// 诊断说明(未知态时说明为什么问不出来)
|
||||
final String? detail;
|
||||
|
||||
@@ -111,14 +147,31 @@ class ResidentStatus {
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
|
||||
// `probe_available` 是三态:缺字段(老 CLI)→ null,不看这个判据。
|
||||
final rawProbe = process['probe_available'];
|
||||
final probeAvailable = rawProbe is bool ? rawProbe : null;
|
||||
final probeError = (process['probe_error'] as String?) ?? '';
|
||||
|
||||
final running = process['running'] == true || pids.isNotEmpty;
|
||||
final registered = autostart['registered'] == true;
|
||||
final loaded = autostart['loaded'] == true;
|
||||
final agentExists = installed['agent_exists'] == true;
|
||||
|
||||
final ResidentState state;
|
||||
// 探针不可用 ≠ 没在跑
|
||||
String? resolutionDetail;
|
||||
if (running) {
|
||||
// 正面观察到的"在跑"永远优先:探针能给出 pid,就说明它至少是能用的
|
||||
state = ResidentState.running;
|
||||
} else if (probeAvailable == false) {
|
||||
// ★「查不了」不是「没在跑」:本平台没有探针(Windows 暂无)时,
|
||||
// `running` 只能是 false —— 若照旧往下判,会得出"已安装但没起来"这个
|
||||
// **看起来很确定、实际没查过**的结论,卡片还会染成告警色。
|
||||
// 本项目的规矩是"不知道"优于"错的答案",故降级为 unknown(中性灰)。
|
||||
state = ResidentState.unknown;
|
||||
resolutionDetail = '本平台无法判定采集端进程'
|
||||
'(${probeError.isEmpty ? '缺少可用的进程探针' : probeError}),'
|
||||
'这不等于"没在运行"';
|
||||
} else if (registered || agentExists) {
|
||||
state = ResidentState.installedNotRunning;
|
||||
} else {
|
||||
@@ -137,6 +190,11 @@ class ResidentStatus {
|
||||
logExists: log['exists'] == true,
|
||||
agentPath: (installed['agent_path'] as String?) ?? '',
|
||||
agentExists: agentExists,
|
||||
probeAvailable: probeAvailable,
|
||||
probeError: probeError,
|
||||
serverUrl: (config['server_url'] as String?) ?? '',
|
||||
deviceId: (config['device_id'] as String?) ?? '',
|
||||
detail: resolutionDetail,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'host/agent_lock.dart';
|
||||
import 'host/app_config.dart';
|
||||
import 'host/app_paths.dart';
|
||||
import 'host/boot_report.dart';
|
||||
import 'host/device_id.dart';
|
||||
import 'host/link.dart';
|
||||
@@ -818,6 +819,21 @@ class _ControlPanelPageState extends State<ControlPanelPage>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
// ── 沙箱导致的"两份配置"必须在这里就说清(诚实标注,非修好)──
|
||||
// macOS GUI 是真沙箱:它自己读写的配置在应用容器里;而「常驻采集」
|
||||
// 是 launchd 拉起的非沙箱进程,读真实家目录那份。用户在下面两项里
|
||||
// 改地址,只有界面内采集会跟着变,常驻采集不会 —— 不说清的话,
|
||||
// 症状是"改了网关地址、后台那台还在往旧地址发",两边都不报错。
|
||||
if (currentPlatform != PmonPlatform.android) ...[
|
||||
const Text(
|
||||
'上面两项只对「界面内采集」生效。常驻采集是另一个进程,'
|
||||
'读的是安装器那份配置 —— 装好后在「常驻采集」卡里能看到它'
|
||||
'实际读到的网关地址与设备标识。',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5, color: PmonColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
SwitchListTile(
|
||||
value: _autoStart,
|
||||
onChanged: (v) {
|
||||
|
||||
@@ -126,6 +126,7 @@ class _ResidentCardState extends State<ResidentCard> {
|
||||
ResidentState.notInstalled => (PmonColors.offline, '未安装'),
|
||||
ResidentState.unknown => (PmonColors.offline, '状态未知'),
|
||||
};
|
||||
final effectiveConfig = _effectiveConfigNote();
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -166,6 +167,10 @@ class _ResidentCardState extends State<ResidentCard> {
|
||||
),
|
||||
const SizedBox(height: PmonSpace.md),
|
||||
_statusLine(),
|
||||
if (effectiveConfig != null) ...[
|
||||
const SizedBox(height: PmonSpace.sm),
|
||||
effectiveConfig,
|
||||
],
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: PmonSpace.sm),
|
||||
_errorBox(_error!),
|
||||
@@ -213,6 +218,67 @@ class _ResidentCardState extends State<ResidentCard> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 「采集端**实际读到**的配置」—— 诚实标注,不是"修好"
|
||||
///
|
||||
/// ── 为什么必须显示它(而不是显示界面自己那份)────────────────────
|
||||
/// macOS GUI 是**真沙箱**(`com.apple.security.app-sandbox`,`HOME` 指向
|
||||
/// `~/Library/Containers/com.pmon.pmonClient/Data`),它读写的 `ConfigStore`
|
||||
/// 在应用容器里;而常驻采集端由 launchd 拉起(**非沙箱**),读真实家目录。
|
||||
/// 于是磁盘上**两份 config 同时活着**:用户在界面里改网关地址,只改了容器那份,
|
||||
/// 采集端看到的还是旧的 —— 而两边都不会报错。这比"读不到配置"更隐蔽。
|
||||
///
|
||||
/// ── 为什么本轮不做同步 ──────────────────────────────────────────
|
||||
/// 三条路都不通:①安装器双写 —— 沙箱 GUI 无权写容器外;②GUI 调 CLI 去写 ——
|
||||
/// 沙箱的**子进程继承沙箱**,一样写不了;③让 agent 改读容器 —— 没装过 GUI 的
|
||||
/// 机器根本没有容器,给主路径加了一条"必须装过 GUI"的依赖。
|
||||
/// 故本版只做**诚实标注**:把采集端真正读到的东西摆出来,不一致就明说。
|
||||
/// 真解要等 `files.user-selected.read-write` 或非沙箱 helper。
|
||||
Widget? _effectiveConfigNote() {
|
||||
final st = _st;
|
||||
// 未安装 / 问不到:没有"采集端读到的配置"这回事,别凭空造一行出来
|
||||
if (st.state == ResidentState.notInstalled ||
|
||||
st.state == ResidentState.unknown) {
|
||||
return null;
|
||||
}
|
||||
final effUrl = st.serverUrl;
|
||||
final effId = st.deviceId;
|
||||
if (effUrl.isEmpty && effId.isEmpty) {
|
||||
return Text(
|
||||
'采集端还没读到配置(${st.configPath.isEmpty ? '路径未知' : st.configPath})。',
|
||||
style: PmonText.caption,
|
||||
);
|
||||
}
|
||||
|
||||
final wantUrl = widget.serverUrl;
|
||||
final wantId = widget.deviceId;
|
||||
// 只比"两边都有值且不同":空值表示"沿用现有配置",那不算冲突
|
||||
final mismatch =
|
||||
(wantUrl.isNotEmpty && effUrl.isNotEmpty && wantUrl != effUrl) ||
|
||||
(wantId.isNotEmpty && effId.isNotEmpty && wantId != effId);
|
||||
final effective = [effUrl, effId].where((s) => s.isNotEmpty).join(' · ');
|
||||
final wanted = [wantUrl, wantId].where((s) => s.isNotEmpty).join(' · ');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('采集端当前读到:$effective', style: PmonText.caption),
|
||||
const SizedBox(height: PmonSpace.xs),
|
||||
if (mismatch)
|
||||
Text(
|
||||
'与上方输入框($wanted)不一致:常驻采集读的是安装器那份配置。'
|
||||
'点一次「重新安装并启动」即可让它改成上面填的值。',
|
||||
style: PmonText.caption.copyWith(color: PmonColors.suspect),
|
||||
)
|
||||
else
|
||||
const Text(
|
||||
'(常驻采集读的是安装器那份配置,与本界面容器内的配置是两份;'
|
||||
'本版尚未打通,故如实标注)',
|
||||
style: PmonText.caption,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusLine() {
|
||||
final st = _st;
|
||||
switch (st.state) {
|
||||
|
||||
@@ -55,7 +55,13 @@ class _FakeOps implements ResidentOps {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> mount(WidgetTester tester, _FakeOps ops, {VoidCallback? onStarted}) async {
|
||||
Future<void> mount(
|
||||
WidgetTester tester,
|
||||
_FakeOps ops, {
|
||||
VoidCallback? onStarted,
|
||||
String serverUrl = '',
|
||||
String deviceId = '',
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1000, 2200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
@@ -65,6 +71,9 @@ Future<void> mount(WidgetTester tester, _FakeOps ops, {VoidCallback? onStarted})
|
||||
child: ResidentCard(
|
||||
ops: ops,
|
||||
onResidentStarted: onStarted,
|
||||
// 界面内采集用的网关地址/设备标识(与采集端实际读到的可能不同)
|
||||
serverUrl: serverUrl,
|
||||
deviceId: deviceId,
|
||||
// 轮询间隔拉长到一天:测试只关心"这一帧显示什么",
|
||||
// 周期性定时器会让 pumpAndSettle 永远不收敛
|
||||
refreshInterval: const Duration(days: 1),
|
||||
@@ -210,4 +219,83 @@ void main() {
|
||||
expect(allText(tester), contains('未安装'));
|
||||
});
|
||||
});
|
||||
|
||||
/// 「查不了」不是「出事了」—— 探针不可用时卡片只能说"不知道",
|
||||
/// 且必须是中性灰那一档(不是橙色告警)。这类错的可恶之处在于:
|
||||
/// 它看起来**很确定**,用户会照着它去重装一个已经装好的东西。
|
||||
group('探针不可用(未知态)', () {
|
||||
testWidgets('★ 显示"状态未知"与原因,绝不显示"已安装但没起来"', (tester) async {
|
||||
final ops = _FakeOps(const ResidentStatus(
|
||||
state: ResidentState.unknown,
|
||||
autostartRegistered: true,
|
||||
agentExists: true,
|
||||
probeAvailable: false,
|
||||
probeError: 'Windows 尚无进程探针(待多端扩面实现)',
|
||||
detail: '本平台无法判定采集端进程(Windows 尚无进程探针(待多端扩面实现)),'
|
||||
'这不等于"没在运行"',
|
||||
));
|
||||
await mount(tester, ops);
|
||||
final text = allText(tester);
|
||||
expect(text, contains('状态未知'));
|
||||
expect(text, contains('无法判定采集端进程'));
|
||||
expect(text, contains('这不等于'), reason: '要顺手否掉"没在跑"这个误读');
|
||||
expect(text, isNot(contains('已安装但没起来')),
|
||||
reason: '探针不可用时给告警态,等于把"不知道"渲染成"出事了"');
|
||||
});
|
||||
});
|
||||
|
||||
/// 「采集端当前读到的配置」——本轮只做诚实标注(不做同步,沙箱下也同步不了)
|
||||
group('采集端实际读到的配置(诚实标注)', () {
|
||||
testWidgets('★ 在跑时显示采集端读到的网关地址与设备标识', (tester) async {
|
||||
final ops = _FakeOps(const ResidentStatus(
|
||||
state: ResidentState.running,
|
||||
pids: [777],
|
||||
autostartRegistered: true,
|
||||
serverUrl: 'http://127.0.0.1:3000',
|
||||
deviceId: 'mac-01',
|
||||
));
|
||||
await mount(tester, ops,
|
||||
serverUrl: 'http://127.0.0.1:3000', deviceId: 'mac-01');
|
||||
final text = allText(tester);
|
||||
expect(text, contains('采集端当前读到'));
|
||||
expect(text, contains('http://127.0.0.1:3000 · mac-01'));
|
||||
expect(text, isNot(contains('不一致')));
|
||||
});
|
||||
|
||||
testWidgets('★ 与界面输入不一致 → 明说不一致,并给出可执行的动作', (tester) async {
|
||||
// 这正是沙箱两份配置的现场:界面里改了地址,采集端还在读旧的
|
||||
final ops = _FakeOps(const ResidentStatus(
|
||||
state: ResidentState.running,
|
||||
pids: [777],
|
||||
autostartRegistered: true,
|
||||
serverUrl: 'http://127.0.0.1:3000',
|
||||
deviceId: 'mac-01',
|
||||
));
|
||||
await mount(tester, ops,
|
||||
serverUrl: 'http://192.168.1.9:3000', deviceId: 'mac-01');
|
||||
final text = allText(tester);
|
||||
expect(text, contains('不一致'));
|
||||
expect(text, contains('http://192.168.1.9:3000'),
|
||||
reason: '要同时显示"界面填的"和"采集端读的",否则用户不知道差在哪');
|
||||
expect(text, contains('重新安装并启动'),
|
||||
reason: '只说"不一致"是抱怨;给出下一步才是诚实且有用');
|
||||
});
|
||||
|
||||
testWidgets('未安装时不显示这一行(没有"采集端读到的配置"这回事)',
|
||||
(tester) async {
|
||||
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled));
|
||||
await mount(tester, ops);
|
||||
expect(allText(tester), isNot(contains('采集端当前读到')));
|
||||
});
|
||||
|
||||
testWidgets('问不到状态时也不显示(不能凭空造一份配置出来)', (tester) async {
|
||||
final ops = _FakeOps(const ResidentStatus(
|
||||
state: ResidentState.unknown,
|
||||
detail: '拿不到状态',
|
||||
serverUrl: 'http://127.0.0.1:3000',
|
||||
));
|
||||
await mount(tester, ops);
|
||||
expect(allText(tester), isNot(contains('采集端当前读到')));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,10 +19,14 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:pmon_client/host/autostart_label.dart';
|
||||
import 'package:pmon_client/host/install_paths.dart';
|
||||
import 'package:pmon_client/host/resident_service.dart';
|
||||
|
||||
/// 造一个 `pmon-install status --json` 的输出
|
||||
///
|
||||
/// [probeAvailable] 传 `null` 表示**省略该字段**(老 CLI 的真实形态)——
|
||||
/// 这正是三态要覆盖的部分:缺字段 ≠ false。
|
||||
String statusJson({
|
||||
bool running = false,
|
||||
List<int> pids = const [],
|
||||
@@ -32,6 +36,10 @@ String statusJson({
|
||||
bool configExists = true,
|
||||
bool logExists = true,
|
||||
bool agentExists = true,
|
||||
bool? probeAvailable,
|
||||
String probeError = '',
|
||||
String serverUrl = 'http://127.0.0.1:3000',
|
||||
String deviceId = 'dev-x',
|
||||
}) =>
|
||||
jsonEncode({
|
||||
'platform': Platform.operatingSystem,
|
||||
@@ -42,9 +50,18 @@ String statusJson({
|
||||
'loaded': loaded,
|
||||
'detail_lines': ['state = running'],
|
||||
},
|
||||
'process': {'running': running, 'pids': pids},
|
||||
'process': {
|
||||
'running': running,
|
||||
'pids': pids,
|
||||
// 只有显式要求时才写这个字段(模拟老二进制)——三态必须靠"字段在不在"测
|
||||
if (probeAvailable != null) 'probe_available': probeAvailable,
|
||||
if (probeAvailable != null) 'probe_tool': 'pgrep',
|
||||
if (probeAvailable != null) 'probe_error': probeError,
|
||||
},
|
||||
'config': {'path': '/Users/me/Library/Application Support/pmon_client/config.json',
|
||||
'exists': configExists},
|
||||
'exists': configExists,
|
||||
'server_url': serverUrl,
|
||||
'device_id': deviceId},
|
||||
'log': {'path': '/tmp/com.pmon.collector.err.log', 'exists': logExists},
|
||||
'installed': {'agent_path': '/Users/me/Library/Application Support/pmon_client/bin/pmon-agent',
|
||||
'agent_exists': agentExists},
|
||||
@@ -323,4 +340,130 @@ void main() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/// 「查不了」≠「没在跑」。
|
||||
///
|
||||
/// `process.probe_available` 是 CLI 侧新增的判据(Windows 上还没有 pgrep →
|
||||
/// false)。消费方若不看它,`running` 只能是 false,于是得出"已安装但没起来"
|
||||
/// 这个**看起来很确定、实际一次都没查过**的结论,卡片还会染成告警色。
|
||||
/// 故除单点外还加一条矩阵守卫:这个洞不该靠"偶然被发现"来堵。
|
||||
group('⑦ 探针不可用 → 只能报"不知道"', () {
|
||||
test('★ probe_available=false 且没观测到进程 → unknown(不是 installedNotRunning)',
|
||||
() {
|
||||
final st = ResidentStatus.fromJson(jsonDecode(statusJson(
|
||||
probeAvailable: false,
|
||||
probeError: 'Windows 尚无进程探针(待多端扩面实现)',
|
||||
registered: true,
|
||||
agentExists: true,
|
||||
)) as Map<String, dynamic>);
|
||||
expect(st.state, ResidentState.unknown,
|
||||
reason: '没查过就不能给结论,哪怕自启项和落位文件都在');
|
||||
expect(st.detail, contains('无法判定'));
|
||||
expect(st.detail, contains('Windows 尚无进程探针'));
|
||||
expect(st.probeAvailable, isFalse);
|
||||
});
|
||||
|
||||
test('★ 矩阵守卫:任何 probe_available=false 的快照都不得产出"确定的没在跑"', () {
|
||||
// 三种结论态里,只有"确实观测到进程"才允许说 running;其余一律 unknown。
|
||||
for (final registered in [true, false]) {
|
||||
for (final agentExists in [true, false]) {
|
||||
for (final running in [true, false]) {
|
||||
final st = ResidentStatus.fromJson(jsonDecode(statusJson(
|
||||
probeAvailable: false,
|
||||
probeError: 'no pgrep',
|
||||
registered: registered,
|
||||
agentExists: agentExists,
|
||||
running: running,
|
||||
)) as Map<String, dynamic>);
|
||||
final label = 'registered=$registered '
|
||||
'agentExists=$agentExists running=$running';
|
||||
if (running) {
|
||||
expect(st.state, ResidentState.running, reason: label);
|
||||
} else {
|
||||
expect(st.state, ResidentState.unknown,
|
||||
reason: '$label —— 探针不可用时"没在跑"是猜的,不是结论');
|
||||
expect(st.state, isNot(ResidentState.installedNotRunning),
|
||||
reason: '$label —— 这一态是橙色告警,等于把"不知道"渲染成"出事了"');
|
||||
expect(st.state, isNot(ResidentState.notInstalled),
|
||||
reason: '$label —— 更糟:会让用户去重装一个已经装好的东西');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('老 CLI 没有 probe_available 字段 → 判定不变(缺字段 ≠ 不可用)', () {
|
||||
// 把"缺字段"当成 false,所有老二进制/老快照都会被打成"未知" ——
|
||||
// 那是用另一种方式骗人(明明查到了,却说不知道)。
|
||||
final st = ResidentStatus.fromJson(
|
||||
jsonDecode(statusJson(registered: true, agentExists: true))
|
||||
as Map<String, dynamic>);
|
||||
expect(st.probeAvailable, isNull);
|
||||
expect(st.state, ResidentState.installedNotRunning);
|
||||
expect(st.detail, isNull, reason: '正常判定不该附带诊断文字');
|
||||
});
|
||||
|
||||
test('观测到进程时 running 优先(探针元数据不能压过正面证据)', () {
|
||||
final st = ResidentStatus.fromJson(jsonDecode(statusJson(
|
||||
probeAvailable: false,
|
||||
running: true,
|
||||
pids: [99],
|
||||
)) as Map<String, dynamic>);
|
||||
expect(st.state, ResidentState.running);
|
||||
expect(st.pids, [99]);
|
||||
});
|
||||
|
||||
test('★ config.server_url / device_id 必须解析出来(卡片据此显示"采集端读到什么")',
|
||||
() {
|
||||
final st = ResidentStatus.fromJson(jsonDecode(statusJson(
|
||||
running: true,
|
||||
pids: [1],
|
||||
serverUrl: 'http://192.168.1.9:3000',
|
||||
deviceId: 'mac-01',
|
||||
)) as Map<String, dynamic>);
|
||||
expect(st.serverUrl, 'http://192.168.1.9:3000');
|
||||
expect(st.deviceId, 'mac-01');
|
||||
});
|
||||
|
||||
test('config 缺 server_url(老快照)→ 空串,不崩', () {
|
||||
final st = ResidentStatus.fromJson({
|
||||
'autostart': {'registered': true},
|
||||
'process': {'running': true},
|
||||
'config': {'path': '/x/config.json', 'exists': true},
|
||||
});
|
||||
expect(st.state, ResidentState.running);
|
||||
expect(st.serverUrl, isEmpty);
|
||||
expect(st.deviceId, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
/// 自启 label 的两处消费者(安装器 / GUI)必须是同一份常量。
|
||||
///
|
||||
/// 漂移的症状极隐蔽:GUI 查 `A.plist`、安装器注册 `B.plist` —— 两边都"正常",
|
||||
/// 只是问的不是同一个自启项,表现是"**明明装了、卡片却说未注册**"。
|
||||
/// 这与 install_paths / 指标字段 / 配对码是同一族缺陷,故用同款源码守卫。
|
||||
group('⑥ label 只有一处真相源', () {
|
||||
test('★ 字面量只许出现在 host/autostart_label.dart', () {
|
||||
const source = 'lib/host/autostart_label.dart';
|
||||
const consumers = [
|
||||
'lib/host/resident_service.dart',
|
||||
'bin/pmon_install.dart',
|
||||
];
|
||||
final src = File(source).readAsStringSync();
|
||||
expect(src, contains("'com.pmon.collector'"),
|
||||
reason: '真相源里必须有这个字面量(否则守卫自己在扫空气)');
|
||||
for (final path in consumers) {
|
||||
final body = File(path).readAsStringSync();
|
||||
expect(body, isNot(contains("'com.pmon.collector'")),
|
||||
reason: '$path 又写死了一份 label —— 请改用 kDefaultAutostartLabel');
|
||||
expect(body, contains('kDefaultAutostartLabel'),
|
||||
reason: '$path 应当引用真相源常量');
|
||||
}
|
||||
});
|
||||
|
||||
test('GUI 传给 CLI 的 label 就是那份常量', () {
|
||||
final svc = ResidentService();
|
||||
expect(svc.label, kDefaultAutostartLabel);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user