/** * CLI **进程退出码** e2e —— 守「失败语义真的到达进程边界」 * * ── 为什么必须单独一套(这是本仓库真实踩过的坑)─────────────── * 本项目在 Dart SDK 3.13.3 上实测: * * | 写法 | dart run | dart compile exe | * |--------------------------------------------|----------|------------------| * | `int main() { return 3; }` | 0 | 0 | * | `Future 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); });