Files
MailUI4Agents/plugins/pi-mail-bridge/test/env-guard.test.mjs
JianFeeeee 0b548b8fcf test(pi-bridge): 临时目录满时不再伪装成内存缺陷 —— 前置自检 + ENOSPC 兜底
现场(2026-09-14 实测):`/tmp` 是 tmpfs,被别人占满,`statfsSync` 实读
`bavail*bsize` 只剩 **0.70 MiB**。此时 `npm test` 红一条

    not ok 323 - ★巨大的 message 行不进内存也不影响解析
      error: 'ENOSPC: no space left on device, write'

那条红的**形状指向内存**(用例名里就写着"不进内存",而它恰好是往临时目录写文件的
用例)⇒ 下一个踩到的人会去 `session-scan.mjs` 找一个**不存在**的内存缺陷。

改:
- `lib/tmp-space.mjs`:测量与判据分开,判据是纯函数 `judgeSpace`,喂字节数即可验;
  读不到可用空间(null/NaN)⇒ **不判红**(不知道 ≠ 不对,否则会造出"总在亮"的红灯)。
  **但 0 字节不是"不知道"** —— 第一版把 `<=0` 一并当"没测到",于是 `bavail` 只剩
  712 字节时前置自检放行、紧接着 17 条用例 ENOSPC 全红:前置自检装了等于没装。
  阈值 32 MiB = 实测单条用例最大写入量(`session-scan` 那条写 3×3 MiB 行 ≈ 12 MiB)
  ×2 + 8 MiB 机动,不是总容量的百分比(百分比在这套测试上没有依据)。
- `test/env-preflight.mjs`(名字不带 `.test.`,不被 glob 收进用例):
  `package.json` 的 test 改成先跑它;不足时打印实测/阈值/目录并 **exit 2**
  —— 与 `deploy/redeploy-plugin.sh` 的 `2=环境问题` 同一套约定,看到 2 才知道
  去查机器而不是查代码。文案里明写「这是环境不足,不是断言失败」。
- `session-scan.test.mjs`:兜底翻译 ENOSPC(`node --test 'test/*.test.mjs'` 会绕过
  前置脚本,这一句不管套件怎么被调起来都生效)—— 这正是治那条误导的关键。
- `test/env-guard.test.mjs`:8 条自证 —— 纯函数两头 + 边界(≥阈值算够、<阈值不够)
  + 0 字节必须红 + 读不到不判红 + 阈值有据 + 端到端 exit 2 且文案对得上。
  端到端那条**不假设本机 /tmp 仍然满**:先自己量一次,够用就跳过并说明原因,
  免得它退化成一条"总在亮"或"总在绿"的假判据。

顺带修 `deploy/check-deploy-drift.mjs` 两处同源问题:
- `selfCheck()` 要在临时目录造两棵小树,`/tmp` 满时抛 ENOSPC —— 而它是**未捕获异常**,
  堆栈指向本文件,看起来像检查器坏了。翻译成说得清的错并让 main() 报 2。
- 新增判据 ⑥「工作区干净」—— **只提示,不参与 exit code**。判据 ① 比的是
  「仓库工作区→快照」这一跳,覆盖不到「HEAD→工作区」那一跳(实证:一行未提交的
  死代码被 17:20 的快照带进生产,而 ① 报的是"逐字节一致")。做成红灯就是一条
  总在亮的判据(本文件头自己骂过的病),所以只说、不判。

验证:`npm test` 453/453(新增 8 条);`npm test` 在 /tmp 满时 exit 2 且不再跑用例;
`node deploy/check-deploy-drift.mjs --self-check` 17 条全过(含 ⑥ 的三条正反面)。
2026-09-14 19:29:44 +08:00

130 lines
6.3 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.

