Files
inkwatch/tests/cli-exit-code-e2e.js
T
jkooe 5f419bcb91 refactor(rename): pmon → inkwatch 全量改名(阶段 A:代码层,不碰数据层标识)
## 范围与边界

**本次只改代码层**:客户端包名、Android/iOS/macOS 标识、环境变量前缀、Rust crate 名、
类名/文件名、以及全部文案与注释。**数据层标识一处未动**(卷名 `pmon_pg_data`、
`POSTGRES_USER/DB`、连接串默认值),那些在阶段 B 单独做迁移,理由见下。

统计:**114 个文件、1687 处**替换 + **8 个文件/目录重命名**。

## 改了什么

| 类别 | 旧 | 新 |
|---|---|---|
| Dart 包名 | `pmon_client` | `inkwatch_client`(41 个文件的 import 同步) |
| Android namespace | `com.pmon.pmon_client` | `com.inkwatch.eink` |
| Android applicationId | `com.inkwatch.eink` | **保持不变** —— 它本来就已是新名 |
| Kotlin 包目录 | `kotlin/com/pmon/pmon_client/` | `kotlin/com/inkwatch/eink/` |
| macOS bundle | `com.pmon.pmonClient` | `com.inkwatch.eink` |
| 环境变量 | `PMON_*`(12 个) | `INKWATCH_*` |
| Rust crate | `pmon-core` / `pmon_core` | `inkwatch-core` / `inkwatch_core` |
| 动态库 | `libpmon_core.dylib` | `libinkwatch_core.dylib` |
| 类/文件 | `PmonApp` `PmonColors` `PmonChannel` `PmonCollectorService` `pmon_agent.dart` … | `Inkwatch*` / `inkwatch_*.dart` |

## 三处**必须单独说**的判断

**① applicationId 刻意没动。** `build.gradle.kts:49` 有既有说明:水墨屏专属版用
`com.inkwatch.eink`,与主干通用版**刻意不同**,为的是两版本能在同一台手机上共存
(versionCode 各自递增,同包名会互相覆盖且降级被拒装)。它已经是新名,动它反而
破坏这个设计。我改的是 `namespace`(内部分包名,只影响编译期),并把它统一到同一个
`com.inkwatch.eink`。

**② 局域网 P2P 线协议前缀一起改了**(`PMON-PUNCH:` / `PMON-PONG:` / `PMON-FRAME:` →
`INKWATCH-*`)。这**不是纯文案**:两端必须同时升级,否则新旧版本在同一局域网里互不
认。判断依据是这三个前缀只用于同一用户的两台设备直连,且当前尚未对外发布;若以后
要支持跨版本,正确做法是加协议版本协商而不是回退旧前缀。

**③ 数据层标识一处未动。** `deploy/` 下 28 个文件仍是 `pmon`,因为卷名/库名/库用户
的改动会直接导致连不上库或指向新空卷(`docker-compose.all.yml:26-29` 里写着:
改名会让 compose 去找 `inkwatch_pg_data` 这套新空卷,等于把生产清库)。实测当前
数据是 **2.5GB、128 个账号、58 台设备**,不是测试库。

## 安全网

动手前做了完整备份:
`.backup-rename-20261108/pmon_before_rename.dump`(`pg_dump -Fc`,152MB),
并记录了迁移前各表行数基线,供阶段 B 逐表比对。

## 验证

客户端全量 **595 通过 / 2 失败** —— 与改名前的数字**完全一致**,两条仍为
`libinkwatch_core.dylib` 未编译(Rust 核心未构建,环境缺口)。**数字一致**是这次
改名的核心判据:一个纯机械改名必须不改变任何测试结果,若通过数变了就说明改坏了东西。

服务端代码只动了注释与环境变量名,逻辑零改动,阶段 B 后一并回归。
2026-11-08 20:07:30 +08:00

