Files
Notes/tests/unit/config-store-quit-gate.test.js
2026-10-16 16:34:15 +08:00

121 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// main/config-store.js —— saveConfig isQuitting 闸(audit P1-2)
//
// 背景:renderer 错误风暴(如 settingsStore.update 触发循环)会建无界
// Promise 链;进程退出时 IPC handler `await configStore.saveConfig(...)`
// 被卡在最末一个未完成 task,before-quit 等不到 save 全部完成 → quit 阻塞。
//
// 修复:加 isQuitting 闸。before-quit 触发时把闸置 true,新 saveConfig
// 立即返回 { ok: false, error: 'APP_QUITTING' }(同步值,不是 Promise,
// 避免 IPC await 永不 resolve)。in-flight 任务照常完成(已 fsync 的
// 文件 rename 完才返回 ok:true,强制 abort 会丢正在写的 tmp)—— 「
// 进程退出」语意下「最新一个 save 完成」即可接受。
//
// 测试策略:直接 import config-store 模块,用 _setIsQuitting 控制闸。
// saveConfig 内部写 tmp + rename 到真文件 → 测试用真 fs + tmpdir 隔离。
// 注意:每次测试前 _reset 清状态 + _setIsQuitting(false),避免 case 串扰。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as configStore from '../../main/config-store.js';
let mockHomeDir;
let mockUserDataDir;
let home;
function makeIsolatedHome() {
const dir = mkdtempSync(join(tmpdir(), 'notes-config-quit-'));
// 写入一个最小可用的 config.json(init 需要它存在)
writeFileSync(join(dir, 'config.json'), JSON.stringify({ alwaysOnTop: false }, null, 2), 'utf-8');
return dir;
}
describe('config-store saveConfig isQuitting 闸(audit P1-2)', () => {
beforeEach(async () => {
mockHomeDir = mkdtempSync(join(tmpdir(), 'notes-cq-home-'));
mockUserDataDir = mkdtempSync(join(tmpdir(), 'notes-cq-ud-'));
configStore._setApp({
getPath: (name) => {
if (name === 'home') return mockHomeDir;
if (name === 'userData') return mockUserDataDir;
throw new Error(`unexpected getPath(${name})`);
},
});
configStore._reset();
configStore._setIsQuitting(false);
home = makeIsolatedHome();
await configStore.init({ homeDir: home });
});
it('默认状态:isQuitting = false', () => {
expect(configStore._isQuitting()).toBe(false);
});
it('_setIsQuitting(true) 后 isQuitting 反映为 true', () => {
configStore._setIsQuitting(true);
expect(configStore._isQuitting()).toBe(true);
});
it('isQuitting=true 时 saveConfig 同步返 { ok:false, error:APP_QUITTING },不写盘', async () => {
configStore._setIsQuitting(true);
const before = readFileSync(join(home, 'config.json'), 'utf-8');
const result = await configStore.saveConfig({ alwaysOnTop: true });
expect(result).toEqual({ ok: false, error: 'APP_QUITTING' });
// 文件未被改写(before vs after 内容一致)
const after = readFileSync(join(home, 'config.json'), 'utf-8');
expect(after).toBe(before);
});
it('isQuitting=true 时 saveConfig 返的 Promise 已 resolve(不等链尾)', async () => {
// 关键:闸开时返回的必须是「已 resolve 的值」,否则 IPC handler
// `await saveConfig(...)` 永远挂在链尾 → before-quit 阻塞。
configStore._setIsQuitting(true);
// 10 次并发调用 → 全部应立即 resolve
const results = await Promise.all(
Array.from({ length: 10 }, () => configStore.saveConfig({ alwaysOnTop: true }))
);
expect(results).toHaveLength(10);
for (const r of results) {
expect(r).toEqual({ ok: false, error: 'APP_QUITTING' });
}
});
it('isQuitting=true 时 in-flight 之前的 save 仍正常完成(不强制 abort)', async () => {
// 第一个 save 在闸开启前排进 queue,应正常完成
const p1 = configStore.saveConfig({ alwaysOnTop: true });
configStore._setIsQuitting(true);
const r1 = await p1;
expect(r1.ok).toBe(true);
expect(r1.value.alwaysOnTop).toBe(true);
// 闸开后后续 save 被拒
const r2 = await configStore.saveConfig({ alwaysOnTop: false });
expect(r2).toEqual({ ok: false, error: 'APP_QUITTING' });
});
it('isQuitting=true → _setIsQuitting(false) 后 saveConfig 恢复工作', async () => {
configStore._setIsQuitting(true);
const r1 = await configStore.saveConfig({ alwaysOnTop: true });
expect(r1).toEqual({ ok: false, error: 'APP_QUITTING' });
configStore._setIsQuitting(false);
const r2 = await configStore.saveConfig({ alwaysOnTop: true });
expect(r2.ok).toBe(true);
});
afterEach(() => {
if (home && existsSync(home)) {
rmSync(home, { recursive: true, force: true });
}
if (mockHomeDir) rmSync(mockHomeDir, { recursive: true, force: true });
if (mockUserDataDir) rmSync(mockUserDataDir, { recursive: true, force: true });
});
});