/** * 「临时目录空间不足必须被说出来,而不是伪装成断言失败」的判据。 * * # 缺陷现场(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, /环境/, '兜底信息里必须点明是环境问题'); });