Files
MailUI4Agents/plugins/pi-mail-bridge/test/session-scan.test.mjs

297 lines
11 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 化)。 */
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');
writeFileSync(file, `${body}\n`);
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 {
// 用一个普通文件当 sessionsDirreaddir 会给 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 });
}
});