Files
Literature-Management/tests/unit/main/notes-self-write-echo.test.ts
2026-10-21 14:42:59 +08:00

197 lines
8.7 KiB
TypeScript

// tests/unit/main/notes-self-write-echo.test.ts
//
// 守 perf r239「notes 域的自写回声不触发全量重扫」契约。
//
// 问题(用户诉求「使用流畅,不要有卡顿」的直接来源):
// autosave 每 1.5s 落一次盘 → chokidar 'change' → scheduleReload(100ms
// 防抖)→ **全量重扫**(scanNotes 把每个 .md 都 open+read+gray-matter
// 解析一遍)。实测(本机,8KB/篇):500 篇 → 124ms;2000 篇 → 410ms。
// 主进程被占住 ⇒ 整个应用卡顿(不只是列表)。
// 而这次重扫是**纯浪费**:write() 自己已经做完 byId.set + syncResource
// (DB/FTS) + emit('changed'),watcher 的回声只是把同样的状态重新从
// 磁盘读一遍。
//
// 顺带修掉的第二个浪费:原子写的 tmp 文件(`<target>.rkms-a<rand>.tmp`)
// 也会命中 chokidar 'add' ⇒ 旧实现**每次 autosave 多触发一次全量重扫**。
// tmp 后缀不是 .md/.markdown ⇒ 按后缀过滤天然滤掉。
//
// 本测锁三条行为:
// A. 应用自己写盘 ⇒ watcher 回声**不**触发全量重扫。
// B. 外部改文件 ⇒ **照常**触发全量重扫(不能被误吞)。
// C. DB 同步失败时 ⇒ 回声**仍然**触发重扫(自愈不能被优化掉)。
//
// 「有没有发生全量重扫」的可观测口径:scanNotes 的调用次数
// (重扫 = scanNotes,这是 reload() 的唯一入口)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
failUpsert: false,
upsertResource: vi.fn(() => {
if (repo.failUpsert) throw new Error('boom: db write failed');
}),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
// 计数「全量重扫」次数 —— 部分 mock:其余导出原样透传。
const scan = vi.hoisted(() => ({ calls: 0 }));
vi.mock('../../../src/main/notes/standalone', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/main/notes/standalone')>();
return {
...actual,
scanNotes: async (...args: Parameters<typeof actual.scanNotes>) => {
scan.calls += 1;
return actual.scanNotes(...args);
},
};
});
vi.mock('electron', () => ({
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
/** 等到 predicate 成立(或超时)。 */
async function waitFor(predicate: () => boolean, timeoutMs = 8000): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 50));
}
return predicate();
}
const NOTE_FM = (title: string) => `---\nid: "x"\ntitle: "${title}"\n---\n\nbody of ${title}\n`;
describe('notes 自写回声不触发全量重扫(r239)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
/** 落一个笔记文件到磁盘(不经过 service ⇒ 算「外部」内容)。 */
async function putFile(name: string, title: string): Promise<string> {
const p = join(notesDir, name);
await fs.writeFile(p, NOTE_FM(title), 'utf8');
return p;
}
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-selfecho-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
await putFile('a.md', 'A');
await putFile('b.md', 'B');
svc = new NotesService();
await svc.start();
// chokidar 的初始扫描是异步的 —— watcher 还没 ready 时发生的变化收不到事件
await new Promise((r) => setTimeout(r, 700));
scan.calls = 0;
repo.failUpsert = false;
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('A. 应用自己写盘 ⇒ 回声不触发全量重扫', { timeout: 20000 }, async () => {
const note = svc.list()[0]!;
await svc.write(note.id, 'new body from the app', 'A2');
// 落盘 → chokidar(awaitWriteFinish 50ms)→ 事件 → 100ms 防抖 → reload
// ⇒ 给足时间,确保「如果没被抑制,重扫一定已经发生」
await new Promise((r) => setTimeout(r, 2500));
expect(scan.calls).toBe(0);
// 写本身必须成功(否则上面的 0 只是因为压根没写成功)
const onDisk = await fs.readFile(join(notesDir, note.relPath), 'utf8');
expect(onDisk).toContain('new body from the app');
});
it('B. 外部改文件 ⇒ 照常触发全量重扫(不能被误吞)', { timeout: 20000 }, async () => {
const target = svc.list().find((n) => n.filename === 'b.md')!;
await fs.writeFile(join(notesDir, 'b.md'), NOTE_FM('B edited outside'), 'utf8');
const fired = await waitFor(() => scan.calls > 0);
expect(fired).toBe(true);
// 重扫之后内存里的内容必须是外部改后的
await waitFor(() => svc.findById(target.id)?.body.includes('B edited outside') === true);
expect(svc.findById(target.id)?.body).toContain('B edited outside');
});
it('C. DB 同步失败 ⇒ 回声仍触发重扫(自愈不被优化掉)', { timeout: 20000 }, async () => {
const note = svc.list()[0]!;
repo.failUpsert = true;
await svc.write(note.id, 'body that fails to reach the db', 'A3');
// DB 写失败 ⇒ 不打自写标记 ⇒ watcher 回声照常触发全量重扫
// (否则这条笔记的 resources 行永远停在旧正文,搜索搜不到新内容)
const fired = await waitFor(() => scan.calls > 0);
expect(fired).toBe(true);
});
it('D. 往 notes/ 里丢非笔记文件(图片等)⇒ 不触发全量重扫', { timeout: 20000 }, async () => {
await fs.writeFile(join(notesDir, 'screenshot.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
// 等足够久(chokidar 事件 + 100ms 防抖 + reload)
await new Promise((r) => setTimeout(r, 2500));
expect(scan.calls).toBe(0);
// 对照:同目录下的 .md 改动**必须**照常触发(不能被过滤误伤)
await fs.writeFile(join(notesDir, 'b.md'), NOTE_FM('B edited again'), 'utf8');
const fired = await waitFor(() => scan.calls > 0);
expect(fired).toBe(true);
});
it('E. app 写盘后**立刻**被外部改 ⇒ 外部内容必须可见(不得被自写回声吞掉)', { timeout: 30000 }, async () => {
const target = svc.list().find((n) => n.filename === 'b.md')!;
// 1) app 自己写盘 → 记下「写完时的文件指纹」
await svc.write(target.id, 'body written by the APP', 'B-app');
// 2) 紧接着外部工具(Obsidian / 同步盘)改**同一个**文件
await fs.writeFile(join(notesDir, 'b.md'), NOTE_FM('B EXTERNAL EDIT'), 'utf8');
// 3) 等 watcher 事件 + 去抖 + reload 全部发生
await new Promise((r) => setTimeout(r, 3000));
expect(scan.calls, '外部改动必须触发一次全量重扫').toBeGreaterThan(0);
expect(svc.findById(target.id)?.body ?? '', '内存里必须是外部改后的内容').toContain(
'B EXTERNAL EDIT',
);
});
it('F. app 写盘后**立刻**被外部删 ⇒ 删除必须可见(watcher 可能整个吞掉这次删除)', { timeout: 30000 }, async () => {
const target = svc.list().find((n) => n.filename === 'b.md')!;
await svc.write(target.id, 'body written by the APP', 'B-app');
// 外部在 app 写盘后 1ms 内把文件删了。
// 危险点:chokidar 的 awaitWriteFinish 还在等这个文件「大小稳定 50ms」,
// 文件却先没了 ⇒ chokidar 认为「那个待上报的写没了」,**既不发
// 'change' 也不发 'unlink'** ⇒ 外部删除彻底不可见(机器繁忙时必现)。
// 兜底 = 写盘后的**延迟复核**(见 service.scheduleWriteVerify)。
await fs.unlink(join(notesDir, 'b.md'));
await new Promise((r) => setTimeout(r, 3000));
expect(scan.calls, '外部删除必须触发一次全量重扫').toBeGreaterThan(0);
expect(
svc.list().some((n) => n.filename === 'b.md'),
'已被外部删除的笔记不得还留在内存索引里',
).toBe(false);
});
});