241 lines
10 KiB
JavaScript
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.
/**
* CLI **进程退出码** e2e —— 守「失败语义真的到达进程边界」
*
* ── 为什么必须单独一套(这是本仓库真实踩过的坑)───────────────
* 本项目在 Dart SDK 3.13.3 上实测:
*
* | 写法 | dart run | dart compile exe |
* |--------------------------------------------|----------|------------------|
* | `int main() { return 3; }` | 0 | 0 |
* | `Future<int> main() async { return 3; }` | 0 | 0 |
* | `void main() { exitCode = 3; }` | 3 | 3 |
*
* 即 **main 的返回值完全不设进程退出码**,只有显式 `exitCode =` / `exit()` 才生效。
* 后果:安装器「必成命令失败 → 返回非零」的修复曾经**在屏幕上说了、在进程边界上是死的**
* —— 部署脚本 / CI 依然看到 0,假成功。采集端更重:plist 用
* `KeepAlive.SuccessfulExit=false`,launchd 靠"非零退出"判崩溃,退出码被吞成 0
* 就变成"崩了也不会被拉起",用户只看到历史断档。
*
* 所以本套件的断言**一律读子进程的真实 exit code**(`spawnSync`/`spawn` 的 status),
* **绝不 import 被测代码去断言函数返回值** —— 断言返回值正是当初漏掉的东西。
*
* 覆盖:
* ① `inkwatch-agent --help` → 0
* ② `inkwatch-agent --print-plan` → 0(桌面三端有计划)
* ③ `inkwatch-agent` 抢不到单实例锁 → 0(必须 0,非零会被 launchd 当崩溃→重启风暴)
* ④ `inkwatch-agent` 已持锁的实例 `--duration` 正常到期 → 0
* ⑤ `inkwatch-install install --agent-bin <不存在>` → ≠0(依赖缺失)
* ⑥ `inkwatch-viewer` 缺 `--account` → 4(不是 0)
* ⑦ `relay_check` 打不通网关 → ≠0
*
* 说明:`inkwatch-install`「必成命令失败 ⇒ 退出码非零」那条在
* `installer-clean-e2e.js` 里已用真实子进程断言(那里顺带验证落位),此处不重复。
*
* 用法:node tests/cli-exit-code-e2e.js
*/
'use strict';
const { spawn, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const CLIENT = path.join(ROOT, 'client');
let pass = 0;
let fail = 0;
const ok = (name, cond, extra = '') => {
if (cond) {
pass++;
console.log(` ✓ ${name}`);
} else {
fail++;
console.log(` ✗ ${name}${extra ? ' <- ' + extra : ''}`);
}
};
/** 依赖缺失 → 明确 ⚠ 跳过并返回成功(且不打印汇总,免得结构守卫把"没跑"看成"全绿") */
function bail(message) {
console.log('=== CLI 进程退出码 e2e ===');
console.log(` ⚠ 跳过:${message}`);
console.log(' (未执行任何断言:依赖缺失,属明确跳过,不是静默放行)');
process.exit(0);
}
function findDart() {
const cands = [
process.env.INKWATCH_DART,
path.join(os.homedir(), 'development', 'flutter', 'bin', 'dart'),
].filter(Boolean);
for (const c of cands) if (fs.existsSync(c)) return c;
const which = spawnSync('dart', ['--version'], { encoding: 'utf8' });
return which.status === 0 ? 'dart' : null;
}
const DART = findDart();
if (!DART) bail('找不到 dart(可用 INKWATCH_DART=/path/to/dart 指定)');
const coreLib = process.platform === 'darwin'
? 'libinkwatch_core.dylib'
: process.platform === 'win32' ? 'inkwatch_core.dll' : 'libinkwatch_core.so';
if (!fs.existsSync(path.join(ROOT, 'rust-core', 'target', 'release', coreLib))) {
bail(`缺 Rust 核心产物 rust-core/target/release/${coreLib}(先 cargo build --release)`);
}
/** 临时 HOME:单实例锁与配置都落在它下面,绝不碰真实家目录 */
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'inkwatch-exitcode-home-'));
function baseEnv() {
const env = { ...process.env, HOME, USERPROFILE: HOME };
delete env.INKWATCH_CORE_LIB;
for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY',
'http_proxy', 'https_proxy', 'all_proxy']) {
env[k] = '';
}
env.NO_PROXY = 'localhost,127.0.0.1,::1';
return env;
}
/** 同步跑一个 CLI,返回**真实进程退出码** */
function runCli(entry, args, { timeout = 90000 } = {}) {
const r = spawnSync(DART, ['run', `bin/${entry}`, ...args], {
cwd: CLIENT,
encoding: 'utf8',
env: baseEnv(),
timeout,
});
return {
status: r.status,
signal: r.signal,
out: `${r.stdout || ''}${r.stderr || ''}`,
};
}
/** 起一个后台 CLI(用于"先占锁"),返回句柄与累积输出 */
function spawnCli(entry, args) {
const child = spawn(DART, ['run', `bin/${entry}`, ...args], {
cwd: CLIENT,
env: baseEnv(),
});
const state = { buf: '', child, done: false, status: null };
child.stdout.on('data', (d) => { state.buf += d.toString(); });
child.stderr.on('data', (d) => { state.buf += d.toString(); });
child.on('exit', (code) => { state.done = true; state.status = code; });
return state;
}
/** 等某个标记出现在后台进程输出里(超时返回 false,不假装成功) */
function waitFor(state, marker, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const tick = () => {
if (state.buf.includes(marker)) return resolve(true);
if (state.done) return resolve(false);
if (Date.now() > deadline) return resolve(false);
setTimeout(tick, 120);
};
tick();
});
}
/** 等后台进程退出 */
function waitDone(state, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const tick = () => {
if (state.done) return resolve(true);
if (Date.now() > deadline) return resolve(false);
setTimeout(tick, 120);
};
tick();
});
}
(async () => {
console.log('=== CLI 进程退出码 e2e(真实子进程)===');
console.log(` HOME = ${HOME}`);
console.log(` dart = ${DART}`);
/* ① --help → 0 */
const help = runCli('inkwatch_agent.dart', ['--help']);
ok('① inkwatch-agent --help 退出码 0', help.status === 0,
`exit=${help.status} ${help.out.slice(0, 200)}`);
ok('① --help 确实打印了用法(不是静默退出)',
/用法: inkwatch-agent/.test(help.out), help.out.slice(0, 200));
/* ② --print-plan → 0(桌面三端有计划;iOS 才会 error) */
const plan = runCli('inkwatch_agent.dart', ['--print-plan']);
if (process.platform === 'darwin' || process.platform === 'linux' ||
process.platform === 'win32') {
ok('② inkwatch-agent --print-plan 退出码 0(拿到了计划)', plan.status === 0,
`exit=${plan.status} ${plan.out.slice(0, 300)}`);
} else {
ok('② inkwatch-agent --print-plan 无计划时退出码非零', plan.status !== 0,
`exit=${plan.status}`);
}
/* ③④ 单实例锁:先占锁 → 第二个必须 0;第一个 --duration 到期也必须 0 */
const holder = spawnCli('inkwatch_agent.dart', [
'--collector', '--duration', '4',
// ⚠️ 用一个**保证连不上**的端口,别用"看起来没人用"的端口:
// 第一版写 3107,实测 8 次里有 2 次 3107 上真的有人听(relay 往返成功、
// relay_check 退出 0),于是"打不通"这条断言被静默反转。端口是否空闲
// 是环境属性,测试不能赌它。端口 1 是特权端口、正常环境永不监听。
'--server', 'http://127.0.0.1:1',
'--device-id', 'e2e-lock-holder', '--quiet',
]);
const started = await waitFor(holder, '采集端启动', 30000);
ok('③ 持锁实例确实起来了(否则下面的锁冲突无从验证)', started,
holder.buf.slice(0, 400));
if (started) {
const second = runCli('inkwatch_agent.dart', [
'--collector', '--duration', '1',
'--server', 'http://127.0.0.1:1', // 同上:只验证退出码,不碰真网关
'--device-id', 'e2e-lock-second', '--quiet',
]);
ok('③ 抢不到单实例锁 → 退出码 **0**(非零会被 launchd 当崩溃 → 无限重启互踢)',
second.status === 0, `exit=${second.status} ${second.out.slice(0, 300)}`);
ok('③ 并且如实说明为什么退出(不是静默消失)',
/已有采集进程在运行/.test(second.out), second.out.slice(0, 300));
}
const holderDone = await waitDone(holder, 30000);
if (!holderDone) {
try { holder.child.kill('SIGTERM'); } catch (_) { /* 已退出 */ }
}
ok('④ --duration 正常到期 → 退出码 0(冒烟/CI 的正常路径)',
holderDone && holder.status === 0,
`done=${holderDone} exit=${holder.status} ${holder.buf.slice(-300)}`);
/* ⑤ 依赖缺失(找不到 agent 可执行文件)→ 非零 */
const missing = runCli('inkwatch_install.dart', [
'install', '--no-start', '--agent-bin', path.join(HOME, 'nope', 'inkwatch-agent'),
]);
ok('⑤ 缺采集端可执行文件 → 退出码 ≠ 0(不是只打印一行就返回 0)',
missing.status !== 0, `exit=${missing.status} ${missing.out.slice(0, 300)}`);
ok('⑤ 并说清缺的是什么',
/找不到采集端可执行文件/.test(missing.out), missing.out.slice(0, 300));
/* ⑥ inkwatch-viewer 缺 --account → 4(证明它的退出码真的到达边界) */
const viewer = runCli('inkwatch_viewer.dart', []);
ok('⑥ inkwatch-viewer 缺 --account → 退出码 ≠ 0(实测 4)',
viewer.status === 4, `exit=${viewer.status} ${viewer.out.slice(0, 200)}`);
/* ⑦ relay_check 打不通 → 非零 */
// 端口 1:保证 Connection refused(理由同上,别再换成"看起来空闲"的端口)
const relay = runCli('relay_check.dart', ['--server', 'http://127.0.0.1:1']);
ok('⑦ relay_check 打不通网关 → 退出码 ≠ 0(诊断工具的退出码就是它的结论)',
relay.status !== 0, `exit=${relay.status} ${relay.out.slice(0, 300)}`);
fs.rmSync(HOME, { recursive: true, force: true });
console.log(`\n测试通过: ${pass} 失败: ${fail}`);
process.exit(fail > 0 ? 1 : 0);
})().catch((e) => {
console.error('套件自身异常:', e);
try { fs.rmSync(HOME, { recursive: true, force: true }); } catch (_) { /* ignore */ }
process.exit(1);
});