update
This commit is contained in:
@@ -542,6 +542,57 @@ describe('createAiProxy.runEdit —— API Key 脱敏 + URL 清洗', () => {
|
||||
expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/);
|
||||
});
|
||||
|
||||
// M2 follow-up (Round 14):percent-encoded userinfo 也必须剥。
|
||||
// 旧 regex `^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@` 不接受 `%` 之外的字符,但
|
||||
// percent-encoded 形式 `user%40evil.com:pass@host` 字面上没有 `@` —— fetch
|
||||
// 在解析 URL 时把 `%40` 还原成 `@`,再按 Basic Auth 发送,等于把
|
||||
// "user%40evil.com:pass" 整段写到 Authorization 头,敏感凭据直发中转。
|
||||
it('M2 percent-encoded:baseURL 含 %40 编码的 userinfo → 实际 fetch 的 URL 已剥 userinfo', async () => {
|
||||
let captured = null;
|
||||
const fetchImpl = async (url) => {
|
||||
captured = url;
|
||||
return new Response(JSON.stringify({
|
||||
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
const proxy = createAiProxy({
|
||||
getConfig: () => ({
|
||||
aiProvider: 'openai',
|
||||
aiBaseUrl: 'https://hunter2%40evil.com:secret@relay.example.com/v1',
|
||||
aiApiKey: 'k', aiModel: 'm',
|
||||
}),
|
||||
fetchImpl,
|
||||
});
|
||||
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2pct' });
|
||||
expect(r.ok).toBe(true);
|
||||
// 剥 userinfo 之后只剩 host + path,不能再含 hunter2 / secret 字样
|
||||
expect(captured).toBe('https://relay.example.com/v1/chat/completions');
|
||||
expect(captured).not.toMatch(/hunter2/);
|
||||
expect(captured).not.toMatch(/secret/);
|
||||
});
|
||||
|
||||
// M2 follow-up (Round 14):sanitizeUrl 也走 new URL 解析路径,percent-encoded
|
||||
// userinfo 在错误日志里也必须被遮罩(与 fetch URL 行为一致)。
|
||||
it('M2 percent-encoded:sanitizeUrl 输出(HTTP 错误日志)已剥 percent-encoded userinfo', async () => {
|
||||
const logs = [];
|
||||
const proxy = createAiProxy({
|
||||
getConfig: () => ({
|
||||
aiProvider: 'openai',
|
||||
aiBaseUrl: 'https://user%40evil.com:pass@api.example.com/v1',
|
||||
aiApiKey: 'k', aiModel: 'm',
|
||||
}),
|
||||
fetchImpl: async () => new Response('oops', { status: 500 }),
|
||||
log: (event, data) => logs.push({ event, data }),
|
||||
});
|
||||
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2pctlog' });
|
||||
expect(r.ok).toBe(false);
|
||||
const httpErrLog = logs.find((l) => l.event === 'ai:http_error');
|
||||
expect(httpErrLog).toBeDefined();
|
||||
expect(httpErrLog.data.url).not.toMatch(/user/);
|
||||
expect(httpErrLog.data.url).not.toMatch(/pass/);
|
||||
expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/);
|
||||
});
|
||||
|
||||
// M3 fix:HTTP 错误回显里出现 JSON 风格的 `"authorization": "Bearer xxx"` 文本
|
||||
// (错误信息里把 header 序列化成字符串,AI proxy / 网关常见),旧 regex 遇到 `"`
|
||||
// 就停,只盖到开引号,剩下 `xxx` 全部漏出。新增的 JSON 包装 pattern 一次性吃完整段。
|
||||
@@ -733,10 +784,13 @@ describe('max_tokens 上限按模型路由', () => {
|
||||
}
|
||||
|
||||
// ---- Anthropic:5 系与 4.6+ 拿到 32k ----
|
||||
// Round 13 fix (H4):新增 claude-haiku-5-*(旧正则漏命中落到 16k 档)。
|
||||
it.each([
|
||||
'claude-opus-5',
|
||||
'claude-sonnet-5',
|
||||
'claude-fable-5',
|
||||
'claude-haiku-5',
|
||||
'claude-haiku-5-20251001',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-sonnet-4-6',
|
||||
@@ -757,13 +811,16 @@ describe('max_tokens 上限按模型路由', () => {
|
||||
expect(body.max_tokens).toBe(16_384);
|
||||
});
|
||||
|
||||
// ---- Anthropic:claude-3 老家族与未知模型保守 8k ----
|
||||
// ---- Anthropic:claude-3 老家族 4k(官方上限),未知模型 8k ----
|
||||
// Round 13 fix (H2):claude-3 老家族官方 max output = 4096。旧值 8192
|
||||
// 会让 opus-3 / sonnet-3 / haiku-3 被服务端 400 拒绝。
|
||||
it.each([
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
])('Anthropic %s(claude-3 老家族)→ max_tokens 8192', async (model) => {
|
||||
'claude-3-sonnet-20240229',
|
||||
])('Anthropic %s(claude-3 老家族)→ max_tokens 4096', async (model) => {
|
||||
const body = await capturedBody('anthropic', model);
|
||||
expect(body.max_tokens).toBe(8_192);
|
||||
expect(body.max_tokens).toBe(4_096);
|
||||
});
|
||||
|
||||
it('Anthropic 未知模型(自部署 / 中转)→ max_tokens 8192,最大限度兼容', async () => {
|
||||
|
||||
@@ -545,22 +545,22 @@ describe('resolveDataDirOrFallback(auto-fallback 2026-08)', () => {
|
||||
});
|
||||
|
||||
describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => {
|
||||
// 用 welcome.md 自己作为「已种子」标记,不再写 .notes-seeded 隐藏文件
|
||||
// 用 README.md 自己作为「已种子」标记,不再写 .notes-seeded 隐藏文件
|
||||
// —— 用户数据目录应该只放用户的内容。
|
||||
|
||||
it('welcome.md 已存在 → 跳过(幂等)', async () => {
|
||||
it('README.md 已存在 → 跳过(幂等)', async () => {
|
||||
configStore.init();
|
||||
const defaultDir = join(mockHomeDir, 'Notes');
|
||||
mkdirSync(defaultDir, { recursive: true });
|
||||
writeFileSync(join(defaultDir, 'welcome.md'), '用户改过的内容', 'utf-8');
|
||||
writeFileSync(join(defaultDir, 'README.md'), '用户改过的内容', 'utf-8');
|
||||
|
||||
await configStore.ensureDefaultDataDir();
|
||||
|
||||
// welcome.md 存在 → 不重新复制
|
||||
expect(readFileSync(join(defaultDir, 'welcome.md'), 'utf-8')).toBe('用户改过的内容');
|
||||
// README.md 存在 → 不重新复制
|
||||
expect(readFileSync(join(defaultDir, 'README.md'), 'utf-8')).toBe('用户改过的内容');
|
||||
});
|
||||
|
||||
it('默认目录已有其它 .md → 不复制 welcome.md,不写隐藏文件', async () => {
|
||||
it('默认目录已有其它 .md → 不复制任何 onboarding,不写隐藏文件', async () => {
|
||||
configStore.init();
|
||||
const defaultDir = join(mockHomeDir, 'Notes');
|
||||
mkdirSync(defaultDir, { recursive: true });
|
||||
@@ -568,23 +568,25 @@ describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => {
|
||||
|
||||
await configStore.ensureDefaultDataDir();
|
||||
|
||||
// 不复制 welcome.md,也不创建 .notes-seeded(用户数据目录应该干净)
|
||||
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(false);
|
||||
// 不复制 README.md,也不创建 .notes-seeded(用户数据目录应该干净)
|
||||
expect(existsSync(join(defaultDir, 'README.md'))).toBe(false);
|
||||
expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false);
|
||||
});
|
||||
|
||||
it('默认目录为空 → 复制 welcome.md(不创建隐藏文件)', async () => {
|
||||
it('默认目录为空 → 复制 README.md(不创建隐藏文件)', async () => {
|
||||
configStore.init();
|
||||
const defaultDir = join(mockHomeDir, 'Notes');
|
||||
mkdirSync(defaultDir, { recursive: true });
|
||||
|
||||
await configStore.ensureDefaultDataDir();
|
||||
|
||||
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
|
||||
// README.md 是 sentinel + 唯一 onboarding 文件
|
||||
expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
|
||||
// 不写隐藏文件
|
||||
expect(existsSync(join(defaultDir, '.notes-seeded'))).toBe(false);
|
||||
});
|
||||
|
||||
it('默认目录不存在 → 自动创建 + 复制', async () => {
|
||||
it('默认目录不存在 → 自动创建 + 复制 README.md', async () => {
|
||||
configStore.init();
|
||||
// 不 mkdir,让 ensure 自己去建
|
||||
|
||||
@@ -592,7 +594,7 @@ describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => {
|
||||
|
||||
const defaultDir = join(mockHomeDir, 'Notes');
|
||||
expect(existsSync(defaultDir)).toBe(true);
|
||||
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
|
||||
expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('用户已设 customDir → 仍种子默认目录(auto-fallback 需要)', async () => {
|
||||
@@ -606,20 +608,20 @@ describe('ensureDefaultDataDir(auto-create + seed 2026-08)', () => {
|
||||
|
||||
const defaultDir = join(mockHomeDir, 'Notes');
|
||||
expect(existsSync(defaultDir)).toBe(true);
|
||||
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
|
||||
expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('重复调用幂等(welcome.md 命中后第二次不覆盖用户内容)', async () => {
|
||||
it('重复调用幂等(README.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');
|
||||
// 用户改 README.md 后再调一次 ensure → 内容应保留
|
||||
writeFileSync(join(defaultDir, 'README.md'), '用户改过的内容', 'utf-8');
|
||||
await configStore.ensureDefaultDataDir();
|
||||
|
||||
expect(readFileSync(join(defaultDir, 'welcome.md'), 'utf-8')).toBe('用户改过的内容');
|
||||
expect(readFileSync(join(defaultDir, 'README.md'), 'utf-8')).toBe('用户改过的内容');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -664,7 +666,7 @@ describe('ensureDefaultDataDirSync(sync mkdir)', () => {
|
||||
|
||||
it('resolveDataDirOrFallback fallback 路径也自动 ensure 默认目录', async () => {
|
||||
// U 盘掉线 → custom 失效 → runtime fallback 到默认。如果默认也从未
|
||||
// 建过/用过,要立刻能列出文件(welcome.md 由异步 ensure 种子)。
|
||||
// 建过/用过,要立刻能列出文件(README.md 由异步 ensure 种子)。
|
||||
configStore.init();
|
||||
const realDir = mkdtempSync(join(tmpdir(), 'notes-fb-ensure-'));
|
||||
await configStore.saveConfig({ dataDir: realDir });
|
||||
@@ -679,7 +681,7 @@ describe('ensureDefaultDataDirSync(sync mkdir)', () => {
|
||||
expect(existsSync(defaultDir)).toBe(true);
|
||||
// 异步种子:fire-and-forget,等 in-flight 完成
|
||||
await configStore.scheduleEnsureDefaultDataDir();
|
||||
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
|
||||
expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -708,7 +710,7 @@ describe('scheduleEnsureDefaultDataDir(fire-and-forget 去重)', () => {
|
||||
mkdirSync(defaultDir, { recursive: true });
|
||||
|
||||
await configStore.seedDefaultDataDir();
|
||||
expect(existsSync(join(defaultDir, 'welcome.md'))).toBe(true);
|
||||
expect(existsSync(join(defaultDir, 'README.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -125,4 +125,28 @@ describe('editorThemeSpec background overrides (oneDark / base theme 互斥)', (
|
||||
expect(v).toMatch(/var\(--fg-primary\)/);
|
||||
expect(v).toMatch(/!important/);
|
||||
});
|
||||
|
||||
// base theme 给 .cm-line 设了 `padding: 0 2px 0 6px`,左侧 6px 内边距会
|
||||
// 形成「行首死区」——鼠标点击这一段 6px 区域时光标无法可靠地放到该行最前面。
|
||||
// 我们的覆盖必须是左内边距 0 + !important(与 base theme 同特异性 (0,1,0),
|
||||
// 源序胜负不可靠,!important 兜底)。
|
||||
it('.cm-line padding 必须覆盖 base theme 的左内边距 6px → 0(带 !important)', () => {
|
||||
const v = __editorThemeSpecForTests.linePadding;
|
||||
// 期望值形如 "0 2px 0 0 !important",左 0、右 2px、上 0、下 0
|
||||
expect(v).toMatch(/^0\s+2px\s+0\s+0/);
|
||||
expect(v).toMatch(/!important/);
|
||||
});
|
||||
|
||||
// basicSetup 的 foldGutter() 渲染的 chevron 列默认宽度 ~16px,会从
|
||||
// .cm-gutters 容器(CSS width 钉死 32px)溢出 ~10px,盖在 cm-line 的开头
|
||||
// 字符上,导致点击「行首最前面」时 hit target 是 gutter SPAN 而非 cm-line
|
||||
// —— CM6 监听挂在 view.contentDOM 上,gutter 不在 contentDOM 子树内,
|
||||
// mousedown 不会冒泡过去,光标位置不变。
|
||||
// 我们用 display:none + !important 隐藏 chevron 渲染部分。foldGutter()
|
||||
// 的 keymap(Ctrl+Shift+[ / ])仍然有效,只是视觉上看不到 chevron。
|
||||
it('.cm-foldGutter 必须 display:none(带 !important)— 隐藏溢出 chevron', () => {
|
||||
const v = __editorThemeSpecForTests.foldGutterDisplay;
|
||||
expect(v).toMatch(/^none/);
|
||||
expect(v).toMatch(/!important/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,14 +80,14 @@ describe('FileList 搜索 / 排序行为', () => {
|
||||
feed([
|
||||
{ name: 'alpha.md', path: '/d/alpha.md', mtimeMs: 300 },
|
||||
{ name: 'beta.md', path: '/d/beta.md', mtimeMs: 100 },
|
||||
{ name: 'welcome.md', path: '/d/welcome.md', mtimeMs: 50 },
|
||||
{ name: 'README.md', path: '/d/README.md', mtimeMs: 50 },
|
||||
]);
|
||||
// 搜 'wel' 应该让 welcome.md 排前,不管它的 mtime 是最旧的
|
||||
searchInput.value = 'wel';
|
||||
// 搜 'read' 应该让 README.md 排前,不管它的 mtime 是最旧的
|
||||
searchInput.value = 'read';
|
||||
searchInput.dispatchEvent(new Event('input'));
|
||||
// 等 debounce flush(100ms)
|
||||
return new Promise((r) => setTimeout(r, 150)).then(() => {
|
||||
expect(renderedNames()[0]).toBe('welcome.md');
|
||||
expect(renderedNames()[0]).toBe('README.md');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1030,4 +1030,75 @@ describe('atomicWriteFile fsync 行为 (audit Round 4 P0-1)', () => {
|
||||
const leftover = readdirSync(dir).filter((f) => f.includes('.tmp.'));
|
||||
expect(leftover).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// audit fix (Tmp-L2):ATOMIC_TMP_RE 之前太宽,把用户合法文件(`report.tmp.<ts>.<ts>.<rand>`)
|
||||
// 也当成孤儿静默 unlink。这组测试钉死「仅匹配 EDITABLE_EXTS.tmp.<pid>.<ts>.<rand>」的边界。
|
||||
describe('ATOMIC_TMP_RE(cleanupStaleFileTmpFiles 模式 audit Tmp-L2)', () => {
|
||||
it('匹配:<basename>.<editableExt>.tmp.<pid>.<ts>.<rand>', () => {
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.12345.1700000000000.abcdef')).toBe(true);
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('a.py.tmp.999.1700000000000.a1b2c3')).toBe(true);
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('sub_deep_name.json.tmp.7.1700000000000.zz9xka')).toBe(true);
|
||||
});
|
||||
|
||||
it('拒绝:用户文件 report.tmp.<ts>.<ts>.<rand>(无 EDITABLE_EXT 前缀)', () => {
|
||||
// 这正是误伤 bug 的源头 —— 没有 md / py / json 等扩展名前缀,
|
||||
// 旧版 `/\\.tmp\\.\\d+\\.\\d+\\.[a-z0-9]+$/i` 会把这条匹配上。
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('report.tmp.2024.1234567.abcdef')).toBe(false);
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('backup.tmp.1700000000000.1700000000000.abcdef')).toBe(false);
|
||||
});
|
||||
|
||||
it('拒绝:binary / 非白名单扩展名', () => {
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('photo.jpg.tmp.123.1700000000000.abcdef')).toBe(false);
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('archive.zip.tmp.123.1700000000000.abcdef')).toBe(false);
|
||||
});
|
||||
|
||||
it('拒绝:config-store 的 config.json.tmp.* 也在范围外(由它自己管)', () => {
|
||||
// config-store 的旧 tmp 不被 fileOps 清理 —— 这里只确认 ATOMIC_TMP_RE 不会
|
||||
// 误伤。注意 config.json 不在 EDITABLE_EXTS(json 在)是,所以这条 *会* 匹配上;
|
||||
// 故 cleanupStaleFileTmpFiles 内部排除逻辑在更上层处理。tmp 段格式补 digit.ts.rand 即可。
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000.abcdef')).toBe(true);
|
||||
// 但 `config.json.tmp.123` 这种三段缺一不可被拒(rand 不在)。
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000')).toBe(false);
|
||||
});
|
||||
|
||||
it('拒绝:长度字段结构错误(rand 大于 8 字符 / pid 非数字)', () => {
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.abc.1700000000000.abcdef')).toBe(false); // pid 非数字
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.123.abc.abcdef')).toBe(false); // ts 非数字
|
||||
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.123.1700000000000.UPPERCASE')).toBe(false); // rand 范围
|
||||
});
|
||||
});
|
||||
|
||||
// audit fix (Tmp-L2):端到端验证 cleanupStaleFileTmpFiles 不会清理非自家产物
|
||||
describe('cleanupStaleFileTmpFiles(端到端 audit Tmp-L2)', () => {
|
||||
it('用户文件 report.tmp.<ts>.<ts>.<rand> 即使旧到 24h+ 也不被删', async () => {
|
||||
// 写入一个旧 mtime 的文件(远大于 CROSS_PID_STALE_THRESHOLD_MS = 24h)。
|
||||
const userFile = join(dir, 'report.tmp.1700000000000.1700000000000.aabbcc');
|
||||
writeFileSync(userFile, 'user data', 'utf-8');
|
||||
const past = Date.now() - (48 * 60 * 60 * 1000); // 48 小时前
|
||||
const { utimesSync } = require('fs');
|
||||
utimesSync(userFile, past / 1000, past / 1000);
|
||||
|
||||
await fileOps.cleanupStaleFileTmpFiles(dir);
|
||||
|
||||
// 必须仍在 —— 旧版会因为 tmp 段格式匹配而删掉它。
|
||||
const { existsSync } = require('fs');
|
||||
expect(existsSync(userFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('真正属于自家 atomicWriteFile 产物的孤儿 .md.tmp.<pid>.<ts>.<rand> 会按阈值清理', async () => {
|
||||
// 模拟本进程 PID + 一个非常旧的时间戳(>24h)
|
||||
const orphanName = `note.md.tmp.${process.pid}.1000000000000.abc123`;
|
||||
const orphanFile = join(dir, orphanName);
|
||||
writeFileSync(orphanFile, 'orphan', 'utf-8');
|
||||
const past = Date.now() - (48 * 60 * 60 * 1000);
|
||||
const { utimesSync } = require('fs');
|
||||
utimesSync(orphanFile, past / 1000, past / 1000);
|
||||
|
||||
await fileOps.cleanupStaleFileTmpFiles(dir);
|
||||
|
||||
// 自家 PID 时 STALE_THRESHOLD_MS = 60s,48h 自然删除
|
||||
const { existsSync } = require('fs');
|
||||
expect(existsSync(orphanFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,16 @@ describe('friendlyFsError —— 已知 errno 映射', () => {
|
||||
it('ENOTEMPTY → 目标文件夹不为空', () => {
|
||||
expect(friendlyFsError('ENOTEMPTY')).toBe('目标文件夹不为空');
|
||||
});
|
||||
|
||||
it('ENOENT → 目标路径不存在', () => {
|
||||
expect(friendlyFsError('ENOENT')).toMatch(/不存在/);
|
||||
});
|
||||
|
||||
// audit fix (Round 17 H2):FS_TIMEOUT 是 main withTimeout() 自造 code,
|
||||
// 不是 Node fs 内置 errno。SMB / OneDrive 挂死时透传,renderer 走这里。
|
||||
it('FS_TIMEOUT → 磁盘暂时不可用文案', () => {
|
||||
expect(friendlyFsError('FS_TIMEOUT')).toMatch(/超时|磁盘/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('friendlyFsError —— fallback 行为', () => {
|
||||
|
||||
182
tests/unit/fs-watcher.test.js
Normal file
182
tests/unit/fs-watcher.test.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// main/fs-watcher.js 单测(Case-M1 audit fix + Round 13 fix 回归)
|
||||
//
|
||||
// 不在仓库里的测试 —— fs-watcher 600+ 行(数据目录监听 + 事件防抖 + 自写过滤 +
|
||||
// 错误闩锁 + 退避重连 + generation token)一直没单元测试。这次先补 case-insensitive
|
||||
// self-write filter 这一条(核心 bug 修复的回归锚点),其他路径(轮询 / 重连 / 错误
|
||||
// 抑制)需要 jsdom + 真实 fs.watch 集成,留给后续 Story。
|
||||
//
|
||||
// 覆盖:
|
||||
// - markExpectedWrite / isSelfWrite:登记大小写 X 的路径,命中查询大小写 Y 也能识别
|
||||
// - 与 case-sensitive 的 Linux 行为对照(platform-based 切换)
|
||||
// - 过期条目被惰性 GC
|
||||
// - updateSnapshot 轮询路径 push 回归(Round 13 fix fce2d83 修复的 bug):
|
||||
// 旧实现 baseline 在 updateSnapshot 先写、emitFilesChanged 又读,看到同一
|
||||
// snapshot 时 if 块不进、safeSend 跳过 —— 2s 轮询(fs.watch 不可靠环境的
|
||||
// 唯一兜底)检测到的改动永远不通知 renderer。
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
const { createFsWatcher } = require('../../main/fs-watcher.js');
|
||||
|
||||
/**
|
||||
* 构造一个最小化的 fsWatcher 实例用于单元测试。
|
||||
* 不实际订阅 fs.watch、轮询或 IPC —— 我们只关心 self-write filter 的 Map 行为。
|
||||
*/
|
||||
function makeWatcher() {
|
||||
return createFsWatcher({
|
||||
// 不用 —— 只用 mark/isSelfWrite
|
||||
getMainWindow: () => null,
|
||||
resolveDataDir: () => null,
|
||||
scanDir: async () => ({ ok: true, entries: [] }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造带 ipc spy 的 fsWatcher,用于验证 updateSnapshot regular path 是否
|
||||
* 真的把列表变化 push 到 renderer。getMainWindow 返回一个虚拟 BrowserWindow,
|
||||
* webContents.send 被替换为 vi.fn(),方便断言调用次数 + payload。
|
||||
*/
|
||||
function makeWatcherWithIpc(scanResults) {
|
||||
const sends = [];
|
||||
const fakeWin = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: (channel, payload) => sends.push({ channel, payload }) },
|
||||
};
|
||||
let scanIdx = 0;
|
||||
const w = createFsWatcher({
|
||||
getMainWindow: () => fakeWin,
|
||||
resolveDataDir: () => '/data',
|
||||
scanDir: async () => scanResults[scanIdx++] || { ok: true, entries: [] },
|
||||
});
|
||||
return { watcher: w, sends, fakeWin };
|
||||
}
|
||||
|
||||
describe('fs-watcher markExpectedWrite / isSelfWrite (Case-M1)', () => {
|
||||
it('同 case 命中(sanity)', () => {
|
||||
const w = makeWatcher();
|
||||
w.markExpectedWrite('C:\\Notes\\foo.md', 1000);
|
||||
expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('Windows / darwin:跨 case 命中(Case-M1 修复)', () => {
|
||||
// audit fix (Case-M1):fs.watch 在 Windows 上报的 filename 与 caller 写入的
|
||||
// case 可能不一致(取决于 ReadDirectoryChangesW / NTFS 短名 8.3 / 同步盘
|
||||
// 大小写敏感层)。strict Map.get 会漏命中 → renderer 把自写当成外部修改
|
||||
// 误报,用户选「用磁盘版」会覆盖刚保存的内容。
|
||||
const w = makeWatcher();
|
||||
const mixed = process.platform;
|
||||
// 在 Windows / darwin 上模拟「登记 Foo.md,watcher 上报 foo.md」
|
||||
w.markExpectedWrite(`C:\\Notes\\Foo.md`, 1000);
|
||||
const lookups = (process.platform === 'win32' || process.platform === 'darwin')
|
||||
? [`C:\\Notes\\foo.md`, `C:\\Notes\\FOO.md`, `c:\\notes\\Foo.md`, `C:/Notes/Foo.md`]
|
||||
: [`C:\\Notes\\Foo.md`];
|
||||
for (const alt of lookups) {
|
||||
expect(w.isSelfWrite(alt)).toBe(true);
|
||||
}
|
||||
expect(mixed).toBeTruthy(); // sanity guard
|
||||
});
|
||||
|
||||
it('Linux(case-sensitive 平台):跨 case 不命中(旧行为保留)', () => {
|
||||
// 仅当跑在 Linux 上才相关:默认模拟 darwin 风格的 expect 命中安全,
|
||||
// 但 Linux 上 case-different 路径不应被误当作自写。
|
||||
if (process.platform === 'linux') {
|
||||
const w = makeWatcher();
|
||||
w.markExpectedWrite('/tmp/Foo.md', 1000);
|
||||
expect(w.isSelfWrite('/tmp/Foo.md')).toBe(true);
|
||||
expect(w.isSelfWrite('/tmp/foo.md')).toBe(false);
|
||||
} else {
|
||||
// 在非 Linux 上跳过,但保持测试存在以便 CI 多平台矩阵触达。
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('未登记的路径:isSelfWrite 返回 false', () => {
|
||||
const w = makeWatcher();
|
||||
expect(w.isSelfWrite('C:\\Notes\\unknown.md')).toBe(false);
|
||||
w.markExpectedWrite('C:\\Notes\\expected.md', 1000);
|
||||
expect(w.isSelfWrite('C:\\Notes\\other.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('clearExpectedWrites 后所有命中失效', () => {
|
||||
const w = makeWatcher();
|
||||
w.markExpectedWrite('C:\\Notes\\foo.md', 1000);
|
||||
expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(true);
|
||||
// markExpectedWrite 的 expiresAt = Date.now() + 500ms —— 不 clear 也得过 500ms
|
||||
// 才失效。clear 是立即清理入口(切目录 / stop 监听时调用)。
|
||||
w.clearExpectedWrites();
|
||||
expect(w.isSelfWrite('C:\\Notes\\foo.md')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fs-watcher updateSnapshot —— push 决策回归 (Round 13 fix fce2d83)', () => {
|
||||
// 关键回归锚点:2s 轮询(OneDrive / SMB fs.watch 不可靠环境唯一兜底)检测
|
||||
// 到列表变化,必须把新列表推到 renderer 侧栏刷新。历史 bug 把 skipInitialEmit
|
||||
// 透传给 emitFilesChanged + baseline 重复写,导致 safeSend 永远跳过。
|
||||
//
|
||||
// 测试通过 internal _test_updateSnapshot 触发该路径,避免引入 jsdom + 真 fs.watch。
|
||||
|
||||
it('startup 路径(skipInitialEmit=true):同步 baseline 但不 push', async () => {
|
||||
// audit fix:startWatchingDir 内部 updateSnapshot(dir, true) 拿到的「真实状态」
|
||||
// 只用来同步 baseline,不能误当成「新增变化」推给 renderer —— renderer 会通过
|
||||
// 'file:list' 自己拉取启动文件列表,避免「刚加载就 emitFilesChanged 又被 renderer
|
||||
// 当 fs:changed 事件 → 触发 scanDir 重复请求」的循环。
|
||||
const { watcher: w, sends } = makeWatcherWithIpc([
|
||||
{ ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] },
|
||||
]);
|
||||
await w._test_updateSnapshot('/data', /* skipInitialEmit */ true);
|
||||
// startup 只写 baseline 不 push
|
||||
const filesChanged = sends.filter((s) => s.channel === 'files:changed');
|
||||
expect(filesChanged.length).toBe(0);
|
||||
});
|
||||
|
||||
it('常规路径(skipInitialEmit=false):检测到变化必须 push', async () => {
|
||||
// 关键回归:第一次 startup(不 push)后,第二次常规 updateSnapshot 看到列表变化
|
||||
// → 必须 safeSend 通知 renderer。Round 13 fix 之前的实现 silent 失败。
|
||||
const { watcher: w, sends } = makeWatcherWithIpc([
|
||||
// 1st call: startup snapshot, list = [a.md]
|
||||
{ ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] },
|
||||
// 2nd call: polling tick, list grew to [a.md, b.md]
|
||||
{ ok: true, entries: [
|
||||
{ name: 'a.md', size: 10, mtimeMs: 1 },
|
||||
{ name: 'b.md', size: 20, mtimeMs: 2 },
|
||||
] },
|
||||
]);
|
||||
await w._test_updateSnapshot('/data', true); // startup
|
||||
await w._test_updateSnapshot('/data', false); // polling tick detects new file
|
||||
const pushes = sends
|
||||
.filter((s) => s.channel === 'files:changed')
|
||||
.map((s) => s.payload);
|
||||
expect(pushes.length).toBeGreaterThanOrEqual(1);
|
||||
const last = pushes[pushes.length - 1];
|
||||
expect(last.entries.map((e) => e.name).sort()).toEqual(['a.md', 'b.md']);
|
||||
// relOf 没有 toRelDir 注入时返回 null(fs-watcher 契约:可选字段,
|
||||
// payload 缺 relDir 仍合法;renderer 走 fallback 不依赖此字段)
|
||||
expect(last.relDir == null).toBe(true);
|
||||
});
|
||||
|
||||
it('常规路径但列表未变:不 push(baseline 一致)', async () => {
|
||||
// 同一列表连扫两次 → baseline 不变 → emitFilesChanged 内部 if 块不进 → 不 push。
|
||||
// 避免 fs.watch + polling 双通道把同一次变化报两遍。
|
||||
const { watcher: w, sends } = makeWatcherWithIpc([
|
||||
{ ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] },
|
||||
{ ok: true, entries: [{ name: 'a.md', size: 10, mtimeMs: 1 }] },
|
||||
]);
|
||||
await w._test_updateSnapshot('/data', true);
|
||||
await w._test_updateSnapshot('/data', false);
|
||||
const pushes = sends.filter((s) => s.channel === 'files:changed');
|
||||
expect(pushes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('扫描失败(result.ok=false)→ 通过错误通道 push,不走 entries 通道', async () => {
|
||||
// 与 change 分支独立 —— 错误走 'files:changed' 但 payload 不带 entries,
|
||||
// renderer 用 error 字段弹 toast 并保留旧列表(Round 7 FS-F12 行为)。
|
||||
const { watcher: w, sends } = makeWatcherWithIpc([
|
||||
{ ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT' },
|
||||
]);
|
||||
await w._test_updateSnapshot('/data', false);
|
||||
const pushes = sends.filter((s) => s.channel === 'files:changed');
|
||||
expect(pushes.length).toBe(1);
|
||||
expect(pushes[0].payload.error).toBe('DATA_DIR_NOT_FOUND');
|
||||
expect(pushes[0].payload.entries).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,12 +488,17 @@ describe('MarkdownViewer resolveRelativeImages 副作用', () => {
|
||||
emptyElement: document.querySelector('.empty'),
|
||||
statusPath: document.createElement('div'),
|
||||
statusMeta: document.createElement('div'),
|
||||
// audit fix (Sec-M2):新签名需要 getDataDir 才能继续 setAttribute
|
||||
// loading/decoding;这里给一个「包含路径」的等价 dir,让 img.png 视为
|
||||
// 「在数据目录内」跑通副作用断言。原本旧行为「不传 getDataDir 仍解析」
|
||||
// 已删,这里补齐构造以让 loading/decoding 这条副作用断言继续有意义。
|
||||
getDataDir: () => posixPath('/notes'),
|
||||
});
|
||||
});
|
||||
|
||||
it('图片自动加 loading="lazy" + decoding="async"', () => {
|
||||
it('图片自动加 loading="lazy" + decoding="async"', async () => {
|
||||
body.innerHTML = '<img src="img.png" /><img src="img2.png" />';
|
||||
viewer.resolveRelativeImages('/notes/test.md');
|
||||
await viewer.resolveRelativeImages('/notes/test.md');
|
||||
|
||||
for (const img of body.querySelectorAll('img')) {
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
@@ -501,18 +506,18 @@ describe('MarkdownViewer resolveRelativeImages 副作用', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('已有 loading/decoding 不覆盖', () => {
|
||||
it('已有 loading/decoding 不覆盖', async () => {
|
||||
body.innerHTML = '<img src="img.png" loading="eager" decoding="sync" />';
|
||||
viewer.resolveRelativeImages('/notes/test.md');
|
||||
await viewer.resolveRelativeImages('/notes/test.md');
|
||||
|
||||
const img = body.querySelector('img');
|
||||
expect(img.getAttribute('loading')).toBe('eager');
|
||||
expect(img.getAttribute('decoding')).toBe('sync');
|
||||
});
|
||||
|
||||
it('data: URL 图片不动', () => {
|
||||
it('data: URL 图片不动', async () => {
|
||||
body.innerHTML = '<img src="data:image/png;base64,iVBOR..." />';
|
||||
viewer.resolveRelativeImages('/notes/test.md');
|
||||
await viewer.resolveRelativeImages('/notes/test.md');
|
||||
|
||||
const img = body.querySelector('img');
|
||||
expect(img.getAttribute('src')).toBe('data:image/png;base64,iVBOR...');
|
||||
@@ -520,9 +525,9 @@ describe('MarkdownViewer resolveRelativeImages 副作用', () => {
|
||||
expect(img.getAttribute('loading')).toBeNull();
|
||||
});
|
||||
|
||||
it('绝对 file:// / https:// URL 图片不动', () => {
|
||||
it('绝对 file:// / https:// URL 图片不动', async () => {
|
||||
body.innerHTML = '<img src="https://cdn.example.com/img.png" />';
|
||||
viewer.resolveRelativeImages('/notes/test.md');
|
||||
await viewer.resolveRelativeImages('/notes/test.md');
|
||||
|
||||
const img = body.querySelector('img');
|
||||
expect(img.getAttribute('src')).toBe('https://cdn.example.com/img.png');
|
||||
@@ -583,25 +588,28 @@ describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)',
|
||||
expect(img.getAttribute('src')).toMatch(/img\.png$/);
|
||||
});
|
||||
|
||||
it('getDataDir 返回 null(旧版未注入) → 不做边界检查,仅防 ../.. 越界', async () => {
|
||||
it('getDataDir 返回 null(不可用) → fail-closed:src 被剥,data-blocked="external"', async () => {
|
||||
// audit fix (Sec-M2):旧版「getDataDir 返回 null → 跳过边界检查」是文件
|
||||
// 系统枚举 oracle —— 恶意 md 可以用 `<img src="../victim.png">` 探测数据
|
||||
// 目录外任意可读图片是否存在。新版 fail-closed:dataDir 不可用时一律拒写 src。
|
||||
viewer = makeViewer(() => null);
|
||||
// 不传 / 传 null 时 resolveRelativeImages 跳过 isPathInside 校验。
|
||||
// resolvePath 内部仍会拒绝 '../..' 越界(保留旧行为)。
|
||||
body.innerHTML = '<img src="../../outside.png">';
|
||||
body.innerHTML = '<img src="img.png" alt="x">';
|
||||
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
|
||||
const img = body.querySelector('img');
|
||||
// resolvePath 返回 null → src 不变
|
||||
expect(img.hasAttribute('src')).toBe(true);
|
||||
expect(img.getAttribute('src')).toBe('../../outside.png');
|
||||
expect(img.hasAttribute('src')).toBe(false);
|
||||
expect(img.getAttribute('data-blocked')).toBe('external');
|
||||
});
|
||||
|
||||
it('getDataDir 抛错 → 不阻断(catch 降级,旧行为继续)', async () => {
|
||||
it('getDataDir 抛错 → fail-closed:src 被剥,不阻断后续调用', async () => {
|
||||
// audit fix (Sec-M2):旧版 catch 里静默吞掉错误并继续,相当于「IPC 故障时
|
||||
// 整个数据目录边界检查被绕过」。新版与 null 同走 fail-closed 路径,保证
|
||||
// 「dataDir 不可用 = 一律拦截」语义统一。catch 仍不阻断后续 DOM 处理,
|
||||
// 但图片一律不下发 src(与 null 一致),这是有意的更安全默认。
|
||||
viewer = makeViewer(() => { throw new Error('IPC down'); });
|
||||
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
body.innerHTML = '<img src="img.png">';
|
||||
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
|
||||
// 不抛错;图片被正常解析
|
||||
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
|
||||
expect(body.querySelector('img').hasAttribute('src')).toBe(false);
|
||||
consoleWarn.mockRestore();
|
||||
});
|
||||
|
||||
@@ -614,7 +622,8 @@ describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)',
|
||||
expect(img.getAttribute('data-blocked')).toBe('external');
|
||||
});
|
||||
|
||||
it('未传 getDataDir 选项 → 不做边界检查(旧行为兼容)', async () => {
|
||||
it('未传 getDataDir 选项 → fail-closed:src 被剥', async () => {
|
||||
// audit fix (Sec-M2):与 null / 抛错同走 fail-closed —— 边界守护不可降级。
|
||||
viewer = new MarkdownViewer({
|
||||
bodyElement: body,
|
||||
emptyElement: document.querySelector('.empty'),
|
||||
@@ -624,7 +633,8 @@ describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)',
|
||||
});
|
||||
body.innerHTML = '<img src="img.png">';
|
||||
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
|
||||
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
|
||||
expect(body.querySelector('img').hasAttribute('src')).toBe(false);
|
||||
expect(body.querySelector('img').getAttribute('data-blocked')).toBe('external');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
183
tests/unit/rescue.test.js
Normal file
183
tests/unit/rescue.test.js
Normal file
@@ -0,0 +1,183 @@
|
||||
// src/rescue.js 测试
|
||||
//
|
||||
// 覆盖:
|
||||
// - buildRescueName 的命名规则(去 .md 后缀、毫秒精度、随机后缀、默认值 fallback)
|
||||
// - rescueDirtyContent 的三种结局:成功 / IPC result.error / IPC 异常
|
||||
// - friendlyFsError 错误码 vs message 的语义(audit Round 16 bug)
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { buildRescueName, rescueDirtyContent } from '../../src/rescue.js';
|
||||
import * as feedback from '../../src/feedback.js';
|
||||
|
||||
describe('buildRescueName —— 命名规则', () => {
|
||||
it('原名带 .md → 去掉后缀再加 -rescued-...', () => {
|
||||
const name = buildRescueName('test.md');
|
||||
expect(name).toMatch(/^test-rescued-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}-[a-z0-9]{4}\.md$/);
|
||||
});
|
||||
|
||||
it('原名大小写混合的 .MD 也去后缀(不区分大小写)', () => {
|
||||
const name = buildRescueName('NOTES.MD');
|
||||
expect(name.startsWith('NOTES-rescued-')).toBe(true);
|
||||
});
|
||||
|
||||
it('原名不带 .md → 直接拼 -rescued-...', () => {
|
||||
const name = buildRescueName('plain-text');
|
||||
expect(name.startsWith('plain-text-rescued-')).toBe(true);
|
||||
expect(name.endsWith('.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('原名为空 / null / undefined → 用 untitled.md 作 base', () => {
|
||||
expect(buildRescueName('').startsWith('untitled-rescued-')).toBe(true);
|
||||
expect(buildRescueName(null).startsWith('untitled-rescued-')).toBe(true);
|
||||
expect(buildRescueName(undefined).startsWith('untitled-rescued-')).toBe(true);
|
||||
});
|
||||
|
||||
it('时间戳含毫秒(避免同秒内重名,详见 audit renderer-M9)', () => {
|
||||
const name = buildRescueName('foo.md');
|
||||
// 形如 2026-09-21T10-30-45-123(slice(0,23) 保留 .mmm)
|
||||
expect(name).toMatch(/-\d{3}-[a-z0-9]{4}\.md$/);
|
||||
});
|
||||
|
||||
it('随机后缀每次都不同(碰撞概率 ~1/1.6M)', () => {
|
||||
const a = buildRescueName('x.md');
|
||||
const b = buildRescueName('x.md');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rescueDirtyContent —— 三种结局', () => {
|
||||
let showToastSpy;
|
||||
beforeEach(() => {
|
||||
showToastSpy = vi.spyOn(feedback, 'showToast').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
showToastSpy.mockRestore();
|
||||
delete window.api;
|
||||
});
|
||||
|
||||
it('createFile ok=true → 弹 warning 成功 toast(含 rescue 名)', async () => {
|
||||
const api = { createFile: vi.fn().mockResolvedValue({ ok: true }) };
|
||||
const name = rescueDirtyContent({
|
||||
api,
|
||||
originalName: 'note.md',
|
||||
content: 'unsaved body',
|
||||
event: '文件已不在',
|
||||
});
|
||||
expect(name).toMatch(/^note-rescued-.+\.md$/);
|
||||
// await 让 promise 链执行
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(showToastSpy).toHaveBeenCalledTimes(1);
|
||||
expect(showToastSpy.mock.calls[0][0]).toMatch(/^文件已不在,未保存内容已救回到「note-rescued-.+\.md」$/);
|
||||
expect(showToastSpy.mock.calls[0][1]).toBe('warning');
|
||||
expect(showToastSpy.mock.calls[0][2]).toBe(4500);
|
||||
});
|
||||
|
||||
it('createFile ok=false → 弹 error toast,friendlyFsError 传 message 而非 error 码', async () => {
|
||||
// audit fix (Round 16):旧版直接传 r?.error(业务码字符串如 'INVALID_NAME'),
|
||||
// 用户在 toast 里看到原始业务码而非中文提示。
|
||||
//
|
||||
// 第二阶段代码审查 (Phase 2 review #2):friendlyFsError 现在走注入的 api
|
||||
// 参数,不再 fallback 到 window.api。测试也对应调整 —— 把 friendlyFsError
|
||||
// 挂在 api param 上,模拟生产里 ctx.api(contextBridge 暴露的对象)。
|
||||
const api = {
|
||||
createFile: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'EACCES',
|
||||
error: 'INVALID_NAME', // 业务码,不该给用户看
|
||||
message: '文件名包含 Windows 保留字符', // 中文明文,应该展示
|
||||
}),
|
||||
friendlyFsError: (code, fallback) => fallback || code || '未知错误',
|
||||
};
|
||||
rescueDirtyContent({
|
||||
api,
|
||||
originalName: 'bad.md',
|
||||
content: 'x',
|
||||
event: '原文件已被删除',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(showToastSpy).toHaveBeenCalledTimes(1);
|
||||
expect(showToastSpy.mock.calls[0][0]).toContain('原文件已被删除;未保存内容丢失(救回失败:文件名包含 Windows 保留字符)');
|
||||
// 关键断言:业务码「INVALID_NAME」**不能**出现在 toast 里
|
||||
expect(showToastSpy.mock.calls[0][0]).not.toContain('INVALID_NAME');
|
||||
});
|
||||
|
||||
it('createFile 抛 IPC 异常 → 弹 error toast,不抛 unhandled rejection', async () => {
|
||||
const api = {
|
||||
createFile: vi.fn().mockRejectedValue(new Error('IPC channel disconnected')),
|
||||
};
|
||||
rescueDirtyContent({
|
||||
api,
|
||||
originalName: 'foo.md',
|
||||
content: 'x',
|
||||
event: '文件已不在',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(showToastSpy).toHaveBeenCalledTimes(1);
|
||||
expect(showToastSpy.mock.calls[0][0]).toContain('文件已不在;未保存内容丢失');
|
||||
});
|
||||
|
||||
it('createFile 返回 ok=false 且无 message(极端) → fallback 到 error 码', async () => {
|
||||
const api = {
|
||||
createFile: vi.fn().mockResolvedValue({ ok: false, code: 'EACCES', error: 'UNKNOWN' }),
|
||||
friendlyFsError: (code, fallback) => fallback || code || '未知错误',
|
||||
};
|
||||
rescueDirtyContent({
|
||||
api,
|
||||
originalName: 'foo.md',
|
||||
content: 'x',
|
||||
event: '文件已不在',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(showToastSpy.mock.calls[0][0]).toContain('救回失败:UNKNOWN');
|
||||
});
|
||||
|
||||
it('api.friendlyFsError 不可用(极端) → 直接走 r?.message || r?.error 字符串', async () => {
|
||||
// 早期启动 / preload 不可用时,注入的 api 上没有 friendlyFsError,
|
||||
// fallback 仍给出可读提示(与第二阶段审查 review #2 一致:不再回退到
|
||||
// window.api,契约是「谁注入谁负责」)。
|
||||
const api = {
|
||||
createFile: vi.fn().mockResolvedValue({ ok: false, message: '磁盘已满' }),
|
||||
};
|
||||
rescueDirtyContent({
|
||||
api,
|
||||
originalName: 'foo.md',
|
||||
content: 'x',
|
||||
event: '文件已不在',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(showToastSpy.mock.calls[0][0]).toContain('救回失败:磁盘已满');
|
||||
});
|
||||
|
||||
it('createFile 接受新签名 payload { initialContent }(第二阶段 review #1 回归)', async () => {
|
||||
// 之前 rescue.js 用 string overload `api.createFile(name, content)`,依赖 preload
|
||||
// 的向后兼容分支。如果 preload 移除 string overload,所有 rescue 调用会静默
|
||||
// 失败(fire-and-forget 调用方收不到错误,只看到「未保存内容丢失」toast)。
|
||||
// 现在 rescue.js 显式用 `{ initialContent }` 对象形式 —— 断言 createFile 收到的
|
||||
// 第二参是对象、content 落在 initialContent 字段,与 main/file-ops.js
|
||||
// resolveFileName 的新签名对齐。
|
||||
const api = {
|
||||
createFile: vi.fn().mockResolvedValue({ ok: true }),
|
||||
};
|
||||
rescueDirtyContent({
|
||||
api,
|
||||
originalName: 'note.md',
|
||||
content: 'unsaved body',
|
||||
event: '文件已不在',
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(api.createFile).toHaveBeenCalledTimes(1);
|
||||
const [name, payload] = api.createFile.mock.calls[0];
|
||||
expect(name).toMatch(/^note-rescued-.+\.md$/);
|
||||
expect(payload).toEqual({ initialContent: 'unsaved body' });
|
||||
// 关键:不再是 string 第二参
|
||||
expect(typeof payload).toBe('object');
|
||||
expect(typeof payload).not.toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -73,23 +73,29 @@ describe('decideSaveToast —— STALE 反馈(核心 bug #1 修复 + Phase 2 P
|
||||
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
|
||||
});
|
||||
|
||||
it('silent=false + reason=STALE + ok=true → 同样弹「文件已切换」', () => {
|
||||
// Phase 2 audit 修复 (P1 #3):writeFile 在 STALE 检查前已 resolve,
|
||||
// ok=true 表示 data 已落盘,但 editor 已不在 targetPath 上。用户视角
|
||||
// 仍然需要「文件已切换」反馈(编辑器和预期的不一致)。
|
||||
it('silent=false + reason=STALE + ok=true → 弹「已保存」(success,data 已落盘)', () => {
|
||||
// Round 13 fix (M20):writeFile 在 STALE 检查前已 resolve → data 已落
|
||||
// targetPath,ok=true 表示写盘成功。此时用户已切走,editor 与 targetPath
|
||||
// 解耦,但磁盘上数据是对的 —— 弹「已保存」(success) 比「文件已切换」更
|
||||
// 准确反映「数据有没有存」。失败分支(ok=false)才弹「文件已切换」。
|
||||
const showToast = makeShowToast();
|
||||
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
|
||||
expect(showToast).toHaveBeenCalledTimes(1);
|
||||
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
|
||||
expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
|
||||
});
|
||||
|
||||
it('STALE 优先于 ok=true:reason=STALE 时一律走 warning', () => {
|
||||
// 防呆测试:即使 ok=true,reason=STALE 必须弹 warning 而不是 success。
|
||||
// 否则 Phase 2 修复会把「data 已落盘」的 success 文案误导成「保存好了」。
|
||||
const showToast = makeShowToast();
|
||||
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
|
||||
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
|
||||
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
|
||||
it('STALE + ok=true 走 success;ok=false 走 warning(防呆双向断言)', () => {
|
||||
// 防呆:必须严格按 ok 分流 —— ok=true 弹 success,ok=false 弹 warning。
|
||||
// 不能「STALE 一律走 warning」(会把「data 已落盘」误导成「没存」),
|
||||
// 也不能「STALE 一律走 success」(写盘失败却被告知「已保存」)。
|
||||
const okToast = makeShowToast();
|
||||
decideSaveToast(false, { ok: true, reason: 'STALE' }, okToast);
|
||||
expect(okToast).toHaveBeenCalledWith('已保存', 'success', 2500);
|
||||
expect(okToast).not.toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
|
||||
const failToast = makeShowToast();
|
||||
decideSaveToast(false, { ok: false, reason: 'STALE' }, failToast);
|
||||
expect(failToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
|
||||
expect(failToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
|
||||
});
|
||||
|
||||
it('silent=false + 其它失败 reason(CONFLICT_*/WRITE_FAILED/IPC_ERROR/...)→ 不弹', () => {
|
||||
@@ -132,12 +138,13 @@ describe('decideSaveToast —— shared path 场景(核心 bug #2 修复)',
|
||||
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
|
||||
});
|
||||
|
||||
it('auto-save silent=true 飞行期 + manual silent=false STALE+ok=true → 弹「文件已切换」', () => {
|
||||
// Phase 2 P1 #3:writeFile 成功但用户已切走 → 关窗路径用 ok=true 退出,
|
||||
// 但 silent=false manual caller 仍应弹 STALE 反馈。
|
||||
it('auto-save silent=true 飞行期 + manual silent=false STALE+ok=true → 弹「已保存」', () => {
|
||||
// Round 13 fix (M20):writeFile 成功但用户已切走 → 关窗路径用 ok=true
|
||||
// 退出,silent=false manual caller 看到「数据已落盘」弹 success 而非
|
||||
// 「文件已切换」—— 后者会误导(明明已存却说没存)。
|
||||
const showToast = makeShowToast();
|
||||
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
|
||||
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
|
||||
expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
|
||||
});
|
||||
|
||||
it('同 IIFE 结果:auto-save silent=true 不弹 + manual silent=false 弹 → 不互相干扰', () => {
|
||||
|
||||
50
tests/unit/settings-dialog.test.js
Normal file
50
tests/unit/settings-dialog.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// settings-dialog.js 的纯函数测试。
|
||||
//
|
||||
// settings-dialog 整个类没有单测(mount 路径太重、依赖 modal-stack / settingsStore
|
||||
// / window.api 等太多胶水),但其中有几个无副作用的小函数值得保护。
|
||||
// 本文件只覆盖那些 —— 别引整个 dialog 进来,那会拖一堆 stub。
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { clearAiKeyDefaultValue } from '../../src/settings-dialog.js';
|
||||
|
||||
describe('clearAiKeyDefaultValue(Sec-M3 修复:防 dataset 残留真 Key)', () => {
|
||||
// 背景:reveal flow 会先写 input.dataset.defaultValue = 真 Key,再把
|
||||
// .value 同步暴露给用户;关 dialog 时必须把 dataset.defaultValue 删掉,
|
||||
// 否则 XSS / DevTools / 复制粘贴能直接读出真 Key,绕过 arm/reveal 5 秒窗口。
|
||||
// 这条函数被 3 处调用:mount 后 / modal.setOnClose 早期 handler / 主 close handler。
|
||||
|
||||
it('正常 input 元素:删掉 defaultValue,保留其他 dataset 属性', () => {
|
||||
const input = {
|
||||
dataset: { defaultValue: 'sk-real-key', other: 'untouched' },
|
||||
};
|
||||
clearAiKeyDefaultValue(input);
|
||||
expect(input.dataset.defaultValue).toBeUndefined();
|
||||
expect(input.dataset.other).toBe('untouched');
|
||||
});
|
||||
|
||||
it('dataset 上没有 defaultValue 时不抛错(幂等)', () => {
|
||||
const input = { dataset: { other: 'x' } };
|
||||
expect(() => clearAiKeyDefaultValue(input)).not.toThrow();
|
||||
expect(input.dataset.other).toBe('x');
|
||||
});
|
||||
|
||||
it('null / undefined 输入静默 no-op,不抛', () => {
|
||||
expect(() => clearAiKeyDefaultValue(null)).not.toThrow();
|
||||
expect(() => clearAiKeyDefaultValue(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('没有 dataset 属性的对象静默 no-op', () => {
|
||||
// 防御:如果调用方传错对象(比如 element ref 但还没 mount、或者 wrapper),
|
||||
// 不能让 reveal 关 dialog 流程崩。真实 DOM 元素总有 dataset,所以这是兜底。
|
||||
const fake = {};
|
||||
expect(() => clearAiKeyDefaultValue(fake)).not.toThrow();
|
||||
});
|
||||
|
||||
it('多次调用幂等(连续关 dialog / 双保险路径不会破坏)', () => {
|
||||
const input = { dataset: { defaultValue: 'k' } };
|
||||
clearAiKeyDefaultValue(input);
|
||||
clearAiKeyDefaultValue(input);
|
||||
clearAiKeyDefaultValue(input);
|
||||
expect(input.dataset.defaultValue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -444,4 +444,50 @@ describe('nullable-number typeof 守卫对称(Round 4 P2-3)', () => {
|
||||
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: null });
|
||||
expect(r.sidebarWidth).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SETTINGS_UI_OPTIONS.aiProvider[*].defaults (audit T-4)', () => {
|
||||
// T-4 把原本散在 src/settings-dialog.js 顶部的 AI_DEFAULTS(baseURL + model
|
||||
// 占位符)迁到这里与 label / hint 同源。settings-dialog 通过
|
||||
// getAiProviderDefaults(value) 查表,schema 是唯一事实源。
|
||||
//
|
||||
// 这里保护:
|
||||
// 1. 两个 provider 都必须有 defaults 字段(不能让 placeholder 退化成空串)
|
||||
// 2. defaults 必须含 baseUrl + model 两个 string 字段
|
||||
// 3. 默认 baseUrl 必须与 provider 的协议约定一致 —— OpenAI 含 /v1,
|
||||
// Anthropic 不含;否则用户在空白字段看到错误提示就会按 placeholder 填错。
|
||||
const { SETTINGS_UI_OPTIONS } = schema;
|
||||
const providers = SETTINGS_UI_OPTIONS.aiProvider;
|
||||
|
||||
it('OpenAI 兼容项的 defaults 含 /v1 + model', () => {
|
||||
const p = providers.find((x) => x.value === 'openai');
|
||||
expect(p?.defaults, 'openai 项必须有 defaults 字段').toBeDefined();
|
||||
expect(typeof p.defaults.baseUrl).toBe('string');
|
||||
expect(typeof p.defaults.model).toBe('string');
|
||||
expect(p.defaults.baseUrl.endsWith('/v1'), 'OpenAI 兼容默认 baseURL 必须以 /v1 结尾').toBe(true);
|
||||
});
|
||||
|
||||
it('Anthropic 兼容项的 defaults 不含 /v1', () => {
|
||||
const p = providers.find((x) => x.value === 'anthropic');
|
||||
expect(p?.defaults).toBeDefined();
|
||||
expect(p.defaults.baseUrl.endsWith('/v1'), 'Anthropic 默认 baseURL 不能含 /v1 后缀').toBe(false);
|
||||
expect(p.defaults.baseUrl.length).toBeGreaterThan(0);
|
||||
expect(p.defaults.model.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('所有 aiProvider 项都覆盖 defaults(新增 provider 时强制填齐)', () => {
|
||||
for (const p of providers) {
|
||||
expect(p.defaults, `${p.value} 缺 defaults`).toBeDefined();
|
||||
expect(typeof p.defaults.baseUrl, `${p.value}.defaults.baseUrl 不是 string`).toBe('string');
|
||||
expect(typeof p.defaults.model, `${p.value}.defaults.model 不是 string`).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults 字段被 Object.freeze 保护(防运行时突变)', () => {
|
||||
const p = providers.find((x) => x.value === 'openai');
|
||||
// Object.freeze 在严格模式写入会抛错;非严格模式静默失败(settings-dialog 是
|
||||
// ESM 在 module top-level 加载时是 strict,但 try/catch 包住更稳)。
|
||||
expect(() => { p.defaults.baseUrl = 'http://evil'; }).toThrow();
|
||||
expect(p.defaults.baseUrl).toBe('https://api.openai.com/v1');
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,7 @@ describe('shortcuts 基本派发', () => {
|
||||
onToggleFocusMode: vi.fn(),
|
||||
onToggleAi: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
onCloseFile: vi.fn(),
|
||||
};
|
||||
detach = mountKeyboardShortcuts(handlers);
|
||||
});
|
||||
@@ -111,6 +112,26 @@ describe('shortcuts 基本派发', () => {
|
||||
expect(handlers.onReload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// 关闭当前文件:Ctrl+W。无文件时调 onCloseFile 也安全 —— app.js 的
|
||||
// closeCurrentFile() 内部对 !state.currentFile 直接 return,handler 失活无副作用。
|
||||
it('Ctrl+W → onCloseFile', () => {
|
||||
fireKey({ key: 'w', ctrlKey: true });
|
||||
expect(handlers.onCloseFile).toHaveBeenCalledTimes(1);
|
||||
fireKey({ key: 'W', ctrlKey: true });
|
||||
expect(handlers.onCloseFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Ctrl+W 必须 preventDefault(防止 Electron 默认关闭 BrowserWindow)', () => {
|
||||
const ev = fireKey({ key: 'w', ctrlKey: true });
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('Ctrl+Shift+W / Ctrl+Alt+W 不应触发 onCloseFile(避免误触)', () => {
|
||||
fireKey({ key: 'w', ctrlKey: true, shiftKey: true });
|
||||
fireKey({ key: 'w', ctrlKey: true, altKey: true });
|
||||
expect(handlers.onCloseFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非修饰键 → 不触发任何 handler', () => {
|
||||
fireKey({ key: 'a', ctrlKey: false, metaKey: false });
|
||||
for (const [name, fn] of Object.entries(handlers)) {
|
||||
@@ -271,6 +292,7 @@ describe('shortcuts handler 缺失时静默', () => {
|
||||
fireKey({ key: 'n', ctrlKey: true });
|
||||
fireKey({ key: 'O', ctrlKey: true, shiftKey: true });
|
||||
fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
|
||||
fireKey({ key: 'w', ctrlKey: true });
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -313,6 +335,19 @@ describe('shortcuts IME 合成期间(C3 audit)', () => {
|
||||
fireKey({ key: 'e', ctrlKey: true, isComposing: false });
|
||||
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Ctrl+W 在 IME 合成期也应被吞:用户在 CJK 拼音中途按 Ctrl+W 不应该
|
||||
// 立即关文件,IME buffer 还在编辑器里。
|
||||
it('isComposing=true 时 Ctrl+W 不触发 onCloseFile', () => {
|
||||
const handlers2 = { onCloseFile: vi.fn() };
|
||||
const detach2 = mountKeyboardShortcuts(handlers2);
|
||||
try {
|
||||
fireKey({ key: 'w', ctrlKey: true, isComposing: true });
|
||||
expect(handlers2.onCloseFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
detach2();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcuts 模态打开时 Ctrl+W 抑制(M1 audit)', () => {
|
||||
@@ -338,12 +373,18 @@ describe('shortcuts 模态打开时 Ctrl+W 抑制(M1 audit)', () => {
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('模态未开时 Ctrl+W 不在抑制列表里(normal 行为:不被 preventDefault)', () => {
|
||||
it('模态未开时 Ctrl+W 仍 preventDefault(挡 Electron 关窗口),但不在 SUPPRESS 列表里', () => {
|
||||
if (modalApi) modalApi.close('test-no-modal');
|
||||
modalApi = null;
|
||||
const closeHandler = vi.fn();
|
||||
const detachNoModal = mountKeyboardShortcuts({ onCloseFile: closeHandler });
|
||||
const ev = fireKey({ key: 'w', ctrlKey: true });
|
||||
// 不被任何 handler 拦截 → defaultPrevented 应该是 false(由浏览器处理关窗口)
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
// Ctrl+W 必须 preventDefault:否则 Electron 默认行为是直接关 BrowserWindow,
|
||||
// 整个应用被干掉,与"关闭当前文件"语义不符。这是 2026-09 设计优化引入
|
||||
// 的新行为(之前测试期望 defaultPrevented=false 是旧语义)。
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(closeHandler).toHaveBeenCalled();
|
||||
if (detachNoModal && typeof detachNoModal.dispose === 'function') detachNoModal.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
106
tests/unit/toolbar-close-file.test.js
Normal file
106
tests/unit/toolbar-close-file.test.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// src/toolbar.js 的 onCloseFile 接线测试
|
||||
//
|
||||
// 覆盖:
|
||||
// - btnCloseFile click → onCloseFile 被调用
|
||||
// - 缺失 onCloseFile 时不挂 listener(防御)
|
||||
// - 缺失 btnCloseFile 时不挂 listener
|
||||
//
|
||||
// 真实运行链路:app.js 在 syncSaveButton() 末尾调 syncCloseFileButton(),
|
||||
// 把按钮从 disabled → enabled;用户点击 → Toolbar 构造时挂的 listener 触发
|
||||
// onCloseFile() → closeCurrentFile()。本测试只覆盖 Toolbar 自身的接线
|
||||
// 段(构造时是否挂对 listener),不覆盖 closeCurrentFile 内部逻辑
|
||||
// (那个由 app.js 模块顶层,单元测试触不到)。
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Toolbar } from '../../src/toolbar.js';
|
||||
|
||||
describe('toolbar.onCloseFile 接线', () => {
|
||||
let btnCloseFile;
|
||||
let elements;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<button id="btn-close-file" type="button" disabled>关闭文件</button>
|
||||
<button id="btn-theme">主题</button>
|
||||
<button id="btn-pin">置顶</button>
|
||||
<button id="btn-settings">设置</button>
|
||||
<button id="btn-reset-layout">重置布局</button>
|
||||
<button id="btn-min">最小化</button>
|
||||
<button id="btn-max">最大化</button>
|
||||
<button id="btn-close">关闭窗口</button>
|
||||
`;
|
||||
btnCloseFile = document.getElementById('btn-close-file');
|
||||
elements = {
|
||||
btnTheme: document.getElementById('btn-theme'),
|
||||
btnPin: document.getElementById('btn-pin'),
|
||||
btnSettings: document.getElementById('btn-settings'),
|
||||
btnResetLayout: document.getElementById('btn-reset-layout'),
|
||||
btnMin: document.getElementById('btn-min'),
|
||||
btnMax: document.getElementById('btn-max'),
|
||||
btnClose: document.getElementById('btn-close'),
|
||||
btnCloseFile,
|
||||
};
|
||||
});
|
||||
|
||||
/** 用 dispatchEvent 触发 click —— jsdom 的 .click() 在 form 容器里行为不稳。 */
|
||||
function clickEl(el) {
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
it('btnCloseFile click → onCloseFile 被调用一次', () => {
|
||||
const onCloseFile = vi.fn();
|
||||
new Toolbar({ elements, onCloseFile });
|
||||
clickEl(btnCloseFile);
|
||||
expect(onCloseFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('多次点击 → onCloseFile 调用相应次数(无去重)', () => {
|
||||
const onCloseFile = vi.fn();
|
||||
new Toolbar({ elements, onCloseFile });
|
||||
clickEl(btnCloseFile);
|
||||
clickEl(btnCloseFile);
|
||||
clickEl(btnCloseFile);
|
||||
expect(onCloseFile).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('btnCloseFile 缺失 → onCloseFile 不被调用也不抛错', () => {
|
||||
const onCloseFile = vi.fn();
|
||||
const elementsNoBtn = { ...elements };
|
||||
delete elementsNoBtn.btnCloseFile;
|
||||
expect(() => new Toolbar({ elements: elementsNoBtn, onCloseFile })).not.toThrow();
|
||||
expect(onCloseFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('btnCloseFile 存在但 onCloseFile 缺失 → 不挂 listener', () => {
|
||||
// 验证方式:btnCloseFile.click() 时不应触发任何引用错误。
|
||||
// 由于 listener 没挂,调用就是一个空操作;测试"不抛错"即可。
|
||||
expect(() => new Toolbar({ elements })).not.toThrow();
|
||||
// 没有 onCloseFile 时 listener 没挂,逻辑无副作用。
|
||||
// (更深层的"没挂"需要 spy addEventListener,超出本测试范围。)
|
||||
});
|
||||
|
||||
it('按钮 disabled 状态不影响 listener 挂载', () => {
|
||||
// 浏览器层面 disabled 按钮 click 不派发,但 listener 应在构造时已挂。
|
||||
// jsdom 不会模拟浏览器的 disabled-click 抑制(disabled 后 click 仍派发),
|
||||
// 所以这个测试主要防回归:listener 必须挂上、调用 click 不抛错。
|
||||
btnCloseFile.disabled = true;
|
||||
const onCloseFile = vi.fn();
|
||||
new Toolbar({ elements, onCloseFile });
|
||||
expect(() => clickEl(btnCloseFile)).not.toThrow();
|
||||
expect(onCloseFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('btnClose 与 btnCloseFile 互不影响', () => {
|
||||
const onClose = vi.fn();
|
||||
const onCloseFile = vi.fn();
|
||||
new Toolbar({ elements, onClose, onCloseFile });
|
||||
clickEl(document.getElementById('btn-close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseFile).not.toHaveBeenCalled();
|
||||
clickEl(btnCloseFile);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user