Files
MailUI4Agents/plugins/pi-mail-bridge/test/session-scan.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

325 lines
12 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.

import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createSessionScanner } from '../src/session-scan.mjs';
/** 造一个会话目录树。返回根目录,用完由调用方删。 */
function makeRoot() {
return mkdtempSync(join(tmpdir(), 'pi-scan-'));
}
/** 写一条会话文件。lines 是 header 之后的行(对象,会被 JSON 化)。
*
* ★ 空间不足时**不要让它伪装成内存缺陷**(2026-09-14 实测的教训)。
*
* 本文件在 `/tmp` 是满的 tmpfs(`bavail` 只剩 0.70 MiB)时会红一条
* `★巨大的 message 行不进内存也不影响解析`,报 `ENOSPC` —— 而那条用例的名字里
* 就写着"不进内存",于是那条红**长得像一个内存缺陷**,让人去 `session-scan.mjs`
* 里找一个不存在的东西。
*
* 正常的挡法在 `test/env-preflight.mjs`(由 `npm test` 先跑,不足时 exit 2)。
* 但 `node --test 'test/*.test.mjs'` 会绕过它,所以这里再兜一道:
* **ENOSPC 一律翻译成"环境不足"并说清是环境问题** —— 不管套件是怎么被调起来的。
*/
function envHint(e) {
if (e && (e.code === 'ENOSPC' || /no space left on device/i.test(String(e.message)))) {
const err = new Error(
`环境不足:临时目录 ${tmpdir()} 写不进去(ENOSPC)—— 这是环境问题,不是内存缺陷。` +
'药方:TMPDIR=<有空间的目录> npm test'
);
err.cause = e;
return err;
}
return e;
}
function writeSession(root, cwdSlug, fileName, header, lines = []) {
const dir = join(root, cwdSlug);
mkdirSync(dir, { recursive: true });
const file = join(dir, fileName);
const body = [JSON.stringify({ type: 'session', version: 3, ...header })]
.concat(lines.map((l) => JSON.stringify(l)))
.join('\n');
try {
writeFileSync(file, `${body}\n`);
} catch (e) {
throw envHint(e);
}
return file;
}
const msg = (text) => ({
type: 'message',
id: Math.random().toString(36).slice(2, 10),
message: { role: 'user', content: [{ type: 'text', text }] },
});
const info = (name) => ({ type: 'session_info', id: 'aa', parentId: 'bb', name });
test('取到 header 的 id 与 cwd,以及 session_info 的 name', async () => {
const root = makeRoot();
try {
writeSession(root, '--tmp-proj--', 'a.jsonl',
{ id: 'sess-1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/tmp/proj' },
[msg('你好'), info('重构导入路径'), msg('继续')]);
const { scan } = createSessionScanner({ sessionsDir: root });
const out = await scan();
assert.equal(out.length, 1);
assert.equal(out[0].id, 'sess-1');
assert.equal(out[0].cwd, '/tmp/proj');
assert.equal(out[0].name, '重构导入路径');
assert.ok(out[0].modified instanceof Date);
assert.ok(out[0].path.endsWith('a.jsonl'));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('多次改名时取最后一个 session_info(与 SDK 语义一致)', async () => {
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('旧名'), msg('干活'), info('新名')]);
const { scan } = createSessionScanner({ sessionsDir: root });
assert.equal((await scan())[0].name, '新名');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('显式清名(session_info 不带 name)→ name 为 undefined', async () => {
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('有名字'), { type: 'session_info', id: 'cc', parentId: 'dd' }]);
const { scan } = createSessionScanner({ sessionsDir: root });
assert.equal((await scan())[0].name, undefined);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('老会话的 cwd 是空串时照实返回,不冒充', async () => {
// snapshotPiSessions 会按空 workspace 上报。拿桥自己的 cwd 顶上去会让
// 那条会话在错误的工作目录下出现在补全候选里。
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z' },
[info('无 cwd 的老会话')]);
const { scan } = createSessionScanner({ sessionsDir: root });
assert.equal((await scan())[0].cwd, '');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('第二次扫描不重读未变化的文件(这就是省下来的那 282MB)', async () => {
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('甲'), msg('内容')]);
writeSession(root, '--y--', 'b.jsonl',
{ id: 's2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/y' },
[info('乙')]);
const sc = createSessionScanner({ sessionsDir: root });
await sc.scan();
const after1 = sc.stats();
assert.equal(after1.fullScans, 2);
await sc.scan();
const after2 = sc.stats();
assert.equal(after2.fullScans, 2, '没变化的文件不该被重扫');
assert.equal(after2.tailScans, 0);
assert.equal(after2.tailBytes, after1.tailBytes, '一个字节都不该多读');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('文件追加后只扫尾部,且能看到新名字', async () => {
const root = makeRoot();
try {
const file = writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('第一版')]);
const sc = createSessionScanner({ sessionsDir: root });
assert.equal((await sc.scan())[0].name, '第一版');
const bytes1 = sc.stats().tailBytes;
appendFileSync(file, `${JSON.stringify(msg('新一轮'))}\n${JSON.stringify(info('第二版'))}\n`);
const out = await sc.scan();
assert.equal(out[0].name, '第二版');
assert.equal(sc.stats().tailScans, 1);
const delta = sc.stats().tailBytes - bytes1;
assert.ok(delta > 0 && delta < 400, `只该读新增的那一小段,实际 ${delta} 字节`);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('★尾部没有 session_info 时保留旧 name,不能清空', async () => {
// 写错成 `cached.name = name` 会让每次有新消息的会话都丢掉名字,
// 而没有 name 的会话不上报(S-1)—— 于是**活跃**会话反而从补全里消失。
const root = makeRoot();
try {
const file = writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[info('要保住的名字')]);
const sc = createSessionScanner({ sessionsDir: root });
assert.equal((await sc.scan())[0].name, '要保住的名字');
appendFileSync(file, `${JSON.stringify(msg('只是普通消息'))}\n`);
assert.equal((await sc.scan())[0].name, '要保住的名字');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('★巨大的 message 行不进内存也不影响解析', async () => {
// 本机实测单行最长 2.63MB。listAll 会把它整行读进来并 parse;
// 这里只要求 name 仍能取到,且扫描不抛错。
const root = makeRoot();
try {
const huge = 'x'.repeat(3 * 1024 * 1024);
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[msg(huge), info('巨行之后的名字'), msg(huge)]);
const sc = createSessionScanner({ sessionsDir: root });
const out = await sc.scan();
assert.equal(out.length, 1);
assert.equal(out[0].name, '巨行之后的名字');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('首行不是 session 的文件被忽略,且不会每拍重读', async () => {
const root = makeRoot();
try {
const dir = join(root, '--x--');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'junk.jsonl'), `${JSON.stringify({ type: 'message' })}\n`);
writeFileSync(join(dir, 'empty.jsonl'), '');
writeFileSync(join(dir, 'broken.jsonl'), '这不是 json\n');
const sc = createSessionScanner({ sessionsDir: root });
assert.deepEqual(await sc.scan(), []);
const n = sc.stats().fullScans;
await sc.scan();
assert.equal(sc.stats().fullScans, n, '空壳条目不该被反复重读');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('★会话文件被删后缓存条目跟着走(缓存自身不是下一个泄露源)', async () => {
const root = makeRoot();
try {
writeSession(root, '--x--', 'a.jsonl',
{ id: 's1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('甲')]);
writeSession(root, '--x--', 'b.jsonl',
{ id: 's2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('乙')]);
const sc = createSessionScanner({ sessionsDir: root });
await sc.scan();
assert.equal(sc.stats().tracked, 2);
rmSync(join(root, '--x--', 'a.jsonl'));
const out = await sc.scan();
assert.equal(out.length, 1);
assert.equal(sc.stats().tracked, 1, '磁盘上没了的文件必须从缓存里消失');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('文件变小(被截断/重写)时整份重扫而不是从越界偏移读', async () => {
const root = makeRoot();
try {
const file = writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' },
[msg('很多内容'.repeat(200)), info('旧')]);
const sc = createSessionScanner({ sessionsDir: root });
await sc.scan();
const fulls = sc.stats().fullScans;
// 重写成更短的内容,且换了名字
writeSession(root, '--x--', 'a.jsonl',
{ id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('新')]);
const out = await sc.scan();
assert.equal(out[0].name, '新');
assert.equal(sc.stats().fullScans, fulls + 1, '变小必须触发整份重扫');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('目录不存在 → 空列表(pi 从没跑过是正常状态)', async () => {
const sc = createSessionScanner({ sessionsDir: join(tmpdir(), `nope-${Date.now()}`) });
assert.deepEqual(await sc.scan(), []);
});
test('★读目录遇到非 ENOENT 错误必须抛出,不能返回空数组', async () => {
// 返回空数组的语义是「平台确实没有会话」,会把服务端镜像抹掉(W-3 / N-7)。
// 一次 EACCES 就能清空别人的补全候选 —— 必须让调用方看到失败并省略字段。
const root = makeRoot();
try {
// 用一个普通文件当 sessionsDir:readdir 会给 ENOTDIR,而不是 ENOENT
const notADir = join(root, 'file');
writeFileSync(notADir, 'x');
const sc = createSessionScanner({ sessionsDir: notADir });
await assert.rejects(() => sc.scan(), (e) => e?.code === 'ENOTDIR');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('缺 sessionsDir 时当场抛错', () => {
assert.throws(() => createSessionScanner(), /sessionsDir/);
assert.throws(() => createSessionScanner({}), /sessionsDir/);
});
test('跨多个 cwd 子目录汇总', async () => {
const root = makeRoot();
try {
writeSession(root, '--a--', '1.jsonl', { id: 'a1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/a' }, [info('甲')]);
writeSession(root, '--b--', '2.jsonl', { id: 'b1', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/b' }, [info('乙')]);
writeSession(root, '--b--', '3.jsonl', { id: 'b2', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/b' }, [info('丙')]);
const sc = createSessionScanner({ sessionsDir: root });
const out = await sc.scan();
assert.equal(out.length, 3);
assert.deepEqual([...out.map((s) => s.id)].sort(), ['a1', 'b1', 'b2']);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('非 .jsonl 文件被跳过', async () => {
const root = makeRoot();
try {
const dir = join(root, '--x--');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'notes.txt'), 'hello');
writeSession(root, '--x--', 'a.jsonl', { id: 's', timestamp: '2026-09-01T00:00:00.000Z', cwd: '/x' }, [info('甲')]);
const sc = createSessionScanner({ sessionsDir: root });
assert.equal((await sc.scan()).length, 1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});