This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
// tests/unit/main/watcher-restart-budget.test.ts
//
// 守 r197「watcher 连续失败上限」的真<E79A84><E79C9F><EFBFBD>语义。
//
// 旧实现:`watcherRestartAttempts` 只在 `start()` / `stop()` 里清零 ——
// 于是「连续失败上限」实际是**终身**预算:
// - 用户在一个长会话里遭遇 6 次**孤立**的 watcher 错误(网络盘抖一下 /
// OneDrive 同步冲突 / Defender 扫一下 / 目录被临时改名),每次都成功
// 重启、每次都正常工作 —— 计数却一路累加;
// - 第 6 次错误时计数越过上限 ⇒ 永久放弃 watcher(只在日志里,UI 无提示)
// ⇒ 之后**所有**外部改动都不再被感知 —— 用户看到的是「我在资源管理器
// 里改了文件,应用里的列表一直不动」。
//
// 上限本身要留着(永久坏路径下 2s 一轮的 log 风暴是真实问题),所以修法不是
// 删上限,而是让「连续」名副其实:重启成功并**健康存活一段时间**后,计数
// 清零;而「起来就又报错」不算健康(清零 ⇒ 无限重试 ⇒ 上限形同虚设)。
//
// 本测是行为级守卫:mock chokidar(不依赖真实文件系统事件),用 fake timers
// 精确推进 2s 退避与 30s 健康期。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
vi.mock('electron', () => ({
shell: { trashItem: async () => undefined },
}));
interface FakeWatcher {
on(ev: string, fn: (p?: unknown) => void): FakeWatcher;
removeAllListeners(): void;
close(): Promise<void>;
__emit(ev: string, p?: unknown): void;
}
const chok = vi.hoisted(() => ({ watchers: [] as FakeWatcher[] }));
vi.mock('chokidar', () => {
const make = (): FakeWatcher => {
const handlers = new Map<string, Array<(p?: unknown) => void>>();
const w: FakeWatcher = {
on(ev, fn) {
const arr = handlers.get(ev) ?? [];
arr.push(fn);
handlers.set(ev, arr);
return w;
},
removeAllListeners() {
handlers.clear();
},
close: async () => undefined,
__emit(ev, p) {
for (const fn of [...(handlers.get(ev) ?? [])]) fn(p);
},
};
chok.watchers.push(w);
return w;
};
return { default: { watch: make }, watch: make };
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
const RESTART_DELAY_MS = 2000;
const HEALTHY_MS = 30_000;
/** 让当前 watcher 报一次错,并推进 2s 让重启发生。
* healthyGapMs > 0 时再推进健康期(让计数有机会清零)。 */
async function failOnce(healthyGapMs = 0): Promise<void> {
const w = chok.watchers[chok.watchers.length - 1];
w.__emit('error', new Error('watcher boom'));
await vi.advanceTimersByTimeAsync(RESTART_DELAY_MS);
if (healthyGapMs > 0) {
await vi.advanceTimersByTimeAsync(healthyGapMs);
}
}
describe('watcher 连续失败上限(r197)', () => {
let root: string;
beforeEach(async () => {
chok.watchers.length = 0;
vi.useFakeTimers();
root = await fs.mkdtemp(join(tmpdir(), 'rkms-watchbudget-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
});
afterEach(async () => {
vi.useRealTimers();
await fs.rm(root, { recursive: true, force: true });
});
it('notes:连续 6 次错误(每次都活不过健康期)→ 第 6 次放弃 watcher', async () => {
const svc = new NotesService();
let gaveUp = 0;
svc.on('watcher-failed', () => {
gaveUp += 1;
});
await svc.start({ skipInitialLoad: true });
for (let i = 0; i < 5; i++) await failOnce(0);
expect(gaveUp).toBe(0); // 前 5 次还在重试预算内
await failOnce(0);
expect(gaveUp).toBe(1); // 第 6 次越限 → 放弃
await svc.stop();
});
it('notes:6 次孤立错误(每次都健康存活 30s)→ 不放弃 watcher', async () => {
const svc = new NotesService();
let gaveUp = 0;
svc.on('watcher-failed', () => {
gaveUp += 1;
});
await svc.start({ skipInitialLoad: true });
// 每次错误都隔着「重启 + 30s 健康期」—— 计数器应当每次都归零
for (let i = 0; i < 6; i++) await failOnce(HEALTHY_MS);
expect(gaveUp).toBe(0);
await svc.stop();
});
it('literature:6 次孤立错误(每次都健康存活 30s)→ 不放弃 watcher', async () => {
const svc = new LiteratureService();
let gaveUp = 0;
svc.on('watcher-failed', () => {
gaveUp += 1;
});
await svc.start({ skipInitialLoad: true });
for (let i = 0; i < 6; i++) await failOnce(HEALTHY_MS);
expect(gaveUp).toBe(0);
await svc.stop();
});
it('literature:连续 6 次错误 → 第 6 次放弃(上限语义不变)', async () => {
const svc = new LiteratureService();
let gaveUp = 0;
svc.on('watcher-failed', () => {
gaveUp += 1;
});
await svc.start({ skipInitialLoad: true });
for (let i = 0; i < 5; i++) await failOnce(0);
expect(gaveUp).toBe(0);
await failOnce(0);
expect(gaveUp).toBe(1);
await svc.stop();
});
});