/** * 「临时目录空间不足必须被说出来,而不是伪装成断言失败」的判据。 * * # 缺陷现场(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 } 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 './session-scan.test.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` 是同一手法),把"可用空间"直接喂进去。代价:守卫多一个测试后门; // 收益:判据在任何机器上、任何时刻都验得了。 const out = runPreflight(['--inject-avail=0']); assert.equal(out.status, 2, '环境不足必须是 2(环境问题),不是 1(断言失败)'); assert.match(out.stderr, /这是环境不足,不是断言失败/); assert.match(out.stderr, /TMPDIR=/); // 反向对照:喂一个充足的数 → 必须放行。 // 没有这条,"恒报不足"的坏守卫也能让上面那条绿 —— 反面样本只挡一半。 const ok = runPreflight(['--inject-avail=999999999']); assert.equal(ok.status, 0, '充足时必须放行'); assert.match(ok.stdout, /env-preflight/); }); test('开关真的被认(不是被当成未知参数忽略掉):--inject-avail=0 必须改变判定', () => { // 若 `--inject-avail` 被忽略,脚本就退回"量真实临时目录"。本机 /tmp 现在是 0 字节, // 于是它**也会** exit 2 —— 那样上一条判据照样绿,但验的其实是机器、不是开关。 // 这里用"充足"那个方向做判据:注入一个大数必须放行;若开关被忽略,本机当前是满的 // ⇒ 会 exit 2 ⇒ 这条判据红。**一个只在"开关坏掉"时才红的判据。** const injected = runPreflight(['--inject-avail=999999999']); const measured = runPreflight(['--measure=/tmp']); assert.equal(measured.status, 0, '--measure 只量一次并 exit 0'); assert.match(measured.stdout, /"availBytes"/); if (/"availBytes":\s*0\b/.test(measured.stdout)) { // 只有在"真实测量确实不足"时这条才有区分力,此时注入大数却放行 ⇒ 证明开关生效。 assert.equal(injected.status, 0, '真实测量不足而注入充足 ⇒ 必须按注入的走,说明开关被认'); } else { // 真实测量本身就充足 ⇒ 这条没有区分力,明说而不是假装验过。 console.log(' (本机临时目录当前充足,此条退化为弱检查:仅验 --measure 可用)'); } }); 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 的 // 假写。删掉 `catch { throw translateEnvError(e).error }` 这一段,这条立刻红。 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('残留缺口写明:绕过前置脚本时仍有一条 ENOSPC 兜底(文本接线检查)', () => { // 保留一条**弱**的接线检查:翻译函数必须真的被 session-scan 的写点用着。 // 它比原来那条强的地方是:不再要求注释里出现某个词,而是要求**调用点**存在。 const src = readFileSync(join(HERE, 'session-scan.test.mjs'), 'utf8'); assert.match(src, /translateEnvError\(e\)\.error/, '写点必须调用翻译函数'); });