Files
MailUI4Agents/plugins/pi-mail-bridge/test/env-guard.test.mjs
JianFeeeee f1059c5a6b fix(pi-bridge): 夹具移出测试文件(import 它会二次注册整套用例)+ 测试侧写点全部兜住 + 判据不再往 /tmp 留垃圾
pi 复核了探针形状(论证闭合),又报了三条,全部实测成立。

## 一、从测试文件 import 助手 ⇒ 那套用例被**再注册一遍**(最实质)

`env-guard.test.mjs` 曾从 `session-scan.test.mjs` 取 `writeSession`。`node --test`
默认每个文件一个子进程,模块导入是进程内的 ⇒ 那个文件的 16 条用例在 env-guard
的进程里**又注册了一遍**。

实测确认:TAP 里巨行用例(单条往临时目录写 ~12 MiB)出现**两次**
(`ok 87` / `ok 353`),测试总数 475。**判据自己在加倍压 /tmp** —— 而 /tmp 正是
这次事件的主角。修完:459 条,巨行用例 1 次。

修法就是 pi 指的形状,也正是 `translateEnvError` 那次的同一手法:
夹具移到**非测试模块** `lib/session-fixtures.mjs`(可被引用,不被注册进套件)。

## 二、测试侧写点还是裸的

`makeRoot()` 的 `mkdtempSync`、以及 `writeSession` 里在 try **之外**的 `mkdirSync`
(ENOSPC 也可能从这里出来)⇒ 绕过前置脚本时抛的仍是原始英文堆栈,
而"绕过前置也要说人话"正是这套兜底存在的理由。现在整段包一层,与 `selfCheck()`
同一形状:**覆盖范围不取决于"我以为的哪一行"**。

## 三、判据往共享 /tmp 里留垃圾

`writeSession(tmpdir(), '--probe--', …)` / `'--probe2--'` 每跑一次就留两个目录、
且永不清理。现在改用 `os.tmpdir()`(纯字符串,不 statfs)当根:那两条的创建都被
假写打断 ⇒ 目录根本不会建出来 ⇒ 既不读也不写真实临时目录。

## 四、一条新判据替代原来的文本接线检查

守**机制**:解析测试文件里的模块引用(静态 `from` / 动态 `import()` / `require()`),
任何指向另一个 `.test.mjs` 的引用都算违规 —— 注释里提到文件名不算(注释不会注册用例)。

这条判据自己踩了两次,都留在注释里:
  第 1 版 只匹配静态 from ⇒ 漏掉动态导入;
  第 2 版 "文件里出现别的测试文件名" ⇒ 把**注释里的散文引用**也算成违规
          (本仓库有 3 处这样的注释,逼人删掉有用的注释),
          而且**它被自己注释里的示例字面量扫到**。
**过宽和过窄都是坏的** —— 这正是这一串评审反复出现的同一族错误。

验证:`npm test` **459/459**(少了 16 条重复注册);巨行用例出现 1 次;
`--self-check` 18 条全过;`TMPDIR=/tmp node deploy/check-deploy-drift.mjs --self-check` ⇒ exit 2 + 人话。
2026-09-14 19:51:44 +08:00

