## 范围与边界 **本次只改代码层**:客户端包名、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 后一并回归。
241 lines
10 KiB
JavaScript
241 lines
10 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|