293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
|
||
import { makeStore } from './_lib/store-fixture.mjs';
|
||
import { installApiStub } from './_lib/api-stub.mjs';
|
||
// 回归测试:脏标记竞态 ——「await writeFile 期间用户又改了」必须保留 dirty
|
||
//
|
||
// v4 起去掉 800ms debounce 改为全实时同步:
|
||
// 用户改 A → _markDirty → 立即 _scheduleAutoSave → _doAutoSave 启动 →
|
||
// 序列化(含 A)→ await writeFile(期间用户改 B / C / D → _markDirty 只
|
||
// 设 dirty=true + 推 _dirtyVersion + 设 _needsResave=true,因为有 in-flight)
|
||
// → writeFile 返回 → 版本检查发现快照不含最新 → 不清 dirty,
|
||
// finally 检测 _needsResave → 立即再启动一次 _doAutoSave 把累积改动写出去。
|
||
//
|
||
// 旧机制(800ms debounce)的关键回归仍是 v4 的核心不变:
|
||
// in-flight 期间的用户改动**必须**最终落盘,绝不能因为 dirty 被误清而丢。
|
||
//
|
||
// 跑法:node scripts/check-dirty-race.mjs
|
||
|
||
// 桩出 window.api,把 writeFile 换成可手动控制的 Promise,
|
||
// 这样能精确卡在「await writeFile 期间」注入下一次 _markDirty。
|
||
let writeFileResolver;
|
||
const calls = { writeFile: 0 };
|
||
installApiStub({
|
||
writeFile: (_path, _content) => {
|
||
calls.writeFile++;
|
||
return new Promise((resolve) => { writeFileResolver = resolve; });
|
||
}
|
||
});
|
||
|
||
const { TaskStore } = await import('../src/task-store.js');
|
||
const { CategoryKind } = await import('../src/markdown-parser.js');
|
||
|
||
// ============================================
|
||
// [1] _doAutoSave 路径:await writeFile 期间有修改 → 保留 dirty
|
||
// ============================================
|
||
console.log('\n[1] _doAutoSave 路径下「await 期间修改」必须保留 dirty');
|
||
|
||
{
|
||
const store = new TaskStore();
|
||
store.filePath = '/tmp/dirty-race-test.md';
|
||
// 直接 addTask 触发 _markDirty 路径
|
||
store.categories = [
|
||
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
|
||
];
|
||
// 用内部 API 触发一次"修改",让 _markDirty 把 _dirtyVersion 推到 1
|
||
store.addTask('未分类', 'A');
|
||
ok('首次 addTask 后 dirty 为 true', store.dirty === true);
|
||
ok('首次 addTask 后 _dirtyVersion = 1', store._dirtyVersion === 1);
|
||
|
||
// 把 debounce 定时器立刻 flush 掉,让 _doAutoSave 走到 await writeFile 那一行
|
||
store._autoSave.flush();
|
||
// 此时 writeFile 已发起,calls.writeFile 应为 1;_doAutoSave 还在等 resolver
|
||
ok('flush 后 writeFile 被调用一次', calls.writeFile === 1);
|
||
|
||
// 「用户在 await 期间又改东西」:再次 addTask —— 只走 _markDirty,不推 _writeGeneration
|
||
store.addTask('未分类', 'B');
|
||
ok('await 期间 addTask 后 dirty 仍为 true', store.dirty === true);
|
||
ok('await 期间 addTask 后 _dirtyVersion = 2', store._dirtyVersion === 2);
|
||
// 关键:_writeGeneration 仍是 1(没有新 save 启动)—— 这是 bug 触发条件
|
||
ok('_writeGeneration 仍为 1(没有新 save 接力)', store._writeGeneration === 1);
|
||
|
||
// 释放 writeFile —— 让第一次 _doAutoSave 醒来
|
||
writeFileResolver({ ok: true });
|
||
// 给 microtask 几次 tick 让 await 后的代码跑完
|
||
await new Promise(r => setTimeout(r, 20));
|
||
|
||
// 关键断言:dirty 必须仍为 true —— 否则 B 永远写不出去
|
||
if (store.dirty === true) {
|
||
ok('await 回来后 dirty 仍为 true(修复有效)', true);
|
||
} else {
|
||
bad('await 回来后 dirty 仍为 true(修复有效)', `dirty=${store.dirty}, _dirtyVersion=${store._dirtyVersion}`);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// [2] saveNow 路径:await writeFile 期间有修改 → 保留 dirty
|
||
// ============================================
|
||
console.log('\n[2] saveNow 路径下「await 期间修改」必须保留 dirty');
|
||
|
||
{
|
||
// 重置桩
|
||
calls.writeFile = 0;
|
||
let resolver2;
|
||
globalThis.window.api.writeFile = () => {
|
||
calls.writeFile++;
|
||
return new Promise((resolve) => { resolver2 = resolve; });
|
||
};
|
||
|
||
const store = new TaskStore();
|
||
store.filePath = '/tmp/dirty-race-saveNow.md';
|
||
store.categories = [
|
||
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
|
||
];
|
||
|
||
// 启动 saveNow(不 await —— 让它走到 await writeFile 后挂起)
|
||
const savePromise = store.saveNow();
|
||
ok('saveNow 启动后 writeFile 被调用', calls.writeFile === 1);
|
||
|
||
// await 期间模拟用户改动
|
||
store.addTask('未分类', 'X');
|
||
ok('await 期间 addTask 后 dirty 为 true', store.dirty === true);
|
||
ok('await 期间 _dirtyVersion 已 +1', store._dirtyVersion === 1);
|
||
|
||
// 释放 + 等 saveNow 收尾
|
||
resolver2({ ok: true });
|
||
await savePromise;
|
||
await new Promise(r => setTimeout(r, 20));
|
||
|
||
if (store.dirty === true) {
|
||
ok('saveNow await 回来后 dirty 仍为 true(修复有效)', true);
|
||
} else {
|
||
bad('saveNow await 回来后 dirty 仍为 true(修复有效)', `dirty=${store.dirty}`);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// [3] 正常路径:await 期间无修改 → 仍正确清 dirty(防止误伤)
|
||
// ============================================
|
||
console.log('\n[3] 正常路径(无并发修改)必须正确清 dirty —— 别把修复写成永不清');
|
||
|
||
{
|
||
calls.writeFile = 0;
|
||
let resolver3;
|
||
globalThis.window.api.writeFile = () => {
|
||
calls.writeFile++;
|
||
return new Promise((resolve) => { resolver3 = resolve; });
|
||
};
|
||
|
||
const store = new TaskStore();
|
||
store.filePath = '/tmp/dirty-race-clean.md';
|
||
store.categories = [
|
||
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
|
||
];
|
||
|
||
let savedEmitted = false;
|
||
store.on('saved', () => { savedEmitted = true; });
|
||
|
||
store.addTask('未分类', 'Y');
|
||
store._autoSave.flush();
|
||
ok('flush 后 writeFile 被调用', calls.writeFile === 1);
|
||
|
||
// 不再做任何修改,直接释放
|
||
resolver3({ ok: true });
|
||
await new Promise(r => setTimeout(r, 20));
|
||
|
||
if (store.dirty === false) {
|
||
ok('正常路径下 dirty 被正确清掉', true);
|
||
} else {
|
||
bad('正常路径下 dirty 被正确清掉', `dirty=${store.dirty}`);
|
||
}
|
||
if (savedEmitted) {
|
||
ok('正常路径下 emit 了 saved 事件', true);
|
||
} else {
|
||
bad('正常路径下 emit 了 saved 事件');
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// [4] cancelPendingSave 仍要让 in-flight 过期(回归测试现有行为)
|
||
// ============================================
|
||
console.log('\n[4] cancelPendingSave 让 in-flight 写过期,dirty 保留');
|
||
|
||
{
|
||
calls.writeFile = 0;
|
||
let resolver4;
|
||
globalThis.window.api.writeFile = () => {
|
||
calls.writeFile++;
|
||
return new Promise((resolve) => { resolver4 = resolve; });
|
||
};
|
||
|
||
const store = new TaskStore();
|
||
store.filePath = '/tmp/dirty-race-cancel.md';
|
||
store.categories = [
|
||
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
|
||
];
|
||
|
||
store.addTask('未分类', 'Z');
|
||
store._autoSave.flush();
|
||
ok('flush 后 writeFile 被调用', calls.writeFile === 1);
|
||
|
||
// 模拟「文件被外部改了」 → 取消 pending
|
||
store.cancelPendingSave();
|
||
// _writeGeneration 在 cancelPendingSave 里被 +1,所以已不再是 1
|
||
ok('cancel 后 _writeGeneration 已被推过(in-flight 失效)', store._writeGeneration > 1);
|
||
|
||
// 释放 writeFile —— 已取消,dirty 必须保留
|
||
resolver4({ ok: true });
|
||
await new Promise(r => setTimeout(r, 20));
|
||
|
||
if (store.dirty === true) {
|
||
ok('cancel 后 dirty 被保留', true);
|
||
} else {
|
||
bad('cancel 后 dirty 被保留', `dirty=${store.dirty}`);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// [5] v4 全实时同步:单次 addTask 立即触发 writeFile(无 800ms 等待)
|
||
// ============================================
|
||
console.log('\n[5] 单次 addTask 立即触发 writeFile(无 debounce 延迟)');
|
||
|
||
{
|
||
calls.writeFile = 0;
|
||
let resolver5;
|
||
globalThis.window.api.writeFile = (_path, _content) => {
|
||
calls.writeFile++;
|
||
return new Promise((resolve) => { resolver5 = resolve; });
|
||
};
|
||
|
||
const store = new TaskStore();
|
||
store.filePath = '/tmp/real-time-sync.md';
|
||
store.categories = [
|
||
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
|
||
];
|
||
|
||
store.addTask('未分类', '即时');
|
||
// 关键:addTask 一返回 writeFile 就已经发起 —— 不再等 800ms
|
||
ok('addTask 后立即触发 writeFile(无 debounce 延迟)', calls.writeFile === 1,
|
||
`calls.writeFile=${calls.writeFile}`);
|
||
|
||
// 收尾:释放 resolver 让 save 完成
|
||
resolver5({ ok: true });
|
||
await new Promise(r => setTimeout(r, 20));
|
||
ok('保存完成后 dirty 被清掉', store.dirty === false, `dirty=${store.dirty}`);
|
||
}
|
||
|
||
// ============================================
|
||
// [6] v4 尾随写:in-flight 期间多次 addTask,finally 自动补一次写覆盖最新状态
|
||
// ============================================
|
||
console.log('\n[6] in-flight 期间累积改动 → finally 自动补一次写(覆盖最新状态)');
|
||
|
||
{
|
||
calls.writeFile = 0;
|
||
// 用队列收集 resolver,避免后续尾随写无法 release 导致悬挂
|
||
const resolvers = [];
|
||
globalThis.window.api.writeFile = (_path, content) => {
|
||
calls.writeFile++;
|
||
const capturedContent = content;
|
||
return new Promise((resolve) => {
|
||
resolvers.push({ resolve, capturedContent });
|
||
});
|
||
};
|
||
|
||
const store = new TaskStore();
|
||
store.filePath = '/tmp/trailing-save.md';
|
||
store.categories = [
|
||
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
|
||
];
|
||
|
||
// 第 1 次:直接 addTask 触发第一次 writeFile
|
||
store.addTask('未分类', 'A');
|
||
ok('addTask A 后 writeFile 被立即调用', calls.writeFile === 1);
|
||
|
||
// in-flight 期间:连续改 3 次 —— 应走 _needsResave 路径,**不**起新 writeFile
|
||
store.addTask('未分类', 'B');
|
||
store.addTask('未分类', 'C');
|
||
store.addTask('未分类', 'D');
|
||
ok('in-flight 期间 3 次 addTask 后 writeFile 仍 = 1(被合并)',
|
||
calls.writeFile === 1, `calls.writeFile=${calls.writeFile}`);
|
||
ok('in-flight 期间 _writeGeneration 仍 = 1(没新 save 启动)',
|
||
store._writeGeneration === 1);
|
||
ok('in-flight 期间 _dirtyVersion = 4(addTask 4 次)',
|
||
store._dirtyVersion === 4);
|
||
|
||
// 释放第一次写:finally 应触发尾随写
|
||
resolvers[0].resolve({ ok: true });
|
||
await new Promise(r => setTimeout(r, 20));
|
||
ok('第一次释放后 writeFile 被尾随启动一次(共 2 次)',
|
||
calls.writeFile === 2, `calls.writeFile=${calls.writeFile}`);
|
||
ok('尾随写启动后 _writeGeneration = 2',
|
||
store._writeGeneration === 2);
|
||
ok('第一次释放后 dirty 仍 = true(尾随写尚未完成)',
|
||
store.dirty === true);
|
||
|
||
// 释放尾随写:这次应包含 ABCD 全部内容
|
||
const trailingContent = resolvers[1].capturedContent;
|
||
ok('尾随写的内容含 A/B/C/D 全部 4 条任务',
|
||
trailingContent.includes('A') &&
|
||
trailingContent.includes('B') &&
|
||
trailingContent.includes('C') &&
|
||
trailingContent.includes('D'),
|
||
`内容预览: ${trailingContent.slice(0, 200)}`);
|
||
|
||
resolvers[1].resolve({ ok: true });
|
||
await new Promise(r => setTimeout(r, 20));
|
||
ok('尾随写完成后 dirty 被清掉', store.dirty === false,
|
||
`dirty=${store.dirty}`);
|
||
ok('无更多 _needsResave → writeFile 共 2 次(不多次重复)',
|
||
calls.writeFile === 2, `calls.writeFile=${calls.writeFile}`);
|
||
}
|
||
|
||
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
|
||
process.exit(summary.fail === 0 ? 0 : 1);
|