This commit is contained in:
2026-09-21 02:30:48 +08:00
parent a1089efd18
commit 7a3769670b
50 changed files with 3375 additions and 783 deletions

View File

@@ -0,0 +1,182 @@
// 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();
});
});