// main/fs-watcher.js 单测(Case-M1 audit fix + Round 13 fix 回归) // // 不在仓库里的测试 —— fs-watcher 600+ 行(数据目录监听 + 事件防抖 + 自写过滤 + // 错误闩锁 + 退避重连 + generation token)一直没单元测试。这次先补 case-insensitive // self-write filter 这一条(核心 bug 修复的回归锚点),其他路径(轮询 / 重连 / 错误 // 抑制)需要 jsdom + 真实 fs.watch 集成,留给后续 Story。 // // 覆盖: // - markExpectedWrite / isSelfWrite:登记大小写 X 的路径,命中查询大小写 Y 也能识别 // - 与 case-sensitive 的 Linux 行为对照(platform-based 切换) // - 过期条目被惰性 GC // - updateSnapshot 轮询路径 push 回归(Round 13 fix fce2d83 修复的 bug): // 旧实现 baseline 在 updateSnapshot 先写、emitFilesChanged 又读,看到同一 // snapshot 时 if 块不进、safeSend 跳过 —— 2s 轮询(fs.watch 不可靠环境的 // 唯一兜底)检测到的改动永远不通知 renderer。 import { describe, it, expect, vi } from 'vitest'; const { createFsWatcher } = require('../../main/fs-watcher.js'); /** * 构造一个最小化的 fsWatcher 实例用于单元测试。 * 不实际订阅 fs.watch、轮询或 IPC —— 我们只关心 self-write filter 的 Map 行为。 */ function makeWatcher() { return createFsWatcher({ // 不用 —— 只用 mark/isSelfWrite getMainWindow: () => null, resolveDataDir: () => null, scanDir: async () => ({ ok: true, entries: [] }), }); } /** * 构造带 ipc spy 的 fsWatcher,用于验证 updateSnapshot regular path 是否 * 真的把列表变化 push 到 renderer。getMainWindow 返回一个虚拟 BrowserWindow, * webContents.send 被替换为 vi.fn(),方便断言调用次数 + payload。 */ function makeWatcherWithIpc(scanResults) { const sends = []; const fakeWin = { isDestroyed: () => false, webContents: { send: (channel, payload) => sends.push({ channel, payload }) }, }; let scanIdx = 0; const w = createFsWatcher({ getMainWindow: () => fakeWin, resolveDataDir: () => '/data', scanDir: async () => scanResults[scanIdx++] || { ok: true, entries: [] }, }); return { watcher: w, sends, fakeWin }; } describe('fs-watcher markExpectedWrite / isSelfWrite (Case-M1)', () => { it('同 case 命中(sanity)', () => { const w = makeWatcher(); w.markExpectedWrite('C:\\Notes\\foo.md', 1000); expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(true); }); it('Windows / darwin:跨 case 命中(Case-M1 修复)', () => { // audit fix (Case-M1):fs.watch 在 Windows 上报的 filename 与 caller 写入的 // case 可能不一致(取决于 ReadDirectoryChangesW / NTFS 短名 8.3 / 同步盘 // 大小写敏感层)。strict Map.get 会漏命中 → renderer 把自写当成外部修改 // 误报,用户选「用磁盘版」会覆盖刚保存的内容。 const w = makeWatcher(); const mixed = process.platform; // 在 Windows / darwin 上模拟「登记 Foo.md,watcher 上报 foo.md」 w.markExpectedWrite(`C:\\Notes\\Foo.md`, 1000); const lookups = (process.platform === 'win32' || process.platform === 'darwin') ? [`C:\\Notes\\foo.md`, `C:\\Notes\\FOO.md`, `c:\\notes\\Foo.md`, `C:/Notes/Foo.md`] : [`C:\\Notes\\Foo.md`]; for (const alt of lookups) { expect(w.isSelfWrite(alt)).toBe(true); } expect(mixed).toBeTruthy(); // sanity guard }); it('Linux(case-sensitive 平台):跨 case 不命中(旧行为保留)', () => { // 仅当跑在 Linux 上才相关:默认模拟 darwin 风格的 expect 命中安全, // 但 Linux 上 case-different 路径不应被误当作自写。 if (process.platform === 'linux') { const w = makeWatcher(); w.markExpectedWrite('/tmp/Foo.md', 1000); expect(w.isSelfWrite('/tmp/Foo.md')).toBe(true); expect(w.isSelfWrite('/tmp/foo.md')).toBe(false); } else { // 在非 Linux 上跳过,但保持测试存在以便 CI 多平台矩阵触达。 expect(true).toBe(true); } }); it('未登记的路径:isSelfWrite 返回 false', () => { const w = makeWatcher(); expect(w.isSelfWrite('C:\\Notes\\unknown.md')).toBe(false); w.markExpectedWrite('C:\\Notes\\expected.md', 1000); expect(w.isSelfWrite('C:\\Notes\\other.md')).toBe(false); }); it('clearExpectedWrites 后所有命中失效', () => { const w = makeWatcher(); w.markExpectedWrite('C:\\Notes\\foo.md', 1000); expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(true); // markExpectedWrite 的 expiresAt = Date.now() + 500ms —— 不 clear 也得过 500ms // 才失效。clear 是立即清理入口(切目录 / stop 监听时调用)。 w.clearExpectedWrites(); expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(false); }); }); describe('fs-watcher updateSnapshot —— push 决策回归 (Round 13 fix fce2d83)', () => { // 关键回归锚点:2s 轮询(OneDrive / SMB fs.watch 不可靠环境唯一兜底)检测 // 到列表变化,必须把新列表推到 renderer 侧栏刷新。历史 bug 把 skipInitialEmit // 透传给 emitFilesChanged + baseline 重复写,导致 safeSend 永远跳过。 // // 测试通过 internal _test_updateSnapshot 触发该路径,避免引入 jsdom + 真 fs.watch。 it('startup 路径(skipInitialEmit=true):同步 baseline 但不 push', async () => { // audit fix:startWatchingDir 内部 updateSnapshot(dir, true) 拿到的「真实状态」 // 只用来同步 baseline,不能误当成「新增变化」推给 renderer —— renderer 会通过 // 'file:list' 自己拉取启动文件列表,避免「刚加载就 emitFilesChanged 又被 renderer // 当 fs:changed 事件 → 触发 scanDir 重复请求」的循环。 const { watcher: w, sends } = makeWatcherWithIpc([ { ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] }, ]); await w._test_updateSnapshot('/data', /* skipInitialEmit */ true); // startup 只写 baseline 不 push const filesChanged = sends.filter((s) => s.channel === 'files:changed'); expect(filesChanged.length).toBe(0); }); it('常规路径(skipInitialEmit=false):检测到变化必须 push', async () => { // 关键回归:第一次 startup(不 push)后,第二次常规 updateSnapshot 看到列表变化 // → 必须 safeSend 通知 renderer。Round 13 fix 之前的实现 silent 失败。 const { watcher: w, sends } = makeWatcherWithIpc([ // 1st call: startup snapshot, list = [a.md] { ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] }, // 2nd call: polling tick, list grew to [a.md, b.md] { ok: true, entries: [ { name: 'a.md', size: 10, mtimeMs: 1 }, { name: 'b.md', size: 20, mtimeMs: 2 }, ] }, ]); await w._test_updateSnapshot('/data', true); // startup await w._test_updateSnapshot('/data', false); // polling tick detects new file const pushes = sends .filter((s) => s.channel === 'files:changed') .map((s) => s.payload); expect(pushes.length).toBeGreaterThanOrEqual(1); const last = pushes[pushes.length - 1]; expect(last.entries.map((e) => e.name).sort()).toEqual(['a.md', 'b.md']); // relOf 没有 toRelDir 注入时返回 null(fs-watcher 契约:可选字段, // payload 缺 relDir 仍合法;renderer 走 fallback 不依赖此字段) expect(last.relDir == null).toBe(true); }); it('常规路径但列表未变:不 push(baseline 一致)', async () => { // 同一列表连扫两次 → baseline 不变 → emitFilesChanged 内部 if 块不进 → 不 push。 // 避免 fs.watch + polling 双通道把同一次变化报两遍。 const { watcher: w, sends } = makeWatcherWithIpc([ { ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] }, { ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] }, ]); await w._test_updateSnapshot('/data', true); await w._test_updateSnapshot('/data', false); const pushes = sends.filter((s) => s.channel === 'files:changed'); expect(pushes.length).toBe(0); }); it('扫描失败(result.ok=false)→ 通过错误通道 push,不走 entries 通道', async () => { // 与 change 分支独立 —— 错误走 'files:changed' 但 payload 不带 entries, // renderer 用 error 字段弹 toast 并保留旧列表(Round 7 FS-F12 行为)。 const { watcher: w, sends } = makeWatcherWithIpc([ { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT' }, ]); await w._test_updateSnapshot('/data', false); const pushes = sends.filter((s) => s.channel === 'files:changed'); expect(pushes.length).toBe(1); expect(pushes[0].payload.error).toBe('DATA_DIR_NOT_FOUND'); expect(pushes[0].payload.entries).toBeUndefined(); }); });