/**
* 「临时目录空间不足必须被说出来,而不是伪装成断言失败」的判据。
*
* # 缺陷现场(2026-09-14 实测)
*
* `/tmp` 是 tmpfs,被别人的东西占满,`bavail` 只剩 **0.70 MiB**。此时:
*
* not ok 323 - ★巨大的 message 行不进内存也不影响解析
* error: 'ENOSPC: no space left on device, write'
*
* 那条红的**形状**指向内存(用例名里就写着"不进内存"),真相是环境不足。
* 没有这一条判据时,下一个人会去 `session-scan.mjs` 找一个不存在的内存缺陷。
*
* # 三段判据(缺一段都不算数)
*
* 1. 纯函数两头都对:够 → 绿;不足 → 红**且说得出差多少**;
* 2. 端到端退出码:不足 → `2`(环境问题,与 `deploy/redeploy-plugin.sh` 同义),
* 不是 `1`(断言失败)—— 看到 `2` 才知道去查机器而不是查代码;
* 3. **反面样本**:喂一个不足的可用空间,判据必须红。
* 没有反面样本的判据等于没有判据(仓库既有规矩)。
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { execFileSync } from 'node:child_process';
import { readFileSync, statfsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { judgeSpace, MIN_FREE_BYTES, MEASURED_MAX_CASE_WRITE } from '../lib/tmp-space.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const PREFLIGHT = join(HERE, 'env-preflight.mjs');
// ─── 1. 纯函数:两头都对 ───────────────────────────────────────
test('空间充足 → 判绿,并报出可用与需要', () => {
const v = judgeSpace({ availBytes: MIN_FREE_BYTES + 1 });
assert.equal(v.ok, true);
assert.equal(v.known, true);
assert.match(v.note, /可用/);
assert.match(v.note, /需要/);
});
test('★反面样本:空间不足 → 判红,且说得出差多少', () => {
// 实测那个数:0.70 MiB 可用(`/tmp` 满时的真实值)。
const v = judgeSpace({ availBytes: 729_088 });
assert.equal(v.ok, false, '不足必须判红 —— 这是这条判据存在的理由');
assert.equal(v.known, true);
assert.match(v.note, /0\.7 MiB/);
assert.match(v.note, /32\.0 MiB/);
});
test('刚好多一点就够、刚好少一点就不够(边界不靠感觉)', () => {
assert.equal(judgeSpace({ availBytes: MIN_FREE_BYTES }).ok, true, '≥ 阈值算够');
assert.equal(judgeSpace({ availBytes: MIN_FREE_BYTES - 1 }).ok, false, '< 阈值算不够');
});
test('读不到可用空间 → 不判红(不知道 ≠ 不对)', () => {
// 平台不支持 statfs、或字段缺 —— 此时放行。理由见 lib/tmp-space.mjs 头注释:
// 在认不出的文件系统上判红,会造出一条总在亮的红灯,人就会学会忽略它。
for (const availBytes of [null, undefined, NaN]) {
const v = judgeSpace({ availBytes });
assert.equal(v.ok, true, `${String(availBytes)} 不该判红`);
assert.equal(v.known, false);
}
});
test('★0 字节不是「不知道」,是「真的没有」—— 必须判红', () => {
// 这条是实测踩出来的:第一版把 `availBytes <= 0` 一并当"没测到",
// 于是 `bavail` 只剩 712 字节时前置自检**放行**,紧接着 17 条用例 ENOSPC 全红
// —— 前置自检装了等于没装。0 是测量结果,不是测量失败。
const v = judgeSpace({ availBytes: 0 });
assert.equal(v.ok, false, '0 字节必须判红');
assert.equal(v.known, true);
});
test('阈值有据:等于「实测用例最大写入量 × 2 + 机动」,不是总容量的百分比', () => {
assert.equal(MEASURED_MAX_CASE_WRITE, 12 * 1024 * 1024, '12 MiB 来自 session-scan 那条用例的写入量');
assert.ok(MIN_FREE_BYTES > MEASURED_MAX_CASE_WRITE, '阈值必须大于单条用例的写入量');
});
// ─── 2. 端到端:退出码与文案 ───────────────────────────────────
/** 跑一次前置脚本,取 { status, stdout, stderr }。失败(非零退出)不抛。 */
function runPreflight(env) {
try {
const stdout = execFileSync(process.execPath, [PREFLIGHT], {
encoding: 'utf8', env: { ...process.env, ...env },
});
return { status: 0, stdout, stderr: '' };
} catch (e) {
return { status: e.status, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
}
}
test('端到端:空间不足的临时目录 → 退出码 2 且文案说「这是环境不足,不是断言失败」', () => {
// 本机 `/tmp` 在写这条判据时恰好是满的(0.70 MiB 可用)。若哪天它被清空了,
// 这一条就失去意义 —— 所以**不假设**它仍然满:先用判据自己量一次,
// 真的够用就跳过(并说明为什么跳过),绝不让它变成一条"总在亮"或"总在绿"的假判据。
const avail = (() => {
try {
const s = statfsSync(tmpdir());
return s.bavail * s.bsize;
} catch { return null; }
})();
if (avail === null || avail >= MIN_FREE_BYTES) {
console.log(` (跳过:本机 ${tmpdir()} 当前可用 ${avail} 字节,已够用,造不出"不足"的真实环境)`);
return;
}
const r = runPreflight({ TMPDIR: tmpdir() });
assert.equal(r.status, 2, '环境不足必须是 2(环境问题),不是 1(断言失败)');
assert.match(r.stderr, /这是环境不足,不是断言失败/);
assert.match(r.stderr, /TMPDIR=/);
});
// ─── 3. 兜底:绕过前置脚本时也不能伪装成内存缺陷 ────────────────
test('直接跑 node --test 绕过前置脚本时,ENOSPC 仍被翻译成环境问题', () => {
// 残留缺口(写进注释是必须的):`node --test 'test/*.test.mjs'` 会绕过
// `npm test` 里的前置自检。所以 `session-scan.test.mjs` 里那条用例自己
// 也带了一句 ENOSPC 兜底。这里直接对着**那段兜底逻辑的产物**断言:
// 在临时目录写不进去时,抛出的错误信息里必须出现"环境"字样。
const src = readFileSync(join(HERE, 'session-scan.test.mjs'), 'utf8');
assert.match(src, /ENOSPC/, '那条用例必须自己兜底判 ENOSPC');
assert.match(src, /环境/, '兜底信息里必须点明是环境问题');
});