324 lines
16 KiB
JavaScript
324 lines
16 KiB
JavaScript
// 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();
|
||
});
|
||
});
|
||
|
||
// audit R7a P1-19:fs-watcher 轮询改为「自适应间隔 + 不 drop」pending
|
||
// 单测覆盖三件事:
|
||
// 1. getPollInterval:lastChangeAt 在 ACTIVE_WINDOW 内 → 500ms,否则 5s
|
||
// 2. markActivity 刷新 lastChangeAt → 立刻切到 active
|
||
// 3. pending 语义:in-flight 期间触发的 tick 不丢,in-flight 完成后立即补一次
|
||
describe('fs-watcher 自适应轮询 (audit R7a P1-19)', () => {
|
||
it('getPollInterval:初始(lastChangeAt=0,距 now 远)→ QUIET 5000ms', () => {
|
||
const w = makeWatcher();
|
||
expect(w._test_getPollInterval()).toBe(5000);
|
||
});
|
||
|
||
it('getPollInterval:markActivity 后立即 → ACTIVE 500ms', () => {
|
||
const w = makeWatcher();
|
||
w._test_markActivity();
|
||
expect(w._test_getPollInterval()).toBe(500);
|
||
});
|
||
|
||
it('getPollInterval:markActivity 后等到 ACTIVE_WINDOW 之外 → QUIET', async () => {
|
||
const w = makeWatcher();
|
||
w._test_markActivity();
|
||
expect(w._test_getPollInterval()).toBe(500);
|
||
// 模拟时间推进 POLL_ACTIVE_WINDOW_MS + 1:用 setTimeout 等真实时间。
|
||
// 2100ms 等得起,测试只跑一次。备选用 vi.useFakeTimers + setSystemTime
|
||
// 也行,但 fake timer + markActivity 内部 Date.now() 兼容性有边角问题。
|
||
await new Promise((r) => setTimeout(r, 2100));
|
||
expect(w._test_getPollInterval()).toBe(5000);
|
||
});
|
||
|
||
it('pending 语义:in-flight 期间触发的 tick 不丢,in-flight 完后立即补', async () => {
|
||
// 设计要点:pendingPoll 是**幂等**标志,不是队列。
|
||
// - in-flight 期间任何 tick 都只把 pendingPoll 置 true(不重复累计)
|
||
// - in-flight 完成后 finally 检查 pendingPoll:true → scheduleNextPoll(0)
|
||
// - 紧跟的「补」是 1 次新 scanDir,不是 N 次
|
||
// 所以「3 次 tick + resolve」→ scanCalls 期望 2(1 原 + 1 补),不是 3。
|
||
//
|
||
// 旧版(fixed 2s + pollInFlight drop)的语义是:3 次 tick 期间全部被 drop,
|
||
// scanCalls 期望 1(且永远不会补)。这条测试是回归锚点:
|
||
// 拿掉 pendingPoll 行为 → scanCalls 在 resolve 后仍为 1,测试失败。
|
||
//
|
||
// 时序坑:test 里 `await new Promise(r => setTimeout(r, 0))` 是先于
|
||
// runPollTick finally 调度的;Node 同 delay 的 setTimeout 严格按调度顺序
|
||
// 触发,test 的会先 fire,导致 scanCalls 还在 1 时 expect(2) 失败。
|
||
// 修:用一个 polling 等待「scanCalls 达到 2」,让事件循环多跑几个 tick。
|
||
let resolveScan;
|
||
const scanCalls = [];
|
||
const w = createFsWatcher({
|
||
getMainWindow: () => null,
|
||
resolveDataDir: () => '/data',
|
||
scanDir: async () => {
|
||
scanCalls.push(Date.now());
|
||
// 慢:等我们手动 resolve
|
||
await new Promise((r) => { resolveScan = r; });
|
||
return { ok: true, entries: [] };
|
||
},
|
||
});
|
||
// 跳过 fs.watch 订阅(测试不依赖真实目录事件)
|
||
w._test_setWatchedDir('/data');
|
||
// 第一次 tick:in-flight 启动,scanDir 进入 await
|
||
w._test_runPollTick();
|
||
// 第二次 tick:in-flight 还在,pendingPoll = true,scanDir 不再调用
|
||
w._test_runPollTick();
|
||
// 第三次 tick:同样 pendingPoll = true(幂等)
|
||
w._test_runPollTick();
|
||
// 期望 1 次 in-flight scanDir,pendingPoll 期间 0 次额外调用
|
||
expect(scanCalls.length).toBe(1);
|
||
// 解除 in-flight:第一次 scanDir 完成 → runPollTick finally 触发「补一次」
|
||
resolveScan({ ok: true, entries: [] });
|
||
// 等「补」的 scanDir 真的发起(scanCalls 长度变 2)。最坏情况:
|
||
// in-flight 微任务 + emitFilesChanged 微任务 + setTimeout(0) 调度 +
|
||
// setTimeout(0) 触发 + 新 runPollTick 进入 + 新 scanDir push —— 几十 ms 内。
|
||
await new Promise((resolve) => {
|
||
const start = Date.now();
|
||
const tick = () => {
|
||
if (scanCalls.length >= 2) return resolve();
|
||
if (Date.now() - start > 200) return resolve(); // 兜底
|
||
setTimeout(tick, 5);
|
||
};
|
||
tick();
|
||
});
|
||
expect(scanCalls.length).toBe(2);
|
||
// 把「补」触发的第 2 次 scanDir 也 resolve 掉,不让它的 in-flight
|
||
// 干扰其它测试
|
||
if (resolveScan) resolveScan({ ok: true, entries: [] });
|
||
// 关键:runPollTick 的 finally 走「无 pending → scheduleNextPoll(
|
||
// getPollInterval())」会挂 5s 后的 timer。不显式清掉的话,timer 触发时
|
||
// 又会调一次 scanDir(scanCalls 变 3),且新 scanDir 等到没人 resolve
|
||
// 会一直挂着 —— watcher + scanCalls 闭包泄漏到进程结束,vitest 报告
|
||
// "tests passed" 但实际上 open handle 没释放。pause 清 dirPollTimer +
|
||
// pollPaused,让 watcher 真正失活。
|
||
w._test_pauseDirPolling();
|
||
});
|
||
|
||
// audit R7a P1-19 bugfix:pauseDirPolling 必须真正停止轮询。回归锚点:
|
||
// - in-flight tick 还在 await scanDir 时调 pause
|
||
// - scanDir 完成后 runPollTick finally 走 scheduleNextPoll(...)
|
||
// - 旧版(无 pollPaused 闸)→ 仍挂 5s 后才退到的定时器
|
||
// - 修后(有 pollPaused 闸)→ 不挂任何新 timer,轮询真停
|
||
// 拿掉 pollPaused 检查 → _test_hasPollTimer() 在 resolveScan 后仍返回 true,
|
||
// 本测试失败。仅查 scanCalls 长度不够 —— 5s 间隔下 50ms 等不出新 tick,
|
||
// 必须直接断言 dirPollTimer 是否被挂。
|
||
it('pause 期间 in-flight runPollTick finally 不 re-schedule', async () => {
|
||
let resolveScan;
|
||
const scanCalls = [];
|
||
const w = createFsWatcher({
|
||
getMainWindow: () => null,
|
||
resolveDataDir: () => '/data',
|
||
scanDir: async () => {
|
||
scanCalls.push(Date.now());
|
||
await new Promise((r) => { resolveScan = r; });
|
||
return { ok: true, entries: [] };
|
||
},
|
||
});
|
||
w._test_setWatchedDir('/data');
|
||
// 启动一个 in-flight tick(scanDir 进入 await)→ runPollTick 被 setTimeout
|
||
// 调度时 dirPollTimer 已被清,所以这一步后 hasPollTimer() = false
|
||
w._test_runPollTick();
|
||
expect(scanCalls.length).toBe(1);
|
||
expect(w._test_hasPollTimer()).toBe(false);
|
||
// 暂停:模拟窗口最小化 → pollPaused = true
|
||
w._test_pauseDirPolling();
|
||
// in-flight scanDir 完成 → runPollTick finally 执行:
|
||
// 旧版:scheduleNextPoll(getPollInterval()) 挂 5s 后的 timer
|
||
// 修后:pollPaused 短路,dirPollTimer 保持 null
|
||
resolveScan({ ok: true, entries: [] });
|
||
// 等 microtask + setTimeout(0) 都跑过。50ms 足够覆盖几轮事件循环。
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
// 核心断言:pause 之后不应该有新的 poll timer 被挂上
|
||
expect(w._test_hasPollTimer()).toBe(false);
|
||
});
|
||
|
||
// 配对测试:resume 后必须能再跑轮询。验证 pollPaused 不会让恢复路径失活。
|
||
it('resume 后轮询可重新启动', () => {
|
||
const w = makeWatcher();
|
||
w._test_setWatchedDir('/data');
|
||
w._test_pauseDirPolling();
|
||
// pause 后调 resume —— 不报错、watchedDir 还在即可(startDirPoll 在
|
||
// makeWatcher 里没有真实 scanDir,所以不验证 emit 次数)。
|
||
expect(() => w._test_resumeDirPolling()).not.toThrow();
|
||
});
|
||
});
|
||
|