251 lines
14 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, readdirSync } 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, measureAvailBytes } from '../lib/tmp-space.mjs';
import { translateEnvError } from '../lib/env-error.mjs';
import { writeSession } from '../lib/session-fixtures.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(args = [], env = {}) {
try {
const stdout = execFileSync(process.execPath, [PREFLIGHT, ...args], {
encoding: 'utf8', env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'],
});
return { status: 0, stdout, stderr: '' };
} catch (e) {
return { status: e.status, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
}
}
test('端到端:退出码 2 与文案(两个方向都验,不依赖机器状态)', () => {
// ★ 这一条**不依赖机器状态**。第一版靠"本机 /tmp 恰好是满的"来验,pi 评审时
// 指出那是把判据绑在一个会变的环境上:/tmp 一被清空,这条就自动跳过、无声失效。
// 所以前置脚本开了 **只为测试存在** 的开关 `--inject-avail`(`pool.mjs` 的
// `workerPath` 是同一手法),把"可用空间"直接喂进去。
//
// 两个方向都要验:只验"不足⇒2",一个恒报不足的坏守卫也能绿;
// 只验"充足⇒0",一个恒放行的守卫也能绿。
const bad = runPreflight(['--inject-avail=0']);
assert.equal(bad.status, 2, '不足必须是 2(环境问题),不是 1(断言失败)');
assert.match(bad.stderr, /这是环境不足,不是断言失败/);
assert.match(bad.stderr, /TMPDIR=/);
const good = runPreflight(['--inject-avail=999999999']);
assert.equal(good.status, 0, '充足必须放行');
assert.match(good.stdout, /env-preflight/);
});
test('★开关真的被认:两个探针必须给出**相反**的判定与相反的关键词', () => {
// pi 评审的漏洞一:上一版只在"真实测量不足"那个分支里断言 ⇒ 机器一恢复健康
// (/tmp 被清空)这条就退化成弱检查,而它守的恰恰是"开关别静默失效"。
//
// 漏洞二(我做变异时撞上的,比漏洞一更隐蔽):按"真实测量"分叉的写法本身留了一个
// 短路分支 —— 本机真实可用就是 0 ⇒ 永远走不足分支,而那个分支只看退出码;
// 于是把开关**整个忽略掉**(永远用真实测量),断言**照样绿**。
// "断言在,区分力不在" —— 与 pi 点的是同一类病,只是它藏在"跑不到的分支"里。
//
// 修法:**不跟真实测量比,让两个探针自己互为反面**,并断言**输出里的判定词**
// (不只看退出码 —— 退出码可能与真实状态巧合相同):
// 探针 A:注入 1 字节 ⇒ exit 2 + 必须打印「< 需要」
// 探针 B:注入 128 MiB(>阈值)⇒ exit 0 + 必须打印「≥ 需要」
// 若开关被忽略,两次都按**真实**测量给同一个答案 ⇒ 至少一条红。
// 这个论证不依赖真实测量是多少。
const tiny = runPreflight(['--inject-avail=1']);
assert.equal(tiny.status, 2, '注入 1 字节必须 exit 2');
assert.match(tiny.stderr + tiny.stdout, /<\s*需要/, '必须打印「不足」的判定');
const plenty = runPreflight(['--inject-avail=134217728']);
assert.equal(plenty.status, 0, '注入 128 MiB(> 32 MiB 阈值)必须放行');
assert.match(plenty.stdout, /≥\s*需要/, '必须打印「充足」的判定(不是靠退出码近似)');
});
test('★非法参数必须炸(exit 2),不能静默放行', () => {
// `Number('abc')` = NaN ⇒ 判据当"没测到" ⇒ 放行。笔误在这条链上等于**跳过守卫**,
// 这是 pi 评审时点出来的:`--inject-avail=abc` 原本会安安静静地放行。
for (const bad of ['--inject-avail=abc', '--inject-avail=-1', '--inject-avail=', '--typo=1', '--measure']) {
const r = runPreflight([bad]);
assert.equal(r.status, 2, `${bad} 必须 exit 2(参数/环境问题),实际 ${r.status}`);
}
// 合法值不能被这条误伤。
assert.equal(runPreflight(['--inject-avail=null']).status, 0, 'null 是合法值(没测到 ⇒ 放行)');
});
test('测量层:读不到的目录 → null(这条判据不依赖机器状态,永远跑得了)', () => {
// 覆盖"读不到 ⇒ null ⇒ 放行"那一支。端到端那条一旦被跳过,就只剩这条管它。
assert.equal(measureAvailBytes('/definitely/not/here'), null);
assert.equal(measureAvailBytes(''), null, '空串不是合法目录');
assert.equal(measureAvailBytes(undefined), null, '不传也要能兜住');
// 真实的临时目录必须量得出一个数(健康时是正数、满时可能是 0 —— 两者都是
// "量到了",都不是 null;这正是 `0` 与"没测到"必须分开的那条线)。
const real = measureAvailBytes(tmpdir());
assert.ok(real === null || typeof real === 'number', '要么量到数,要么明确 null');
});
// ─── 3. 兜底:绕过前置脚本时也不能伪装成内存缺陷 ────────────────
//
// 这一段原先靠**读源码文本**(断言 `session-scan.test.mjs` 里出现 `/ENOSPC/`)。
// pi 评审时指出它钉的是装饰不是机制:那段解释性注释里本来就有 "ENOSPC" 这个词,
// **把整段翻译逻辑删掉、只留注释,判据照样绿**。按仓库规矩改成行为判据 ——
// 纯函数喂反面样本 + 接线检查(`WIRING` 那套:直接调被接上的那个函数)。
test('★反面样本:ENOSPC 必须被翻译成「环境问题」,普通错误必须原样返回', () => {
const enospc = Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' });
const t = translateEnvError(enospc);
assert.equal(t.translated, true, 'ENOSPC 必须翻译');
assert.match(t.error.message, /环境问题/);
assert.match(t.error.message, /不是内存缺陷/, '必须点明它不是内存缺陷 —— 这正是那次误导的根');
assert.match(t.error.message, /TMPDIR=/, '必须带药方');
assert.equal(t.error.cause, enospc, '原始错误要挂上 cause,别把现场丢了');
// 别的错误必须**原样**返回(不是包一层)—— 否则真正的代码缺陷会被套上
// "环境问题"的外衣,那比不翻译更坏。
const other = new Error('Cannot read properties of undefined');
const o = translateEnvError(other);
assert.equal(o.translated, false);
assert.equal(o.error, other, '普通错误必须原样返回同一个对象');
});
test('只有 message 里写着 no space left(没有 code)时也认', () => {
// 跨平台差异:不同 Node/文件系统的 errno 包装不一律带 code。
const t = translateEnvError(new Error('write failed: no space left on device'));
assert.equal(t.translated, true);
});
test('接线:writeSession 撞上 ENOSPC 时抛出的必须是翻译过的错(喂假写,不靠机器状态)', () => {
// 这条是**行为**判据,不是文本判据:直接调被接上的那个函数,喂一个必然 ENOSPC 的
// 假写。删掉 `lib/session-fixtures.mjs` 里那段 `translateEnvError(e).error`,这条立刻红。
//
// ★ 用 `os.tmpdir()`(纯字符串)而不是 `tmpdir()`(会 statfs)当根:
// 这里所有创建都被假写打断 ⇒ 目录不会被真正建出来 ⇒ 不需要真临时目录,
// 也不会往共享 /tmp 里留东西(pi 评审:判据自己别往被测资源里丢垃圾)。
const fakeWrite = () => {
throw Object.assign(new Error('ENOSPC: no space left on device, write'), { code: 'ENOSPC' });
};
assert.throws(
() => writeSession(tmpdir(), '--probe--', 'x.jsonl', { id: 's' }, [], fakeWrite),
(e) => {
assert.match(e.message, /环境问题/, '必须是人话,不是原始英文 ENOSPC');
assert.match(e.message, /不是内存缺陷/);
return true;
}
);
// 反向对照:普通写失败必须原样抛(证明上面那条不是因为"什么都翻译"才过的)。
const brokenWrite = () => { throw new Error('EACCES: permission denied'); };
assert.throws(
() => writeSession(tmpdir(), '--probe2--', 'y.jsonl', { id: 's' }, [], brokenWrite),
/EACCES/,
'不该被翻译成环境问题'
);
});
test('★夹具不在测试文件里(否则引用它会二次注册整个套件)', () => {
// 这条替代了原来那条文本接线检查,并且守一个**更实质**的东西:
// `env-guard` 曾经从 `session-scan` 那个**测试文件**里取 `writeSession`,
// 而 `node --test` 每个文件一个进程、模块导入是进程内的 ⇒
// **那个文件的 16 条用例在 env-guard 的进程里又注册了一遍**,包括那条单条写
// ~12 MiB 的巨行用例 —— 实测 TAP 里它确实出现两次(`ok 87` / `ok 353`),
// 也就是往当时已经满的 /tmp 又多写一份。
//
// 判据形状:**解析文件里的模块引用**,看它指不指向另一个测试文件。
//
// 踩过两次,都写在这里免得下一个人重踩:
// 第 1 版 只匹配静态 from 子句 ⇒ 漏掉**动态导入**(同样会二次注册,
// 只是躲开了静态语法);
// 第 2 版 改成"文件里出现别的测试文件名"⇒ 把**注释里的散文引用**也算成违规
// (本仓库有 3 处这样的注释,包括本条自己的说明)。**过宽和过窄都是坏的** ——
// 前者逼人删掉有用的注释,后者漏掉真违规。
// 现在只认**真引用**:`from` / `import(` / `require(` 后面跟着的那个字符串。
// 注释里提到文件名不算(注释不会让用例注册)。
const refRe = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)['"]\.\/([\w.-]+\.test\.mjs)['"]/g;
const offenders = [];
for (const f of readdirSync(HERE)) {
if (!f.endsWith('.test.mjs')) continue;
const src = readFileSync(join(HERE, f), 'utf8');
for (const m of src.matchAll(refRe)) {
offenders.push(`${f} → ${m[1]}`);
}
}
assert.deepEqual(offenders, [], `测试文件之间不许互相 import(会二次注册整套用例):${offenders.join('、')}`);
});