Files
MailUI4Agents/plugins/pi-mail-bridge/test/session-scan.test.mjs
JianFeeeee 5bc579f910 fix(pi-bridge): 按评审补三处 —— ENOSPC 只盖了一个写点、旧注释自相矛盾、兜底判据钉的是文本
pi 逐字读了上一版落地的代码,报了三个"还差一格"。都不是推翻,是同一根因
("环境不足伪装成别的")在这套守卫自己身上的残留。

## 一、翻译只覆盖了 5 个写点里的 1 个(最实质)

`selfCheck()` 要在临时目录造两棵样本树,写点有**五处**;上一版只把 `mk()` 里那两处
包了 try/catch,后面三处(`README.md` / `extra.mjs` / `test/t.mjs`)裸写。它们撞上
ENOSPC 时异常冒到 `main()` 的 catch:**退出码是对的(2),但打印的是原始英文
`ENOSPC: no space left on device, write` 加一段指向本文件的堆栈** —— 也就是上一版
要治的那个信号("看起来像检查器坏了")**恰恰在最需要它的路径上还在**。

改法:抽一个 `describeEnvError(e, what)`,在 `main()` 的 catch 里**统一**换成人话。
一处覆盖全部写点,以后再加写点也不用管。`mk()` 里那段裸判断一并换成调用它。

## 二、`lib/tmp-space.mjs` 的头注释在说谎(读者已误读一次)

原文写"`availBytes` 为 `null`(读不到 / 平台不支持 / **字段为 0**)" —— 而"字段为 0"
指的其实是 `statfs.bsize === 0`(测量层确实 `if (!s.bsize) return null`),读起来
却像是在说"可用 0 字节也算不知道" —— **正是我上一版刚踩、刚补判据的那个坑**。
pi 第一遍读就误读成了后者。已把两个 case 分开写死,并注明"这条注释写错过一次"。

## 三、兜底判据钉的是文本,不是机制

`env-guard.test.mjs` 原来对 `session-scan.test.mjs` 断言 /ENOSPC/ 与 /环境/,
而那段**解释性注释里本来就有这两个词** ⇒ 删掉整段翻译逻辑、只留注释,判据照样绿。
这正是 `permission-note.test.mjs` 自己警告过的"钉装饰不钉机制"。

改法(按仓库规矩,纯函数 + 反面样本 + 接线):
- 翻译逻辑提到 `lib/env-error.mjs` 的 `translateEnvError`(纯函数);
- 判据喂构造出来的错误验**行为**:ENOSPC 必须翻译且带药方、普通错误必须**原样返回
  同一个对象**("什么都翻译"比不翻译更坏 —— 真缺陷会被套上环境的外衣);
- `writeSession` 抽出 `write` 参数(**只为测试存在**,`pool.mjs` 的 `workerPath` 同一手法),
  于是"接线还在不在"是**行为**判据:喂一个必然 ENOSPC 的假写,翻译必须发生。
  抽它的理由写在注释里 —— 是"可被反面样本喂",不是复用(只有一个调用点)。
- 变异自检:删掉写点的翻译 ⇒ 第 28、29 两条立刻红(已实测)。

## 四、顺带三处小的一致性问题

- 端到端那条判据原靠"本机 /tmp 恰好是满的"来验 —— 那是把判据绑在**会变的环境**上,
  /tmp 一清空就自动跳过、无声失效。前置脚本加两个**只为测试存在**的开关:
  `--measure=<dir>`(只量并打印 JSON)与 `--inject-avail=<n>`(绕过测量直接判定),
  于是"不足⇒exit 2"与"充足⇒放行"在任何机器上都验得了(两个方向都验,缺一即假绿)。
- 判据 ⑥ 原先只有它自己带圈号前缀,读者会去找不存在的第 ⑤ 条。改成 `checkLayout`
  的每条都带**连续 id**(1..N),`name` 是纯展示串,并加一条"id 不许跳号"的自检。
- 两个实测数(`729_088` 字节 = 0.70 MiB、`712` 字节)是**不同时刻**量的,并列摆着像抄错,
  各标了来历;`lib/tmp-space.mjs` 里那条改用"一度真是 0"的说法。

验证:`npm test` **474/474**(上一版 453);`--self-check` **18 条全过**(新增 id 连续);
`npm test` 在临时目录不足时仍 exit 2 且一条用例都不跑。
2026-09-14 19:39:07 +08:00

322 lines
13 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';
import { translateEnvError } from '../lib/env-error.mjs';
/** 造一个会话目录树。返回根目录,用完由调用方删。 */
function makeRoot() {
return mkdtempSync(join(tmpdir(), 'pi-scan-'));
}
/** 写一条会话文件。lines 是 header 之后的行(对象,会被 JSON 化)。
*
* ★ 空间不足时**不要让它伪装成内存缺陷**(2026-09-14 实测的教训)。
*
* 本文件在 `/tmp` 是满的 tmpfs(`bavail` 一度真是 **0**)时会红一条
* `★巨大的 message 行不进内存也不影响解析`,报 `ENOSPC` —— 而那条用例的名字里
* 就写着"不进内存",于是那条红**长得像一个内存缺陷**,让人去 `session-scan.mjs`
* 里找一个不存在的东西。
*
* 正常的挡法在 `test/env-preflight.mjs`(由 `npm test` 先跑,不足时 exit 2)。
* 但 `node --test 'test/*.test.mjs'` 会绕过它,所以这里再兜一道:
* **ENOSPC 一律翻译成"环境不足"** —— 不管套件是怎么被调起来的。
*
* 翻译逻辑在 `lib/env-error.mjs`(纯函数,`env-guard.test.mjs` 用构造出来的
* ENOSPC 喂它验行为)。**别把判断写回这里**:写在这里就只能靠"读源码文本"去判,
* 而本段注释里本来就有 "ENOSPC" 这个词 —— 删掉逻辑只留注释,文本判据照样绿
* (pi 评审时就是这么指出来的)。
*
* `write` 只为测试存在(默认 `writeFileSync`):让这个接线能被喂一个必然 ENOSPC 的假写,
* 于是"机制在不在"是**行为**判据而不是文本判据。
*/
export function writeSession(root, cwdSlug, fileName, header, lines = [], write = writeFileSync) {
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 {
write(file, `${body}\n`);
} catch (e) {
throw translateEnvError(e).error;
}
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 });
}
});