This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

View File

@@ -0,0 +1,735 @@
// 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 });
});
});