Files
Notes/tests/unit/config-store.test.js
2026-09-12 14:15:26 +08:00

735 lines
32 KiB
JavaScript
Raw Permalink 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.
// Stage 7 tests: main/config-store.js
//
// 覆盖:
// - init() 触发 loadConfig + 设定 DEFAULT_DATA_DIR
// - saveConfig 合并写入 + 返回新 config
// - resolveDataDir用户自定义 > 默认
// - seedDefaultDataDirsentinel 已存在 / 已有 .md / 自定义目录 → 跳过
// - getConfig / getDefaultDataDir / loadConfig 在 init 后行为
//
// 配置模块依赖 `electron.app` —— 通过 _setApp() 注入 fake appvi.mock('electron')
// 拦不住 CJS 的 require 调用,所以走 config-store 自己暴露的 setter
// 文件系统通过临时目录 + 真实的 fs.promisesmock 太多会失去意义)。
//
// 注意:模块 init 会被显式调用,不能假设 module 级副作用。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, statSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// 真实生产环境走 require('electron'),单测里 vi.mock('electron') 拦不住 CJS 的 require。
// config-store 暴露了 _setApp(mockApp) 让我们注入假 app。
const configStore = await import('../../main/config-store.js');
const { DEFAULT_SETTINGS: DEFAULT_CONFIG, coerceLoadedSettings } = await import('../../shared/settings-schema.js');
let mockHomeDir;
let mockUserDataDir;
beforeEach(() => {
mockHomeDir = mkdtempSync(join(tmpdir(), 'notes-test-home-'));
mockUserDataDir = mkdtempSync(join(tmpdir(), 'notes-test-userdata-'));
// 注入假 app
configStore._setApp({
getPath: (name) => {
if (name === 'home') return mockHomeDir;
if (name === 'userData') return mockUserDataDir;
throw new Error(`unexpected getPath(${name})`);
},
});
// 重置模块状态appConfig / DEFAULT_DATA_DIR / configLoaded
configStore._reset();
});
afterEach(() => {
if (mockHomeDir) rmSync(mockHomeDir, { recursive: true, force: true });
if (mockUserDataDir) rmSync(mockUserDataDir, { recursive: true, force: true });
});
describe('init', () => {
it('返回 {appConfig, defaultDataDir}', () => {
const result = configStore.init();
expect(result.appConfig).toBeDefined();
expect(result.defaultDataDir).toBe(join(mockHomeDir, 'Notes'));
});
it('loadConfig 后 getConfig 返回当前配置', () => {
configStore.init();
expect(configStore.getConfig()).toEqual({ ...DEFAULT_CONFIG });
});
it('userData/config.json 不存在时用默认值', () => {
configStore.init();
expect(configStore.getConfig()).toEqual({ ...DEFAULT_CONFIG });
});
it('userData/config.json 存在时读取并 sanitize', () => {
writeFileSync(
join(mockUserDataDir, 'config.json'),
JSON.stringify({ theme: 'light', dataDir: 'D:\\notes' }),
'utf-8'
);
configStore.init();
const cfg = configStore.getConfig();
expect(cfg.theme).toBe('light');
expect(cfg.dataDir).toBe('D:\\notes');
});
it('JSON 损坏 → fallback 到默认', () => {
writeFileSync(join(mockUserDataDir, 'config.json'), '{not json', 'utf-8');
configStore.init();
expect(configStore.getConfig()).toEqual({ ...DEFAULT_CONFIG });
});
});
describe('saveConfig', () => {
it('合并写入并返回新配置', async () => {
configStore.init();
const result = await configStore.saveConfig({ theme: 'light' });
// audit fix (C2)saveConfig 现在返回 {ok, value|error}
expect(result.ok).toBe(true);
expect(result.value.theme).toBe('light');
});
it('不传 → 等价于 no-op', async () => {
configStore.init();
const before = configStore.getConfig();
const result = await configStore.saveConfig({});
expect(result.ok).toBe(true);
expect(result.value).toEqual(before);
});
it('多次 saveConfig 累加字段', async () => {
configStore.init();
await configStore.saveConfig({ theme: 'light' });
await configStore.saveConfig({ themePalette: 'ocean' });
const cfg = configStore.getConfig();
expect(cfg.theme).toBe('light');
expect(cfg.themePalette).toBe('ocean');
});
it('写入磁盘后再 init → 拿到上次的值', async () => {
configStore.init();
await configStore.saveConfig({ theme: 'light', themePalette: 'forest' });
// 重新加载模块(在另一个进程里没法做,但 init 重读即可)
configStore.init();
expect(configStore.getConfig().theme).toBe('light');
expect(configStore.getConfig().themePalette).toBe('forest');
});
it('audit fix (C2)saveConfig 返回 Promise<{ok,value}> 而非合并后的对象', async () => {
// 旧 API 是直接返回 appConfigrenderer 端 settings-store 误把它当作
// 已经持久化的对象来用。改异步后契约改成 {ok:true, value:...},让失败路径
// 能区分。回归这条断言防止以后又退回到同步返回 appConfig。
configStore.init();
const result = await configStore.saveConfig({ theme: 'light' });
expect(result).toMatchObject({ ok: true });
expect(result).toHaveProperty('value');
expect(result.value).toEqual(expect.objectContaining({ theme: 'light' }));
});
// audit fix (Round 4 P0-1)saveConfig 必须 fsync tmp 文件再 rename否则断电
// 后磁盘上可能是新 inode + 空内容rename 是原子的,但写盘数据还在 page cache
// 没 fsync 就断电 → 内核丢弃 page cache → 文件确实是 0 字节)。
// 之前实现直接走 fs.writeFile + rename没任何 fsync —— AI key / 自定义 dataDir
// 这类关键配置可能因为断电被永久清空。本测试用 spy 钉死「先 sync 再 close 再 rename」
// 的时序,防止未来重构改回 fs.writeFile 而无人察觉。
it('audit fix (P0-1)saveConfig 在 rename 前调用 fh.sync()', async () => {
configStore.init();
const fsPromises = await import('fs').then((m) => m.promises);
const realOpen = fsPromises.open;
/** @type {Array<{method: string, whenMs: number}>} */
const calls = [];
let syncCalledAt = -1;
let closeCalledAt = -1;
/** @type {string[]} */
const openedFiles = [];
fsPromises.open = async function spyOpen(p, flags) {
openedFiles.push(String(p));
const fh = await realOpen.call(fsPromises, p, flags);
// 只 spy tmp 文件(路径含 .tmp.),不动 userData 目录本身
if (String(p).includes('.tmp.')) {
const realSync = fh.sync.bind(fh);
const realClose = fh.close.bind(fh);
fh.sync = async function spySync() {
syncCalledAt = Date.now();
calls.push({ method: 'sync' });
return realSync();
};
fh.close = async function spyClose() {
closeCalledAt = Date.now();
calls.push({ method: 'close' });
return realClose();
};
}
return fh;
};
try {
await configStore.saveConfig({ theme: 'light' });
} finally {
fsPromises.open = realOpen;
}
// 关键断言sync 必须在 close 之前被调用(即「先落盘再 close」的原子模式
expect(syncCalledAt).toBeGreaterThan(0);
expect(closeCalledAt).toBeGreaterThan(0);
expect(syncCalledAt).toBeLessThanOrEqual(closeCalledAt);
// 调用顺序必须是 [sync, close](不能 close 在 sync 之前)
const syncIdx = calls.findIndex((c) => c.method === 'sync');
const closeIdx = calls.findIndex((c) => c.method === 'close');
expect(syncIdx).toBeGreaterThanOrEqual(0);
expect(closeIdx).toBeGreaterThan(syncIdx);
// tmp 路径应被打开过(确保本测试在真路径上跑)
expect(openedFiles.some((p) => p.includes('.tmp.'))).toBe(true);
});
// audit fix (Round 12 P1)saveConfig 在 rename 成功后必须对父目录 fsync
// POSIX only与 main/file-ops.js#atomicWriteFile 的 Round 4 修复对称。
// Windows NTFS journal 元数据自带 fsync 语义跳过POSIX rename(2) 同分区下
// 原子,但「目录项本身」写入磁盘的时机由内核控制 —— rename 完不 fsync 父目录
// 就断电,下次启动目录里可能仍是旧名字 + 新 inode 已分配但未刷盘。
// 这里 spy fsPromises.open 抓「tmp 之后」的 directory open + sync + close
// 钉死时序:先 open parent → sync → close → 之后才走后续逻辑。
it('audit fix (Round 12 P1)saveConfig rename 后对父目录 fsyncPOSIX', async () => {
// 只在 POSIX 平台跑Windows NTFS 不需要这步journal 元数据自带 fsync
if (process.platform === 'win32') {
// Windows 跳过但仍验证代码没崩rename 路径本身要走完)
configStore.init();
await configStore.saveConfig({ theme: 'dark' });
return;
}
configStore.init();
const fsPromises = await import('fs').then((m) => m.promises);
const realOpen = fsPromises.open;
/** @type {Array<{method: string, path: string}>} */
const parentCalls = [];
let parentSyncAt = -1;
let parentCloseAt = -1;
let tmpSyncAt = -1;
let tmpRenameDoneAt = -1;
/** @type {string[]} */
const openedPaths = [];
fsPromises.open = async function spyOpen(p, flags) {
openedPaths.push(String(p));
const fh = await realOpen.call(fsPromises, p, flags);
const pathStr = String(p);
// 父目录 fsync不是 .tmp. 路径,且不是 userData/config.json 本身,
// 就是 userData 父目录saveConfig fs.open(parent, 'r')
if (!pathStr.includes('.tmp.') && !pathStr.endsWith('config.json')) {
const realSync = fh.sync.bind(fh);
const realClose = fh.close.bind(fh);
fh.sync = async function spySync() {
parentSyncAt = Date.now();
parentCalls.push({ method: 'sync', path: pathStr });
return realSync();
};
fh.close = async function spyClose() {
parentCloseAt = Date.now();
parentCalls.push({ method: 'close', path: pathStr });
return realClose();
};
} else if (pathStr.includes('.tmp.')) {
const realSync = fh.sync.bind(fh);
fh.sync = async function spySync() {
tmpSyncAt = Date.now();
return realSync();
};
}
return fh;
};
try {
await configStore.saveConfig({ theme: 'dark' });
// rename 是同步的 renameSync通过 fs.promises.rename 调用spy 不抓
// 我们假设它发生在 sync 之后(生产代码时序就是 rename → fsync parent
// 简化:把 tmpSyncAt+1 当作 rename 完成时间rename 紧跟 tmp sync 后)。
tmpRenameDoneAt = tmpSyncAt + 1;
} finally {
fsPromises.open = realOpen;
}
// 关键parent fsync 必须发生在 tmp sync + rename 之后
expect(openedPaths.some((p) => !p.includes('.tmp.') && !p.endsWith('config.json'))).toBe(true);
expect(parentSyncAt).toBeGreaterThan(0);
expect(parentCloseAt).toBeGreaterThan(0);
// 时序parent.sync 在 parent.close 之前
const syncIdx = parentCalls.findIndex((c) => c.method === 'sync');
const closeIdx = parentCalls.findIndex((c) => c.method === 'close');
expect(syncIdx).toBeGreaterThanOrEqual(0);
expect(closeIdx).toBeGreaterThan(syncIdx);
// 时序parent.sync 必须在 rename 之后(防止以后有人手贱把 fsync 挪到 rename 前)
expect(parentSyncAt).toBeGreaterThanOrEqual(tmpRenameDoneAt);
});
// audit fix (Round 12 P2)saveConfig 失败时 result.error 走 friendly-fs-error
// 中文文案,不再泄漏原始 e.message英文 errno + 路径)。
it('audit fix (Round 12 P2)saveConfig 失败 error 走 friendly-fs-error 而非原始 e.message', async () => {
configStore.init();
const fsPromises = await import('fs').then((m) => m.promises);
const realOpen = fsPromises.open;
fsPromises.open = async function boom() {
const err = new Error('EACCES: permission denied, open \'/secret/path\'');
err.code = 'EACCES';
throw err;
};
try {
const result = await configStore.saveConfig({ theme: 'light' });
expect(result.ok).toBe(false);
// 不能再含英文 errno 原文
expect(result.error).not.toMatch(/^EACCES:/);
// 必须是友好中文friendly-fs-error 对 EACCES 给的固定文案)
expect(typeof result.error).toBe('string');
expect(result.error.length).toBeGreaterThan(0);
} finally {
fsPromises.open = realOpen;
}
});
// audit fix (Round 12 P2)_reset 必须清 saveQueue否则上一个 case 的
// fire-and-forget save 会污染下一个 case 的 saveConfig 串行化链。
// 验证方式init → 拿第一个 promise (p1) → _reset清队列→ 重新 init
// → 拿第二个 promise (p2)。若 _reset 没清队列p2 会链到 p1.then(...) 的
// 尾部,但 p1 的 task 在 _reset 后 appConfig 已被清空的状态下仍会跑with
// 空 before / 异常合并)—— 副作用:模拟器内的 fs 在 _reset 后 mockHomeDir
// 被释放p1 实际 reject资源不可用。新 case 的 p2 必须独立 ok=true
// 不被 p1 的失败拖累。
it('audit fix (Round 12 P2)_reset 后 saveConfig 独立成功saveQueue 与旧 case 断链)', async () => {
configStore.init();
// 旧 casefire-and-forget saveConfigp1 任务在 saveQueue 尾排队
const p1 = configStore.saveConfig({ theme: 'first' });
// 等一拍微任务,让 p1 进入 task 阶段open tmp...
await new Promise((r) => setImmediate(r));
// _reset 切断 saveQueue —— 关键修复点
configStore._reset();
// 重新注入 app + init新 case
configStore._setApp({
getPath: (name) => {
if (name === 'home') return mockHomeDir;
if (name === 'userData') return mockUserDataDir;
throw new Error(`unexpected getPath(${name})`);
},
});
configStore.init();
// p2独立 saveConfig必须独立成功。如果 _reset 没清 saveQueue
// p2 会链到 p1.then(...),而 p1 此刻正在用被 _reset 清空的 appConfig 跑
// taskmerge 时 `before = {}`(被清)、`merged = {theme:'second'}` 表面
// 正常但 fs 操作可能因时序问题让 p2 也 fail / hang。
const p2 = configStore.saveConfig({ theme: 'second' });
const result = await p2;
expect(result.ok).toBe(true);
expect(result.value.theme).toBe('second');
// 让旧 case 的 p1 跑完(避免污染下一个 test
await Promise.race([p1.catch(() => null), new Promise((r) => setTimeout(r, 100))]);
});
});
describe('getDefaultDataDir', () => {
it('返回 <home>/Notes', () => {
configStore.init();
expect(configStore.getDefaultDataDir()).toBe(join(mockHomeDir, 'Notes'));
});
});
describe('resolveDataDir', () => {
it('未设置 dataDir → 返回默认', () => {
configStore.init();
expect(configStore.resolveDataDir()).toBe(join(mockHomeDir, 'Notes'));
});
it('设置了 dataDir → 优先用自定义', async () => {
configStore.init();
await configStore.saveConfig({ dataDir: 'C:\\custom' });
expect(configStore.resolveDataDir()).toBe('C:\\custom');
});
it('dataDir 是空白字符串 → 走默认', async () => {
configStore.init();
await configStore.saveConfig({ dataDir: ' ' });
expect(configStore.resolveDataDir()).toBe(join(mockHomeDir, 'Notes'));
});
it('dataDir 是空字符串 → 走默认', async () => {
configStore.init();
await configStore.saveConfig({ dataDir: '' });
expect(configStore.resolveDataDir()).toBe(join(mockHomeDir, 'Notes'));
});
});
describe('resolveDataDirOrFallbackauto-fallback 2026-08', () => {
// 这些测试只关心函数返回值,不在乎 schema 默认值。
// 直接走 fsSync生产代码用的也是 fsSync.statSync用临时目录 + rmSync 制造
// 存在/不存在的目标;不 mock fs —— 真正 stat 一次成本可忽略(毫秒级 tmp 目录)。
//
// 关于 spy试过 vi.spyOn + 直接赋值 + defineProperty都因为 fs.statSync 在
// Node ESM 模块导出上 {configurable:false} 而抛 "Cannot redefine property"。
// 改用「文件系统观察」验证缓存:先 populate 缓存(首次调用),然后 mutate 磁盘
// (删除目录 / 重建目录),再调用 → 如果返回值跟着磁盘变说明没缓存、跟着首次调用
// 时的状态说明命中缓存。这样测「不重复 stat」的语义更可靠行为契约而非 spy 计数)。
it('未设置 dataDir → 返回默认 + fellBack:false', () => {
configStore.init();
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(false);
expect(out.saved).toBe('');
});
it('dataDir 指向存在的目录 → 返回 custom + fellBack:false', async () => {
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-exist-'));
await configStore.saveConfig({ dataDir: realDir });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(realDir);
expect(out.fellBack).toBe(false);
expect(out.saved).toBe(realDir);
rmSync(realDir, { recursive: true, force: true });
});
it('dataDir 指向不存在的路径 → 回退到默认 + fellBack:true + saved 原值', async () => {
configStore.init();
const missing = join(tmpdir(), 'notes-fb-missing-' + Date.now() + '-' + Math.random());
await configStore.saveConfig({ dataDir: missing });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(true);
expect(out.saved).toBe(missing);
});
it('dataDir 指向文件而非目录ENOTDIR→ 回退到默认', async () => {
// ENOTDIR 不像 ENOENT 那样常见,但属于「路径存在但不可用」一类,仍应 fallback。
configStore.init();
const tmpFile = join(mockUserDataDir, 'not-a-dir-' + Date.now() + '.txt');
writeFileSync(tmpFile, 'x', 'utf-8');
await configStore.saveConfig({ dataDir: tmpFile });
const out = configStore.resolveDataDirOrFallback();
expect(out.fellBack).toBe(true);
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.saved).toBe(tmpFile);
});
it('缓存:首次 stat 后再删目录 → 仍返回原路径(命中缓存)', async () => {
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-cache-'));
await configStore.saveConfig({ dataDir: realDir });
// 首次调用populate 缓存exists=true
const out1 = configStore.resolveDataDirOrFallback();
expect(out1.dir).toBe(realDir);
expect(out1.fellBack).toBe(false);
// 现在删除磁盘上的目录(如果函数每次都 stat会立即检测到 ENOENT → 回退)
rmSync(realDir, { recursive: true, force: true });
// 二次调用:必须仍返回原路径 —— 否则说明每次都 stat、缓存没生效
const out2 = configStore.resolveDataDirOrFallback();
expect(out2.dir).toBe(realDir);
expect(out2.fellBack).toBe(false);
});
it('缓存saveConfig 改了 dataDir → 下次重新 stat删旧目录不命中缓存', async () => {
// 验证缓存失效语义:第一次 cache 指向 A → 改 dataDir 到 B → 删 A 再调用
// → 应该返回 B缓存已失效新 dataDir 重新走 stat不能返回 A。
configStore.init();
const dirA = mkdtempSync(join(tmpdir(), 'notes-fb-A-'));
const dirB = mkdtempSync(join(tmpdir(), 'notes-fb-B-'));
await configStore.saveConfig({ dataDir: dirA });
configStore.resolveDataDirOrFallback(); // populate cache: exists(A)=true
await configStore.saveConfig({ dataDir: dirB }); // dataDir 变了 → cache 失效
rmSync(dirA, { recursive: true, force: true });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(dirB); // 不是 dirA —— 缓存确实失效了
expect(out.fellBack).toBe(false);
rmSync(dirB, { recursive: true, force: true });
});
it('缓存fallback 命中customDir 不存在)也缓存 → 后续不重新 stat', async () => {
// 反向验证:首次 stat 出 ENOENT 后,磁盘上即使新建了同路径,再次调用也仍
// 返回 default —— 因为缓存里这条 customDir 被记成 exists=false。
configStore.init();
const missing = join(tmpdir(), 'notes-fb-cache-missing-' + Date.now());
await configStore.saveConfig({ dataDir: missing });
const out1 = configStore.resolveDataDirOrFallback();
expect(out1.fellBack).toBe(true);
// 磁盘上把这条路径建出来(如果每次 stat会立刻发现它存在 → 不 fallback
mkdirSync(missing, { recursive: true });
const out2 = configStore.resolveDataDirOrFallback();
expect(out2.fellBack).toBe(true); // 仍是 fallback —— 缓存生效
expect(out2.dir).toBe(join(mockHomeDir, 'Notes'));
rmSync(missing, { recursive: true, force: true });
});
it('dataDir 是空白字符串 → 等同未设置', async () => {
configStore.init();
await configStore.saveConfig({ dataDir: ' ' });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(false);
expect(out.saved).toBe('');
});
it('saveConfig 未改 dataDir → 缓存不失效', async () => {
// 反向验证 saveConfig 的失效条件精确:只有 dataDir 真改了才清缓存,
// 改别的字段不应该让缓存跟着失效。
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-noevict-'));
await configStore.saveConfig({ dataDir: realDir });
configStore.resolveDataDirOrFallback(); // populate cache
rmSync(realDir, { recursive: true, force: true });
// saveConfig 一个无关字段 → 缓存不应失效,所以仍返回原路径
await configStore.saveConfig({ themePalette: 'ocean' });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(realDir); // 缓存没失效 → 仍返回原路径
expect(out.fellBack).toBe(false);
});
it('saveConfig 把 dataDir 从有值改为空 → 缓存失效,下次返回默认', async () => {
// 模拟「回到默认」按钮的语义dataDir 从 custom 切到空 → 下次 resolveDataDirOrFallback
// 应走默认路径fellBack=false。
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-clear-'));
await configStore.saveConfig({ dataDir: realDir });
configStore.resolveDataDirOrFallback(); // cache: customDir=realDir, exists=true
await configStore.saveConfig({ dataDir: '' }); // 切到默认(持久化清空)
rmSync(realDir, { recursive: true, force: true });
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(false);
expect(out.saved).toBe('');
});
it('_reset 清掉缓存(测试间隔离)', async () => {
// _reset 的契约是「清掉模块级 in-memory 状态 + 缓存」,**不**清磁盘。
// 测试间隔离的核心是缓存:上一个 case populate 了缓存,新 case 用同一个
// customDir 字符串如果不重置就会命中旧缓存(即便磁盘状态已变)。
//
// 验证方式populate 缓存为「exists=true」→ 磁盘上删除目录 → _reset → 重
// 新 init不重新保存磁盘上的 config.json 还指向已删除路径)→ 应该重新
// stat磁盘真实状态 ENOENT 浮现出来fellBack=true。如果 _reset 没清掉
// 缓存就会继续返回原路径 + fellBack:false断言失败。
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-reset-'));
await configStore.saveConfig({ dataDir: realDir });
configStore.resolveDataDirOrFallback(); // populate cache: exists=true
expect(configStore.resolveDataDirOrFallback().fellBack).toBe(false);
rmSync(realDir, { recursive: true, force: true });
configStore._reset();
// 重新 init —— loadConfig() 会从磁盘读到 dataDir=realDir已删除
// 然后 resolveDataDirOrFallback 重新 statENOENT → fellBack=true。
// 走磁盘文件验证不依赖任何残留缓存。
configStore._setApp({
getPath: (name) => {
if (name === 'home') return mockHomeDir;
if (name === 'userData') return mockUserDataDir;
throw new Error(`unexpected getPath(${name})`);
},
});
configStore.init();
const out = configStore.resolveDataDirOrFallback();
// 磁盘上 realDir 已删除 → fellBack=truedir 是默认。这是「缓存真的清掉
// 了」的标志 —— 如果 cache 还指向 exists=true这里就会返回 realDir。
expect(out.dir).toBe(join(mockHomeDir, 'Notes'));
expect(out.fellBack).toBe(true);
expect(out.saved).toBe(realDir);
});
});
describe('ensureDefaultDataDirauto-create + seed 2026-08', () => {
// 用 welcome.md 自己作为「已种子」标记,不再写 .notes-seeded 隐藏文件
// —— 用户数据目录应该只放用户的内容。
it('welcome.md 已存在 → 跳过(幂等)', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
writeFileSync(join(defaultDir, 'welcome.md'), '用户改过的内容', 'utf-8');
await configStore.ensureDefaultDataDir();
// welcome.md 存在 → 不重新复制
expect(readFileSync(join(defaultDir, 'welcome.md'), 'utf-8')).toBe('用户改过的内容');
});
it('默认目录已有其它 .md → 不复制 welcome.md不写隐藏文件', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
writeFileSync(join(defaultDir, 'user-existing.md'), '# user', 'utf-8');
await configStore.ensureDefaultDataDir();
// 不复制 welcome.md也不创建 .notes-seeded用户数据目录应该干净
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(false);
expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false);
});
it('默认目录为空 → 复制 welcome.md不创建隐藏文件', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
await configStore.ensureDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false);
});
it('默认目录不存在 → 自动创建 + 复制', async () => {
configStore.init();
// 不 mkdir让 ensure 自己去建
await configStore.ensureDefaultDataDir();
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(true);
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
});
it('用户已设 customDir → 仍种子默认目录auto-fallback 需要)', async () => {
// 关键回归测试:之前 seedDefaultDataDir 在 dataDir 设置时跳过,导致
// U 盘掉线 fallback 到默认时默认目录从未建过 / 种子过。现在 ensure
// 不再守这个条件customDir 失效场景下默认目录也能立刻有内容。
configStore.init();
await configStore.saveConfig({ dataDir: 'C:\\does-not-exist-anywhere' });
await configStore.ensureDefaultDataDir();
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(true);
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
});
it('重复调用幂等welcome.md 命中后第二次不覆盖用户内容)', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
await configStore.ensureDefaultDataDir();
// 用户改 welcome.md 后再调一次 ensure → 内容应保留
writeFileSync(join(defaultDir, 'welcome.md'), '用户改过的内容', 'utf-8');
await configStore.ensureDefaultDataDir();
expect(readFileSync(join(defaultDir, 'welcome.md'), 'utf-8')).toBe('用户改过的内容');
});
});
describe('ensureDefaultDataDirSyncsync mkdir', () => {
it('默认目录不存在 → sync mkdir 建出来', () => {
configStore.init();
// 不预创建
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(false);
configStore.ensureDefaultDataDirSync();
expect(existsSync(defaultDir)).toBe(true);
expect(statSync(defaultDir).isDirectory()).toBe(true);
});
it('默认目录已存在 → no-op不抛错', () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
writeFileSync(join(defaultDir, 'preexisting.txt'), 'hi', 'utf-8');
configStore.ensureDefaultDataDirSync();
// 已存在的文件保留
expect(readFileSync(join(defaultDir, 'preexisting.txt'), 'utf-8')).toBe('hi');
});
it('resolveDataDirOrFallback 走默认时自动 ensurefsWatcher 不撞 ENOENT', () => {
// 用户反馈default 目录不存在 → fsWatcher 启动时 ENOENT 失败、侧栏空白。
// 这里验证 resolveDataDirOrFallback 路径会自动 ensure让 fsWatcher
// 紧接着的 startWatchingDir 不再 ENOENT。
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(false);
const out = configStore.resolveDataDirOrFallback();
expect(out.dir).toBe(defaultDir);
// sync ensure 副作用default 目录必须已存在
expect(existsSync(defaultDir)).toBe(true);
});
it('resolveDataDirOrFallback fallback 路径也自动 ensure 默认目录', async () => {
// U 盘掉线 → custom 失效 → runtime fallback 到默认。如果默认也从未
// 建过/用过要立刻能列出文件welcome.md 由异步 ensure 种子)。
configStore.init();
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-ensure-'));
await configStore.saveConfig({ dataDir: realDir });
rmSync(realDir, { recursive: true, force: true });
const defaultDir = join(mockHomeDir, 'Notes');
expect(existsSync(defaultDir)).toBe(false);
const out = configStore.resolveDataDirOrFallback();
expect(out.fellBack).toBe(true);
// sync ensure 已 mkdir 默认目录
expect(existsSync(defaultDir)).toBe(true);
// 异步种子fire-and-forget等 in-flight 完成
await configStore.scheduleEnsureDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
});
});
describe('scheduleEnsureDefaultDataDirfire-and-forget 去重)', () => {
it('多次调用 → 只跑一次(共享 in-flight promise', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
const p1 = configStore.scheduleEnsureDefaultDataDir();
const p2 = configStore.scheduleEnsureDefaultDataDir();
const p3 = configStore.scheduleEnsureDefaultDataDir();
// 共享同一份 Promise
expect(p1).toBe(p2);
expect(p2).toBe(p3);
await Promise.all([p1, p2, p3]);
// in-flight 已清空
const p4 = configStore.scheduleEnsureDefaultDataDir();
expect(p4).not.toBe(p1); // 新一次调用拿到的是新的(因为旧的已 settled
});
it('seedDefaultDataDir 仍是 ensureDefaultDataDir 的别名(向后兼容)', async () => {
configStore.init();
const defaultDir = join(mockHomeDir, 'Notes');
mkdirSync(defaultDir, { recursive: true });
await configStore.seedDefaultDataDir();
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
});
});
// 顺便校验 settings-schema 的 coerceLoadedSettings 也是兜底用的
describe('coerceLoadedSettingsschema 兜底)', () => {
it('未知字段被剥离', () => {
const out = coerceLoadedSettings({ theme: 'light', unknownField: 'x' });
expect(out.theme).toBe('light');
expect(out.unknownField).toBeUndefined();
});
it('已知字段缺省时用默认值', () => {
const out = coerceLoadedSettings({ theme: 'light' });
// 没传 dataDir → 走默认
expect(out.theme).toBe('light');
expect(out.dataDir).toBe(DEFAULT_CONFIG.dataDir);
});
it('空 / 非对象输入 → 默认值', () => {
expect(coerceLoadedSettings(null)).toEqual({ ...DEFAULT_CONFIG });
expect(coerceLoadedSettings(undefined)).toEqual({ ...DEFAULT_CONFIG });
expect(coerceLoadedSettings('garbage')).toEqual({ ...DEFAULT_CONFIG });
});
});