375 lines
16 KiB
JavaScript
375 lines
16 KiB
JavaScript
// 测试 0.5 秒轮询兜底(main.js v5 方案)
|
||
//
|
||
// 复刻 main.js 中 startPolling 的逻辑(隔离测试,不引入 electron 依赖):
|
||
// - statSync 间隔 POLL_INTERVAL_MS 检查 (mtimeMs, size)
|
||
// - 变化即触发 IPC(用事件回调模拟)
|
||
// - 与 fs.watch 共用 dedup 窗口
|
||
// - 主进程自写入后 recordFileStat 跳过那次变化
|
||
// - fs.watch 回调里同步 statSync 刷新 lastSeen,避免 lastSeen 滞后
|
||
//
|
||
// 用户的真实反馈:fs.watch 在他的环境不触发 → 外部修改软件没反应。
|
||
// 0.5 秒轮询兜底:用 statSync 探测 (mtime, size) 变化,绕开 fs.watch 的不可靠性。
|
||
|
||
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
|
||
import { makeStore } from './_lib/store-fixture.mjs';
|
||
import { makeTmpTodoFile, cleanupTmpTodo } from './_lib/tmp-todo.mjs';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
|
||
const { dir: tmpDir, file: tmpFile } = makeTmpTodoFile({ prefix: 'poll-test-' });
|
||
|
||
// === 复刻 main.js 的 polling 逻辑(最小可测版本) ===
|
||
const POLL_INTERVAL_MS = 50; // 测试加速:50ms 替代 500ms
|
||
const EXTERNAL_CHANGE_DEDUP_MS = 20; // 同步缩短 dedup
|
||
|
||
let lastSeenMtimeMs = 0;
|
||
let lastSeenSize = 0;
|
||
let pollTimer = null;
|
||
let lastEventAt = 0;
|
||
let eventCount = 0;
|
||
let currentFilePath = null;
|
||
const events = [];
|
||
|
||
function recordFileStat(filePath) {
|
||
try {
|
||
const st = fs.statSync(filePath);
|
||
lastSeenMtimeMs = st.mtimeMs;
|
||
lastSeenSize = st.size;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function fireIpc(filePath) {
|
||
// 模拟 mainWindow.webContents.send('file:external-change', filePath)
|
||
const now = Date.now();
|
||
if (now - lastEventAt < EXTERNAL_CHANGE_DEDUP_MS) return;
|
||
lastEventAt = now;
|
||
eventCount++;
|
||
events.push({ at: now, filePath });
|
||
}
|
||
|
||
function startPolling(filePath) {
|
||
stopPolling();
|
||
currentFilePath = filePath;
|
||
recordFileStat(filePath);
|
||
pollTimer = setInterval(() => {
|
||
if (!currentFilePath) return;
|
||
let st;
|
||
try { st = fs.statSync(currentFilePath); } catch { return; }
|
||
if (st.mtimeMs === lastSeenMtimeMs && st.size === lastSeenSize) return;
|
||
lastSeenMtimeMs = st.mtimeMs;
|
||
lastSeenSize = st.size;
|
||
fireIpc(currentFilePath);
|
||
}, POLL_INTERVAL_MS);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||
}
|
||
|
||
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||
|
||
// ============================================================
|
||
// [1] 初始 baseline 不会误触发
|
||
// ============================================================
|
||
console.log('\n[1] 初始 baseline 不触发事件');
|
||
{
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
eventCount = 0;
|
||
events.length = 0;
|
||
startPolling(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 4);
|
||
check('初始 baseline 后 4 轮无事件', eventCount === 0, `eventCount=${eventCount}`);
|
||
stopPolling();
|
||
}
|
||
|
||
// ============================================================
|
||
// [2] 外部修改 → polling 检测到 mtime/size 变化 → 触发 IPC
|
||
// ============================================================
|
||
console.log('\n[2] 外部修改 → polling 触发 IPC');
|
||
{
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
startPolling(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
check('watcher 稳定后无事件', eventCount === 0);
|
||
|
||
// 用户在外部编辑器里改了一条任务
|
||
await wait(60); // 确保 mtime 变化
|
||
eventCount = 0;
|
||
events.length = 0;
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a 修改\n');
|
||
|
||
await wait(POLL_INTERVAL_MS * 3);
|
||
check('外部修改触发 IPC', eventCount === 1, `eventCount=${eventCount}`);
|
||
check('IPC 携带正确路径', events[0]?.filePath === tmpFile);
|
||
stopPolling();
|
||
}
|
||
|
||
// ============================================================
|
||
// [3] 多次外部修改 → 多次 IPC
|
||
// ============================================================
|
||
console.log('\n[3] 多次外部修改 → 多次 IPC');
|
||
{
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
startPolling(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
|
||
eventCount = 0;
|
||
for (let i = 0; i < 3; i++) {
|
||
await wait(60);
|
||
fs.writeFileSync(tmpFile, `# 全部任务\n\n## 工作\n\n- [ ] 修改 ${i}\n`);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
}
|
||
check('3 次外部修改 → 3 次 IPC(不漏报)', eventCount === 3,
|
||
`eventCount=${eventCount}`);
|
||
stopPolling();
|
||
}
|
||
|
||
// ============================================================
|
||
// [4] 静默(无修改)→ 不触发 IPC
|
||
// ============================================================
|
||
console.log('\n[4] 无修改 → 不触发 IPC');
|
||
{
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
startPolling(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
|
||
eventCount = 0;
|
||
await wait(POLL_INTERVAL_MS * 8);
|
||
check('8 轮静默期内 0 事件', eventCount === 0, `eventCount=${eventCount}`);
|
||
stopPolling();
|
||
}
|
||
|
||
// ============================================================
|
||
// [5] 主进程自写入:recordFileStat 刷新基线 → polling 跳过
|
||
// ============================================================
|
||
console.log('\n[5] 主进程自写入 → recordFileStat 跳过那次变化');
|
||
{
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
startPolling(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
|
||
// 模拟主进程 file:write handler:写完调 recordFileStat
|
||
eventCount = 0;
|
||
await wait(60); // mtime 推进
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 自写入\n');
|
||
recordFileStat(tmpFile); // 关键:刷新基线
|
||
|
||
await wait(POLL_INTERVAL_MS * 4);
|
||
check('自写入刷新基线 → polling 不发 IPC', eventCount === 0,
|
||
`eventCount=${eventCount}`);
|
||
stopPolling();
|
||
}
|
||
|
||
// ============================================================
|
||
// [6] dedup 窗口:连续 burst 事件只发一次 IPC
|
||
// ============================================================
|
||
console.log('\n[6] dedup 窗口:连续 burst 事件只发一次 IPC');
|
||
{
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
startPolling(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
|
||
// 模拟「一次外部保存触发 fs.watch + polling 多次回调」:
|
||
// 第一次 fireIpc 应当通过;同窗口内后续 fireIpc 应当被 dedup。
|
||
// 注意:必须先 fireIpc 一次(设置 lastEventAt),后面再连续触发才看得到 dedup。
|
||
eventCount = 0;
|
||
lastEventAt = 0; // 重置 dedup 起点
|
||
fireIpc(tmpFile); // 第 1 次:应当通过
|
||
const afterFirst = eventCount;
|
||
fireIpc(tmpFile); // 第 2 次:同窗口内,应被 dedup
|
||
fireIpc(tmpFile); // 第 3 次:同窗口内,应被 dedup
|
||
|
||
check('首次 fireIpc 通过 dedup', afterFirst === 1, `eventCount=${afterFirst}`);
|
||
check('dedup 窗口内后续 fireIpc 被吞', eventCount === 1,
|
||
`eventCount=${eventCount}`);
|
||
stopPolling();
|
||
}
|
||
|
||
// ============================================================
|
||
// [7] 文件被外部删除 → polling 不崩
|
||
// ============================================================
|
||
console.log('\n[7] 文件被外部删除 → polling 静默不崩');
|
||
{
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
startPolling(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
|
||
eventCount = 0;
|
||
fs.unlinkSync(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 4);
|
||
check('文件被删后 polling 不触发 IPC(不能误判为外部修改)',
|
||
eventCount === 0, `eventCount=${eventCount}`);
|
||
|
||
// 文件被恢复
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 恢复\n');
|
||
await wait(POLL_INTERVAL_MS * 4);
|
||
check('文件恢复后 polling 检测到 mtime 变化 → 触发 IPC',
|
||
eventCount === 1, `eventCount=${eventCount}`);
|
||
stopPolling();
|
||
}
|
||
|
||
// ============================================================
|
||
// [8] 端到端:polling + 渲染端 v4 内容比对联调
|
||
// ============================================================
|
||
console.log('\n[8] 端到端:polling 触发后,渲染端 v4 内容比对仍能识别自写入');
|
||
{
|
||
// 这是 main.js + app.js 的联调:主进程 polling 触发 IPC 后,
|
||
// 渲染端 readFile + store.serialize() 比对,决定 reload or 静默。
|
||
// 这里只测内容比对那一步(渲染端逻辑在 app.js,无法直接测)。
|
||
|
||
const initialMd = '# 全部任务\n\n## 工作\n\n- [ ] a\n';
|
||
|
||
// 模拟 store 内存状态(autoSave 已把内存序列化到磁盘)
|
||
const storeSerialize = initialMd;
|
||
|
||
// (a) 自写入场景:fileContent === storeSerialize → 静默
|
||
const fileContentAfterAutoSave = initialMd;
|
||
check('自写入:fileContent === storeSerialize → 静默',
|
||
fileContentAfterAutoSave === storeSerialize);
|
||
|
||
// (b) 外部修改:fileContent !== storeSerialize → reload
|
||
const userEdited = '# 全部任务\n\n## 工作\n\n- [ ] a 修改\n';
|
||
check('外部修改:fileContent !== storeSerialize → reload',
|
||
userEdited !== storeSerialize);
|
||
|
||
// (c) 单字节差异(用户改了 1 个字符)也能识别
|
||
const tinyEdit = '# 全部任务\n\n## 工作\n\n- [ ] A\n'; // a → A
|
||
check('单字节差异也能识别',
|
||
tinyEdit !== storeSerialize);
|
||
|
||
// (d) 用户删除任务
|
||
const userDeleted = '# 全部任务\n\n## 工作\n';
|
||
check('用户删除任务也能识别',
|
||
userDeleted !== storeSerialize);
|
||
}
|
||
|
||
// ============================================================
|
||
// [9] fs.watch 回调里 statSync 刷新 lastSeen —— race 修复核心
|
||
// ============================================================
|
||
//
|
||
// race 场景:旧实现下 fs.watch 只发 IPC,不更新 lastSeen。polling 在 0.5 秒
|
||
// 之后才检测到变化,期间 force=false 的 autoSave 用陈旧 lastSeen 做检查 ——
|
||
// 即使外部修改已经发生,statSync 拿到的「新磁盘状态」与 lastSeen 一致 →
|
||
// 静默覆盖外部编辑(数据丢失)。
|
||
//
|
||
// 修复后:fs.watch 触发时立刻 statSync 更新 lastSeen。polling tick 内
|
||
// 看到 lastSeen 与磁盘一致就不再触发 IPC,但 force=false autoSave 的
|
||
// 二次 statSync 检查也会发现 lastSeen 与磁盘一致 —— 此时外部修改已被
|
||
// 察觉、IPC 已发给 renderer 让用户介入,窗口期(< 100ms)几乎不可见。
|
||
//
|
||
// 测试方式:先停掉 polling,再写文件(模拟 fs.watch 触发的瞬间),
|
||
// 立刻刷新 lastSeen,重启 polling —— 后续 tick 应该看不到差异。
|
||
console.log('\n[9] fs.watch 回调里 statSync 刷新 lastSeen');
|
||
{
|
||
// 重置全局状态
|
||
lastSeenMtimeMs = 0;
|
||
lastSeenSize = 0;
|
||
eventCount = 0;
|
||
lastEventAt = 0;
|
||
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
stopPolling(); // 先停掉 polling
|
||
// 把 lastSeen 初始化为磁盘当前状态(相当于 startPolling 内部的 recordFileStat)
|
||
recordFileStat(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
|
||
// 现在模拟「fs.watch 触发的瞬间」:
|
||
// 1) 外部编辑器写盘
|
||
// 2) fs.watch callback 立刻 statSync → 更新 lastSeen
|
||
// 3) IPC 发给 renderer
|
||
// 整个动作在 polling tick 之前发生(stopPolling 期间 polling 不会跑)。
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 外部修改\n');
|
||
// 让 mtime 推进(保险,rename 后通常也需要 mtime 推进)
|
||
await wait(60);
|
||
|
||
// 关键修复点:fs.watch 触发时立刻 statSync → 更新 lastSeen。
|
||
// 模拟 main.js startWatchingFile 回调里 statSync 行为
|
||
const externalStat = fs.statSync(tmpFile);
|
||
lastSeenMtimeMs = externalStat.mtimeMs;
|
||
lastSeenSize = externalStat.size;
|
||
|
||
// 现在重启 polling,记录 fireIpc 次数
|
||
eventCount = 0;
|
||
startPolling(tmpFile); // 内部会再次 recordFileStat 刷新基线
|
||
|
||
await wait(POLL_INTERVAL_MS * 4);
|
||
check('fs.watch 已刷新 lastSeen → polling 后续 tick 看到一致不发 IPC',
|
||
eventCount === 0, `eventCount=${eventCount}`);
|
||
|
||
// 此时跑一次 force=false autoSave 的 statSync 检查:
|
||
// lastSeen === 磁盘状态 → 检查通过。这不是 bug:
|
||
// IPC 已经先发给 renderer 让用户介入,force=false autoSave 在 IPC 之后
|
||
// 跑通是允许的 —— 数据丢失窗口被压缩到 IPC 触发 → autoSave 真正写盘之间。
|
||
const autoSaveCheck = fs.statSync(tmpFile);
|
||
const checkPass = autoSaveCheck.mtimeMs === lastSeenMtimeMs &&
|
||
autoSaveCheck.size === lastSeenSize;
|
||
check('force=false autoSave 的 statSync 检查与刷新后的基线一致(预期)',
|
||
checkPass === true);
|
||
|
||
// 对照:把 lastSeen 重置为陈旧基线,再 statSync:
|
||
// 旧实现下这种场景下「lastSeen 陈旧 + statSync 新」会导致漏检(race)。
|
||
// 这里只验证「陈旧 lastSeen 与磁盘新状态确实不等」—— 旧实现没刷新导致 race 正是这种不一致。
|
||
lastSeenMtimeMs = 0;
|
||
lastSeenSize = 0;
|
||
const staleCheck = fs.statSync(tmpFile);
|
||
const wouldBeMissed = staleCheck.mtimeMs === lastSeenMtimeMs &&
|
||
staleCheck.size === lastSeenSize;
|
||
check('对照:陈旧 lastSeen=0 与磁盘不一致(理应被旧 mtime 检查拦下)',
|
||
wouldBeMissed === false,
|
||
'修复前 race 窗口里这种不一致就是误判漏检的根因');
|
||
|
||
stopPolling();
|
||
}
|
||
|
||
// ============================================================
|
||
// [10] 文件被外部删 + 同 mtime/size 重建 → 必须能检测到(不能漏报)
|
||
// ============================================================
|
||
console.log('\n[10] 文件被外部删 + 同 mtime/size 重建 → polling 必须检测到');
|
||
{
|
||
// 审计里的边界 case:云同步目录的客户端偶尔会「整文件删除 + 重建完全相同内容」
|
||
// 来做去重优化,文件系统可能沿用原 inode → mtime + size 不变。
|
||
// 旧实现:statSync 抛错后只 return,lastSeen 保留旧值,下一轮 statSync 命中
|
||
// 「mtime + size 与 lastSeen 一致」→ 漏报。
|
||
// 新实现:statSync 抛错时把 lastSeen 清零,下一轮重建后 statSync 拿到新 mtime →
|
||
// lastSeen=0 不一致 → 触发 IPC。
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
startPolling(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
|
||
// 记录重建前后的 mtime + size,确认确实是「同 mtime/size 重建」场景
|
||
const beforeStat = fs.statSync(tmpFile);
|
||
|
||
eventCount = 0;
|
||
// 1) 外部删
|
||
fs.unlinkSync(tmpFile);
|
||
await wait(POLL_INTERVAL_MS * 2);
|
||
check('文件被删期间:polling 不触发 IPC(避免误报)',
|
||
eventCount === 0, `eventCount=${eventCount}`);
|
||
|
||
// 2) 重建内容完全相同的文件,强制沿用同一 mtime(utimes 显式设置)
|
||
fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n');
|
||
fs.utimesSync(tmpFile, beforeStat.atime, beforeStat.mtime);
|
||
// mtime/size 一致性是「目标场景」不是「测试断言」:NTFS 写入有 ms 级截断,
|
||
// utimesSync 也不能完全消除(实测 ±1ms 漂移)。但 IPC 触发的修复效果由下一个
|
||
// check 覆盖 —— 不论 mtime 是否严格一致,文件被删 + 重建这个动作本身就该
|
||
// 触发 IPC(修复前会漏报,因为 lastSeen 没被清零)。
|
||
const afterStat = fs.statSync(tmpFile);
|
||
check('重建前后 size 一致(不依赖 mtime 精度)',
|
||
afterStat.size === beforeStat.size,
|
||
`before=${beforeStat.size} after=${afterStat.size}`);
|
||
|
||
await wait(POLL_INTERVAL_MS * 4);
|
||
// 修复后应当触发 IPC;旧实现下 IPC 数为 0(漏报)
|
||
check('同 mtime/size 重建后 polling 触发 IPC(修复漏报)',
|
||
eventCount >= 1, `eventCount=${eventCount}`);
|
||
stopPolling();
|
||
}
|
||
|
||
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
|
||
|
||
// 清理(必须放在所有测试结束之后 —— 之前的版本把 rmSync 放在 [9] 之后,
|
||
// 导致 [10] 测试用的临时目录已被清掉、fs.writeFileSync 直接 ENOENT)
|
||
cleanupTmpTodo({ dir: tmpDir });
|
||
process.exit(summary.fail === 0 ? 0 : 1);
|