This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
// 测试 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 抛错后只 returnlastSeen 保留旧值,下一轮 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) 重建内容完全相同的文件,强制沿用同一 mtimeutimes 显式设置)
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);