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,44 @@
// shared/ai-errors.js 与 renderer 端 LOCAL_AI_ERROR_FALLBACK 的漂移防护测试
//
// audit fix (CQ-MED-7):之前主进程错误码字面量靠注释提醒同步,实际曾漂移
// renderer ERR_TIMEOUT='ERR_TIMEOUT' 与 main 'AI_TIMEOUT' 对不上,
// aiErrorMessage switch 永不命中)。现在 renderer 在 jsdom 单测里走
// LOCAL_AI_ERROR_FALLBACK 这份本地副本,与 shared/ai-errors.js#AI_ERROR 必须
// 严格一致 —— 本测试校验两边字面值一一对应,新增 / 删除 / 改名都必须在两边同步改。
import { describe, it, expect } from 'vitest';
// shared/ai-errors.js 是 CJSmodule.exports = { AI_ERROR }ESM 解析器
// 不支持 named import from CJS —— vitest 在 node 环境下走 require 链路。
const { AI_ERROR: SHARED_AI_ERROR } = require('../../shared/ai-errors.js');
import { AI_ERROR as RENDERER_AI_ERROR } from '../../src/ai/ai-status.js';
describe('shared/ai-errors.js 与 src/ai/ai-status.js 错误码漂移防护 (CQ-MED-7)', () => {
it('shared 与 renderer 的 key 集合完全一致', () => {
const sharedKeys = Object.keys(SHARED_AI_ERROR).sort();
const rendererKeys = Object.keys(RENDERER_AI_ERROR).sort();
expect(rendererKeys).toEqual(sharedKeys);
});
it('shared 与 renderer 的每个 key 字面值完全一致', () => {
for (const key of Object.keys(SHARED_AI_ERROR)) {
expect(RENDERER_AI_ERROR[key]).toBe(SHARED_AI_ERROR[key]);
}
});
it('renderer 的 AI_ERROR 在 jsdom 环境(无 preload下能取到非空对象', () => {
// 本测试文件跑在 jsdom 下,没有 window.api → 应走 LOCAL_AI_ERROR_FALLBACK。
// 若拿到空对象 / undefined说明 fallback 链路断了 —— 单测会假阳性通过,
// 实际渲染时 aiErrorMessage 永远走 default 分支。
expect(RENDERER_AI_ERROR).toBeDefined();
expect(Object.keys(RENDERER_AI_ERROR).length).toBeGreaterThan(0);
});
it('跨进程错误码main ↔ renderer 都会引用)的字面值是稳定的字符串', () => {
// 不允许 undefined / null / 空串 —— main 返回这些值给 renderer 后,
// ai-controller 会拿它们走 switch落到 default 分支。
for (const code of Object.values(SHARED_AI_ERROR)) {
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
}
});
});

871
tests/unit/ai.test.js Normal file
View File

@@ -0,0 +1,871 @@
// main/ai.js —— AI 代理的协议分支测试
//
// 覆盖:
// - aiProvider 默认走 OpenAI 分支(旧版配置 / 没填 provider
// - aiProvider='openai' → POST /chat/completions · Bearer 鉴权
// - aiProvider='anthropic' → POST /v1/messages · x-api-key + anthropic-version
// - 未配置 / 超时 / 取消 / HTTP 错误 / 响应格式错误的稳定错误码
// - OpenAI / Anthropic 各自的截断信号finish_reason / stop_reason
//
// 不依赖 Electronai.js 只用 Node 内置 fetch通过 deps.fetchImpl 注入
// 假 fetch 验证 URL / headers / body避免真实网络。
import { describe, it, expect } from 'vitest';
const { createAiProxy } = require('../../main/ai.js');
/**
* 构造一个 mock fetch断言请求、返回预设响应。
* @param {object} opts
* @param {(url:string, init:object) => void} [opts.onCall] 每次调用都跑一次
* @param {Response|Error} [opts.response] 默认 200 + 合法 OpenAI 回复
*/
function mockFetch({ onCall, response } = {}) {
/** @type {Array<{url:string, init:object}>} */
const calls = [];
const fn = async (url, init = {}) => {
calls.push({ url, init });
if (onCall) onCall(url, init);
if (response instanceof Error) throw response;
return response || new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
return { fn, calls };
}
describe('createAiProxy.runEdit —— Provider 分发', () => {
it('aiProvider 缺省 / 未知 → 走 OpenAI 分支(向后兼容)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({ aiBaseUrl: 'https://x/v1', aiApiKey: 'sk-x', aiModel: 'gpt-x' }),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r1' });
expect(r.ok).toBe(true);
expect(captured.url).toBe('https://x/v1/chat/completions');
expect(captured.init.headers.Authorization).toBe('Bearer sk-x');
});
it('aiProvider=openai → POST /chat/completions · Bearer', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
choices: [{ message: { content: 'plain reply' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://api.openai.com/v1',
aiApiKey: 'sk-test',
aiModel: 'gpt-4o-mini',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r2' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(captured.url).toBe('https://api.openai.com/v1/chat/completions');
expect(captured.init.method).toBe('POST');
expect(captured.init.headers.Authorization).toBe('Bearer sk-test');
expect(captured.init.headers['x-api-key']).toBeUndefined();
// body 应含 messages 数组system + user
const body = JSON.parse(captured.init.body);
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages[0].role).toBe('system');
expect(body.messages[1].role).toBe('user');
});
it('aiProvider=anthropic → POST /v1/messages · x-api-key + anthropic-version', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"hello"}' }],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'anthropic',
aiBaseUrl: 'https://api.anthropic.com',
aiApiKey: 'sk-ant-test',
aiModel: 'claude-3-5-sonnet-latest',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r3' });
expect(r.ok).toBe(true);
expect(r.content).toBe('hello');
expect(captured.url).toBe('https://api.anthropic.com/v1/messages');
expect(captured.init.headers['x-api-key']).toBe('sk-ant-test');
expect(captured.init.headers['anthropic-version']).toBe('2023-06-01');
expect(captured.init.headers.Authorization).toBeUndefined();
const body = JSON.parse(captured.init.body);
// Anthropic: system 在顶层messages 只有 user
expect(body.system).toBeDefined();
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages.length).toBe(1);
expect(body.messages[0].role).toBe('user');
});
});
describe('createAiProxy.runEdit —— 错误码', () => {
it('未配置 → AI_NOT_CONFIGURED', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiBaseUrl: '', aiApiKey: '', aiModel: '' }),
fetchImpl: async () => { throw new Error('should not call'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
});
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
// 本地代理Ollama / LM Studio / vllm走 http://localhost / 127.0.0.1 不需要 Key
// 留空 apiKey 即过这里测的是「Key 存在 + 非loopback 明文」必须被拒。
it('OpenAI 明文 http + 非loopback + 已配 Key → AI_NOT_CONFIGURED防 Key 泄露)', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://evil.example.com/v1', aiApiKey: 'sk-real-key', aiModel: 'gpt-4' }),
fetchImpl: async () => { throw new Error('should not call — 明文 HTTP + 非loopback 必须在 fetch 前拒绝'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
expect(r.message).toMatch(/明文 HTTP/);
expect(r.message).toMatch(/https/);
});
it('Anthropic 明文 http + 非loopback + 已配 Key → AI_NOT_CONFIGURED', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'http://gateway.example.com', aiApiKey: 'sk-ant-key', aiModel: 'claude-3-5-sonnet' }),
fetchImpl: async () => { throw new Error('should not call'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_NOT_CONFIGURED');
expect(r.message).toMatch(/明文 HTTP/);
});
it('明文 http + localhost + 已配 Key → 仍放过(本地代理合法场景)', async () => {
// localhost 上 Key 即使被同机嗅探到也比公网风险低且本地代理Ollama
// 走 http 是惯例。这里验证 validateBaseUrl + http 守卫不误伤。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://localhost:11434/v1', aiApiKey: 'k', aiModel: 'gpt-4' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(true);
});
it('明文 http + 127.0.0.1 + 已配 Key → 仍放过', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://127.0.0.1:1234/v1', aiApiKey: 'k', aiModel: 'gpt-4' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(true);
});
it('OpenAI 401 → AI_PROVIDER_ERROR"API Key 无效或没有权限"', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'bad', aiModel: 'gpt' }),
fetchImpl: async () => new Response('Unauthorized', { status: 401 }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/API Key/);
});
it('Anthropic 400 + invalid_request_error → 透出 error.message', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
type: 'error',
error: { type: 'invalid_request_error', message: 'max_tokens: too large' },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/max_tokens: too large/);
});
it('OpenAI finish_reason=length → AI_BAD_RESPONSE截断提示', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"abc' }, finish_reason: 'length' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/不完整/);
});
it('Anthropic stop_reason=max_tokens → AI_BAD_RESPONSE截断提示', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"trunc' }],
stop_reason: 'max_tokens',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/不完整/);
});
it('网络错误 → AI_PROVIDER_ERROR带原始 message', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('ECONNREFUSED'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/ECONNREFUSED/);
});
});
describe('createAiProxy —— timer / pending Map cleanup', () => {
// audit fixpostJson 之前只 clearTimeout 没从 timers Map delete entry
// 长时间使用会内存泄漏。这里通过重复 runEdit 验证 timer/pending 都已清空。
it('连续 10 次成功请求后 timers / pending Map 都为空', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
for (let i = 0; i < 10; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `r${i}` });
expect(r.ok).toBe(true);
}
// 通过 cancelAll 的清理量反推cancelAll 应该不需要清任何东西(如果还有泄漏,会 abort 已完成的 controller
proxy.cancelAll();
// 重复 cancelAll 不应该抛错(说明 Map 已空)
expect(() => proxy.cancelAll()).not.toThrow();
});
it('失败响应HTTP 400后 timer Map 已清', async () => {
let count = 0;
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => {
count++;
return new Response('Bad Request', { status: 400 });
},
});
for (let i = 0; i < 5; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `err${i}` });
expect(r.ok).toBe(false);
}
expect(count).toBe(5);
// 后续 cancelAll 无副作用说明没有累积
expect(() => proxy.cancelAll()).not.toThrow();
});
it('网络异常fetch reject后 timer Map 已清', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('ECONNRESET'); },
});
for (let i = 0; i < 5; i++) {
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `net${i}` });
expect(r.ok).toBe(false);
}
expect(() => proxy.cancelAll()).not.toThrow();
});
// looksLikeEditJson 是 main/ai.js 的内部函数,没导出。这里通过 runEdit 行为
// 反推 —— 当 AI 返回「带嵌套 JSON 但格式损坏」的文本时,应得到 AI_BAD_RESPONSE
// (提示不完整)而不是 raw 回退(让用户看到残缺 JSON 当作文本)。
describe('createAiProxy.runEdit —— looksLikeEditJson嵌套 JSON 截断识别)', () => {
// 这些测试间接覆盖嵌套对象场景:原来的正则 `/\{[^{}]*"content"\s*:[^{}]*\}/`
// 在 content 后跟嵌套 {} 时会失配,导致 looksLikeEditJson 误报 false
// normalizeAssistantText 走 raw 回退 —— AI 返回半截带嵌套对象的 JSON 时,
// 用户看到的是残缺 JSON 当文本,不是「请缩小文档」的提示。
function makeProxyWithText(aiText) {
const fetchImpl = async () => new Response(JSON.stringify({
choices: [{ message: { content: aiText }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
return createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
}
it('AI 返回半截嵌套 JSON外层 {} 未闭合)→ raw 回退responseFormat=raw', async () => {
// 模拟 AI 想要返回 {content:"...", patches:[...]} 但 token 耗尽、整个对象都
// 没闭合tryParseJson 失败、extractFirstJsonObject 也找不到配对对象、
// looksLikeEditJson 返回 false → 走 raw 路径把残文本返回给用户。
// 这是设计raw 回退让用户至少能看到 AI 输出了什么TRUNCATED 是 OpenAI /
// Anthropic 自己的 finish_reason=length / stop_reason=max_tokens 触发的,
// 不靠正文里的 {} 配对判断。)
const truncated = '{"content":"hi","patches":[{"op":"replace","old":"a","new":"b"},';
const proxy = makeProxyWithText(truncated);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt1' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(truncated);
});
it('AI 返回半截 JSON 但 brace-pair slice 仍能拿到合法对象 → 解析成功', async () => {
// 复杂 case文本里有 prose + JSON 截断(缺尾 `}`),但第一个 { 到最后一个 }
// 之间存在合法 JSON。这种情况应当被 getJsonCandidates 的 brace-pair slice
// 提取并解析 —— 验证新 extractFirstJsonObject 与既有 brace-pair slice
// 路径都不破坏嵌套解析。
const mixed = 'Here is the edit:\n{"content":"abc","meta":{"note":"done"}}\n[truncated';
const proxy = makeProxyWithText(mixed);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt2' });
expect(r.ok).toBe(true);
expect(r.content).toBe('abc');
expect(r.responseFormat).toBe('json');
});
it('AI 返回完整嵌套 JSON合法→ ok=true', async () => {
// 完整嵌套 JSON 应当被 tryParseJson 解析通过、走 responseFormat='json'。
const valid = '{"content":"hi","patches":[{"op":"replace"}]}';
const proxy = makeProxyWithText(valid);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt3' });
expect(r.ok).toBe(true);
expect(r.content).toBe('hi');
expect(r.responseFormat).toBe('json');
});
it('用户提示词纯文本里出现 "content" 子串 → 不误判 TRUNCATED', async () => {
// 老正则 `"content"\s*:` 在纯文本里有 `please update the "content" of section` 也会命中。
// 新实现要求体内有真配对的 JSON 对象 + `"content"` 键,纯文本不会触发。
const prose = '请把 "content" 字段里那个段落的标题改了。';
const proxy = makeProxyWithText(prose);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt4' });
// 不是 JSON → responseFormat='raw',当成普通回复返回,不应报错。
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(prose);
});
it('AI 返回带 fenced code block 的 JSON含嵌套 → ok=true', async () => {
// ```json\n{...}\n``` 也应被 tryParseJson 处理,新实现的
// extractFirstJsonObject 在 fenced block 内也能正常工作(先 trim 后扫描)。
const fenced = '```json\n{"content":"fenced ok","meta":{"k":"v"}}\n```';
const proxy = makeProxyWithText(fenced);
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt5' });
expect(r.ok).toBe(true);
expect(r.content).toBe('fenced ok');
});
});
it('重复 requestId 第二次进入 runEdit 会取消第一次', async () => {
let callCount = 0;
const fetchImpl = (url) => {
callCount++;
// 第一次永远不返回(模拟长任务)
if (callCount === 1) return new Promise(() => {});
// 第二次正常返回
return Promise.resolve(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://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
// 第一次请求(永远 hang
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
// 第二次同 requestId → 应该 cancel 第一次并发新请求
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
const r2 = await p2;
expect(r2.ok).toBe(true);
expect(r2.content).toBe('ok');
// p1 应该被 cancel不需要 resolvecancelAll 会清掉)
proxy.cancelAll();
// 等 p1 真的 reject被 abort 后 fetch promise 不会自己结束)
// 加个超时兜底防止测试挂住
await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]);
expect(() => proxy.cancelAll()).not.toThrow();
});
});
// audit F1cleanup 必须 identity-check 后再 delete详细注释见 main/ai.js 的 cleanup
describe('createAiProxy —— requestId 重入取消', () => {
it('重复 requestId 第二次进入 runEdit 会取消第一次', async () => {
let callCount = 0;
const fetchImpl = () => {
callCount++;
if (callCount === 1) return new Promise(() => {});
return Promise.resolve(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://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
const r2 = await p2;
expect(r2.ok).toBe(true);
expect(r2.content).toBe('ok');
proxy.cancelAll();
await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]);
expect(() => proxy.cancelAll()).not.toThrow();
});
it('同 requestId 重新发起后,新请求仍可被 cancel(requestId) 单独取消', async () => {
const inFlight = [];
const fetchImpl = (url, opts) => {
return new Promise((resolve, reject) => {
inFlight.push({ opts });
// 必须监听 abort 信号;否则 catch 永远不触发,测试会 timeout
if (opts && opts.signal) {
if (opts.signal.aborted) {
reject(new DOMException('aborted', 'AbortError'));
return;
}
opts.signal.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
}
});
};
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl,
});
const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' });
await new Promise((r) => setTimeout(r, 0));
const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' });
await new Promise((r) => setTimeout(r, 0));
proxy.cancel('dup');
// 等两个都 settleaborted
await Promise.all([
p1.catch(() => {}),
p2.catch(() => {}),
]);
expect(inFlight).toHaveLength(2);
// 关键断言:第二个请求的 signal.aborted 必须是 true
// —— F1 修复前:旧 cleanup 把 controller 从 Map 抹掉proxy.cancel('dup') 是 no-op
expect(inFlight[1].opts.signal.aborted).toBe(true);
});
});
// audit M1+M2+M3 回归测试API Key 脱敏 + URL userinfo 剥除 + JSON-wrapped header 修复
describe('createAiProxy.runEdit —— API Key 脱敏 + URL 清洗', () => {
// M1 fixfetch 抛错时 message 里携带 key 也必须脱敏。
// 真实场景fetch reject 的 Error.message 偶尔会包含 URL含 query key
// 自定义 fetch 包装层把请求 headers 拼进 message。
it('M1网络错误 message 含 sk- 前缀的 key → result.message 已脱敏', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('fetch failed for Authorization: Bearer sk-realsk1234567890abcdef'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm1' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).not.toMatch(/sk-realsk1234567890abcdef/);
expect(r.message).toMatch(/\[API_KEY\]/);
});
// M1 follow-up非 sk- 前缀的 key自定义 token / API secret也应被某条规则盖住。
it('M1网络错误 message 含通用 token=xxx → result.message 已脱敏', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => { throw new Error('api_key=hunter2_real_secret_was_here'); },
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm1b' });
expect(r.ok).toBe(false);
expect(r.message).not.toMatch(/hunter2_real_secret_was_here/);
expect(r.message).toMatch(/\[REDACTED\]/);
});
// M2 fixbaseURL 含 userinfohttps://user:pass@host必须剥掉。
// 真实场景:中转服务在 baseURL 里塞 userinfo 简化配置fetch 会把 userinfo
// 当 Basic Auth 自动发出去,凭据泄露到第三方。
it('M2baseURL 含 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:secret@relay.example.com/v1',
aiApiKey: 'k', aiModel: 'm',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2' });
expect(r.ok).toBe(true);
expect(captured).toBe('https://relay.example.com/v1/chat/completions');
expect(captured).not.toMatch(/hunter2/);
expect(captured).not.toMatch(/secret/);
expect(captured).not.toMatch(/@/);
});
// M2 follow-upURL 里 path 段含 sk-xxx 的极端情况也要在 sanitizeUrl 输出里被遮罩。
// 不依赖 joinUrl防御深度sanitizeUrl 单独处理原始 URL 时也应当剥 userinfo。
it('M2sanitizeUrl 输出HTTP 错误日志)已剥 userinfo + path 段 key', async () => {
/** @type {Array<{event:string, data:any}>} */
const logs = [];
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://user: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: 'm2b' });
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).not.toMatch(/@/);
expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/);
});
// M3 fixHTTP 错误回显里出现 JSON 风格的 `"authorization": "Bearer xxx"` 文本
// (错误信息里把 header 序列化成字符串AI proxy / 网关常见),旧 regex 遇到 `"`
// 就停,只盖到开引号,剩下 `xxx` 全部漏出。新增的 JSON 包装 pattern 一次性吃完整段。
// 这里使用非 sk- 前缀的 key确保测试通过 sk- 正则 fail只覆盖 JSON-wrapped 修复路径)。
it('M3JSON-wrapped authorization header非 sk- 前缀)→ 已脱敏', async () => {
const SENSITIVE = 'eyJhbGciOiJIUzI1NiJ9.payload.signature_xxx_no_sk_prefix';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
error: { message: `upstream returned: "authorization": "Bearer ${SENSITIVE}"` },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm3' });
expect(r.ok).toBe(false);
// 关键断言JWT 内容不再出现在 toast message 里
expect(r.message).not.toMatch(/eyJhbGciOiJIUzI1NiJ9/);
expect(r.message).not.toMatch(/payload\.signature_xxx_no_sk_prefix/);
expect(r.message).toMatch(/REDACTED/);
});
// M3 follow-up短 key 阈值 8 → 5 后,截断的 sk- 也能被捕获。
// 状态码选 400 且不带 invalid_request_error.type —— 走 main/ai.js 末尾
// 「AI 请求失败HTTP 400{detail}」路径detail 经 sanitizeDetail 脱敏后
// 进入 result.message。401/403/404/429/5xx 都有早退固定文案,不展示上游 detail。
it('M3截断的 sk-5 字符后缀)→ 已脱敏为 [API_KEY]', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
error: { message: 'invalid token: sk-12345abc (truncated for security)' },
}), { status: 400, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm3b' });
expect(r.ok).toBe(false);
// 不应出现真实截断的 key 段
expect(r.message).not.toMatch(/sk-12345abc/);
expect(r.message).toMatch(/\[API_KEY\]/);
});
});
// audit shared-M11 + shared-M13归一化层对「非字符串 content」和「非文本块」做了区分
describe('createAiProxy.runEdit —— shared-M11 / shared-M13 归一化分支', () => {
// M11Anthropic 返回的 content 全部是 tool_use / image无 text 块)——
// 旧版会误报「AI 返回了空内容」让用户重试;新版提示「无法识别」。
it('M11Anthropic 仅返回 tool_use 块(无 text→ 明确提示 tool_use / 图像块', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [
{ type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: 'BJ' } },
],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11a' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
// 提示里要让用户知道是「格式不识别」而不是「内容为空」
expect(r.message).toMatch(/tool_use|图像块/);
});
it('M11Anthropic 仅返回 image 块(无 text→ 同样提示无法识别', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } }],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11b' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toMatch(/tool_use|图像块/);
});
it('M11Anthropic content 数组空 → 仍报「空内容」(不是 tool_use 路径)', async () => {
// 兜底:旧分支 'AI 返回了空内容' 仍保留,区分「真的没回」和「回了非文本」。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11c' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_BAD_RESPONSE');
expect(r.message).toBe('AI 返回了空内容');
});
it('M11Anthropic 混合 text + tool_use → 只提取 textresponseFormat=json', async () => {
// 混合块:正常路径 —— text 被拼接tool_use 跳过,但不影响 ok=true。
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
content: [
{ type: 'tool_use', id: 't1', name: 'noop', input: {} },
{ type: 'text', text: '{"content":"mixed ok"}' },
],
stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11d' });
expect(r.ok).toBe(true);
expect(r.content).toBe('mixed ok');
expect(r.responseFormat).toBe('json');
});
// M13模型按规矩返回了 JSON 对象但 content 不是字符串(数组 / 对象 / null / 数字)。
// 旧逻辑走「output 截断」分支误导用户;新版把 message 整体当 raw 回退给用户。
it('M13OpenAI 返回的 JSON 解析后 content 是数组 → responseFormat=raw回退原 message', async () => {
const rawMessage = '{"content":[]}';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: rawMessage }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13a' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
// 回退:原 message 整体作为 content让用户看到模型实际输出
expect(r.content).toBe(rawMessage);
});
it('M13OpenAI 返回的 JSON 解析后 content 是 null → responseFormat=raw', async () => {
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: '{"content":null,"meta":"x"}' }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13b' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe('{"content":null,"meta":"x"}');
});
it('M13OpenAI 返回的 JSON 解析后 content 是嵌套对象 → responseFormat=raw', async () => {
const rawMessage = '{"content":{"ops":[{"op":"replace","old":"a","new":"b"}]}}';
const proxy = createAiProxy({
getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }),
fetchImpl: async () => new Response(JSON.stringify({
choices: [{ message: { content: rawMessage }, finish_reason: 'stop' }],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13c' });
expect(r.ok).toBe(true);
expect(r.responseFormat).toBe('raw');
expect(r.content).toBe(rawMessage);
});
});
// ---------------------------------------------------------------------------
// audit round-13Anthropic / OpenAI max_tokens 上限路由
//
// 回归背景pickAnthropicTokenConfig 的正则要求 `claude-` 后紧跟已知片段,
// 于是 claude-opus-5 / claude-sonnet-5 / claude-haiku-4-5 全部漏网 → 回退 8192
// 比 claude-opus-4-816384还低。「越新的模型拿到越小的 max_tokens」
// 长笔记改写在 8k 被截断 → stop_reason: max_tokens → 报「AI 修改结果不完整」。
// ---------------------------------------------------------------------------
describe('max_tokens 上限按模型路由', () => {
/**
* 跑一次 runEdit返回实际发出的请求 body。
* @param {'openai'|'anthropic'} provider
* @param {string} model
*/
async function capturedBody(provider, model) {
let captured = null;
const okBody = provider === 'anthropic'
? { content: [{ type: 'text', text: '{"content":"ok"}' }], stop_reason: 'end_turn' }
: { choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }] };
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: provider,
aiBaseUrl: provider === 'anthropic' ? 'https://x' : 'https://x/v1',
aiApiKey: 'k',
aiModel: model,
}),
fetchImpl: async (url, init) => {
captured = JSON.parse(init.body);
return new Response(JSON.stringify(okBody), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
},
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'cap' });
expect(r.ok).toBe(true);
return captured;
}
// ---- Anthropic5 系与 4.6+ 拿到 32k ----
it.each([
'claude-opus-5',
'claude-sonnet-5',
'claude-fable-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-sonnet-4-6',
])('Anthropic %s → max_tokens 32000官方 128k 上限,保守取值)', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(32_000);
});
// ---- Anthropic其余 4 系 / 3-5 / 3-7 拿到 16k ----
it.each([
'claude-haiku-4-5',
'claude-sonnet-4-5',
'claude-opus-4-5',
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
])('Anthropic %s → max_tokens 16384', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(16_384);
});
// ---- Anthropicclaude-3 老家族与未知模型保守 8k ----
it.each([
'claude-3-opus-20240229',
'claude-3-haiku-20240307',
])('Anthropic %sclaude-3 老家族)→ max_tokens 8192', async (model) => {
const body = await capturedBody('anthropic', model);
expect(body.max_tokens).toBe(8_192);
});
it('Anthropic 未知模型(自部署 / 中转)→ max_tokens 8192最大限度兼容', async () => {
const body = await capturedBody('anthropic', 'my-local-llm-v2');
expect(body.max_tokens).toBe(8_192);
});
// 关键不变式:新模型的上限不得低于老模型(这正是本轮修的 bug
it('回归claude-opus-5 的 max_tokens 不低于 claude-opus-4-8', async () => {
const five = await capturedBody('anthropic', 'claude-opus-5');
const four = await capturedBody('anthropic', 'claude-opus-4-8');
expect(five.max_tokens).toBeGreaterThanOrEqual(four.max_tokens);
});
it('回归claude-haiku-4-5 的 max_tokens 高于 claude-3 老家族', async () => {
const haiku45 = await capturedBody('anthropic', 'claude-haiku-4-5');
const old3 = await capturedBody('anthropic', 'claude-3-haiku-20240307');
expect(haiku45.max_tokens).toBeGreaterThan(old3.max_tokens);
});
// ---- OpenAI 侧token 字段路由不受影响(防回归) ----
it('OpenAI gpt-5 → max_completion_tokens 且不带 temperature', async () => {
const body = await capturedBody('openai', 'gpt-5');
expect(body.max_completion_tokens).toBe(32_000);
expect(body.max_tokens).toBeUndefined();
expect(body.temperature).toBeUndefined();
});
it('OpenAI gpt-4o → max_tokens 16384 且带 temperature', async () => {
const body = await capturedBody('openai', 'gpt-4o');
expect(body.max_tokens).toBe(16_384);
expect(body.max_completion_tokens).toBeUndefined();
expect(typeof body.temperature).toBe('number');
});
});
// ---------------------------------------------------------------------------
// audit round-13 Sec-H3fetch 必须 redirect:'manual' 防 API key 跟着 302 走
//
// 回归背景Anthropic 用 x-api-key 自定义头(不在 fetch 规范 CORS 非通配脱敏集
// 合里undici 默认 follow 重定向到不同 origin 时会原样复传 header。
// 用户配置的中转 / 网关一旦 302 到攻击者域sk-ant-... 跟当前笔记全文会被转发。
// ---------------------------------------------------------------------------
describe('createAiProxy —— redirect: manual 防 API Key 跟随重定向', () => {
it('fetch 调用必带 redirect:"manual"OpenAI 分支)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
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://x/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o-mini',
}),
fetchImpl,
});
await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-1' });
expect(captured.init.redirect).toBe('manual');
});
it('fetch 调用必带 redirect:"manual"Anthropic 分支)', async () => {
let captured = null;
const fetchImpl = async (url, init) => {
captured = { url, init };
return new Response(JSON.stringify({
content: [{ type: 'text', text: '{"content":"ok"}' }], stop_reason: 'end_turn',
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'anthropic',
aiBaseUrl: 'https://x', aiApiKey: 'sk-ant-test', aiModel: 'claude-opus-5',
}),
fetchImpl,
});
await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-2' });
expect(captured.init.redirect).toBe('manual');
});
it('3xx 重定向 → opaqueredirect → AI_PROVIDER_ERROR + 提示用户改 Base URL', async () => {
// 模拟 undici 在 manual 模式下返回的 opaqueredirect Responsestatus=0、type='opaqueredirect'
const fetchImpl = async () => {
const res = new Response(null, { status: 302 });
// 模拟 opaqueredirect 的关键属性status 0 + type 'opaqueredirect'
Object.defineProperty(res, 'status', { value: 0 });
Object.defineProperty(res, 'type', { value: 'opaqueredirect' });
return res;
};
const proxy = createAiProxy({
getConfig: () => ({
aiProvider: 'openai',
aiBaseUrl: 'https://evil-redirect.example/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o',
}),
fetchImpl,
});
const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-3' });
expect(r.ok).toBe(false);
expect(r.error).toBe('AI_PROVIDER_ERROR');
expect(r.message).toMatch(/发生了重定向/);
expect(r.message).toMatch(/不会自动跟随重定向/);
});
});

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 });
});
});

View File

@@ -0,0 +1,115 @@
// src/context-menu.js 的单测
//
// 主要覆盖 audit #C3critical memory leak修复
// - 快速 show→hide→show 循环rAF 回调里的 addEventListener 不应泄漏
// - 同步 hide() 应当取消未 fire 的 rAF避免它在我们 detach 之后再去挂监听
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ContextMenu } from '../../src/context-menu.js';
describe('ContextMenu show/hide lifecycle', () => {
let menu;
let container;
beforeEach(() => {
// 用独立 container避免污染全局 body / 与其它测试串扰
container = document.createElement('div');
container.id = 'context-menu-test-container';
document.body.appendChild(container);
menu = new ContextMenu({ container });
});
afterEach(() => {
menu.hide();
if (container.parentElement) container.parentElement.removeChild(container);
});
it('show 后 isVisible() → true', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
expect(menu.isVisible()).toBe(true);
expect(container.querySelector('.context-menu')).toBeTruthy();
});
it('hide 后 isVisible() → false', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
menu.hide();
expect(menu.isVisible()).toBe(false);
expect(container.querySelector('.context-menu')).toBeNull();
});
it('hide() 可重复调用(不抛错)', () => {
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
expect(() => {
menu.hide();
menu.hide();
menu.hide();
}).not.toThrow();
});
it('audit #C3show 后立即 hide应取消挂起的 rAF', async () => {
// 模拟场景用户打开菜单立刻关闭onSelect 路径)。原实现的 rAF 仍会
// fire 并挂 5 个 listener —— 现在必须被取消。
menu.show({
x: 100, y: 100,
items: [{ label: '复制', value: 'copy' }],
onSelect: () => {},
});
// rAF 还没 firejsdom 默认 fire但 show 同步执行后立即 hide 应抢先)
menu.hide();
// 等待足够时间让原 rAF如果没被取消会 fire 的窗口过去
await new Promise((r) => setTimeout(r, 50));
// 此时再 show 一次:必须能成功挂上新菜单
expect(() => {
menu.show({
x: 200, y: 200,
items: [{ label: '粘贴', value: 'paste' }],
onSelect: () => {},
});
}).not.toThrow();
expect(menu.isVisible()).toBe(true);
});
it('audit #C3show→hide→show 快速循环不抛错、不泄漏菜单节点', async () => {
// 模拟右键 → 选项 → 立即右键 的快速循环
for (let i = 0; i < 5; i += 1) {
menu.show({
x: 50 + i * 10, y: 50 + i * 10,
items: [{ label: `Item ${i}`, value: `v${i}` }],
onSelect: () => {},
});
menu.hide();
}
// 让所有挂起的 rAF 跑完
await new Promise((r) => setTimeout(r, 50));
// 容器里只应该有一个当前菜单,或者零个(取决于时序)
const menus = container.querySelectorAll('.context-menu');
expect(menus.length).toBeLessThanOrEqual(1);
expect(menu.isVisible()).toBe(false);
});
it('空 items 数组 → 不创建菜单', () => {
menu.show({ x: 100, y: 100, items: [], onSelect: () => {} });
expect(menu.isVisible()).toBe(false);
expect(container.querySelector('.context-menu')).toBeNull();
});
it('非数组 items → 不创建菜单', () => {
menu.show({ x: 100, y: 100, items: null, onSelect: () => {} });
expect(menu.isVisible()).toBe(false);
});
});

View File

@@ -0,0 +1,219 @@
// src/dirty-sync.js 测试Round 3 bug 修复 · 2026-08
//
// 验证单向提升同步契约:
// - state.isDirty=false + editor.isDirty()=true → state.isDirty=true提升
// - state.isDirty=true + editor.isDirty()=false → 不动(半步保存场景保留 true
// - state.isDirty=false + editor.isDirty()=false → 不动(真干净)
// - state.isDirty=true + editor.isDirty()=true → 不动(已一致)
//
// 半步保存save 飞行期用户敲字符)契约**绝对不**被破坏state.isDirty=true
// 是「doc 比 writeFile 内容多」的标志,不能被 editor 的基线对比覆盖。这是
// save-toast.js#decideSaveToast 之外的核心 invariant。
//
// 关联:[[project-dirty-sync-ime-race-2026-08]]。
/* @vitest-environment jsdom */
import { describe, it, expect } from 'vitest';
import { syncDirtyFromEditor } from '../../src/dirty-sync.js';
function makeState(isDirty = false) {
return { isDirty };
}
function makeEditor(isDirtyFn) {
return { isDirty: isDirtyFn };
}
describe('syncDirtyFromEditor —— IME race 单向提升契约', () => {
it('state=false + editor=true → 提升到 true核心 IME race bug 修复)', () => {
// 关键场景:用户用 IME 输入字符state.isDirty 因 onChange 被 updateListener
// 跳过而保持 false但 editor.doc 里有 IME 字符。同步后 state 必须跟上 truth
// 否则 save() 走 !state.isDirty 早退返回「已是最新」IME 字符丢失。
const state = makeState(false);
const editor = makeEditor(() => true);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(true);
expect(state.isDirty).toBe(true);
});
it('state=true + editor=false → 不动(半步保存契约,绝不能降级)', () => {
// 半步保存save 飞行期用户敲了字符 → state.isDirty=true 表示「doc 比
// writeFile 内容多」。editor.isDirty() 返回 false 是因为 markSaved 已把
// lastSavedDoc 设为当前 doc。这是有意的「飞行期新字符」标记本函数绝不能
// 把它盖成 false —— 否则下次 save() 会走早退,飞行期字符丢失。
const state = makeState(true);
const editor = makeEditor(() => false);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(true);
});
it('state=false + editor=false → 不动(真干净,无提升需要)', () => {
// 干净文件刚打开state 与 editor 都没改动 —— 同步是 no-op。
const state = makeState(false);
const editor = makeEditor(() => false);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(false);
});
it('state=true + editor=true → 不动(已一致,提升无意义)', () => {
// 普通 dirty 状态state 与 editor 都认为是 dirty —— 同步是 no-op。
const state = makeState(true);
const editor = makeEditor(() => true);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(true);
});
it('editor.isDirty() 是惰性求值:只在 state=false 时调用(短路优化)', () => {
// 性能契约state=true 时绝不应调 editor.isDirty() —— 否则 save() 每次
// 入口都会跑 doc.toString() 这种全量字符串对比。验证 spy 不被调用。
let calls = 0;
const state = makeState(true);
const editor = makeEditor(() => { calls += 1; return false; });
syncDirtyFromEditor(state, editor);
expect(calls).toBe(0);
});
it('editor.isDirty() 在 state=false 时确实被调用', () => {
let calls = 0;
const state = makeState(false);
const editor = makeEditor(() => { calls += 1; return false; });
syncDirtyFromEditor(state, editor);
expect(calls).toBe(1);
});
it('可重复调用(幂等):提升后再次调用是 no-op', () => {
// 多次调用不会「又提升一次」造成副作用累积。第二次 state=true 直接走短路。
const state = makeState(false);
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
expect(state.isDirty).toBe(true);
const secondCall = syncDirtyFromEditor(state, editor);
expect(secondCall).toBe(false);
expect(state.isDirty).toBe(true);
});
it('状态可观察state 是普通的 mutable 对象,函数只写 isDirty 这一个字段', () => {
// 防回归:本函数不应动 state 上的其他字段(比如 lastSavedMtimeMs——
// 调用方app.js用 state 做 dirty 决策外还做别的mtime 比对、IPC setDirty
// 等),如果这里意外改了其他字段会引入很难追的 bug。
const state = { isDirty: false, lastSavedMtimeMs: 12345, currentFile: { path: '/x.md' } };
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
expect(state.isDirty).toBe(true);
expect(state.lastSavedMtimeMs).toBe(12345);
expect(state.currentFile).toEqual({ path: '/x.md' });
});
});
describe('syncDirtyFromEditor —— 与 save() 早退检查的协同', () => {
// 这是 Round 3 bug 的核心契约save() 入口调本函数后,!state.isDirty 早退
// 检查必须可靠 —— 即「editor 有改动」+ 「state 同步后」= 「不早退」。
//
// 这些 case 不是测 save() 本体(那需要 mock 一堆 app.js 闭包),而是测
// syncDirtyFromEditor 产生的 state 状态,确保 save() 早退条件不会误判。
it('IME raceeditor 有字符 + state 已同步 → state.isDirty=truesave 不早退)', () => {
const state = makeState(false);
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
// 模拟 save() 的早退条件:`!state.isDirty`
expect(!state.isDirty).toBe(false); // 应该走 writeFile
});
it('真干净state 与 editor 都 false → state 仍 falsesave 早退 OK', () => {
const state = makeState(false);
const editor = makeEditor(() => false);
syncDirtyFromEditor(state, editor);
expect(!state.isDirty).toBe(true); // save 早退是正确行为
});
it('半步保存state=true + editor=false → state 仍 truesave 走 writeFile', () => {
const state = makeState(true);
const editor = makeEditor(() => false);
syncDirtyFromEditor(state, editor);
expect(!state.isDirty).toBe(false); // save 走 writeFile 落盘飞行期新字符
});
});
// ---------------------------------------------------------------------------
// applyEntries 守卫语义app.js line 2298
//
// 契约editor 有未保存字符时applyEntries 检测「文件被外部删除」必须
// 用 `editor.isDirty()`(实时真相),不能用 `state.isDirty`IME 合成
// 期间被 updateListener 的 !isComposing 守卫屏蔽,可能 stale-false
//
// Round 9 P0 bug旧实现 `!state.isDirty` → IME 期间 stale-false → 守卫
// 通过 → clearCurrentFile() → onFilesChanged line 2901 `if (!state.currentFile)
// return` 早退 → line 2917 syncDirtyFromEditor 永远不到 → line 2944 rescue
// 分支永远不到 → IME 字符随编辑器 view 销毁而丢失 + 窗口关闭 dirty=false
// 不弹保存提示 + 静默丢字。
//
// 这些测试不 import app.js4111 行 + 大量模块级闭包),直接重放守卫的
// if-condition 断言语义。app.js 里改动对应的守卫条件会被 grep 拦住。
// ---------------------------------------------------------------------------
describe('applyEntries 删除守卫语义 —— IME race 必须用 editor.isDirty()', () => {
// 重放 app.js#applyEntries 的守卫state.currentFile && state.currentFile.path && !<DIRTY_TRUTH>
function shouldClearOnFileDeleted(state, dirtyTruth) {
return Boolean(state.currentFile && state.currentFile.path && !dirtyTruth);
}
it('editor 有未保存字符IME 合成中) → 不能清 currentFile', () => {
// 关键场景:用户 IME 拼音期间 state.isDirty 是 stale-false
// 但 editor.isDirty() = true。守卫必须用 editor.isDirty() 才能
// 拦下 clearCurrentFile让 onFilesChanged 的 rescue 路径接管。
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
const editorDirty = true; // IME 缓冲里有字符
const stateDirtyStale = false; // onChange 被守卫屏蔽
// 旧实现state.isDirty会清 currentFile → 丢字
expect(shouldClearOnFileDeleted(state, stateDirtyStale)).toBe(true);
// 新实现editor.isDirty()):拦下 clearCurrentFile → rescue 接管
expect(shouldClearOnFileDeleted(state, editorDirty)).toBe(false);
});
it('正常 dirty 状态 → 不能清 currentFile', () => {
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
expect(shouldClearOnFileDeleted(state, true)).toBe(false);
});
it('真干净 → 清 currentFile外部删除正常处理', () => {
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
expect(shouldClearOnFileDeleted(state, false)).toBe(true);
});
it('没有打开文件 → 不清(无需处理)', () => {
const state = { currentFile: null };
expect(shouldClearOnFileDeleted(state, false)).toBe(false);
expect(shouldClearOnFileDeleted(state, true)).toBe(false);
});
it('currentFile 缺 path 字段 → 不清(异常状态防御)', () => {
const state = { currentFile: { name: 'foo.md' /* no path */ } };
expect(shouldClearOnFileDeleted(state, false)).toBe(false);
});
it('守卫与 syncDirtyFromEditor 的协同IME 提升后 rescue 路径必能触发', () => {
// 端到端验证 P0 fix 的修复链:
// 1. IME 期间 applyEntries 用 editor.isDirty() 拦下 clearCurrentFile
// 2. onFilesChanged 不早退
// 3. syncDirtyFromEditor 把 state.isDirty 提升到 true
// 4. rescue 分支 `if (state.isDirty)` 看到 true → 落救回文件
//
// 这里只测第 1 步和第 3 步(最容易回归),第 4 步是 onFilesChanged 的
// 既有契约applyEntries 不再触发的话 rescue 自然能到)。
const state = { currentFile: { path: '/foo.md', name: 'foo.md' }, isDirty: false };
const editor = makeEditor(() => true); // IME 期间 editor 一定有未保存字符
// step 1: 守卫拦下
expect(shouldClearOnFileDeleted(state, editor.isDirty())).toBe(false);
// step 3: syncDirtyFromEditor 把 state 提升
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(true);
expect(state.isDirty).toBe(true);
});
});

View File

@@ -0,0 +1,128 @@
// Stage 7 tests: editor-theme.js
//
// 覆盖:
// - pickEditorTheme 按 body[data-theme] 返回对应的 extensions 数组
// - 暗色叠加 oneDark亮色只用自己的覆盖
// - backgroundColor / color 必须带 !importantoneDark 后续注入的同 specificity
// 规则会赢,必须用 important 强制覆盖,否则编辑区会显示 #282c34 而不是 --bg-viewer
//
// editorTheme / editorThemeLight 是 EditorView.theme() 返回的 extension 对象,
// 内部是 CM6 私有结构,不展开断言 —— 只验证 pickEditorTheme 的行为即可。
import { describe, it, expect } from 'vitest';
import { pickEditorTheme, editorTheme, editorThemeLight, __editorThemeSpecForTests } from '../../src/editor-theme.js';
describe('pickEditorTheme', () => {
it('theme === "light" → 只返回 editorThemeLight', () => {
const exts = pickEditorTheme('light');
expect(exts).toHaveLength(1);
expect(exts[0]).toBe(editorThemeLight);
});
it('theme === "dark" → 返回 [oneDark, editorTheme]', () => {
const exts = pickEditorTheme('dark');
expect(exts).toHaveLength(2);
expect(exts[1]).toBe(editorTheme);
});
it('theme === undefined → 当暗色处理', () => {
const exts = pickEditorTheme(undefined);
expect(exts).toHaveLength(2);
expect(exts[1]).toBe(editorTheme);
});
it('theme === "auto"(旧版本残留) → 当暗色处理(防御性)', () => {
const exts = pickEditorTheme('auto');
expect(exts).toHaveLength(2);
});
it('theme === ""(空字符串) → 当暗色处理', () => {
const exts = pickEditorTheme('');
expect(exts).toHaveLength(2);
});
it('theme === null → 当暗色处理', () => {
const exts = pickEditorTheme(null);
expect(exts).toHaveLength(2);
});
});
describe('editorThemeSpec background overrides (oneDark / base theme 互斥)', () => {
// oneDark 的 .cm-editor { background-color: #282c34 } 和 .cm-gutters 同色,
// 在 dark theme 下会导致编辑区底色跟 .editor-pane / .viewer 的 --bg-viewer 不一致。
// 必须用 !important 强制覆盖,否则 oneDark 后注入的同 specificity 规则会赢。
it('.cm-editor backgroundColor 必须是 var(--bg-viewer) !important', () => {
const v = __editorThemeSpecForTests.backgroundColor;
expect(v).toMatch(/var\(--bg-viewer\)/);
expect(v).toMatch(/!important/);
});
it('.cm-editor color 必须是 var(--fg-primary) !important', () => {
const v = __editorThemeSpecForTests.color;
expect(v).toMatch(/var\(--fg-primary\)/);
expect(v).toMatch(/!important/);
});
it('.cm-gutters backgroundColor 必须是 var(--bg-viewer) !important', () => {
const v = __editorThemeSpecForTests.gutterBackgroundColor;
expect(v).toMatch(/var\(--bg-viewer\)/);
expect(v).toMatch(/!important/);
});
it('.cm-gutters color 必须是 var(--fg-tertiary) !important', () => {
const v = __editorThemeSpecForTests.gutterColor;
expect(v).toMatch(/var\(--fg-tertiary\)/);
expect(v).toMatch(/!important/);
});
// @codemirror/view 的 base theme 用 `&light .cm-activeLineGutter` / `&dark` 规则
//(特异性 (0,2,0))给 active 行 gutter 涂硬编码色(亮 #e2f2ff / 暗 #222227
// 我们的 .cm-activeLineGutter 是 (0,1,0),必须用 !important 才能让 var(--bg-hover) 稳定生效。
// 否则亮色下「活动行 gutter 变浅蓝色块」就是这个原因。
it('.cm-activeLine backgroundColor 必须是 var(--bg-hover) !important', () => {
const v = __editorThemeSpecForTests.activeLineBackgroundColor;
expect(v).toMatch(/var\(--bg-hover\)/);
expect(v).toMatch(/!important/);
});
it('.cm-activeLineGutter backgroundColor 必须是 var(--bg-hover) !important', () => {
const v = __editorThemeSpecForTests.activeLineGutterBackgroundColor;
expect(v).toMatch(/var\(--bg-hover\)/);
expect(v).toMatch(/!important/);
});
it('.cm-activeLineGutter color 必须是 var(--fg-secondary) !important', () => {
const v = __editorThemeSpecForTests.activeLineGutterColor;
expect(v).toMatch(/var\(--fg-secondary\)/);
expect(v).toMatch(/!important/);
});
// base theme 给 .cm-cursor 设黑色,&dark 下设 #528bffoneDark 的 cursor 蓝)。
// 我们用 var(--accent),需要 !important 才能不被覆盖。
it('.cm-cursor/.cm-dropCursor borderLeftColor 必须是 var(--accent) !important', () => {
const v = __editorThemeSpecForTests.cursorBorderLeftColor;
expect(v).toMatch(/var\(--accent\)/);
expect(v).toMatch(/!important/);
});
// .cm-content caretColor 同样被 base theme 影响 —— 加 !important 兜底。
it('.cm-content caretColor 必须是 var(--accent) !important', () => {
const v = __editorThemeSpecForTests.caretColor;
expect(v).toMatch(/var\(--accent\)/);
expect(v).toMatch(/!important/);
});
// base theme `&light .cm-panels { background: #f5f5f5; color: black }` /
// `&dark { background: #333338; color: white }` 会盖住搜索面板的颜色。
it('.cm-panels backgroundColor 必须是 var(--bg-elevated) !important', () => {
const v = __editorThemeSpecForTests.panelsBackgroundColor;
expect(v).toMatch(/var\(--bg-elevated\)/);
expect(v).toMatch(/!important/);
});
it('.cm-panels color 必须是 var(--fg-primary) !important', () => {
const v = __editorThemeSpecForTests.panelsColor;
expect(v).toMatch(/var\(--fg-primary\)/);
expect(v).toMatch(/!important/);
});
});

View File

@@ -0,0 +1,229 @@
// src/editor-toolbar.js
//
// 三态preview / edit / split视图模式控件图标按钮 + aria-pressed +
// body.dataset.editorModeCSS 据此切布局)。构造函数绑 click → selectMode。
//
// 测试覆盖:
// - selectMode 合法值:触发 onChange + DOM 更新 + aria-pressed 切换
// - selectMode 非法值:被静默忽略(不抛、不触发 onChange、不动 DOM
// - selectMode 同模式no-op不重复触发 onChange
// - setMode同步 DOM 但不触发 onChange用于外部「持久化设置改了」
// 反向同步 UI避免循环触发
// - getMode从 body.dataset.editorMode 读,默认 'preview'
// - cyclepreview → edit → split → preview不依赖 setMode 路径,
// 走 selectMode 路径所以每次都触发 onChange
// - a11y每个按钮都拿到 aria-pressed不仅 active 那个);不依赖按钮
// 缺省时整个 component 仍能工作(按钮可选)
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { EditorToolbar, EDITOR_MODES } from '../../src/editor-toolbar.js';
function makeBtn() {
const btn = document.createElement('button');
btn.type = 'button';
return btn;
}
function setupDOM(initialMode = null) {
document.body.innerHTML = '';
if (initialMode === null) {
delete document.body.dataset.editorMode;
} else {
document.body.dataset.editorMode = initialMode;
}
const btnPreview = makeBtn();
const btnEdit = makeBtn();
const btnSplit = makeBtn();
document.body.append(btnPreview, btnEdit, btnSplit);
return { btnPreview, btnEdit, btnSplit };
}
describe('EditorToolbar 构造 + 事件绑定', () => {
it('按钮 click → selectMode(对应 mode) + onChange', () => {
// 起始 'split' —— 不与任何一个按钮 mode 重合,确保 click 都触发 onChange
const els = setupDOM('split');
const onChange = vi.fn();
new EditorToolbar({ elements: els, onChange });
els.btnPreview.click();
expect(onChange).toHaveBeenCalledWith('preview');
onChange.mockClear();
els.btnEdit.click();
expect(onChange).toHaveBeenCalledWith('edit');
onChange.mockClear();
els.btnSplit.click();
expect(onChange).toHaveBeenCalledWith('split');
});
it('缺按钮:对应 click 不绑、构造不抛', () => {
const els = setupDOM();
const onChange = vi.fn();
// 只给 btnEditbtnPreview / btnSplit 缺
const partial = { btnEdit: els.btnEdit };
expect(() => new EditorToolbar({ elements: partial, onChange })).not.toThrow();
els.btnEdit.click();
expect(onChange).toHaveBeenCalledWith('edit');
// 缺按钮的 click 不应触发 selectMode没绑事件
els.btnPreview.click();
// onChange 仍只被调用 1 次(来自 btnEdit 这次)
expect(onChange).toHaveBeenCalledTimes(1);
});
});
describe('EditorToolbar.selectMode', () => {
it('合法值:更新 body.dataset.editorMode + aria-pressed + is-active class', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.selectMode('edit');
expect(document.body.dataset.editorMode).toBe('edit');
expect(els.btnEdit.classList.contains('is-active')).toBe(true);
expect(els.btnPreview.classList.contains('is-active')).toBe(false);
expect(els.btnSplit.classList.contains('is-active')).toBe(false);
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('true');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('false');
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('false');
});
it('非法值:被忽略(不抛、不触发 onChange、不动 DOM', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
document.body.dataset.editorMode = 'split';
expect(() => tb.selectMode('wide')).not.toThrow();
expect(() => tb.selectMode('')).not.toThrow();
expect(() => tb.selectMode(null)).not.toThrow();
expect(() => tb.selectMode(undefined)).not.toThrow();
expect(onChange).not.toHaveBeenCalled();
expect(document.body.dataset.editorMode).toBe('split');
});
it('同模式 → no-op不重复触发 onChange', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.selectMode('edit');
expect(onChange).toHaveBeenCalledTimes(1);
tb.selectMode('edit');
expect(onChange).toHaveBeenCalledTimes(1);
tb.selectMode('split');
expect(onChange).toHaveBeenCalledTimes(2);
});
it('无 onChange 回调:不抛', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els });
expect(() => tb.selectMode('split')).not.toThrow();
expect(document.body.dataset.editorMode).toBe('split');
});
});
describe('EditorToolbar.setMode外部同步 UI不触发 onChange', () => {
it('同步 DOM 但不调用 onChange避免循环', () => {
const els = setupDOM();
const onChange = vi.fn();
const tb = new EditorToolbar({ elements: els, onChange });
tb.setMode('split');
expect(document.body.dataset.editorMode).toBe('split');
expect(els.btnSplit.classList.contains('is-active')).toBe(true);
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('true');
expect(onChange).not.toHaveBeenCalled();
});
it('同模式也执行 setDOM与 selectMode 行为不同)', () => {
// 用途:外部从持久化设置读了模式,反向同步 UI。即便模式已经是
// 当前值,仍要走一遍 setDOM 确保 aria-pressed / is-active 状态
// 是最新的(防止外部代码直接改过 dataset 但 UI 状态没跟上)。
const els = setupDOM();
document.body.dataset.editorMode = 'edit';
els.btnEdit.classList.add('is-active');
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.setMode('edit');
// 即使「同模式」_setDOM 仍走一遍aria-pressed / is-active 被重写)
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('true');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('false');
});
it('非法值:静默忽略', () => {
const els = setupDOM();
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
document.body.dataset.editorMode = 'preview';
tb.setMode('not-a-mode');
expect(document.body.dataset.editorMode).toBe('preview');
});
});
describe('EditorToolbar.getMode', () => {
it('从 body.dataset.editorMode 读', () => {
const els = setupDOM();
document.body.dataset.editorMode = 'split';
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
expect(tb.getMode()).toBe('split');
});
it('dataset 未设置:返回默认 "preview"', () => {
const els = setupDOM();
delete document.body.dataset.editorMode;
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
expect(tb.getMode()).toBe('preview');
});
});
describe('EditorToolbar.cycle', () => {
it('preview → edit → split → preview每次都触发 onChange', () => {
const els = setupDOM();
const onChange = vi.fn();
delete document.body.dataset.editorMode;
const tb = new EditorToolbar({ elements: els, onChange });
tb.cycle();
expect(tb.getMode()).toBe('edit');
expect(onChange).toHaveBeenLastCalledWith('edit');
tb.cycle();
expect(tb.getMode()).toBe('split');
expect(onChange).toHaveBeenLastCalledWith('split');
tb.cycle();
expect(tb.getMode()).toBe('preview');
expect(onChange).toHaveBeenLastCalledWith('preview');
expect(onChange).toHaveBeenCalledTimes(3);
});
});
describe('EditorToolbar a11y', () => {
it('三个按钮都拿到 aria-pressed不仅 active 那个)', () => {
// 屏幕阅读器依靠 aria-pressed 判状态;非 active 按钮也要明确
// 'false' 而不是缺省(缺省时 SR 不知道这是 toggle
// 起始 'edit'selectMode('preview') 不会因同模式被早退。
const els = setupDOM('edit');
const tb = new EditorToolbar({ elements: els, onChange: vi.fn() });
tb.selectMode('preview');
expect(els.btnPreview.getAttribute('aria-pressed')).toBe('true');
expect(els.btnEdit.getAttribute('aria-pressed')).toBe('false');
expect(els.btnSplit.getAttribute('aria-pressed')).toBe('false');
});
it('EDIT_MODES 导出值与内部一致', () => {
expect(EDITOR_MODES).toEqual(['preview', 'edit', 'split']);
});
});

View File

@@ -0,0 +1,145 @@
// src/event-emitter.js
//
// 极简 EventEmitter被 settings-store 等组件复用 —— 没有测试守护的话:
// - emit 不隔离异常 → 一个抛错的 handler 阻断后续 handler 与其它事件订阅者
// - on 返回的 unsubscribe 漏掉 → 测试 / HMR 场景下旧 listener 触发回调
// - off 不存在的 handler 抛错 → emit 路径异常
//
// 覆盖:
// - on / emit 基本语义payload 透传、多 handler
// - on 返回的 unsubscribe 函数(同一 handler 多次 on → 多次 unsub 才彻底移除)
// - emit 异常隔离:一个抛错不影响其它 handler也不影响后续 emit
// - emit 无订阅者no-op
// - off 不存在的 handler / eventno-op
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from '../../src/event-emitter.js';
describe('EventEmitter 基础语义', () => {
it('on + emithandler 被调用payload 透传', () => {
const ee = new EventEmitter();
const handler = vi.fn();
ee.on('ping', handler);
ee.emit('ping', { a: 1 });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ a: 1 });
});
it('多个 handler 订阅同一事件 → emit 全部调用', () => {
const ee = new EventEmitter();
const h1 = vi.fn();
const h2 = vi.fn();
ee.on('x', h1);
ee.on('x', h2);
ee.emit('x', 'payload');
expect(h1).toHaveBeenCalledWith('payload');
expect(h2).toHaveBeenCalledWith('payload');
});
it('不同事件互不影响', () => {
const ee = new EventEmitter();
const h1 = vi.fn();
const h2 = vi.fn();
ee.on('a', h1);
ee.on('b', h2);
ee.emit('a', 1);
expect(h1).toHaveBeenCalledWith(1);
expect(h2).not.toHaveBeenCalled();
});
it('emit 无订阅者的事件no-op不抛错', () => {
const ee = new EventEmitter();
expect(() => ee.emit('nothing', 'x')).not.toThrow();
});
});
describe('EventEmitter unsubscribe', () => {
it('on 返回的函数等价于 off可作为 unsubscribe', () => {
const ee = new EventEmitter();
const handler = vi.fn();
const unsub = ee.on('e', handler);
expect(typeof unsub).toBe('function');
unsub();
ee.emit('e', 'x');
expect(handler).not.toHaveBeenCalled();
});
it('off 显式移除 handler', () => {
const ee = new EventEmitter();
const handler = vi.fn();
ee.on('e', handler);
ee.off('e', handler);
ee.emit('e', 'x');
expect(handler).not.toHaveBeenCalled();
});
it('off 不存在的 handlerno-op不抛错', () => {
const ee = new EventEmitter();
const handler = vi.fn();
ee.on('e', handler);
expect(() => ee.off('e', () => {})).not.toThrow();
// 原 handler 仍有效
ee.emit('e', 'x');
expect(handler).toHaveBeenCalledWith('x');
});
it('off 不存在的事件no-op不抛错', () => {
const ee = new EventEmitter();
expect(() => ee.off('no-such-event', () => {})).not.toThrow();
});
it('同一 handler on 多次 → unsubscribe 多次才彻底移除', () => {
// Set 语义:同一个 handler 加入 Set 多次只占一个 slotoff 一次就够。
// 这里验证 unsub/off 不会因为「多订阅一次」就漏移除。
const ee = new EventEmitter();
const handler = vi.fn();
ee.on('e', handler);
ee.on('e', handler); // 重复订阅Set 语义:仍是 1 个 entry
ee.off('e', handler);
ee.emit('e', 'x');
expect(handler).not.toHaveBeenCalled();
});
});
describe('EventEmitter 异常隔离', () => {
let errorSpy;
beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
});
it('一个 handler 抛错 → 其它 handler 仍执行(隔离)', () => {
const ee = new EventEmitter();
const after = vi.fn();
ee.on('e', () => {
throw new Error('boom');
});
ee.on('e', after);
expect(() => ee.emit('e', 'x')).not.toThrow();
expect(after).toHaveBeenCalledWith('x');
// 抛错被 console.error 记录
expect(errorSpy).toHaveBeenCalled();
// 记录的应该包含事件名 + error
const call = errorSpy.mock.calls[0];
expect(call[0]).toContain('EventEmitter');
expect(call[0]).toContain('e');
});
it('抛错的 handler 不影响后续 emit', () => {
const ee = new EventEmitter();
const good = vi.fn();
ee.on('e', () => {
throw new Error('boom');
});
ee.on('e', good);
ee.emit('e', 1);
// 第二次 emit抛错 handler 仍是同一闭包引用,照样抛、照样隔离
good.mockClear();
ee.emit('e', 2);
expect(good).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,83 @@
// shared/extension-lists.js 的单一事实源测试
// ============================================================================
//
// 之前 EDITABLE_EXTS 与 MARKDOWN_EXTS 在 main/file-ops.js 和 src/app.js 各
// 维护一份,列表漂移会导致「侧栏显示可编辑但 md 链接打不开」或反之的体验
// 割裂。本测试覆盖共享列表的内容与不变量,确保新增/删除扩展名只在
// shared/extension-lists.js 一处即可生效。
//
// 不变量:
// - MARKDOWN_EXTS ⊆ EDITABLE_EXTSMarkdown 一定可编辑)
// - 大小写:所有 key 已规范化为小写、不含点
// - 关键用户场景:.md / .markdown / .txt / .py / .json / .html 都覆盖
// - 防御:二进制扩展(.png / .exe一定不在
// ============================================================================
import { describe, it, expect } from 'vitest';
import { EDITABLE_EXTS, MARKDOWN_EXTS } from '../../shared/extension-lists.js';
describe('EDITABLE_EXTS', () => {
it('关键 Markdown 扩展在白名单内', () => {
expect(EDITABLE_EXTS.has('md')).toBe(true);
expect(EDITABLE_EXTS.has('markdown')).toBe(true);
});
it('常见纯文本 / 数据 / 配置扩展在白名单内', () => {
const expected = ['txt', 'text', 'log', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'ini', 'env'];
for (const ext of expected) {
expect(EDITABLE_EXTS.has(ext), `expected '${ext}' in EDITABLE_EXTS`).toBe(true);
}
});
it('主流编程语言扩展在白名单内', () => {
const expected = [
'py', 'js', 'ts', 'tsx', 'jsx',
'html', 'css', 'scss', 'vue', 'svelte',
'java', 'go', 'rs', 'rb', 'php',
'sh', 'bash', 'ps1', 'sql',
'c', 'cpp', 'h', 'cs', 'swift', 'scala', 'lua', 'pl', 'r', 'dart',
];
for (const ext of expected) {
expect(EDITABLE_EXTS.has(ext), `expected '${ext}' in EDITABLE_EXTS`).toBe(true);
}
});
it('二进制扩展一定不在白名单内', () => {
const binaries = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'pdf', 'zip', 'tar', 'gz', 'exe', 'dll', 'so', 'dylib', 'mp4', 'mp3', 'wav', 'docx', 'xlsx', 'pptx'];
for (const ext of binaries) {
expect(EDITABLE_EXTS.has(ext), `binary '${ext}' must NOT be in EDITABLE_EXTS`).toBe(false);
}
});
it('所有 key 都规范化为小写、不含点', () => {
for (const ext of EDITABLE_EXTS) {
expect(ext).toBe(ext.toLowerCase());
expect(ext).not.toMatch(/^\./);
expect(ext).not.toMatch(/\./);
}
});
});
describe('MARKDOWN_EXTS', () => {
it('仅 md / markdown', () => {
expect(MARKDOWN_EXTS.has('md')).toBe(true);
expect(MARKDOWN_EXTS.has('markdown')).toBe(true);
expect(MARKDOWN_EXTS.size).toBe(2);
});
it('所有 key 都规范化为小写、不含点', () => {
for (const ext of MARKDOWN_EXTS) {
expect(ext).toBe(ext.toLowerCase());
expect(ext).not.toMatch(/^\./);
expect(ext).not.toMatch(/\./);
}
});
});
describe('不变量', () => {
it('MARKDOWN_EXTS ⊆ EDITABLE_EXTSMarkdown 一定可编辑)', () => {
for (const ext of MARKDOWN_EXTS) {
expect(EDITABLE_EXTS.has(ext), `MARKDOWN_EXTS 的 '${ext}' 必须在 EDITABLE_EXTS 中`).toBe(true);
}
});
});

188
tests/unit/feedback.test.js Normal file
View File

@@ -0,0 +1,188 @@
// Stage 7+: feedback.js (showToast)
//
// 覆盖 feedback.js 的「被广泛调用但没测试」的分支:
// - #toast-container 缺失 → 静默 return不抛、不 console 噪声)
// - MAX_VISIBLE_TOASTS 超出 → 同步移除最早的(不等淡出动画)
// - dismiss() 重入守卫dataset.dismissed=1 后第二次调用直接 return
// (防 transition 重入 + 防 double-remove
// - duration=0 → 不挂 auto-close timer但 click 仍能关)
// - duration>0 → 计时器到期自动 dismiss
// - 默认 type='info' → className 含 is-info
// - message / type 写入 DOMrole=status + textContent
//
// 75+ 个调用点app.js / file-ops.js / settings-dialog.js 等。补上 guard 守护,
// 防止未来重构时无意改坏 eviction / dismiss 语义(数据丢失风险虽低,但 toast
// 满天飞也是用户能感知的 bug
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { showToast } from '../../src/feedback.js';
beforeEach(() => {
// 标准 toast container —— src/index.html 里的 id
document.body.innerHTML = '<div id="toast-container"></div>';
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
/**
* 等待一次 raf 让 DOM 操作 / 微任务走完 —— jsdom 里 setTimeout / 微任务
* 时序差异大,必须显式推进。
*/
function flushAll() {
// 推进 fake timersduration 到期)+ queueMicrotask
return Promise.resolve().then(() => Promise.resolve());
}
describe('showToast 基础行为', () => {
it('#toast-container 缺失 → 静默 return不抛、不写 console', () => {
document.body.innerHTML = '';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(() => showToast('hi')).not.toThrow();
expect(warn).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
warn.mockRestore();
log.mockRestore();
});
it('默认 type=info → toast 含 className "toast is-info"', () => {
showToast('hello');
const t = document.querySelector('#toast-container > .toast');
expect(t).not.toBeNull();
expect(t.className).toContain('is-info');
});
it('type=success / warning / error → 各自 className', () => {
showToast('a', 'success');
showToast('b', 'warning');
showToast('c', 'error');
const list = Array.from(document.querySelectorAll('#toast-container > .toast'));
expect(list.map((el) => el.className)).toEqual([
expect.stringContaining('is-success'),
expect.stringContaining('is-warning'),
expect.stringContaining('is-error'),
]);
});
it('message 写入 textContentrole=status', () => {
showToast('自定义消息', 'warning');
const t = document.querySelector('#toast-container > .toast');
expect(t.textContent).toBe('自定义消息');
expect(t.getAttribute('role')).toBe('status');
});
});
describe('showToast 自动关闭 + duration', () => {
it('duration>0 → 计时器到期后自动 dismiss', () => {
showToast('auto', 'info', 1000);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(1);
vi.advanceTimersByTime(1000);
// dismiss 内部还有 150ms 的 fade-out setTimeout
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
it('duration=0 → 不挂 auto-close timer但 click 仍能关', () => {
showToast('sticky', 'info', 0);
const t = document.querySelector('#toast-container > .toast');
expect(t).not.toBeNull();
vi.advanceTimersByTime(60_000);
// 还在
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(1);
// click 关闭
t.click();
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
});
describe('showToast click 立即关闭', () => {
it('点击 toast → 立即触发 dismiss', () => {
showToast('clickable', 'info', 60_000);
const t = document.querySelector('#toast-container > .toast');
t.click();
vi.advanceTimersByTime(150);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
});
});
describe('showToast MAX_VISIBLE_TOASTS 上限', () => {
// MAX_VISIBLE_TOASTS = 5 — 反馈模块内部常量。如果改了这里也要改测试。
// 抽成常量让失败信息更可读。
const MAX = 5;
it('到上限再 push → 最旧的被同步移除', () => {
for (let i = 0; i < MAX; i += 1) showToast(`t${i}`);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(MAX);
showToast(`t${MAX}`);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(MAX);
// 最旧的 t0 应被踢出
const texts = Array.from(document.querySelectorAll('#toast-container > .toast'))
.map((el) => el.textContent);
expect(texts).not.toContain('t0');
expect(texts).toContain(`t${MAX}`);
});
it('连续 push 6 条 → 仍只剩最新 5 条', () => {
for (let i = 0; i < MAX + 1; i += 1) showToast(`t${i}`);
const list = Array.from(document.querySelectorAll('#toast-container > .toast'));
expect(list).toHaveLength(MAX);
const texts = list.map((el) => el.textContent);
expect(texts).toEqual(['t1', 't2', 't3', 't4', 't5']);
});
it('被踢出的 toast 带 data-dismissed="1"(防 timer / click 回调闭包污染)', () => {
for (let i = 0; i < MAX + 1; i += 1) showToast(`t${i}`);
// 已经从 DOM 移除 —— 但闭包里的 dismiss 标记应当仍为 1。
// 这里只能从新增的第 6 条倒推:第 6 条入 DOM 时把 t0 踢出,
// t0.dataset.dismissed 应是 1。
// 由于 t0 已经脱离 DOMdataset 仍可读dataset 是元素本身的属性)。
// 我们拿到的是被踢的节点引用 —— 没法直接从 container 查。
// 改为验证:再 push 1 条时,没有 console errordismiss 回调跑过、被守卫挡住)。
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
showToast('extra');
// 让所有到期 timer 跑一遍(包括 t0 的 auto-close timer —— 若 dismiss 没被
// 守卫挡,会触发 container.removeChild(t0),但 t0 已经脱离 DOM
// removeChild 抛 NotFoundError 进 console
vi.advanceTimersByTime(60_000);
expect(err).not.toHaveBeenCalled();
err.mockRestore();
});
});
describe('showToast dismiss() 重入守卫', () => {
it('同一 toast 的 dismiss 被多次触发auto + click 抢跑)→ 只移除一次', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
showToast('race', 'info', 1000);
const t = document.querySelector('#toast-container > .toast');
// click 先触发 dismissdataset.dismissed=1, fade-out timer 排队)
t.click();
// 同时计时器到期再触发 dismiss —— 应被 dataset 守卫挡住
vi.advanceTimersByTime(1000);
// 让两段 fade-out 完成
vi.advanceTimersByTime(500);
expect(document.querySelectorAll('#toast-container > .toast')).toHaveLength(0);
// removeChild 不抛 NotFoundError —— 第二次 removeChild 被 parentElement 检查挡住
expect(err).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
err.mockRestore();
});
});

View File

@@ -0,0 +1,258 @@
// 集成测试:模拟完整的「右键文件 → 弹菜单 → 点击重命名」流程。
// 验证 file-list 的 contextmenu 委托、showFileListContextMenu 的菜单构造、
// 以及 ContextMenu 的 onSelect 回调链路。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { FileList } from '../../src/file-list.js';
import { ContextMenu } from '../../src/context-menu.js';
import { createFileOps } from '../../src/file-ops.js';
describe('右键文件 → 弹菜单流程', () => {
let list;
let onContextMenu;
let menu;
let container;
beforeEach(() => {
document.body.innerHTML = `
<ul id="files"></ul>
<div id="context-menu-test-container"></div>
<div id="modal-root"></div>
`;
container = document.getElementById('context-menu-test-container');
menu = new ContextMenu({ container });
onContextMenu = vi.fn();
list = new FileList({
element: document.getElementById('files'),
onSelect: vi.fn(),
onContextMenu,
});
list.setFiles([
{ name: 'a.md', path: '/d/a.md' },
{ name: 'b.md', path: '/d/b.md' },
]);
});
it('右键 li → onContextMenu 拿到对应 file', () => {
const li = document.querySelectorAll('.file-item')[1];
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
clientX: 100, clientY: 200,
});
li.dispatchEvent(evt);
expect(onContextMenu).toHaveBeenCalledTimes(1);
expect(onContextMenu.mock.calls[0][0].name).toBe('b.md');
expect(onContextMenu.mock.calls[0][1]).toBe(100);
expect(onContextMenu.mock.calls[0][2]).toBe(200);
});
it('右键 li → preventDefault 已被调(否则浏览器原生菜单会闪)', () => {
const li = document.querySelector('.file-item');
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
});
li.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(true);
});
it('右键 ul 上的非 li 区域 → 不触发 onContextMenu不 preventDefault', () => {
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
});
document.getElementById('files').dispatchEvent(evt);
expect(onContextMenu).not.toHaveBeenCalled();
expect(evt.defaultPrevented).toBe(false);
});
});
describe('showFileListContextMenu 集成', () => {
let menu;
let container;
let lastOnSelect;
beforeEach(() => {
document.body.innerHTML = `
<ul id="files"></ul>
<div id="context-menu-test-container"></div>
<div id="modal-root"></div>
`;
container = document.getElementById('context-menu-test-container');
menu = new ContextMenu({ container });
lastOnSelect = null;
// 拦截 onSelect 以便测试断言「点击菜单项最终走到哪个回调」
const origShow = menu.show.bind(menu);
menu.show = (opts) => {
lastOnSelect = opts.onSelect;
return origShow(opts);
};
});
// 复刻 src/file-ops.js:260-277 的菜单构造逻辑(不 import file-ops
// 因为它依赖 state / controls / api / callbacks 太重)
function fakeShowFileListContextMenu(entry, x, y, contextMenu, callbacks) {
const isFolder = entry && entry.entryType === 'folder';
const items = [];
if (!isFolder) items.push({ label: '重命名', value: 'rename' });
items.push({ label: '在文件夹中显示', value: 'reveal' });
items.push({ separator: true, value: '' });
items.push({ label: '删除', value: 'delete' });
contextMenu.show({
x, y, items,
onSelect: (value) => {
if (value === 'rename') callbacks.onRename(entry);
else if (value === 'reveal') callbacks.onReveal(entry);
else if (value === 'delete') callbacks.onDelete(entry);
},
});
}
it('菜单包含 rename / reveal / delete + separator', () => {
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu,
{ onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() }
);
const buttons = container.querySelectorAll('.context-menu-item');
expect(Array.from(buttons).map((b) => b.textContent)).toEqual([
'重命名', '在文件夹中显示', '删除',
]);
});
it('folder 条目 → 菜单不包含 rename', () => {
fakeShowFileListContextMenu(
{ name: 'subdir', path: '/d/subdir', entryType: 'folder' }, 10, 10, menu,
{ onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() }
);
const buttons = container.querySelectorAll('.context-menu-item');
expect(Array.from(buttons).map((b) => b.textContent)).toEqual([
'在文件夹中显示', '删除',
]);
});
it('点击「重命名」 → onRename 回调被调,且菜单关闭', () => {
const onRename = vi.fn();
const onReveal = vi.fn();
const onDelete = vi.fn();
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu,
{ onRename, onReveal, onDelete }
);
expect(menu.isVisible()).toBe(true);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[0].click();
expect(onRename).toHaveBeenCalledTimes(1);
expect(onRename.mock.calls[0][0].name).toBe('a.md');
expect(onReveal).not.toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
expect(menu.isVisible()).toBe(false);
});
it('点击「在文件夹中显示」 → onReveal 被调', () => {
const cb = { onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() };
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu, cb
);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[1].click(); // 第二个按钮是「在文件夹中显示」
expect(cb.onReveal).toHaveBeenCalledTimes(1);
expect(cb.onRename).not.toHaveBeenCalled();
});
it('点击「删除」 → onDelete 被调', () => {
const cb = { onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() };
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu, cb
);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[2].click(); // 第三个按钮是「删除」
expect(cb.onDelete).toHaveBeenCalledTimes(1);
});
});
// ==========================================================================
// revealFile 分支:文件走 showItemInFolder文件夹走 openDir
// ==========================================================================
//
// 背景:旧实现把文件和文件夹都丢给 api.showItemInFolder但主进程会
// stat.isFile() 校验目录被拒NOT_A_FILE用户在 folder 条目上点
// 「在文件夹中显示」反而弹「不是一个文件」错误 toast。修复后文件夹走
// api.openDirshell.openPath 直接打开目录本身)。
//
// 这里直接 import createFileOps用 vi.fn() 桩 api.openDir /
// api.showItemInFolder / window.api.friendlyFsError断言 revealFile 命中
// 正确的 IPC。
describe('revealFile IPC 分支', () => {
let fileOps;
let api;
let originalWindowApi;
beforeEach(() => {
document.body.innerHTML = '<div id="modal-root"></div>';
api = {
showItemInFolder: vi.fn().mockResolvedValue({ ok: true }),
openDir: vi.fn().mockResolvedValue({ ok: true }),
};
// window.api.friendlyFsError 在 revealFile 失败分支用到
originalWindowApi = globalThis.window && globalThis.window.api;
if (typeof globalThis.window === 'undefined') globalThis.window = {};
globalThis.window.api = { friendlyFsError: vi.fn().mockReturnValue('stub') };
fileOps = createFileOps({
state: { currentFile: null, isDirty: false },
controls: {
contextMenu: { show: vi.fn(), hide: vi.fn() },
viewer: {},
fileList: {},
editor: { isDirty: () => false },
},
api,
callbacks: {
openFile: vi.fn(),
updateWindowTitle: vi.fn(),
clearCurrentFile: vi.fn(),
},
});
});
afterEach(() => {
if (originalWindowApi === undefined) {
delete globalThis.window.api;
} else {
globalThis.window.api = originalWindowApi;
}
});
it('editable 文件 → 走 api.showItemInFolder不调 openDir', async () => {
await fileOps.revealFile({ name: 'a.md', path: '/d/a.md', entryType: 'editable' });
expect(api.showItemInFolder).toHaveBeenCalledWith('/d/a.md');
expect(api.openDir).not.toHaveBeenCalled();
});
it('binary 文件 → 走 api.showItemInFolder', async () => {
await fileOps.revealFile({ name: 'img.png', path: '/d/img.png', entryType: 'binary' });
expect(api.showItemInFolder).toHaveBeenCalledWith('/d/img.png');
expect(api.openDir).not.toHaveBeenCalled();
});
it('folder 条目 → 走 api.openDir不调 showItemInFolder', async () => {
await fileOps.revealFile({ name: 'subdir', path: '/d/subdir', entryType: 'folder' });
expect(api.openDir).toHaveBeenCalledWith('/d/subdir');
expect(api.showItemInFolder).not.toHaveBeenCalled();
});
it('folder → openDir 返回失败 → 走 friendlyFsError toast不静默吞', async () => {
api.openDir.mockResolvedValue({ ok: false, code: 'NOT_A_DIRECTORY', message: '路径不是文件夹' });
await fileOps.revealFile({ name: 'subdir', path: '/d/subdir', entryType: 'folder' });
expect(globalThis.window.api.friendlyFsError).toHaveBeenCalledWith(
'NOT_A_DIRECTORY',
'路径不是文件夹'
);
});
it('缺 path 的 entry → 不发任何 IPC', async () => {
await fileOps.revealFile({ name: 'x', entryType: 'editable' });
expect(api.showItemInFolder).not.toHaveBeenCalled();
expect(api.openDir).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,348 @@
// src/file-list.js 测试
//
// 覆盖:
// - fuzzyScore 评分函数(纯函数):子串 vs 子序列、单词边界、相邻匹配、空 query
// - setFiles外部删除通知 (audit 2.1)
// - applyFilter排序模式name / mtime-desc、搜索 vs sortMode 优先级
// - moveActive边界空列表、首/尾)+ 键盘导航
// - dispose 后 listener 失效
//
// 涉及 DOM 的部分用 jsdom。注意fuzzyScore 未导出,本文件通过动态 import +
// 反射拿到(避免在生产代码里加 _test_ 前缀的导出)。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as FileListMod from '../../src/file-list.js';
const { FileList } = FileListMod;
// fuzzyScore 是模块内私有函数。办法:通过 setFiles + 排序结果反推(看哪个文件名排前)。
// 简单点:直接构造一个内部 fuzzyScore 的副本契约测试 —— 同样的输入应该得同样的排序。
// 真值表通过构造几个代表性文件名对比 sortMode= name vs search 顺序来锁定。
describe('FileList 搜索 / 排序行为', () => {
let element;
let searchInput;
let list;
beforeEach(() => {
document.body.innerHTML = `
<ul id="files"></ul>
<input id="search-input" type="text" />
`;
element = document.getElementById('files');
searchInput = document.getElementById('search-input');
list = new FileList({ element, searchInput, onSelect: vi.fn() });
});
// 工具:拿到 setFiles 后渲染到 DOM 的 li 列表(按渲染顺序)
function renderedNames() {
return Array.from(element.querySelectorAll('.file-item'))
.map((li) => li.dataset.path)
.map((p) => p.split(/[\\/]/).pop());
}
function feed(files) {
list.setFiles(files);
}
it('默认按文件名zh-CN locale排序', () => {
feed([
{ name: 'b.md', path: '/d/b.md' },
{ name: 'a.md', path: '/d/a.md' },
{ name: 'c.md', path: '/d/c.md' },
]);
expect(renderedNames()).toEqual(['a.md', 'b.md', 'c.md']);
});
it('mtime-desc 模式按修改时间倒序', () => {
list.setSort('mtime-desc');
feed([
{ name: 'old.md', path: '/d/old.md', mtimeMs: 100 },
{ name: 'new.md', path: '/d/new.md', mtimeMs: 300 },
{ name: 'mid.md', path: '/d/mid.md', mtimeMs: 200 },
]);
expect(renderedNames()).toEqual(['new.md', 'mid.md', 'old.md']);
});
it('无 mtime 时 mtime-desc 模式按 name 兜底(不抛错)', () => {
list.setSort('mtime-desc');
feed([
{ name: 'b.md', path: '/d/b.md' },
{ name: 'a.md', path: '/d/a.md' },
]);
// undefined 排序是稳定的 JS 行为;至少不该抛错
expect(renderedNames()).toHaveLength(2);
});
it('搜索词非空时 fuzzy 排序覆盖 mtime-desc', () => {
list.setSort('mtime-desc');
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 },
]);
// 搜 'wel' 应该让 welcome.md 排前,不管它的 mtime 是最旧的
searchInput.value = 'wel';
searchInput.dispatchEvent(new Event('input'));
// 等 debounce flush100ms
return new Promise((r) => setTimeout(r, 150)).then(() => {
expect(renderedNames()[0]).toBe('welcome.md');
});
});
it('搜索词大小写不敏感', () => {
feed([
{ name: 'README.md', path: '/d/README.md' },
{ name: 'notes.md', path: '/d/notes.md' },
]);
searchInput.value = 'read';
searchInput.dispatchEvent(new Event('input'));
return new Promise((r) => setTimeout(r, 150)).then(() => {
expect(renderedNames()).toEqual(['README.md']);
});
});
it('搜索无匹配 → 空状态 + 提示文案', () => {
feed([
{ name: 'alpha.md', path: '/d/alpha.md' },
{ name: 'beta.md', path: '/d/beta.md' },
]);
searchInput.value = 'xyz-no-match';
searchInput.dispatchEvent(new Event('input'));
return new Promise((r) => setTimeout(r, 150)).then(() => {
expect(renderedNames()).toEqual([]);
// 空状态文案:搜索 vs 目录空 区分
expect(element.dataset.empty).toMatch(/没有匹配/);
});
});
it('目录真为空 → 空状态文案 = "此文件夹为空"', () => {
feed([]);
expect(element.dataset.empty).toBe('此文件夹为空');
});
it('搜索词触发模糊匹配:子序列允许字符不相邻', () => {
feed([
{ name: 'foo.md', path: '/d/foo.md' },
{ name: 'f_o_o_bar.md', path: '/d/f_o_o_bar.md' },
]);
searchInput.value = 'fob';
searchInput.dispatchEvent(new Event('input'));
return new Promise((r) => setTimeout(r, 150)).then(() => {
// 'f-o-b' 在 'f_o_o_bar' 里按子序列能匹配f, o[4], b[7]
expect(renderedNames()).toContain('f_o_o_bar.md');
});
});
it('搜索高亮:匹配的子串用 <mark> 包起来', () => {
feed([{ name: 'README.md', path: '/d/README.md' }]);
searchInput.value = 'read';
searchInput.dispatchEvent(new Event('input'));
return new Promise((r) => setTimeout(r, 150)).then(() => {
const mark = element.querySelector('.file-item mark');
expect(mark).not.toBeNull();
expect(mark.textContent.toLowerCase()).toBe('read');
});
});
});
describe('FileList 外部删除通知 (audit 2.1)', () => {
let element;
let list;
beforeEach(() => {
document.body.innerHTML = '<ul id="files"></ul>';
element = document.getElementById('files');
});
it('当前激活文件不在新 entries → activePath 清零(高亮消失)', () => {
// M-series fix (audit Phase M-H2)setFiles 不再触发 onFileRemoved。
// 删除判定上移到 app.js#applyEntries 用 directory 比对做精确区分。
// 这里只验证 setFiles 的渲染侧合约:高亮跟着 activePath 走,
// 不在新列表里的 active 文件会让 activePath 自动归零。
list = new FileList({ element, onSelect: vi.fn() });
list.setActive('/d/target.md');
expect(list.activePath).toBe('/d/target.md');
list.setFiles([{ name: 'other.md', path: '/d/other.md' }]);
expect(list.activePath).toBe(null);
});
it('当前激活文件仍存在 → activePath 保持', () => {
list = new FileList({ element, onSelect: vi.fn() });
list.setActive('/d/target.md');
list.setFiles([
{ name: 'target.md', path: '/d/target.md' },
{ name: 'other.md', path: '/d/other.md' },
]);
expect(list.activePath).toBe('/d/target.md');
});
it('新 entries 只剩同名文件夹folder entryType→ activePath 清零', () => {
// 文件路径 active 时,新 entries 只剩同名文件夹也视为"不在了"
// 因为 activePath 永远指向文件而非目录。这是 Phase 8 旧合约的延伸。
list = new FileList({ element, onSelect: vi.fn() });
list.setActive('/d/target');
list.setFiles([{ name: 'target', path: '/d/target', isFolder: true, entryType: 'folder' }]);
expect(list.activePath).toBe(null);
});
});
describe('FileList entryType 向后兼容', () => {
let element;
let list;
beforeEach(() => {
document.body.innerHTML = '<ul id="files"></ul>';
element = document.getElementById('files');
list = new FileList({ element, onSelect: vi.fn() });
});
it('无 entryType 字段 → 视为 editable', () => {
list.setFiles([{ name: 'foo.md', path: '/d/foo.md' }]);
const li = element.querySelector('.file-item');
expect(li.dataset.entryType).toBe('editable');
expect(li.classList.contains('is-editable')).toBe(true);
});
it('folder entryType → is-folder class', () => {
list.setFiles([
{ name: 'subdir', path: '/d/subdir', isFolder: true, entryType: 'folder' },
]);
const li = element.querySelector('.file-item');
expect(li.dataset.entryType).toBe('folder');
expect(li.classList.contains('is-folder')).toBe(true);
});
it('binary entryType → is-binary class + meta 包含"不可编辑"', () => {
list.setFiles([
{ name: 'image.png', path: '/d/image.png', size: 1234, entryType: 'binary' },
]);
const li = element.querySelector('.file-item');
expect(li.dataset.entryType).toBe('binary');
expect(li.classList.contains('is-binary')).toBe(true);
expect(li.querySelector('.file-meta').textContent).toMatch(/不可编辑/);
});
});
describe('FileList 键盘导航 / 委托', () => {
let element;
let list;
let onSelect;
beforeEach(() => {
document.body.innerHTML = '<ul id="files"></ul>';
element = document.getElementById('files');
onSelect = vi.fn();
list = new FileList({ element, onSelect });
list.setFiles([
{ name: 'a.md', path: '/d/a.md' },
{ name: 'b.md', path: '/d/b.md' },
{ name: 'c.md', path: '/d/c.md' },
]);
});
it('点击 li → onSelect 收到对应 file', () => {
const items = element.querySelectorAll('.file-item');
items[1].click();
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0].name).toBe('b.md');
});
it('Enter 键在 li 上 → onSelect', () => {
const items = element.querySelectorAll('.file-item');
items[0].focus();
items[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0].name).toBe('a.md');
});
it('Space 键在 li 上 → onSelect', () => {
const items = element.querySelectorAll('.file-item');
items[2].focus();
items[2].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0].name).toBe('c.md');
});
it('↓/↑ 不直接触发 onSelect仅移动高亮', () => {
const items = element.querySelectorAll('.file-item');
items[0].focus();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(onSelect).not.toHaveBeenCalled();
// 高亮应该挪到第二项
expect(items[1].classList.contains('is-active')).toBe(true);
});
it('↑/↓ 越界 → 钳制在 [0, last]', () => {
const items = element.querySelectorAll('.file-item');
list.setActive('/d/a.md');
items[0].focus();
// 第一项再按 ↑ → 应仍在第一项
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
expect(items[0].classList.contains('is-active')).toBe(true);
list.setActive('/d/c.md');
items[2].focus();
// 最后一项再按 ↓ → 应仍在最后一项
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(items[2].classList.contains('is-active')).toBe(true);
});
it('Home / End → first / last', () => {
const items = element.querySelectorAll('.file-item');
items[1].focus();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
expect(items[2].classList.contains('is-active')).toBe(true);
items[2].focus();
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
expect(items[0].classList.contains('is-active')).toBe(true);
});
});
describe('FileList dispose lifecycle', () => {
let element;
let searchInput;
let list;
beforeEach(() => {
document.body.innerHTML = `
<ul id="files"></ul>
<input id="search-input" type="text" />
`;
element = document.getElementById('files');
searchInput = document.getElementById('search-input');
});
it('dispose 后点击 li 不再触发 onSelect', () => {
const onSelect = vi.fn();
list = new FileList({ element, searchInput, onSelect });
list.setFiles([{ name: 'a.md', path: '/d/a.md' }]);
list.dispose();
element.querySelector('.file-item').click();
expect(onSelect).not.toHaveBeenCalled();
});
it('dispose 后 searchInput 触发不再 crashdebounce timer 已清)', () => {
list = new FileList({ element, searchInput, onSelect: vi.fn() });
list.setFiles([{ name: 'a.md', path: '/d/a.md' }]);
list.dispose();
searchInput.value = 'query';
expect(() => {
searchInput.dispatchEvent(new Event('input'));
}).not.toThrow();
});
it('dispose 后不会因挂起的 debounce 触发出错', async () => {
list = new FileList({ element, searchInput, onSelect: vi.fn() });
list.setFiles([{ name: 'a.md', path: '/d/a.md' }]);
searchInput.value = 'a';
searchInput.dispatchEvent(new Event('input'));
// 在 100ms debounce 之内 dispose
list.dispose();
// 等到 debounce 应该 fire 的时间点
await new Promise((r) => setTimeout(r, 150));
// 没有任何报错监听已解绑flushSearch 不会跑)
});
});

1033
tests/unit/file-ops.test.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
// src/focus-trap.js 单测Phase O-L14 audit
//
// 覆盖:
// - IME 合成期间isComposing=true按 Tab 不抢焦点
// - keyCode=229Firefox / 旧 Chromium fallback按 Tab 不抢焦点
// - 普通 Tab 行为不变(首尾循环 / 容器内跳转)
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach } from 'vitest';
import { createFocusTrap } from '../../src/focus-trap.js';
let container;
let firstBtn;
let middleBtn;
let lastBtn;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
container.tabIndex = -1;
firstBtn = document.createElement('button');
firstBtn.textContent = 'first';
middleBtn = document.createElement('button');
middleBtn.textContent = 'middle';
lastBtn = document.createElement('button');
lastBtn.textContent = 'last';
container.appendChild(firstBtn);
container.appendChild(middleBtn);
container.appendChild(lastBtn);
document.body.appendChild(container);
});
function fireKey(target, opts) {
const ev = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...opts });
target.dispatchEvent(ev);
return ev;
}
describe('focus-trap IME 合成守门Phase O-L14', () => {
it('isComposing=true 时 Tab 不抢焦点preventDefault 不触发)', () => {
const trap = createFocusTrap(container);
// 模拟 IME 合成期间按 Tab
const ev = fireKey(middleBtn, { key: 'Tab', isComposing: true });
// IME 期间不拦 Tab让浏览器处理候选词
expect(ev.defaultPrevented).toBe(false);
trap.dispose();
});
it('keyCode=229Firefox / 旧 Chromium fallback时 Tab 不抢焦点', () => {
const trap = createFocusTrap(container);
const ev = fireKey(middleBtn, { key: 'Tab', keyCode: 229 });
expect(ev.defaultPrevented).toBe(false);
trap.dispose();
});
});

View File

@@ -0,0 +1,111 @@
// shared/friendly-fs-error.js —— 渲染端 + 主进程共用的 errno → 中文提示
//
// 覆盖 P0Round 4 收尾前 renderer 端有三份独立 mappingsrc/app.js /
// src/file-ops.js / main/file-ops.js用户看到的 toast 文案 EROFS / ENAMETOOLONG
// / ENOTDIR / ENOTEMPTY 不一致。本测试钉死 shared 模块的 mapping 文案main /
// preload / renderer 三处全依赖此单一事实源。每条 case 必须有断言 —— 防止未来
// 重构时改坏 mapping用户会看到误导的"未知错误")。
//
// shared/friendly-fs-error.js 是 CJSmodule.exports = { friendlyFsError }
// ESM 解析器不支持 named import from CJS —— vitest 在 node 环境下走 require 链路。
import { describe, it, expect } from 'vitest';
const { friendlyFsError } = require('../../shared/friendly-fs-error.js');
describe('friendlyFsError —— 已知 errno 映射', () => {
it('EACCES → 权限 / 占用提示', () => {
expect(friendlyFsError('EACCES')).toMatch(/占用|权限/);
});
it('EPERM → 权限 / 占用提示(与 EACCES 共用文案)', () => {
expect(friendlyFsError('EPERM')).toBe(friendlyFsError('EACCES'));
});
it('ENOSPC → 磁盘空间不足', () => {
expect(friendlyFsError('ENOSPC')).toBe('磁盘空间不足');
});
it('EROFS → 只读文件系统', () => {
expect(friendlyFsError('EROFS')).toBe('只读文件系统,无法写入');
});
it('EIO → 磁盘 I/O 错误', () => {
expect(friendlyFsError('EIO')).toBe('磁盘 I/O 错误');
});
it('EBUSY → 文件被其他程序占用', () => {
expect(friendlyFsError('EBUSY')).toBe('文件被其他程序占用');
});
it('ENAMETOOLONG → 路径过长', () => {
expect(friendlyFsError('ENAMETOOLONG')).toBe('路径过长');
});
it('ENOTDIR → 父目录不是目录', () => {
expect(friendlyFsError('ENOTDIR')).toBe('父目录不是目录');
});
it('EISDIR → 目标路径是文件夹', () => {
expect(friendlyFsError('EISDIR')).toBe('目标路径是文件夹,无法写入');
});
it('ENOTEMPTY → 目标文件夹不为空', () => {
expect(friendlyFsError('ENOTEMPTY')).toBe('目标文件夹不为空');
});
});
describe('friendlyFsError —— fallback 行为', () => {
it('未知 errno + 有 fallback → 返回 fallback', () => {
expect(friendlyFsError('EEXIST', '文件已存在')).toBe('文件已存在');
});
it('未知 errno + 无 fallback → 返回「未知错误」', () => {
expect(friendlyFsError('EEXIST')).toBe('未知错误');
});
it('未知 errno + fallback=空串 → 走 fallback空串本身', () => {
// 注意:空串是合法 fallback —— caller 故意传空串时不应被「未知错误」盖掉。
// 但代码实现是 `fallback || '未知错误'`,所以空串会被替换为「未知错误」。
// 这是有意的(避免空 toast但要把行为钉死。
expect(friendlyFsError('EEXIST', '')).toBe('未知错误');
});
it('undefined code → 走 default', () => {
expect(friendlyFsError(undefined, 'fallback')).toBe('fallback');
});
it('null code → 走 default', () => {
expect(friendlyFsError(null, 'fallback')).toBe('fallback');
});
it('空字符串 code → 走 default', () => {
expect(friendlyFsError('', 'fallback')).toBe('fallback');
});
});
describe('friendlyFsError —— 业务码K1-R4 audit 合约)', () => {
// audit fix (K1-R4):所有 file:* / shell:* IPC 错误返回都有 code 字段。
// 业务码PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED / FILE_TOO_LARGE 等)不在
// errno 白名单里renderer 一律走 fallback而 renderer 8 个调用点现在传
// result.message中文而不是 result.error业务码字符串做 fallback
// 这样业务码错误也能给用户友好提示。
it('PATH_NOT_ALLOWED → 走 fallback中文化 message', () => {
expect(friendlyFsError('PATH_NOT_ALLOWED', '路径不在数据目录内')).toBe('路径不在数据目录内');
});
it('SYMLINK_NOT_ALLOWED → 走 fallback', () => {
expect(friendlyFsError('SYMLINK_NOT_ALLOWED', '不允许在符号链接目录下创建文件')).toBe('不允许在符号链接目录下创建文件');
});
it('FILE_TOO_LARGE → 走 fallback', () => {
expect(friendlyFsError('FILE_TOO_LARGE', '文件超过 50 MB 上限')).toBe('文件超过 50 MB 上限');
});
it('FILE_CHANGED_EXTERNALLY → 走 fallback', () => {
expect(friendlyFsError('FILE_CHANGED_EXTERNALLY', '文件在外部被修改')).toBe('文件在外部被修改');
});
it('NOT_A_FILE → 走 fallback', () => {
expect(friendlyFsError('NOT_A_FILE', '该路径是文件夹')).toBe('该路径是文件夹');
});
});

View File

@@ -0,0 +1,364 @@
// Stage 8 tests: shared/markdown-diff.js
//
// 覆盖:
// - computeFullMarkdownDiff纯增 / 纯删 / 改 / 跨多 region / 空内容
// - applyDiffRegionSafely成功路径 + conflict找不到 / 多个匹配)
// - endsWithNewline 保留
// - 行级 MAX_LINE_DIFF_CELLS 守卫(过大输入不爆炸)
// - tokenDiff 选项生效路径(仅断言结构,不验证具体分词)
//
// shared/ 是 CommonJS直接 require 即可。
import { describe, it, expect } from 'vitest';
const { computeFullMarkdownDiff, applyDiffRegionSafely } = require('../../shared/markdown-diff.js');
describe('computeFullMarkdownDiff', () => {
it('identical content → 空 regionsrows 只剩 context', () => {
const out = computeFullMarkdownDiff('a\nb\nc', 'a\nb\nc');
expect(out.regions).toEqual([]);
// 没有 regionId 的行就是纯 context
const regionsInRows = out.rows.filter((r) => r.regionId).length;
expect(regionsInRows).toBe(0);
});
it('空内容 + 空内容 → 无 rows / 无 regions', () => {
const out = computeFullMarkdownDiff('', '');
expect(out.rows).toEqual([]);
expect(out.regions).toEqual([]);
});
it('BOM 仅出现在文件头 → 被剥掉,不影响 diff 结果Phase 8 fix', () => {
// Windows Notepad / PowerShell pipeline 写 UTF-8 BOM (U+FEFF) 在文件头。
// 不剥 → 首行变成 "# Title"LCS 看到 oldLines[0] !== newLines[0]
// 每次「应用全部」都把首行当成「被改」渲染。
const base = '# Title\nline2';
const next = '# Title\nline2'; // 一字未动
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toEqual([]);
// rows 只剩 context无任何 regionId
expect(out.rows.filter((r) => r.regionId)).toEqual([]);
});
it('BOM 只剥头部一次,正文中残留的 BOM 保留', () => {
// intra-content 的 BOM 不剥:可能是有意为之(极少),且剥错会破坏内容。
const base = 'line1\nmiddle\nline3';
const next = 'line1\nmiddle\nline3';
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toEqual([]);
});
it('BOM + 真改动diff 只反映真改动BOM 不计入 regions', () => {
const base = '# Title\nold';
const next = '# Title\nnew';
const out = computeFullMarkdownDiff(base, next);
expect(out.regions).toHaveLength(1);
// region 应只包含改动的行,不包含被剥的 BOM
const changed = out.regions[0];
expect(changed.newLines).toEqual(['new']);
expect(changed.oldLines).toEqual(['old']);
});
it('纯新增一行:产生 1 个 region + 1 个 added row', () => {
const out = computeFullMarkdownDiff('a\nb', 'a\nx\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual([]);
expect(region.newLines).toEqual(['x']);
// 新增行的 type 应是 'added'
const addedRows = out.rows.filter((r) => r.type === 'added' && r.regionId === region.id);
expect(addedRows).toHaveLength(1);
expect(addedRows[0].segments[0].text).toBe('x');
});
it('纯删除一行oldLines 有内容newLines 为空', () => {
const out = computeFullMarkdownDiff('a\nx\nb', 'a\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual(['x']);
expect(region.newLines).toEqual([]);
const removedRows = out.rows.filter((r) => r.type === 'removed' && r.regionId === region.id);
expect(removedRows).toHaveLength(1);
expect(removedRows[0].segments[0].text).toBe('x');
});
it('修改一行:产生 removed + added 一对oldLines/newLines 各 1', () => {
const out = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.oldLines).toEqual(['foo']);
expect(region.newLines).toEqual(['bar']);
});
it('多处独立修改:产生多个 region', () => {
const out = computeFullMarkdownDiff(
'a\nfoo1\nb\nfoo2\nc',
'a\nbar1\nb\nbar2\nc'
);
expect(out.regions).toHaveLength(2);
});
it('行级 MAX_LINE_DIFF_CELLS 守卫超大输入不爆炸fallback 到行级 diff', () => {
// MAX_LINE_DIFF_CELLS = 200_000构造 n*m > 200_000 但内容仍合法的输入
// 期望返回结果仍合理rows / regions 数量与内容一致),不会抛错或返回 undefined
const n = 1000;
const oldLines = Array.from({ length: n }, (_, i) => `old-${i}`);
const newLines = oldLines.map((l, i) => (i === 500 ? `NEW-${i}` : l));
const oldContent = oldLines.join('\n');
const newContent = newLines.join('\n');
// 1000 * 1000 = 1_000_000 cells >> 200_000 守卫
const out = computeFullMarkdownDiff(oldContent, newContent);
expect(out).toBeTruthy();
expect(Array.isArray(out.rows)).toBe(true);
expect(Array.isArray(out.regions)).toBe(true);
// 至少能识别出那 1 处修改
expect(out.regions.length).toBeGreaterThanOrEqual(1);
});
it('H3行级 fallback 触发时返回 warnings 字段audit H3 修复)', () => {
// MAX_LINE_DIFF_CELLS = 200_000。需要构造一个 oldMiddle × newMiddle > 200_000
// 的输入。如果两批行毫无公共前后缀prefix/suffix trim 起不到作用,
// middle 长度就是 n必然走 fallback。
const n = 1000;
const oldLines = Array.from({ length: n }, (_, i) => `old-${i}`);
// 完全打乱:所有 new 行都不等于任何 old 行 → LCS 退化为 O(n²)
const newLines = Array.from({ length: n }, (_, i) => `new-${i}`);
// sanity1000 * 1000 = 1_000_000 >> 200_000
const out = computeFullMarkdownDiff(oldLines.join('\n'), newLines.join('\n'));
expect(Array.isArray(out.warnings)).toBe(true);
expect(out.warnings.length).toBeGreaterThanOrEqual(1);
// 至少一条提示「行级 diff 超过 ... cells 上限」
expect(out.warnings.some((w) => /行级 diff 超过/.test(w))).toBe(true);
});
it('H3正常 diff 不带 warnings 字段(保持 return shape 稳定)', () => {
const out = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
expect(out.warnings).toBeUndefined();
});
it('tokenDiff 选项不破坏结构(接口兼容性)', () => {
const out = computeFullMarkdownDiff('hello world', 'hello there', { tokenDiff: true });
expect(out).toBeTruthy();
expect(Array.isArray(out.rows)).toBe(true);
expect(Array.isArray(out.regions)).toBe(true);
});
});
describe('applyDiffRegionSafely', () => {
it('成功替换:唯一匹配', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb');
});
it('保留末尾换行endsWithNewline', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb\n', 'a\nbar\nb\n');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb\n', region);
expect(r.ok).toBe(true);
expect(r.content.endsWith('\n')).toBe(true);
expect(r.content).toBe('a\nbar\nb\n');
});
it('成功替换 + 没有末尾换行:不补 \\n', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content.endsWith('\n')).toBe(false);
});
it('CRLF 文件apply 后行尾仍是 CRLF不被改成 LF', () => {
const before = 'a\r\nfoo\r\nb\r\n';
const after = 'a\r\nbar\r\nb\r\n';
// 用 LF 描述 diff算法内部按 LF 算 region但实际磁盘上是 CRLF。
const diff = computeFullMarkdownDiff('a\nfoo\nb\n', 'a\nbar\nb\n');
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.includes('\r\n')).toBe(true);
expect(r.content.includes('\nfoo')).toBe(false); // 行内不含裸 LF
});
it('LF 文件apply 后行尾仍是 LF不被改成 CRLF', () => {
const before = 'a\nfoo\nb\n';
const after = 'a\nbar\nb\n';
const diff = computeFullMarkdownDiff(before, after);
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.includes('\r\n')).toBe(false);
});
it('CRLF 文件 + 末尾无换行apply 后仍无末尾换行', () => {
const before = 'a\r\nfoo\r\nb';
const after = 'a\r\nbar\r\nb';
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const r = applyDiffRegionSafely(before, diff.regions[0]);
expect(r.ok).toBe(true);
expect(r.content).toBe(after);
expect(r.content.endsWith('\r\n')).toBe(false);
expect(r.content.endsWith('\n')).toBe(false);
});
it('conflict旧行已被删除 → 0 匹配 → ok:false', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 当前内容里 "foo" 已经被改成别的
const r = applyDiffRegionSafely('a\nbaz\nb', region);
expect(r.ok).toBe(false);
expect(typeof r.reason).toBe('string');
expect(r.reason.length).toBeGreaterThan(0);
});
it('conflict多处重复 + oldStart 失效 → 多匹配 → ok:false', () => {
// region.oldStart 是算法给出的「最佳位置」hint —— 当 preferredIndex 处的行
// 跟 oldLines[0] 对不上时findSequence 才退回去扫描全部匹配。
// 构造场景:用户编辑后,原 region 指向的"foo"被改成别的,
// 而文件其它位置仍出现 "foo"(多次匹配)—— 算法无法确定应替换哪一处。
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 当前内容:原 region 位置的 "foo" 已被改成 "X",但文件其它两处仍有 "foo"
// findSequence 找不到 preferredIndex 命中 → 扫描全文 → 2 处匹配 → conflict
const r = applyDiffRegionSafely('foo\nX\nfoo\nb', region);
expect(r.ok).toBe(false);
expect(typeof r.reason).toBe('string');
});
it('纯新增 + 唯一上下文:能安全插入', () => {
const diff = computeFullMarkdownDiff('a\nb', 'a\nx\nb');
expect(diff.regions).toHaveLength(1);
const region = diff.regions[0];
// region.oldLines=[],但 beforeContext=['a'], afterContext=['b']
expect(region.oldLines).toEqual([]);
expect(region.newLines).toEqual(['x']);
expect(region.beforeContext).toEqual(['a']);
expect(region.afterContext).toEqual(['b']);
const r = applyDiffRegionSafely('a\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nx\nb');
});
it('apply 完一轮后内容应当再次能 computeidempotent 内容一致)', () => {
const oldContent = 'a\nfoo\nb';
const newContent = 'a\nbar\nb';
const diff = computeFullMarkdownDiff(oldContent, newContent);
const region = diff.regions[0];
const applied = applyDiffRegionSafely(oldContent, region);
expect(applied.ok).toBe(true);
// 应用之后再算一次 diffregions 应为空
const secondDiff = computeFullMarkdownDiff(applied.content, newContent);
expect(secondDiff.regions).toEqual([]);
});
});
describe('C1 整篇替换 / 无上下文 regionaudit C1 fix', () => {
it('整篇替换region 无上下文 → 标 conflict 引导走应用全部', () => {
// 场景AI 把整段换成 X/Y前后没保留任何 context 行
const out = computeFullMarkdownDiff('A\nB', 'X\nY');
expect(out.regions).toHaveLength(1);
const region = out.regions[0];
expect(region.beforeContext).toEqual([]);
expect(region.afterContext).toEqual([]);
expect(region.conflict).toMatch(/无上下文/);
});
it('在空文档插入新内容region 无上下文 → 标 conflict', () => {
const out = computeFullMarkdownDiff('', 'X\nY');
expect(out.regions).toHaveLength(1);
expect(out.regions[0].beforeContext).toEqual([]);
expect(out.regions[0].afterContext).toEqual([]);
expect(out.regions[0].conflict).toBeTruthy();
});
it('局部修改(保留 contextregion 不应被标 conflict', () => {
const out = computeFullMarkdownDiff('A\nfoo\nB', 'A\nbar\nB');
expect(out.regions).toHaveLength(1);
expect(out.regions[0].conflict).toBeUndefined();
});
});
describe('C3 歧义匹配二次校验audit C3 fix', () => {
it('oldLines 唯一匹配但上下文漂移 → 拒绝应用', () => {
// region 算出来 oldStart=1前后 context 期望为 ["a"]/["b"]
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 用户在前面又加了一个完全相同的 "foo"——oldLines 还能匹配到 preferredIndex 那个,
// 但**该位置的前后文**与 region 算出的不同(前面是 "X" 不是 "a",后面是 "foo" 不是 "b")。
// 这就是「复制粘贴造成歧义」场景——必须靠 beforeContext/afterContext 二次校验兜底。
const drifted = 'X\nfoo\nfoo\nfoo';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/上下文漂移/);
});
it('oldLines 唯一匹配 + 上下文一致 → 正常替换', () => {
// 控制组:完全没漂移的常规 case
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
const r = applyDiffRegionSafely('a\nfoo\nb', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb');
});
// fix(audit 2026-08):多匹配但 context 唯一 → 仍能安全应用。
// 场景用户在其它位置粘了相同行oldLines 匹配多个下标,但只有一个位置的
// 前后文与 region 一致 —— 用 context 过滤掉歧义,应用 context 唯一的那处。
it('多匹配 + 唯一 context → 应用 context 匹配位置', () => {
// region 期望 oldLines=['foo'], beforeContext=['a'], afterContext=['b']
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// drifted: 有 2 个 foo但只有 index 1 处的 context 是 a...bindex 3 上下文是 c...d
const drifted = 'a\nfoo\nb\nc\nfoo\nd';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb\nc\nfoo\nd');
});
// fix(audit 2026-08):多匹配 + 多处 context 都一致 → 真歧义refuse。
it('多匹配 + 多处 context 都一致 → 拒绝', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0];
// 两段都是 a...foo...b 上下文 + foo 行重复 —— region context 与两处都一致
const duplicated = 'a\nfoo\nb\n...\na\nfoo\nb';
const r = applyDiffRegionSafely(duplicated, region);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/多个相同位置/);
});
// fix(audit 2026-08)preferredIndex 命中 ≠ 唯一匹配时,必须扫描全文。
// 旧版在 preferredIndex 命中时直接 early-return [preferredIndex],跳过扫描 →
// 其它位置的重复匹配被静默忽略apply 会改错行。
// 新行为:扫描全文 → context 过滤 → 唯一 context 匹配位置 → apply。
it('preferredIndex 命中 + 其它位置重复 + context 唯一 → 应用正确位置', () => {
const diff = computeFullMarkdownDiff('a\nfoo\nb', 'a\nbar\nb');
const region = diff.regions[0]; // oldStart=1, context ['a']/['b']
// preferredIndex=1 处仍是 'a\nfoo\nb'context 匹配index 4 也匹配 foo
// 但 index 4 处上下文是 ['X']/['Y'],不匹配 region
const drifted = 'a\nfoo\nb\nX\nfoo\nY';
const r = applyDiffRegionSafely(drifted, region);
expect(r.ok).toBe(true);
expect(r.content).toBe('a\nbar\nb\nX\nfoo\nY');
});
});
describe('C4 整段删除不被空防线误伤audit C4 fix', () => {
// 注意:纯 region 删除合法性的拦截逻辑在 ai-controller.jshandlesApplyRegion
// 空内容防线。shared/markdown-diff.js 自身允许 appliedContent 为空字符串——
// 这部分测试在 ai-controller 层覆盖。这里只验证算法层"整段删除"算出来就是 ""。
it('整段删除apply 后内容为 ""', () => {
const diff = computeFullMarkdownDiff(' \n ', '');
expect(diff.regions).toHaveLength(1);
const region = diff.regions[0];
expect(region.oldLines.length).toBeGreaterThan(0);
expect(region.newLines).toEqual([]);
const r = applyDiffRegionSafely(' \n ', region);
expect(r.ok).toBe(true);
expect(r.content).toBe('');
});
});

View File

@@ -0,0 +1,205 @@
// src/markdown-editor.js 测试 —— IME compositionend dirty flush
//
// 回归背景updateListener 里 `!isComposing` 守卫在 IME 合成期间屏蔽 onChange
// 回调(避免 CJK 每个拼音字母触发一次 scheduleLivePreview 全页重建)。但
// compositionend 后 CM6 不一定再产生新 transaction —— 此时 onChange 永远不
// 触发state.isDirty 保持 falsesave() 走 !isDirty 早退返回 true用户
// 看到中文已上屏但磁盘文件没写入。
//
// 修复compositionend handler 主动比较 doc 与 lastSavedDoc不同则补发
// onChange 让 app 层翻脏态。本文件覆盖这条新路径。
//
// 也覆盖:
// - IME 期间 _composing 标记正确翻转
// - IME 取消doc 未变)不触发 onChange
// - dispose 后 compositionend 不抛
// - applyUserChange 在 IME 期间抛 EDITOR_COMPOSING
//
// 注意jsdom 不实现 contenteditable / 真实 IME 事件,所以直接调
// editor._onCompositionStart / _onCompositionEnd 模拟。
/* @vitest-environment jsdom */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MarkdownEditor } from '../../src/markdown-editor.js';
// jsdom 不实现 scrollIntoViewMarkdownEditor 本身不调,但 CM6 basicSetup 可能
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
// CM6 的 requestAnimationFrame 探测 —— jsdom 没有polyfill
if (typeof window.requestAnimationFrame === 'undefined') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
// CM6 RectangleMarker 测量选中区时调 Range#getClientRectsjsdom 没实现
// Range#textRange 是 jsdom 假实现但没挂 getClientRects。每次 measure 抛 TypeError
// 会被 vitest 写到 stderr污染测试输出。返回空数组让 CM6 跳过选中区绘制。
if (typeof Range.prototype.getClientRects !== 'function') {
Range.prototype.getClientRects = function () {
// 真实浏览器返回 DOMRectListCM6 只迭代 + 读 .top/.left/.width/.height。
// 用 array-like 占位即可,避免导入完整 DOMRect 带来的兼容开销
const arr = [];
arr.item = (i) => arr[i] || null;
return arr;
};
}
if (typeof Range.prototype.getBoundingClientRect !== 'function') {
Range.prototype.getBoundingClientRect = function () {
return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 };
};
}
function createEditor(opts = {}) {
const el = document.createElement('div');
document.body.appendChild(el);
const onChange = vi.fn();
const onSave = vi.fn();
const editor = new MarkdownEditor({ element: el, onChange, onSave });
return { editor, el, onChange, onSave };
}
describe('MarkdownEditor IME compositionend dirty flush', () => {
let editor, el, onChange;
beforeEach(() => {
({ editor, el, onChange } = createEditor());
editor.loadFile({ path: '/test.md', name: 'test.md' }, 'hello');
});
it('IME 提交后 doc 变化 → compositionend 补发 onChange', () => {
// 初始doc === lastSavedDoc → isDirty = false
expect(editor.isDirty()).toBe(false);
expect(onChange).not.toHaveBeenCalled();
// 模拟 IME 开始
editor._onCompositionStart();
expect(editor._composing).toBe(true);
// 模拟 IME 期间 CM6 把中文写入了 doc。
// userEvent: 'input.type.compose' 让 updateListener 识别为 IME 合成事件,
// isComposing=true → onChange 被守卫屏蔽(这是原来的设计意图)。
const view = editor.view;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '你好世界' },
userEvent: 'input.type.compose',
});
// 合成期间 onChange 被守卫屏蔽(这是原来的设计意图)
expect(onChange).not.toHaveBeenCalled();
// 但 doc 已经变了
expect(view.state.doc.toString()).toBe('你好世界');
expect(editor.isDirty()).toBe(true);
// 模拟 IME 结束 —— 这是修复的关键compositionend 必须补发 onChange
editor._onCompositionEnd();
expect(editor._composing).toBe(false);
expect(onChange).toHaveBeenCalledTimes(1);
});
it('IME 取消doc 未变)→ compositionend 不补发 onChange', () => {
expect(editor.isDirty()).toBe(false);
editor._onCompositionStart();
expect(editor._composing).toBe(true);
// 用户取消 IME按 Esc—— doc 没变
editor._onCompositionEnd();
expect(editor._composing).toBe(false);
// doc 没变 → 不应触发 onChange
expect(onChange).not.toHaveBeenCalled();
});
it('IME 期间已有 onChange非 IME 字符触发)→ compositionend 不重复调', () => {
// 场景IME 期间用户也敲了一个英文字符updateListener 因 !isComposing
// 守卫不调 onChange但如果将来守卫被移除或行为变化compositionend 也不
// 应该重复调(因为 doc 已在 updateListener 里触发过 onChange
// 注意:当前实现中 updateListener 在 isComposing 时不调 onChange所以
// 这个测试验证 compositionend 只按「doc != lastSavedDoc」判断
// 不关心 onChange 之前是否已被调过。
editor._onCompositionStart();
const view = editor.view;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '中文' },
userEvent: 'input.type.compose',
});
// 手动模拟 updateListener 路径已调过 onChange假设守卫被绕过的场景
onChange.mockClear();
// compositionend 仍然会触发 onChange因为 doc != lastSavedDoc
// 这是「宁可多调一次,不可漏调」的设计选择
editor._onCompositionEnd();
expect(onChange).toHaveBeenCalledTimes(1);
});
it('dispose 后 compositionend 不抛', () => {
editor._onCompositionStart();
editor.dispose();
// dispose 后 view 被销毁compositionend 不应抛
expect(() => editor._onCompositionEnd()).not.toThrow();
expect(editor._composing).toBe(false);
});
it('IME 期间 applyUserChange 抛 EDITOR_COMPOSING', () => {
editor._onCompositionStart();
expect(() => editor.applyUserChange('新内容')).toThrow('EDITOR_COMPOSING');
});
it('非 IME 期间 applyUserChange 不抛', () => {
expect(() => editor.applyUserChange('新内容')).not.toThrow();
});
// audit fix (Round 4 F2)setExternalContent 也要 IME 守卫。
// fs-watcher 在 IME 合成期推「外部修改」→ view.dispatch 全量替换 doc
// 会摧毁 IME 合成 buffer已输入的拼音字符消失。与 applyUserChange 行为
// 对齐:合成期间抛 EDITOR_COMPOSING 让调用方 catch 后给用户「请稍后再试」。
it('IME 期间 setExternalContent 抛 EDITOR_COMPOSING', () => {
editor._onCompositionStart();
expect(() => editor.setExternalContent('外部内容')).toThrow('EDITOR_COMPOSING');
// doc 没被替换
expect(editor.getContent()).toBe('hello');
});
it('非 IME 期间 setExternalContent 不抛 + lastSavedDoc 同步更新', () => {
expect(editor.isDirty()).toBe(false);
editor.setExternalContent('外部内容');
expect(editor.getContent()).toBe('外部内容');
expect(editor.isDirty()).toBe(false);
});
});
describe('MarkdownEditor 基础行为', () => {
it('loadFile 后 isDirty = false', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'content');
expect(editor.isDirty()).toBe(false);
expect(editor.getContent()).toBe('content');
});
it('输入后 isDirty = true', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'content');
editor.view.dispatch({
changes: { from: 0, to: editor.view.state.doc.length, insert: 'changed' },
});
expect(editor.isDirty()).toBe(true);
expect(editor.getContent()).toBe('changed');
});
it('markSaved 后 isDirty 归零', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'old');
editor.view.dispatch({
changes: { from: 0, to: editor.view.state.doc.length, insert: 'new' },
});
expect(editor.isDirty()).toBe(true);
editor.markSaved();
expect(editor.isDirty()).toBe(false);
});
it('unload 后再 compositionend 不抛view 已 null', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, '');
editor.unload();
expect(() => editor._onCompositionEnd()).not.toThrow();
});
});

View File

@@ -0,0 +1,778 @@
// src/markdown-viewer.js 测试
//
// 覆盖:
// - 链接点击委托:内部锚点 / 外部链接 / 空 href 区分
// - scrollToAnchor含百分号编码 + 中文 id
// - scrollPositions LRU 淘汰:超出上限时按插入顺序删除最早
// - onHeadingChange 订阅 + 立即触发
// - showUnsupportedFile清空 heading observer
// - clearscrollPositions 清空 + 当前文件清空
//
// 不测试 resolveRelativeImages 的实际路径计算(依赖 utils 的 dirnameOf / resolvePath
// 仅测试设置 loading="lazy" / decoding="async" 的副作用。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MarkdownViewer } from '../../src/markdown-viewer.js';
// jsdom 不实现 scrollIntoViewpolyfill 成 noop 让 scrollToAnchor 路径
// 不会因为浏览器 API 缺失抛错。生产环境Chromium总是有这个方法。
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
// jsdom 在 POSIX 路径下行为一致resolvePath 直接走字符串处理);
// 跨盘符Windows D:\\ → C:\\)的真机差异由 utils.isPathInside 自己负责,
// 这里只验证「resolveRelativeImages 调用了边界检查」+「拦截行为正确」。
// 提到 module-scope 让所有 describe 都能直接复用。
function posixPath(s) {
return s.replace(/\\/g, '/');
}
// 把 marked 替换成可控的渲染器:避免依赖完整 marked + DOMPurify 初始化
// preload 才有完整链路;单测只关心 MarkdownViewer 自身逻辑)。
//
// 实际上 MarkdownViewer 内部调的是 utils.safeRenderMarkdown —— 这是个
// preload contextBridge 的产物,单测里没有。我们用 vi.spyOn 拦截 renderMarkdown
// 行为不可行renderMarkdown 是私有),所以:
// 1) 把 bodyElement 的 innerHTML 提前设好,模拟已渲染内容
// 2) 直接调 viewer 暴露的方法scrollToAnchor / resolveRelativeImages / onHeadingChange 等)
// 3) 不走 loadContent 的渲染链路
describe('MarkdownViewer 链接点击委托', () => {
let body;
let empty;
let viewer;
let onExternalLink;
beforeEach(() => {
document.body.innerHTML = `
<article class="body"><a href="https://example.com">外链</a>
<a href="#section-1">内部锚点</a>
<a href="">空 href</a>
</article>
<div class="empty"></div>
`;
body = document.querySelector('.body');
empty = document.querySelector('.empty');
onExternalLink = vi.fn();
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
onExternalLink,
});
});
it('点击外部链接 → onExternalLink 收到 hrefpreventDefault', () => {
const link = body.querySelector('a[href="https://example.com"]');
const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
link.dispatchEvent(ev);
expect(onExternalLink).toHaveBeenCalledWith('https://example.com');
expect(ev.defaultPrevented).toBe(true);
});
it('点击内部锚点 → 不调 onExternalLink自己 scrollToAnchor', () => {
body.innerHTML = '<a id="section-1"></a><a href="#section-1">跳到 section-1</a>';
const link = body.querySelector('a[href="#section-1"]');
// scrollIntoView 在 jsdom 里是 noop不会抛错
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onExternalLink).not.toHaveBeenCalled();
});
it('点击空 href → 既不触发外链也不报错', () => {
const link = body.querySelector('a[href=""]');
expect(() => {
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}).not.toThrow();
expect(onExternalLink).not.toHaveBeenCalled();
});
it('链接之外的 click<p>)→ 不触发任何回调', () => {
body.appendChild(document.createTextNode('普通文本'));
body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(onExternalLink).not.toHaveBeenCalled();
});
});
describe('MarkdownViewer scrollToAnchor', () => {
let body;
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('能找到 id → scrollIntoView + 返回 true', () => {
const target = document.createElement('h2');
target.id = 'section-1';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
const ok = viewer.scrollToAnchor('section-1');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalledWith({ block: 'start' });
});
it('百分号编码的 id → 先 decode 再匹配', () => {
// marked 会把 href 里的中文 percent-encode
const target = document.createElement('h2');
target.id = '你好';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
// '你好' percent-encoded = %E4%BD%A0%E5%A5%BD
const ok = viewer.scrollToAnchor('%E4%BD%A0%E5%A5%BD');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalled();
});
it('非法编码 → 不抛错,正常返回', () => {
// 半截编码在现代 WHATWG 解码下不会抛错,而是返回 replacement char (U+FFFD)。
// 测试契约:调用本身不抛异常;找不到 id 返回 false。
const target = document.createElement('h2');
target.id = 'plain';
body.appendChild(target);
expect(() => viewer.scrollToAnchor('%E0%A4%A')).not.toThrow();
// 因为 decode 出 replacement char原文与目标都不匹配 → 找不到
expect(viewer.scrollToAnchor('%E0%A4%A')).toBe(false);
});
it('找不到 id → 返回 false', () => {
expect(viewer.scrollToAnchor('nonexistent')).toBe(false);
});
it('空 hash → 返回 false', () => {
expect(viewer.scrollToAnchor('')).toBe(false);
});
it('id 含双引号 → 用属性选择器 escape 仍能匹配', () => {
// 防御性:用户笔记的 heading id 含特殊字符时不应破坏选择器
const target = document.createElement('h2');
target.id = 'quote"inside';
body.appendChild(target);
const spy = vi.spyOn(target, 'scrollIntoView');
const ok = viewer.scrollToAnchor('quote"inside');
expect(ok).toBe(true);
expect(spy).toHaveBeenCalled();
});
});
describe('MarkdownViewer scrollPositions LRU 淘汰', () => {
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
viewer = new MarkdownViewer({
bodyElement: document.querySelector('.body'),
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('超出 SCROLL_POSITION_CACHE_LIMIT 后按插入顺序淘汰最旧', () => {
const LIMIT = viewer.SCROLL_POSITION_CACHE_LIMIT;
// 注入 LIMIT + 5 条
for (let i = 0; i < LIMIT + 5; i += 1) {
viewer.scrollPositions.set(`path-${i}`, i * 10);
}
// 触发 LRU 淘汰loadContent 会做,但更直接是模拟 setFiles 的行为:
// 不暴露外部接口添加,就通过 Map 自身操作 + verify size
expect(viewer.scrollPositions.size).toBe(LIMIT + 5);
// 这里我们直接构造 LRU 行为(同 loadContent 内部代码):
while (viewer.scrollPositions.size > LIMIT) {
const oldest = viewer.scrollPositions.keys().next().value;
viewer.scrollPositions.delete(oldest);
}
// 验证path-0 ~ path-4 已被淘汰path-5 还在
expect(viewer.scrollPositions.has('path-0')).toBe(false);
expect(viewer.scrollPositions.has('path-4')).toBe(false);
expect(viewer.scrollPositions.has('path-5')).toBe(true);
expect(viewer.scrollPositions.has(`path-${LIMIT + 4}`)).toBe(true);
});
it('clear() 不清空 scrollPositions保留 LRU 缓存)', () => {
// audit fix (C2-renderer)clear 只应清当前文件的视图状态body hidden、
// currentFile=null不应清 scrollPositions —— loadContent 已经在切换前把
// top 存进 scrollPositions再清一次反而覆盖。
viewer.scrollPositions.set('foo', 100);
viewer.scrollPositions.set('bar', 200);
viewer.clear();
expect(viewer.scrollPositions.size).toBe(2);
expect(viewer.scrollPositions.get('foo')).toBe(100);
expect(viewer.scrollPositions.get('bar')).toBe(200);
});
});
describe('MarkdownViewer 订阅契约', () => {
let viewer;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
viewer = new MarkdownViewer({
bodyElement: document.querySelector('.body'),
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('onHeadingChange 订阅时立即触发一次(拿到当前 heading', () => {
const cb = vi.fn();
viewer.onHeadingChange(cb);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(null);
});
it('返回的 unsubscribe 函数可移除订阅', () => {
const cb = vi.fn();
const unsub = viewer.onHeadingChange(cb);
expect(cb).toHaveBeenCalledTimes(1); // 立即触发
unsub();
// clear / loadContent 不会再调 cb
viewer.clear();
expect(cb).toHaveBeenCalledTimes(1);
});
it('订阅者抛错不应让其它订阅者收不到回调', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const cb1 = vi.fn(() => { throw new Error('boom'); });
const cb2 = vi.fn();
viewer.onHeadingChange(cb1);
viewer.onHeadingChange(cb2);
expect(cb2).toHaveBeenCalledTimes(1); // 立即触发时 cb2 也跑过了
cb1.mockClear();
cb2.mockClear();
viewer.clear();
expect(cb1).toHaveBeenCalledTimes(1); // cb1 又跑了一次并抛错
expect(cb2).toHaveBeenCalledTimes(1); // cb2 仍然被调到
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
it('非函数 onHeadingChange → 返回 noop unsubscribe不抛错', () => {
expect(() => {
const ret = viewer.onHeadingChange(null);
expect(typeof ret).toBe('function');
}).not.toThrow();
});
});
describe('MarkdownViewer showUnsupportedFile / clear', () => {
let viewer;
let body;
let empty;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty">默认空态</div>';
body = document.querySelector('.body');
empty = document.querySelector('.empty');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('showUnsupportedFile → body 清空 + empty 改为"不支持预览" + heading 清空', () => {
viewer.showUnsupportedFile({ name: 'foo.py', path: '/d/foo.py', size: 100 });
expect(body.innerHTML).toBe('');
expect(empty.innerHTML).toMatch(/不支持预览/);
expect(viewer._headingMap.size).toBe(0);
expect(viewer.currentHeadingId).toBe(null);
});
it('clear → body hidden + currentFile=null + scrollPositions 保留', () => {
// scrollPositions 保留 —— 上面的 LRU 测试已覆盖,这里只校验 body / currentFile
viewer.scrollPositions.set('foo', 100);
viewer.currentFile = { path: '/d/foo', name: 'foo' };
viewer.clear();
expect(body.hidden).toBe(true);
expect(viewer.currentFile).toBe(null);
expect(viewer.scrollPositions.has('foo')).toBe(true);
});
it('loadContent 路径里恢复空态默认文案(被 showUnsupportedFile 改过后)', () => {
// 模拟 showUnsupportedFile 改写空态
viewer.showUnsupportedFile({ name: 'foo.py', path: '/d/foo.py' });
expect(empty.innerHTML).toMatch(/不支持预览/);
// 直接用 setBodyElement.innerHTML 模拟 loadContent 里的 _resetEmptyState
// (避免依赖 utils.safeRenderMarkdown
viewer._resetEmptyState();
expect(empty.innerHTML).toBe('默认空态');
});
it('Phase N M-1 修复loadContent(空字符串) → 显示「(空文件)」占位', () => {
// 旧实现 renderMarkdown('') → bodyElement.innerHTML='' → 用户看到空白面板,
// 没有"文件确实是空的"提示。现在显示「(空文件)」占位文案。
viewer.loadContent({ name: 'empty.md', path: '/d/empty.md', size: 0 }, '');
expect(body.hidden).toBe(false);
expect(body.innerHTML).toMatch(/空文件/);
// emptyElement 仍然隐藏(文件是打开状态,不是"未打开文件"空态)
expect(empty.hidden).toBe(true);
// headingMap 清空,避免出现 stale heading
expect(viewer._headingMap.size).toBe(0);
});
it('Phase N M-1loadContent(纯空白字符串) 同样显示「(空文件)」', () => {
// trim 后为空就走占位路径,否则 \n\n\n 这种纯空白文件还走 markdown 渲染
// 会出现一个空的 <p></p> 占位区域(实际 DOM 是空),用户依然没"是空文件"提示。
viewer.loadContent({ name: 'blank.md', path: '/d/blank.md', size: 3 }, ' \n\n ');
expect(body.innerHTML).toMatch(/空文件/);
});
it('Phase N M-1loadContent(非空) 走原 markdown 渲染路径', () => {
// 防御:占位特判只对 trim 后为空的 content 生效。非空文件保持原 renderMarkdown 流程。
viewer.loadContent({ name: 'real.md', path: '/d/real.md', size: 5 }, '# hi');
expect(body.innerHTML).not.toMatch(/空文件/);
expect(body.innerHTML).toMatch(/hi/);
});
});
describe('MarkdownViewer 状态栏路径跟随当前目录 (无文件时)', () => {
let viewer;
let body;
let empty;
let statusPath;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
empty = document.querySelector('.empty');
statusPath = document.createElement('div');
});
function makeViewer(getCurrentDir) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath,
statusMeta: document.createElement('div'),
getCurrentDir,
});
}
it('无 getCurrentDir 时 clear() 沿用「—」占位', () => {
viewer = makeViewer(undefined);
viewer.clear();
expect(statusPath.textContent).toBe('—');
});
it('getCurrentDir 返回路径时clear() 用文件夹路径替代「—」', () => {
viewer = makeViewer(() => '/data/notes/sub');
viewer.clear();
expect(statusPath.textContent).toBe('/data/notes/sub');
});
it('refreshCurrentDir 在无文件时把状态栏路径切到当前目录', () => {
// 模拟「目录路径后续才可用」viewer 构造时 callback 还返回空clear 后再赋值
let cur = '';
viewer = makeViewer(() => cur);
viewer.clear();
expect(statusPath.textContent).toBe('—');
cur = '/data/notes';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes');
cur = '/data/notes/sub/deeper';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes/sub/deeper');
});
it('refreshCurrentDir 不覆盖已打开文件的路径(文件路径优先)', () => {
viewer = makeViewer(() => '/data/notes');
viewer.currentFile = { path: '/data/notes/foo.md', name: 'foo.md' };
statusPath.textContent = '/data/notes/foo.md';
viewer.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes/foo.md');
});
});
describe('MarkdownViewer resolveRelativeImages stale-body 防护 (perf-MEDIUM)', () => {
let viewer;
let body;
it('loadContent B 替换 body 后A 启动的 resolveRelativeImages 不污染 B', async () => {
// 模拟「A→B 快速切换」场景A 的 resolveRelativeImages 启动后,
// loadContent(B) 已替换 bodyElementA 的 await 回来后必须放弃修改。
//
// 用一个 awaitable getDataDir 模拟 IPC调用 resolveRelativeImages 后
// 不 await 立刻构造新 viewer替换 bodyElement再 await 完成。
document.body.innerHTML = '<article class="body"><img src="img.png"></article><div class="empty"></div>';
body = document.querySelector('.body');
let resolveGDR;
const slowDataDir = new Promise((r) => { resolveGDR = r; });
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir: () => slowDataDir,
});
// A 的解析启动fire-and-forget
const aPromise = viewer.resolveRelativeImages('/notes/a.md');
// 等一个 microtask 让 resolveRelativeImages 进入 await
await Promise.resolve();
// B 替换 body —— 直接覆盖 bodyElement 模拟「A→B 切文件」,
// 并自增 _resolveGen generation token让 A 那个早于本次的
// resolveRelativeImages 回调在 await getDataDir 之后失配并自行 return。
// 这是 commit 3cba60d 把 capturedBody 改成 generation token 后的真实路径:
// loadContent/loadContentLive 在替换 innerHTML 之前 ++_resolveGen
// 我们这里直接调那个副作用。
const newBody = document.createElement('article');
newBody.className = 'body';
document.body.appendChild(newBody);
viewer.bodyElement = newBody;
// _resolveGen 是私有字段,但 resolveRelativeImages 入口会 ++ 它并捕获
// 到局部变量 myGen这里再 ++ 一次代表「被 loadContent 顶掉」。
viewer._resolveGen += 1;
// 把新 body 放一个 srcB 不该被 A 改写
const bImg = document.createElement('img');
bImg.setAttribute('src', 'b.png');
newBody.appendChild(bImg);
// A 的 getDataDir 现在 resolve —— A 应当 detect generation 已变并直接 return
resolveGDR(posixPath('/notes'));
await aPromise;
// 关键断言B 的 img src 必须是原始 b.png不能被 A 的解析覆盖
expect(newBody.querySelector('img').getAttribute('src')).toBe('b.png');
});
it('未替换 body 时,正常修改应继续生效(不退化)', async () => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir: () => posixPath('/notes'),
});
body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
});
});
describe('MarkdownViewer resolveRelativeImages 副作用', () => {
let viewer;
let body;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
});
it('图片自动加 loading="lazy" + decoding="async"', () => {
body.innerHTML = '<img src="img.png" /><img src="img2.png" />';
viewer.resolveRelativeImages('/notes/test.md');
for (const img of body.querySelectorAll('img')) {
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.getAttribute('decoding')).toBe('async');
}
});
it('已有 loading/decoding 不覆盖', () => {
body.innerHTML = '<img src="img.png" loading="eager" decoding="sync" />';
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 图片不动', () => {
body.innerHTML = '<img src="data:image/png;base64,iVBOR..." />';
viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img');
expect(img.getAttribute('src')).toBe('data:image/png;base64,iVBOR...');
// 不加 loading/lazy绝对 / data: 不参与处理
expect(img.getAttribute('loading')).toBeNull();
});
it('绝对 file:// / https:// URL 图片不动', () => {
body.innerHTML = '<img src="https://cdn.example.com/img.png" />';
viewer.resolveRelativeImages('/notes/test.md');
const img = body.querySelector('img');
expect(img.getAttribute('src')).toBe('https://cdn.example.com/img.png');
});
});
describe('MarkdownViewer resolveRelativeImages dataDir 越界拦截 (audit C3)', () => {
let viewer;
let body;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
});
// audit C3恶意 markdown 可以写 `<img src="../../../etc/passwd.png">`
// 之前 resolveRelativeImages 不做任何边界检查,解析后的 file:// URL
// 直接让 Electron 加载受害机器上的任意可读文件。现在构造时传入 getDataDir
// 越界图片会被剥 src + 加 data-blocked="external" 标记(不暴露外部路径)。
function makeViewer(getDataDir) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
getDataDir,
});
}
// jsdom 在 POSIX 路径下行为一致resolvePath 直接走字符串处理);
// 跨盘符Windows D:\\ → C:\\)的真机差异由 utils.isPathInside 自己负责,
// 这里只验证「resolveRelativeImages 调用了边界检查」+「拦截行为正确」。
// posixPath 已上提到 module-scope与 stale-body 测试共用)。
it('图片 src 越出数据目录 → src 被剥 + data-blocked="external"', async () => {
viewer = makeViewer(() => posixPath('/home/user/Notes'));
// 父文件在 /home/user/Notes/,相对路径 "../../../etc/passwd.png"
// 解析后会跳到 /etc/passwd.png —— 越过 /home/user/Notes 边界。
body.innerHTML = '<img src="../../../etc/passwd.png" alt="secret">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.hasAttribute('src')).toBe(false);
expect(img.getAttribute('data-blocked')).toBe('external');
// alt 提到 title可访问性兜底
expect(img.getAttribute('title')).toMatch(/外部图片已拦截secret/);
});
it('图片 src 在数据目录内 → 不被拦截', async () => {
viewer = makeViewer(() => posixPath('/home/user/Notes'));
body.innerHTML = '<img src="subdir/img.png" alt="x">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.hasAttribute('data-blocked')).toBe(false);
// src 应该被改成 file:// 绝对路径
expect(img.getAttribute('src')).toMatch(/^file:/);
expect(img.getAttribute('src')).toMatch(/img\.png$/);
});
it('getDataDir 返回 null旧版未注入 → 不做边界检查,仅防 ../.. 越界', async () => {
viewer = makeViewer(() => null);
// 不传 / 传 null 时 resolveRelativeImages 跳过 isPathInside 校验。
// resolvePath 内部仍会拒绝 '../..' 越界(保留旧行为)。
body.innerHTML = '<img src="../../outside.png">';
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');
});
it('getDataDir 抛错 → 不阻断catch 降级,旧行为继续)', async () => {
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:/);
consoleWarn.mockRestore();
});
it('getDataDir 返回 Promise异步 IPC → 正确 await', async () => {
viewer = makeViewer(async () => posixPath('/home/user/Notes'));
body.innerHTML = '<img src="../../../etc/passwd.png">';
await viewer.resolveRelativeImages(posixPath('/home/user/Notes/a.md'));
const img = body.querySelector('img');
expect(img.getAttribute('data-blocked')).toBe('external');
});
it('未传 getDataDir 选项 → 不做边界检查(旧行为兼容)', async () => {
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.querySelector('.empty'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
// 不传 getDataDir
});
body.innerHTML = '<img src="img.png">';
await viewer.resolveRelativeImages(posixPath('/notes/a.md'));
expect(body.querySelector('img').getAttribute('src')).toMatch(/^file:/);
});
});
// audit fix (Round 12)onExternalLink async 回调未接 promise → IPC throw 时
// 变成 unhandledRejection。viewer 这层 catch 住,调用方不能再依赖「调用 fire-and-forget」
// 必须接 promise 或主动 catch。
describe('MarkdownViewer 外链回调错误边界 (audit Round 12 P2)', () => {
let body;
beforeEach(() => {
document.body.innerHTML = '';
body = document.createElement('div');
body.id = 'markdown-body';
document.body.appendChild(body);
});
function makeViewer(onExternalLink) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
onExternalLink,
});
}
function makeLink(href) {
const a = document.createElement('a');
a.setAttribute('href', href);
a.textContent = 'x';
body.appendChild(a);
return a;
}
it('onExternalLink 同步 throw → 不冒到 document unhandledRejection', () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => { throw new Error('sync boom'); });
const a = makeLink('https://example.com');
// 不应 throw — click handler 内部 try/catch 兜住
expect(() => a.click()).not.toThrow();
expect(consoleErr).toHaveBeenCalled();
expect(consoleErr.mock.calls[0][0]).toMatch(/onExternalLink threw synchronously/);
consoleErr.mockRestore();
});
it('onExternalLink 返回 Promise 且 reject → console.error 捕获', async () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => Promise.reject(new Error('async boom')));
const a = makeLink('https://example.com');
a.click();
// 让 microtask 队列跑一下
await new Promise((r) => setTimeout(r, 0));
expect(consoleErr).toHaveBeenCalled();
expect(consoleErr.mock.calls.some((c) => /promise rejected/.test(String(c[0])))).toBe(true);
consoleErr.mockRestore();
});
it('onExternalLink 返回正常 Promise → 不打 console.error', async () => {
const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => {});
const viewer = makeViewer(() => Promise.resolve());
const a = makeLink('https://example.com');
a.click();
await new Promise((r) => setTimeout(r, 0));
expect(consoleErr).not.toHaveBeenCalled();
consoleErr.mockRestore();
});
});
// audit fix (Round 12 P3)dispose 必须显式 disconnect _headingObserver
// 否则 detach 后的 heading 节点被 IntersectionObserver 持有 → 不被 GC。
// 单测构造一个 IntersectionObserver 拦截 _rebuildHeadingObserver → 验证
// dispose 后 observer.disconnect() 被调用。
describe('MarkdownViewer dispose 释放 IntersectionObserver (audit Round 12 P3)', () => {
let body;
beforeEach(() => {
document.body.innerHTML = '';
body = document.createElement('div');
body.id = 'markdown-body';
document.body.appendChild(body);
});
it('dispose() 显式 disconnect _headingObserver', () => {
const disconnectCalls = [];
// jsdom 没有 IntersectionObserver —— 给 bodyElement 一个 mock
// _rebuildHeadingObserver 用它viewer 走「io = new IntersectionObserver(cb)」
// 拿 window.IntersectionObserver
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(function () { disconnectCalls.push(true); }),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
// 触发 _rebuildHeadingObserver往 body 塞 h1/h2 后调该方法
const h1 = document.createElement('h1');
h1.textContent = 'title';
body.appendChild(h1);
const h2 = document.createElement('h2');
h2.textContent = 'sub';
body.appendChild(h2);
viewer._rebuildHeadingObserver();
// 现在 _headingObserver 是 fakeObserver —— 调 dispose 必须 disconnect
viewer.dispose();
expect(disconnectCalls.length).toBe(1);
expect(viewer._headingObserver).toBe(null);
} finally {
window.IntersectionObserver = RealIO;
}
});
it('重复 dispose 不抛错disconnect 已在第一次调过)', () => {
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
const h1 = document.createElement('h1');
body.appendChild(h1);
viewer._rebuildHeadingObserver();
viewer.dispose();
// 第二次 dispose 不抛错addAbort 是 abortController已 abort 但闭包不抛)
expect(() => viewer.dispose()).not.toThrow();
} finally {
window.IntersectionObserver = RealIO;
}
});
});

321
tests/unit/modal.test.js Normal file
View File

@@ -0,0 +1,321 @@
// Stage 7 tests: modal.js
//
// 覆盖:
// - mountModal基本 DOM 结构 / aria 属性
// - 关闭路径:× 按钮 / Esc 键 / overlay-click 背景 / close(value) 显式
// - data-action 按钮 → onAction 回调(**只对 footer/× 生效body 内不拦截**
// - 单槽冲突:已开 modal 时再 mount 返回 null
// - setBody / setFooter 替换内容
// - setOnClose 接收 close 的 value
// - #modal-root 缺失时返回 null
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mountModal } from '../../src/modal.js';
import { isOpen as modalIsOpen } from '../../src/modal-stack.js';
beforeEach(() => {
// 每个测试前清空 #modal-root保证 modal-stack 状态干净
document.body.innerHTML = '<div id="modal-root"></div>';
// 防御:万一上一次测试失败没 unregister
// modal-stack 的 isOpen 用 currentOverlay.isConnected 校验DOM 清了后 isOpen() 就 false 了。
// 不需要手动 unregister。
});
afterEach(() => {
// 兜底:派发 Esc 给 document让任何漏关的 modal 收掉
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
document.body.innerHTML = '';
});
function makeModal(opts = {}) {
return mountModal({
title: opts.title ?? '测试标题',
body: opts.body ?? '<p>内容</p>',
footer: opts.footer ?? '<button class="btn" data-action="ok">确定</button>',
...opts,
});
}
describe('mountModal 基本结构', () => {
it('#modal-root 缺失 → 返回 null', () => {
document.body.innerHTML = '';
const result = mountModal({ title: 'x' });
expect(result).toBeNull();
});
it('挂载后返回 controller 且 overlay 在 DOM 里', () => {
const modal = makeModal();
expect(modal).not.toBeNull();
expect(modal.overlay).toBeInstanceOf(HTMLElement);
expect(modal.overlay.classList.contains('modal-overlay')).toBe(true);
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('设了 ARIA 属性', () => {
const modal = makeModal({ title: 'aria 测试' });
expect(modal.overlay.getAttribute('role')).toBe('dialog');
expect(modal.overlay.getAttribute('aria-modal')).toBe('true');
const titleId = modal.overlay.getAttribute('aria-labelledby');
expect(titleId).toBeTruthy();
// title 元素真有这个 id
const titleEl = document.getElementById(titleId);
expect(titleEl).not.toBeNull();
expect(titleEl.textContent).toBe('aria 测试');
});
it('body / footer 内容写入对应容器', () => {
const modal = makeModal({
body: '<div class="custom-body">hello</div>',
footer: '<button class="btn" data-action="cancel">取消</button>',
});
expect(modal.body.querySelector('.custom-body').textContent).toBe('hello');
expect(modal.footer.querySelector('[data-action="cancel"]')).not.toBeNull();
});
});
describe('mountModal 关闭路径', () => {
it('close(value) → 从 DOM 移除 + onClose(value) 触发', () => {
const modal = makeModal();
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.close('ok');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
expect(modalIsOpen()).toBe(false);
expect(onClose).toHaveBeenCalledWith('ok');
});
it('重复 close 是幂等的', () => {
const modal = makeModal();
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.close('first');
modal.close('second');
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledWith('first');
});
it('点 × 按钮 → onBackdropClose 触发关闭', () => {
const modal = makeModal({ onBackdropClose: 'cancel' });
const onClose = vi.fn();
modal.setOnClose(onClose);
const closeBtn = modal.overlay.querySelector('.modal-close');
closeBtn.click();
expect(onClose).toHaveBeenCalledWith('cancel');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
it('按 Esc → onBackdropClose 触发关闭', () => {
const modal = makeModal({ onBackdropClose: 'esc-cancel' });
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onClose).toHaveBeenCalledWith('esc-cancel');
});
it('点 overlay 背景(不是 modal 内部) → onBackdropClose', () => {
const modal = makeModal({ onBackdropClose: 'dismissed' });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 直接派发 click 到 overlay 本身
modal.overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onClose).toHaveBeenCalledWith('dismissed');
});
it('点 modal 内部(不是 overlay 背景)不关闭', () => {
const modal = makeModal({ onBackdropClose: 'should-not-fire' });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 点击 modal 内部元素
const inner = modal.overlay.querySelector('.modal');
inner.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
});
describe('mountModal onAction 回调', () => {
it('点 footer [data-action] 按钮 → onAction(action, controller)', () => {
const onAction = vi.fn();
const modal = makeModal({
onAction,
footer: `
<button class="btn" data-action="save">保存</button>
<button class="btn" data-action="cancel">取消</button>
`,
});
const cancelBtn = modal.footer.querySelector('[data-action="cancel"]');
cancelBtn.click();
expect(onAction).toHaveBeenCalledWith('cancel', modal);
});
it('onAction 返回 "close" → 自动 close(action)', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
const btn = modal.footer.querySelector('[data-action="ok"]');
btn.click();
expect(onAction).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledWith('ok');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
it('onAction 返回 undefined → 不关', () => {
const onAction = vi.fn();
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
modal.footer.querySelector('[data-action="ok"]').click();
expect(onAction).toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
// audit fix (Round 8 M-3)onAction 抛同步错时 modal 不能挂死。
// 旧实现onAction 抛错 → event listener 抛 → modal 不关 → 用户没法关窗。
// 修复try/catch 兜底,错误打 consolemodal 保持打开(用户看到具体错误)。
it('onAction 抛同步错 → 不挂死modal 仍可关Round 8 M-3', () => {
const onAction = vi.fn(() => { throw new Error('dialog logic exploded'); });
const modal = makeModal({ onAction });
const onClose = vi.fn();
modal.setOnClose(onClose);
// 静默 console.error避免测试输出噪音
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => modal.footer.querySelector('[data-action="ok"]').click()).not.toThrow();
expect(onAction).toHaveBeenCalled();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
// 关键断言 —— modal 仍可关闭(× 按钮正常工作)
modal.footer.querySelector('[data-action="__modal_close__"], .modal-close')?.click();
// 没有 × 时 overlay 背景点击也走 onBackdropClose这里显式 close(value) 模拟用户关窗
modal.close('manual');
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
});
describe('mountModal data-action 作用域(仅 modal-level', () => {
// 契约data-action 是 modal-level 属性,只被 modal 在 footer + × 关闭按钮上拦截。
// body 内的 [data-action] 是 dialog 自己的 in-body action例如 settings-dialog
// 的「显示/隐藏 API Key」「清空 AI 配置」),由 dialog 各自 bind 处理modal
// 不应拦截、不应触发 onAction、也不应自动关闭。
//
// 之前 modal 无差别拦截所有 [data-action]body 内按钮的 onAction 默认返回
// 'close' → 点「显示」就立刻关 modalUX 坏掉。
it('body 内 [data-action] 按钮不触发 onAction', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({
onAction,
body: '<button type="button" data-action="toggle-ai-key">显示 API Key</button>',
footer: '<button class="btn" data-action="ok">确定</button>',
});
modal.body.querySelector('[data-action="toggle-ai-key"]').click();
expect(onAction).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('body 内 [data-action] 按钮即使 onAction 返回 "close" 也不关 modal', () => {
const onAction = vi.fn(() => 'close');
const onClose = vi.fn();
const modal = makeModal({
onAction,
onClose,
body: '<button type="button" data-action="clear-ai">[清空 AI 配置]</button>',
});
modal.setOnClose(onClose);
modal.body.querySelector('[data-action="clear-ai"]').click();
expect(onAction).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(true);
});
it('footer 内 [data-action] 仍然走 onAction回归保护', () => {
const onAction = vi.fn(() => 'close');
const modal = makeModal({
onAction,
body: '<button type="button" data-action="body-btn">body btn</button>',
footer: '<button class="btn" data-action="footer-btn">footer btn</button>',
});
modal.footer.querySelector('[data-action="footer-btn"]').click();
expect(onAction).toHaveBeenCalledWith('footer-btn', modal);
expect(document.getElementById('modal-root').contains(modal.overlay)).toBe(false);
});
});
describe('mountModal 单槽冲突', () => {
it('已开一个再开第二个 → 第二个返回 null', () => {
const first = makeModal({ title: 'first' });
const second = makeModal({ title: 'second' });
expect(first).not.toBeNull();
expect(second).toBeNull();
});
it('关掉第一个后才能再开第二个', () => {
const first = makeModal({ title: 'first' });
expect(makeModal({ title: 'second' })).toBeNull();
first.close('ok');
const second = makeModal({ title: 'second' });
expect(second).not.toBeNull();
});
});
describe('mountModal setBody / setFooter', () => {
it('setBody 替换正文', () => {
const modal = makeModal();
modal.setBody('<p class="replaced">new</p>');
expect(modal.body.querySelector('.replaced').textContent).toBe('new');
});
it('setFooter 替换 footer 且按钮事件继续走委托', () => {
const onAction = vi.fn();
const modal = makeModal({ onAction });
modal.setFooter('<button class="btn" data-action="replaced">替换的按钮</button>');
modal.footer.querySelector('[data-action="replaced"]').click();
expect(onAction).toHaveBeenCalledWith('replaced', modal);
});
});
describe('mountModal abortSignal', () => {
it('暴露 AbortSignal 用于外部挂监听', () => {
const modal = makeModal();
expect(modal.abortSignal).toBeInstanceOf(AbortSignal);
expect(modal.abortSignal.aborted).toBe(false);
modal.close('x');
expect(modal.abortSignal.aborted).toBe(true);
});
});

View File

@@ -0,0 +1,422 @@
// Stage 7+: prompt-dialog.js
//
// 覆盖:
// - 挂载后渲染 input + 两个按钮cancel/confirm+ label
// - 点 Confirm → resolve(input.value),包含空格原样返回
// - 点 Cancel → resolve(null)
// - Esc / × 按钮 / 点 overlay 背景 → resolve(null)onBackdropClose='cancel'
// - Enter 键(在 input 内) → resolve(input.value)
// - input 引用丢失的极端兜底 → resolve(null),不挂死
// - 默认文本 selectAllOnOpen=true 时选中(默认行为)
// - 默认文本 selectAllOnOpen=false 时不选中
// - 用户自定义 confirmLabel / cancelLabel / placeholder 写入 DOM
// - #modal-root 缺失 → resolve(null)
// - 已有 modal 占位 → resolve(null)(单槽冲突)
// - 无 title 参数 → resolve(null)(兜底,不挂死)
//
// 需要 DOMjsdom 环境。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { showPromptDialog } from '../../src/prompt-dialog.js';
import { isOpen as modalIsOpen } from '../../src/modal-stack.js';
beforeEach(() => {
document.body.innerHTML = '<div id="modal-root"></div>';
});
afterEach(() => {
// 兜底:派发 Esc让任何漏关的 modal 收掉
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
document.body.innerHTML = '';
});
/**
* 等待 queueMicrotask + 一帧渲染focus / .select() 都依赖异步)
*/
function flushMicrotasks() {
return new Promise((resolve) => {
queueMicrotask(() => queueMicrotask(resolve));
});
}
describe('showPromptDialog 基本结构', () => {
it('挂载后渲染 input + 两个按钮', async () => {
const promise = showPromptDialog({ title: '新建笔记' });
const input = document.getElementById('prompt-dialog-input');
expect(input).not.toBeNull();
expect(input.tagName).toBe('INPUT');
expect(input.type).toBe('text');
const buttons = document.querySelectorAll('.modal-footer .btn');
expect(buttons.length).toBe(2);
const actions = Array.from(buttons).map((b) => b.dataset.action);
expect(actions).toContain('cancel');
expect(actions).toContain('confirm');
// 清理
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('label 关联到 inputfor 属性匹配 id', async () => {
const promise = showPromptDialog({ title: '重命名', inputLabel: '新文件名' });
const label = document.querySelector('.modal-body .form-label');
expect(label).not.toBeNull();
expect(label.getAttribute('for')).toBe('prompt-dialog-input');
expect(label.textContent).toBe('新文件名');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('inputLabel 缺省时退化为 title', async () => {
const promise = showPromptDialog({ title: '我的标题' });
const label = document.querySelector('.modal-body .form-label');
expect(label.textContent).toBe('我的标题');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('用户自定义 confirmLabel / cancelLabel / placeholder 写入 DOM', async () => {
const promise = showPromptDialog({
title: 't',
confirmLabel: '好的',
cancelLabel: '不要',
placeholder: '请输入文件名',
});
const input = document.getElementById('prompt-dialog-input');
expect(input.getAttribute('placeholder')).toBe('请输入文件名');
const buttons = document.querySelectorAll('.modal-footer .btn');
const labels = Array.from(buttons).map((b) => b.textContent.trim());
expect(labels).toContain('好的');
expect(labels).toContain('不要');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('message 缺省不渲染 <p.conf-message>', async () => {
const promise = showPromptDialog({ title: 'no-msg' });
expect(document.querySelector('.confirm-message')).toBeNull();
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('title / message / label 的 HTML 特殊字符被转义(防止 XSS', async () => {
const promise = showPromptDialog({
title: '<img src=x>',
message: '<script>alert(1)</script>',
inputLabel: '"><b>x</b>',
});
const overlay = document.querySelector('.modal-overlay');
expect(overlay.innerHTML).not.toContain('<img src=x>');
expect(overlay.innerHTML).not.toContain('<script>alert(1)</script>');
// 转义后保留字面字符
expect(overlay.textContent).toContain('<img src=x>');
expect(overlay.textContent).toContain('<script>alert(1)</script>');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
});
describe('showPromptDialog 关闭路径', () => {
it('点 Confirm → resolve(input.value),含空格原样返回', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '默认' });
const input = document.getElementById('prompt-dialog-input');
input.value = ' hello world ';
const confirmBtn = document.querySelector('.modal-footer .btn[data-action="confirm"]');
confirmBtn.click();
await expect(promise).resolves.toBe(' hello world ');
expect(modalIsOpen()).toBe(false);
});
it('点 Cancel → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '默认值' });
const cancelBtn = document.querySelector('.modal-footer .btn[data-action="cancel"]');
cancelBtn.click();
await expect(promise).resolves.toBeNull();
expect(modalIsOpen()).toBe(false);
});
it('按 Esc → resolve(null)onBackdropClose=cancel', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'x' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('点 × 按钮 → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't' });
document.querySelector('.modal-close').click();
await expect(promise).resolves.toBeNull();
});
it('点 overlay 背景 → resolve(null)', async () => {
const promise = showPromptDialog({ title: 't' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('Enter 键(在 input 内) → resolve(input.value)modal 关闭', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'old' });
const input = document.getElementById('prompt-dialog-input');
input.value = 'new name';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('new name');
expect(modalIsOpen()).toBe(false);
});
it('Enter 键被 preventDefault不触发任何潜在 form submit', async () => {
const promise = showPromptDialog({ title: 't' });
const input = document.getElementById('prompt-dialog-input');
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
await promise;
});
it('关闭后 overlay 已从 DOM 移除', async () => {
const promise = showPromptDialog({ title: 't' });
expect(document.querySelector('.modal-overlay')).not.toBeNull();
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
expect(document.querySelector('.modal-overlay')).toBeNull();
});
});
describe('showPromptDialog 默认值 / 全选', () => {
it('defaultValue 写入 input.value', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '未命名-2026' });
const input = document.getElementById('prompt-dialog-input');
expect(input.value).toBe('未命名-2026');
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('selectAllOnOpen=true默认+ defaultValue 非空 → 打开后选中', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: '选中我' });
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('选中我'.length);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
it('selectAllOnOpen=false → 不全选', async () => {
const promise = showPromptDialog({
title: 't',
defaultValue: '保持光标',
selectAllOnOpen: false,
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
// selectionStart === selectionEnd 表示无选中(光标在末尾)
expect(input.selectionStart).toBe(input.selectionEnd);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await promise;
});
});
describe('showPromptDialog validate 选项', () => {
it('validate 返回字符串 → 显示行内错误、dialog 保持打开', async () => {
let calls = 0;
const promise = showPromptDialog({
title: '重命名',
defaultValue: 'old.md',
validate: (raw) => {
calls += 1;
if (raw.includes('/')) return '不能包含 /';
return null;
},
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
const errorEl = document.getElementById('prompt-dialog-error');
// 改成非法值 → 点确认
input.value = 'foo/bar.md';
const confirmBtn = document.querySelector('.modal-footer .btn[data-action="confirm"]');
confirmBtn.click();
await flushMicrotasks();
// validate 调用了错误显示modal 仍在promise 没 resolve
expect(calls).toBeGreaterThanOrEqual(1);
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('不能包含 /');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.classList.contains('is-invalid')).toBe(true);
expect(modalIsOpen()).toBe(true);
// 改成合法值 → input 事件清错误
input.value = 'foo-bar.md';
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(errorEl.hidden).toBe(true);
expect(input.hasAttribute('aria-invalid')).toBe(false);
// 再次确认 → 通过 → resolve
confirmBtn.click();
await expect(promise).resolves.toBe('foo-bar.md');
});
it('validate 抛错 → 错误消息被当作 validate 返回值展示', async () => {
const promise = showPromptDialog({
title: 't',
validate: () => { throw new Error('check failed'); },
});
await flushMicrotasks();
const errorEl = document.getElementById('prompt-dialog-error');
document.querySelector('.modal-footer .btn[data-action="confirm"]').click();
await flushMicrotasks();
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('check failed');
expect(modalIsOpen()).toBe(true);
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await expect(promise).resolves.toBeNull();
});
it('validate 不传 → 行为不变(向后兼容)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'ok' });
document.querySelector('.modal-footer .btn[data-action="confirm"]').click();
await expect(promise).resolves.toBe('ok');
});
// audit fix (Phase L1-Settings P1)Enter 键必须与按钮 click 走完全同一条
// validate 路径。早期实现 Enter 直接 modal.close('confirm') 跳过校验 → 用户
// 重命名输入非法名按 Enter → dialog 静默关闭 → typed text 丢失、错误仅通过
// 通用 toast 模糊暴露。
it('Enter 键 + validate 失败 → dialog 保持打开、行内错误显示', async () => {
let calls = 0;
const promise = showPromptDialog({
title: '重命名',
defaultValue: 'old.md',
validate: (raw) => {
calls += 1;
if (raw.includes('/')) return '不能包含 /';
return null;
},
});
await flushMicrotasks();
const input = document.getElementById('prompt-dialog-input');
const errorEl = document.getElementById('prompt-dialog-error');
// 改成非法值,按 Enter —— 之前会跳过 validate 直接关 dialog
input.value = 'foo/bar.md';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await flushMicrotasks();
expect(calls).toBeGreaterThanOrEqual(1);
expect(errorEl.hidden).toBe(false);
expect(errorEl.textContent).toBe('不能包含 /');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(modalIsOpen()).toBe(true);
// promise 还没 resolve
let resolved = false;
promise.then(() => { resolved = true; });
await flushMicrotasks();
expect(resolved).toBe(false);
// 改成合法值按 Enter → 通过 → resolve
input.value = 'foo-bar.md';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('foo-bar.md');
});
it('Enter 键 + 无 validate → 行为不变(向后兼容)', async () => {
const promise = showPromptDialog({ title: 't', defaultValue: 'ok' });
const input = document.getElementById('prompt-dialog-input');
input.value = 'changed';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await expect(promise).resolves.toBe('changed');
});
});
describe('showPromptDialog 退化路径', () => {
it('#modal-root 缺失 → resolve(null),不挂死', async () => {
document.body.innerHTML = ''; // 干掉 #modal-root
const promise = showPromptDialog({ title: 't', defaultValue: 'd' });
await expect(promise).resolves.toBeNull();
});
it('已有 modal 占位 → resolve(null)(与 mountModal 单槽冲突一致)', async () => {
// 第一个 modal 占住 #modal-root
const first = showPromptDialog({ title: 'first' });
// 第二个开不起来
const second = showPromptDialog({ title: 'second' });
await expect(second).resolves.toBeNull();
// 收掉第一个
document.querySelector('.modal-overlay').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await first;
});
it('无 title 参数 → 立即 resolve(null),不挂死', async () => {
const promise = showPromptDialog();
await expect(promise).resolves.toBeNull();
expect(modalIsOpen()).toBe(false);
});
});
describe('showPromptDialog Promise resolve 幂等性', () => {
it('close 后多次 resolve 不抛错Promise resolve 是幂等的)', async () => {
const promise = showPromptDialog({ title: 't' });
const overlay = document.querySelector('.modal-overlay');
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// 再次尝试关 —— close 内部有 closed 保护
const modalCtrl = document.getElementById('modal-root').firstChild;
// 第一次关闭后 overlay 已经从 DOM 移除;这里只确保 promise 不抛
await expect(promise).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,415 @@
/* @vitest-environment jsdom */
// shared/render-sanitize.js 规则测试
//
// 覆盖:
// 1. XSS 防护:<script> / 事件属性 / javascript: / vbscript: / data:text\/html / iframe
// 2. <img src> 保留策略相对路径、绝对路径、file:、data:image/、https:;危险协议被剥
// 3. 非 IMG 标签剥 file:a / form / iframe
// 4. ALLOWED_URI_REGEXP 单测:覆盖各 scheme / 相对路径 / 危险协议
//
// 这些规则也用于 preload.js 的 renderMarkdown —— 测试与实现共享
// shared/render-sanitize.js禁止规则在两侧漂移。
import { describe, it, expect, beforeAll } from 'vitest';
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
import { marked } from 'marked';
import { ALLOWED_URI_REGEXP, installHooks } from '../../shared/render-sanitize.js';
// audit fix直接 import shared/slug.js不再源码级镜像 heading slug 算法。
// 之前在 beforeAll + render() 里复制了两份 slugify 实现 —— 与 shared/slug.js 漂移
// 时测试还能过但真实场景preload 走 shared/slug.js已经对不上。
import { slugifyHeading } from '../../shared/slug.js';
let dom;
let DOMPurifyInstance;
beforeAll(() => {
dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
DOMPurifyInstance = createDOMPurify(dom.window);
installHooks(DOMPurifyInstance);
// 复制 preload 的 marked 配置target=_blank + heading id slug
// 这里的 slug 算法不再手写 —— 改走 shared/slug.js#slugifyHeading
// 与 preload / outline 三处保持唯一事实源。
const mdRenderer = new marked.Renderer();
const baseLink = mdRenderer.link.bind(mdRenderer);
mdRenderer.link = (h, t, txt) => baseLink(h, t, txt).replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
marked.setOptions({ gfm: true, breaks: false, renderer: mdRenderer });
});
function render(md) {
// 重建 renderer + 每次独立的 seenSlugs与 preload renderMarkdown 行为一致):
// heading id 的去重计数按文档重置,跨文档不串。
const mdRenderer = new marked.Renderer();
const baseLink = mdRenderer.link.bind(mdRenderer);
mdRenderer.link = (h, t, txt) => baseLink(h, t, txt).replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
const seenSlugs = new Set();
mdRenderer.heading = (text, level, raw) => `<h${level} id="${slugifyHeading(raw, seenSlugs)}">${text}</h${level}>\n`;
const rawHtml = marked.parse(md, { renderer: mdRenderer });
return DOMPurifyInstance.sanitize(rawHtml, {
ADD_ATTR: ['target', 'rel', 'id'],
ALLOWED_URI_REGEXP,
FORBID_TAGS: ['svg', 'math', 'template', 'form', 'input', 'button', 'select', 'textarea'],
});
}
/**
* 绕开 marked直接喂 HTML 给 DOMPurify —— 模拟攻击者把恶意 HTML 塞进 markdown
* 文档边缘marked 解析时会吞掉大部分 inline HTML但裸 inline / 嵌入已是 HTML 的
* 子树仍会透传)。这里与 preload 真实 sanitize 配置严格对齐。
*/
function renderHtml(html) {
return DOMPurifyInstance.sanitize(html, {
ADD_ATTR: ['target', 'rel', 'id'],
ALLOWED_URI_REGEXP,
FORBID_TAGS: ['svg', 'math', 'template', 'form', 'input', 'button', 'select', 'textarea'],
});
}
describe('ALLOWED_URI_REGEXP 单测', () => {
it.each([
['https://e/x', true],
['http://e/x', true],
['mailto:x@y', true],
['tel:1234', true],
['callto:x', true],
['file:///C:/x.png', true],
['data:image/png;base64,AAA', true],
['#anchor', true],
['img/a.png', true],
['./other.md', true],
['/abs/x.png', true],
['x.png', true],
['../up.md', true],
// Windows 反斜杠路径(用户在 Windows 上写 `![img\foo.png](img\foo.png)`
['img\\foo.png', true],
['.\\foo.png', true],
['..\\up.md', true],
['\\abs\\x.png', true],
['javascript:alert(1)', false],
['vbscript:msgbox(1)', false],
['data:text/html,xxx', false],
['about:blank', false],
['chrome-extension://x/y', false],
// 协议相对 URL// 或 \\ 开头会被浏览器解析为「继承当前 scheme」
// 攻击面是「静默导航到外站 / 把用户隐私发给 evil.com」。
// 必须与绝对路径 /abs/x.png 区分开 —— 单 / 放行,双 / 拒绝。
['//evil.com/x.png', false],
['\\\\evil.com\\x.png', false],
])('"%s" 匹配 = %s', (input, expected) => {
expect(ALLOWED_URI_REGEXP.test(input)).toBe(expected);
});
});
describe('XSS 防护', () => {
it('<script> 被剥离', () => {
const html = render('# x\n\n<script>alert(1)</script>');
expect(html).not.toMatch(/<script/i);
});
it('onerror= 事件属性被剥离', () => {
const html = render('<img src="x" onerror="alert(1)">');
expect(html).not.toMatch(/onerror/i);
});
it('javascript: 在 <a href> 被剥离', () => {
const html = render('[c](javascript:alert(1))');
expect(html).not.toMatch(/javascript:/i);
});
it('vbscript: 被剥离', () => {
const html = render('[c](vbscript:msgbox(1))');
expect(html).not.toMatch(/vbscript:/i);
});
it('data:text/html 被剥离(仅 data:image/* 放行)', () => {
const html = render('[c](data:text/html,<script>x</script>)');
expect(html).not.toMatch(/href="data:text\/html/i);
});
it('<iframe> 标签被剥离', () => {
const html = render('<iframe src="https://x"></iframe>');
expect(html).not.toMatch(/<iframe/i);
});
it('<form action="javascript:"> action 被剥离', () => {
const html = render('<form action="javascript:alert(1)"><input></form>');
expect(html).not.toMatch(/action="javascript:/i);
});
it('//evil.com/x.png协议相对 URL在 <a href> 被剥离', () => {
// 之前 ALLOWED_URI_REGEXP 的 `[^a-z]` 分支匹配任何非字母字符,
// 放过 `//evil.com/x.png`。浏览器解析为「继承当前 scheme」可静默
// 导航到外站(或在 renderer 上下文里利用 about:blank 之类)。
const html = render('[click](//evil.com/x.png)');
expect(html).not.toMatch(/href="[^"]*evil\.com/i);
});
it('//evil.com/x.png 在 <img src> 被剥(不依赖 DOMPurify 内置)', () => {
const html = render('![a](//evil.com/x.png)');
expect(html).not.toMatch(/src="[^"]*evil\.com/i);
});
});
describe('<img src> 保留策略', () => {
it.each([
['相对路径 img/a.png', 'img/a.png'],
['相对 ./other.md', './other.md'],
['绝对 /abs/x.png', '/abs/x.png'],
['file:/// 协议', 'file:///C:/x.png'],
['data:image/png base64', 'data:image/png;base64,AAA'],
['https://', 'https://e/x.png'],
])('%s 保留 src', (_label, src) => {
const html = render(`![a](${src})`);
expect(html).toContain(`src="${src}"`);
});
it('javascript: 在 <img src> 被剥hook 主动剥,不依赖 DOMPurify 内置)', () => {
// 现在所有 src/href/action 都走 DANGEROUS_URI_REGEXP 主动剥 —— 不再依赖
// DOMPurify v3 内置兜底(虽然 v3 仍会兜底剥 javascript:,但 hook 是第一道闸)。
const html = render('![a](javascript:alert(1))');
expect(html).not.toMatch(/src="javascript:/);
});
it('vbscript: 在 <img src> 被剥', () => {
// 旧实现对 vbscript: 放行keepAttr=true—— DOMPurify v3 不兜底这个。
// 现在由 hook 主动剥,与 javascript: 行为一致。
const html = render('![a](vbscript:msgbox(1))');
expect(html).not.toMatch(/src="vbscript:/i);
});
it('about:blank 在 <img src> 被剥policy 统一:未在白名单的 scheme 一律不放)', () => {
// 旧实现会放行 about:blank不是危险协议但也不在白名单现在统一被剥。
const html = render('![a](about:blank)');
expect(html).not.toMatch(/src="about:/i);
});
it('data:text/html 在 <img src> 被剥(统一策略:仅 data:image/* 放行)', () => {
// 之前测试断言「保留」(理由是浏览器不把 img 渲染为 HTML—— 但跨浏览器 / 跨版本
// 一致性差,且与 ALLOWED_URI_REGEXP 单点策略不一致。现在统一被剥。
const html = render('![a](data:text/html,<script>x</script>)');
expect(html).not.toMatch(/src="data:text\/html/i);
});
// fix(audit 2026-08)SVG 数据 URL 在 <a href> 上点击会导航到 top-level SVG
// 上下文,现代 Chromium 多半拦截脚本执行,但跨浏览器一致性差。
// ALLOWED_URI_REGEXP 限定 data:image 为栅格格式png/jpeg/gif/webp/...
// 显式拒绝 svg+xml —— Markdown 几乎不会内嵌 SVGraster 已覆盖 99% 场景。
it('data:image/svg+xml 在 <img src> 被剥(栅格白名单显式排除 SVG', () => {
const html = render('![a](data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnPjxzY3JpcHQ+YWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4=)');
expect(html).not.toMatch(/src="data:image\/svg\+xml/i);
});
// audit fix (Round 13 / Sec-H2)file:// 必须严格 file:///... 三斜杠。
// 拒绝 file://host/... Windows 上解析为 \\host\share触发 SMB NTLMv2 认证,
// 攻击者能抓受害者的 domain\user + NTLM hash —— CVE-2023-23397 类原语)。
it.each([
['远程主机 evil.comCVE-2023-23397 原语)', 'file://evil.com/share/pixel.png'],
['UNC 形式 ////', 'file:////evilhost/share/x.png'],
['显式 localhost也应拒绝 — 唯一合法形式只有 ///', 'file://localhost/C:/x.png'],
['大小写混合 UNC', 'FILE:////evilhost/x.png'],
['前导空格 + 远程主机(绕过 trim', ' file://evil.com/x.png'],
])('file:// %s 在 <img src> 被剥', (_label, src) => {
const html = render(`![a](${src})`);
expect(html).not.toMatch(/src="file:/i);
});
it('file:/// 三斜杠(无 host在 <img src> 仍保留(合法本地图片)', () => {
const html = render('![a](file:///C:/Users/me/Notes/x.png)');
expect(html).toContain('src="file:///C:/Users/me/Notes/x.png"');
});
// audit fix (Round 13 / Sec-M):项目里 markdown 保留合法 inline stylecolor /
// font-size / background:url(https://...) 等纯视觉属性),由 isDangerousStyleValue
// 按 CSS 属性名做 denylist 拦截。denylist 覆盖了所有能改变布局 / 跳出文档流 /
// 遮挡 UI / 隐藏元素 / 让用户看不清真实界面的属性 —— 即便攻击者把整个浏览器
// 窗口当画布也办不到。
//
// 这些测试走 renderHtml() 而不是 render() —— marked 的 lexer 会对裸 inline
// HTML 做容错(部分 inline 会按字符实体 / 段落拆分),无法精确验证 DOMPurify
// 在 ALLOWED 名单下的剔除策略。绕开 marked 直喂 HTML 给 sanitize行为更可预测。
it.each([
['position:fixed 全屏覆盖层', '<a href="https://phish.example" style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:2147483647;display:block">x</a>'],
['display:none不让用户看到真实 UI', '<div style="display:none">hidden</div>'],
['opacity:0透明覆盖层', '<div style="opacity:0;position:absolute;top:0;left:0;width:100vw;height:100vh">overlay</div>'],
['visibility:hidden', '<span style="visibility:hidden">x</span>'],
['pointer-events:none透传点击到下层', '<span style="pointer-events:none;position:absolute;inset:0">trap</span>'],
['transform绕过父级 contain', '<span style="transform:translate(-100vw,-100vh)">off-screen</span>'],
['content注入伪元素文本', '<span style="content:&apos;会话已过期&apos;">x</span>'],
['top/left/right/bottom/inset绝对定位', '<span style="position:absolute;top:0;left:0">x</span>'],
])('style 含危险属性 %s → style 属性被剥', (_label, htmlIn) => {
const html = renderHtml(htmlIn);
expect(html).not.toMatch(/style=/i);
});
it('合法 stylecolor / font-size / text-align保留', () => {
// 视觉自定义应通过合法 CSS 属性而非 inline style —— 但项目历史包袱允许
// 一些纯样式 style。回归测试确保 denylist 没误伤。
const html = renderHtml('<span style="color:red;font-size:14px;text-align:center">x</span>');
expect(html).toMatch(/color\s*:\s*red/i);
expect(html).toMatch(/font-size\s*:\s*14px/i);
});
it('<svg> 标签被剥(防 SVG-namespaced <style> 注入文档级 CSS', () => {
const html = renderHtml('<svg><style>body::after{content:"phish"}</style></svg>');
expect(html).not.toMatch(/<svg/i);
expect(html).not.toMatch(/phish/);
});
it('<form>/<input> 被剥', () => {
const html = renderHtml('<form action="https://evil.example"><input type="text" name="x"></form>');
expect(html).not.toMatch(/<form/i);
expect(html).not.toMatch(/<input/i);
});
it('data:image/png 仍保留(栅格白名单基线)', () => {
const html = render('![a](data:image/png;base64,iVBORw0KGgo=)');
expect(html).toMatch(/src="data:image\/png;base64,iVBORw0KGgo="/);
});
});
describe('非 IMG 标签的 file: 协议', () => {
it('<a href="file://..."> 被剥', () => {
const html = render('[l](file:///C:/x.txt)');
expect(html).not.toMatch(/href="file:\/\/\/C:\/x\.txt/);
});
it('<form action="file://..."> action 被剥', () => {
const html = render('<form action="file:///C:/x"><input></form>');
expect(html).not.toMatch(/action="file:\/\/\/C:\/x/);
});
// audit fix (Round 4 P1-5):非 IMG 标签的 URI 属性也走 ALLOWED 校验剥
// data:image/svg+xml。ALLOWED_URI_REGEXP 显式排除 svg+xml只允许栅格
// 之前非 IMG 只走 DANGEROUS 负向预查放过 data:image/svg+xml → 完全依赖
// DOMPurify v3 内置兜底。现在 hook 第二道闸显式剥。
it('<a href="data:image/svg+xml;..."> 被剥(非 IMG 也走 ALLOWED', () => {
const html = render('[l](data:image/svg+xml;base64,PHN2Zz4=)');
expect(html).not.toMatch(/href="data:image\/svg\+xml/i);
});
it('<a href="https://..."> 仍保留(普通 https 不被误剥)', () => {
const html = render('[l](https://example.com)');
expect(html).toMatch(/href="https:\/\/example\.com"/);
});
it('target=_blank 自动注入的 rel="noopener noreferrer" 不被误剥', () => {
// 之前 bugnon-IMG ALLOWED 检查对所有非 IMG 属性生效,把 DOMPurify 自动
// 加的安全 rel="noopener noreferrer" 也判定为不通过 ALLOWED → 误剥。
// 现在限定为 URI 类属性href/src/action 等),安全属性放过。
const html = render('[x](https://e)');
expect(html).toMatch(/rel="noopener noreferrer"/);
});
});
describe('<img src> data:image 大小上限 (audit Sec-M3)', () => {
it('正常大小 data:image/png base64 保留', () => {
const html = render('![a](data:image/png;base64,AAAA)');
expect(html).toMatch(/src="data:image\/png;base64,AAAA"/);
});
it('巨大 data:image (>10MB) 被剥 src', () => {
// 绕过 marked 直接 sanitizemarked 的 lexer 在 11MB base64 上会先栈溢出,
// 走 marked 路径反而测不到我们要保护的那一层。这里手搓 <img> 直接喂给
// DOMPurify —— 验证 hook 在 data:image 巨长 base64 上会主动 keepAttr=false。
const huge = `data:image/png;base64,${'A'.repeat(11 * 1024 * 1024)}`;
const html = DOMPurifyInstance.sanitize(`<img src="${huge}">`, {
ADD_ATTR: ['src'],
ALLOWED_URI_REGEXP,
});
expect(html).not.toMatch(new RegExp(`src="data:image/png;base64,${'A'.repeat(100)}`));
});
it('10MB 临界值正好放过(不挡合理大小)', () => {
// 边界:刚好等于上限应当放过;实际我们想留余量,写测试时只看 < 1MB
const small = `data:image/png;base64,${'A'.repeat(1024 * 1024)}`;
const html = render(`![x](${small})`);
expect(html).toMatch(/src="data:image\/png;base64,A{100}/);
});
it('非 data: 的超长 URL 不受影响(绝对 file:/https: 走原路径)', () => {
// file:/// 与 https:// 不应被本次 size 限制波及;行为与先前一致。
const longHttps = `https://e.com/${'a'.repeat(1024 * 1024)}.png`;
const html = render(`![x](${longHttps})`);
expect(html).toMatch(/^<p><img src="https:\/\/e\.com\//);
});
});
// audit fix (Round 9)style 属性里嵌的危险 CSS 模式javascript:/expression/
// behavior/-moz-binding/@import走 DANGEROUS_STYLE_PATTERNS 主动剥。
// DOMPurify v3 自带部分保护,但跨版本一致性差;这里做防御性深度扫描。
describe('style 属性危险 CSS 模式Round 9 防御性深度扫描)', () => {
it('style 里 url(javascript:...) 被剥', () => {
const html = render('<a href="https://e.com" style="background:url(javascript:alert(1))">x</a>');
expect(html).not.toMatch(/url\s*\(\s*javascript:/i);
// 整条 style 被剥(保守策略)
expect(html).not.toMatch(/style=/i);
});
it('style 里 url("javascript:...") 引号包裹也被剥', () => {
const html = render('<span style="background:url(\'javascript:alert(1)\')">x</span>');
expect(html).not.toMatch(/javascript:/i);
});
it('style 里 url(VBScript:) 大小写不敏感也被剥', () => {
const html = render('<span style="background:url(VBScript:msgbox(1))">x</span>');
expect(html).not.toMatch(/vbscript:/i);
});
it('style 里 url(data:text/html...) 被剥(拒绝非 image data:', () => {
const html = render('<span style="background:url(data:text/html,<script>alert(1)</script>)">x</span>');
expect(html).not.toMatch(/data:text\/html/i);
});
it('style 里 expression(alert(1)) legacy IE 模式被剥', () => {
const html = render('<div style="width:expression(alert(1))">x</div>');
expect(html).not.toMatch(/expression\s*\(/i);
});
it('style 里 behavior:url(...) legacy IE HTC 模式被剥', () => {
const html = render('<div style="behavior:url(#default#anchorclick)">x</div>');
expect(html).not.toMatch(/behavior\s*:/i);
});
it('style 里 -moz-binding:url(...) legacy Mozilla XBL 模式被剥', () => {
const html = render('<div style="-moz-binding:url(\'http://evil/xbl.xml#x\')">x</div>');
expect(html).not.toMatch(/-moz-binding/i);
});
it('style 里 @import 外链资源加载被剥', () => {
const html = render('<div style="@import url(http://evil/x.css); color:red">x</div>');
expect(html).not.toMatch(/@import/i);
});
it('合法 style 保留color / font-size 等无害属性不动)', () => {
const html = render('<span style="color:red; font-size:14px">x</span>');
expect(html).toMatch(/style="[^"]*color\s*:\s*red/i);
expect(html).toMatch(/style="[^"]*font-size\s*:\s*14px/i);
});
it('合法 url()http: / data:image/)保留', () => {
const html = render('<div style="background:url(https://e.com/x.png); color:red">x</div>');
expect(html).toMatch(/style="[^"]*color\s*:\s*red/i);
// 字符串里出现的 url(https://...) 是合法上下文不应剥
expect(html).not.toMatch(/style="[^"]*javascript:|vbscript:|expression\s*\(|@import/i);
});
});
describe('marked 输出增强', () => {
it('链接注入 target=_blank + rel="noopener noreferrer"', () => {
const html = render('[x](https://e)');
expect(html).toMatch(/target="_blank"/);
expect(html).toMatch(/rel="noopener noreferrer"/);
});
it('中文标题生成 id 锚点', () => {
const html = render('## 你好世界');
expect(html).toMatch(/<h2 id="你好世界">/);
});
it('同名标题生成 -1/-2 后缀', () => {
const html = render('## 你好世界\n\n## 你好世界');
expect(html).toMatch(/id="你好世界"/);
expect(html).toMatch(/id="你好世界-1"/);
});
});

View File

@@ -0,0 +1,50 @@
// safeRenderMarkdown typeof guard 测试Round 8 R-1
//
// 单独成文件 + jsdom env不污染 utils.test.js 的 node env
// - utils.test.js 里 pathsEqual Windows case-fold 测试依赖 Node 24 的
// navigator.platform='Win32'jsdom 25 是 ''),不同 env 命中不同 isWindows
// 分支。utils.test.js 留在 node env 跑基线更稳。
// - 本测试需要 window 存在以挂 window.api stub → 必须 jsdom。
//
// 覆盖renderMarkdown 契约是 string但 jsdom 单测或未来 mock 替换可能返回
// 非字符串。typeof guard 防 null / undefined / 数字 直接 setBody 触发下游
// DOM 解析错。**关键反例**:必须用 typeof不能用 `||`,因为 '' 是空 markdown
// 的合法返回preload 对空字符串返回 '')—— `result ||` 会把合法空 markdown
// 误降级为 <pre>,污染空文档渲染。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { safeRenderMarkdown } from '../../src/utils.js';
describe('safeRenderMarkdown —— typeof guard (Round 8 R-1)', () => {
let savedApi;
beforeEach(() => {
savedApi = window.api;
});
afterEach(() => {
window.api = savedApi;
});
it('window.api.renderMarkdown 返回 null → 降级为 <pre>', () => {
window.api = { renderMarkdown: () => null };
expect(safeRenderMarkdown('text')).toContain('<pre');
});
it('window.api.renderMarkdown 返回 undefined → 降级为 <pre>', () => {
window.api = { renderMarkdown: () => undefined };
expect(safeRenderMarkdown('text')).toContain('<pre');
});
it('window.api.renderMarkdown 返回数字 → 降级为 <pre>', () => {
window.api = { renderMarkdown: () => 42 };
expect(safeRenderMarkdown('text')).toContain('<pre');
});
it('window.api.renderMarkdown 返回合法字符串 → 透传', () => {
window.api = { renderMarkdown: () => '<p>ok</p>' };
expect(safeRenderMarkdown('text')).toBe('<p>ok</p>');
});
it('空 markdown → window.api.renderMarkdown 返回空字符串 → 透传空字符串(不降级)', () => {
// 关键反例:旧版用 `||` 会把 '' 误降级为 <pre>,污染空文档渲染。
window.api = { renderMarkdown: () => '' };
expect(safeRenderMarkdown('')).toBe('');
});
});

View File

@@ -0,0 +1,200 @@
// src/save-button-ui.js 测试
//
// 覆盖保存按钮 UI 同步函数的三态:
// - 干净isDirty=false, savingCount=0
// - 有待保存改动isDirty=true, savingCount=0
// - 保存中isDirty=*, savingCount>0
// - 同时保存中 + 脏
//
// 设计要点(见 src/save-button-ui.js 注释):
// - 始终显示按钮([[feedback-save-button-always-visible]]),不切 hidden
// - aria-busy 与 is-saving class 同步
// - btnSave=null 时静默 return不抛
// - setDirty 回调只在 isDirty 翻转时才有意义,但函数本身每次都调一次
// (去重由调用方在调用频率上控制;这里只验证契约)
//
// 限制save() / scheduleAutoSave() 内部使用 savingCount但二者都在 src/app.js
// 闭包里耦合state / els / window.api无法直接 import 单测。本文件只覆盖
// 已抽出的 syncSaveButtonUI 纯函数save() 的 in-flight sentinel / 防抖语义等
// 留待未来把 save() 拆出 helper 后再补。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { syncSaveButton } from '../../src/save-button-ui.js';
function makeBtn() {
// 模拟工具栏保存按钮的最小子集classList + setAttribute + title getter/setter
const btn = document.createElement('button');
btn.id = 'btn-save';
return btn;
}
function makeStatusUnsaved() {
const el = document.createElement('span');
el.id = 'status-unsaved';
el.hidden = true;
el.textContent = '';
return el;
}
describe('syncSaveButton 三态同步', () => {
let btn;
let setDirty;
beforeEach(() => {
btn = makeBtn();
setDirty = vi.fn();
});
it('干净态isDirty=false, savingCount=0移除 is-dirty + is-saving', () => {
const state = { isDirty: false, savingCount: 0 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存');
expect(btn.title).toBe('保存 (Ctrl+S)');
expect(setDirty).toHaveBeenCalledWith(false);
});
it('脏态isDirty=true, savingCount=0加 is-dirty + 改文案', () => {
const state = { isDirty: true, savingCount: 0 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存(有待保存的改动)');
expect(btn.title).toBe('保存(有待保存的改动) (Ctrl+S)');
expect(setDirty).toHaveBeenCalledWith(true);
});
it('保存中isDirty=false, savingCount=1加 is-saving + aria-busy=true', () => {
const state = { isDirty: false, savingCount: 1 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
expect(btn.getAttribute('aria-label')).toBe('保存中…');
expect(btn.title).toBe('保存中…');
// savingCount>0 不影响 isDirty 通知setDirty 仍按 isDirty 当前值传
expect(setDirty).toHaveBeenCalledWith(false);
});
it('同时保存中 + 脏savingCount>0 优先于 isDirty 的文案)', () => {
// 在飞行期间用户又敲了字isDirty 重新为 true —— 文案应当显示「保存中…」
// 而不是「有待保存改动」。is-dirty class 保留是为了视觉提示warning 色脉动)
const state = { isDirty: true, savingCount: 1 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
expect(btn.getAttribute('aria-label')).toBe('保存中…');
expect(setDirty).toHaveBeenCalledWith(true);
});
it('savingCount 累加(并发 save归零前不切回非 saving 态', () => {
// 模拟 inFlightSave 共享:第一次 save 入口 ++savingCount → 1第二次 save
// 共享路径又 ++savingCount → 2。共享 then 完成时两次 finally 各 -1最终归零。
// 函数本身只读 savingCount>0 判定,所以中间任意调用都保持 is-saving。
const state = { isDirty: false, savingCount: 2 };
syncSaveButton({ btnSave: btn, state, setDirty });
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
});
it('btnSave=null → 静默 return不抛', () => {
const state = { isDirty: true, savingCount: 0 };
// eslint-disable-next-line no-unused-vars
const localSetDirty = vi.fn();
expect(() => syncSaveButton({ btnSave: null, state, setDirty: localSetDirty }))
.not.toThrow();
// btnSave=null 时整个函数提前 return不会调 setDirty
expect(localSetDirty).not.toHaveBeenCalled();
});
it('setDirty 缺省 → 默认空操作(不抛)', () => {
const state = { isDirty: true, savingCount: 0 };
expect(() => syncSaveButton({ btnSave: btn, state })).not.toThrow();
// 即便 btnSave 存在且 state.isDirty=true没传 setDirty 也不抛
expect(btn.classList.contains('is-dirty')).toBe(true);
});
it('savingCount 归零保护helper 不修改 savingCount', () => {
// 回归保护syncSaveButtonUI 是纯 UI 同步,不应修改 state.savingCount。
// savingCount 的累加/递减必须由 save() 入口/finally 唯一管理,否则会
// 与 save() 内部的累加叠加导致计数永远不平衡 → 保存按钮永远卡在
// 「保存中…」态。手动审计 src/app.js:save() 用外层 try/finally 覆盖
// 所有 6 个 return 路径,但 helper 这里也加一道防线。
const state = { isDirty: true, savingCount: 5 };
const before = state.savingCount;
syncSaveButton({ btnSave: btn, state, setDirty });
expect(state.savingCount).toBe(before);
expect(state.savingCount).toBe(5);
});
it('连续调用:脏→保存中→干净,三阶段过渡', () => {
// 模拟完整 save() 生命周期onChange (脏) → save 入口 (savingCount++) → 成功
// (markSaved + savingCount--)
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 0 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(false);
// save 入口
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 1 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(true);
expect(btn.classList.contains('is-saving')).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
// save 完成isDirty 重读为 falsesavingCount 归零
syncSaveButton({ btnSave: btn, state: { isDirty: false, savingCount: 0 }, setDirty });
expect(btn.classList.contains('is-dirty')).toBe(false);
expect(btn.classList.contains('is-saving')).toBe(false);
expect(btn.getAttribute('aria-busy')).toBe('false');
expect(btn.getAttribute('aria-label')).toBe('保存');
});
});
describe('syncSaveButton 状态栏未保存 chip', () => {
let btn;
let chip;
let setDirty;
beforeEach(() => {
btn = makeBtn();
chip = makeStatusUnsaved();
setDirty = vi.fn();
});
it('脏态chip 显示「未保存」', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: true, savingCount: 0 }, setDirty });
expect(chip.hidden).toBe(false);
expect(chip.textContent).toBe('未保存');
expect(chip.getAttribute('aria-label')).toBe('当前文件有未保存的改动');
});
it('干净态chip hidden', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: false, savingCount: 0 }, setDirty });
expect(chip.hidden).toBe(true);
});
it('保存中但已脏:仍显示 chip不让过渡态闪烁', () => {
// 设计决策savingCount>0 但 isDirty=true 时仍显示 chip —— 飞行结束后
// 会立刻切回脏/干净,状态栏不要一明一灭的视觉抖动。仅 isDirty 驱动 chip。
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: true, savingCount: 1 }, setDirty });
expect(chip.hidden).toBe(false);
expect(chip.textContent).toBe('未保存');
});
it('保存中且不脏飞行期用户撤销编辑chip hidden', () => {
syncSaveButton({ btnSave: btn, statusUnsaved: chip, state: { isDirty: false, savingCount: 1 }, setDirty });
expect(chip.hidden).toBe(true);
});
it('chip 不传DOM 不被影响', () => {
// 兼容旧调用方(不传 statusUnsaved不应抛错
expect(() =>
syncSaveButton({ btnSave: btn, state: { isDirty: true, savingCount: 0 }, setDirty })
).not.toThrow();
});
});

View File

@@ -0,0 +1,270 @@
// src/save-toast.js 单测Phase N Q-fix + Phase 2 audit 修复)
//
// 覆盖 save() outer toast 决策的所有 (silent × result) 组合:
// Phase N Q-fix 修四个真 bug
// 1. shared path silent=false manual + IIFE stale → 旧逻辑不弹 toast
// 用户按 Ctrl+S 切文件后完全无反馈
// 2. shared path silent=true auto-save + manual silent=false 写盘成功
// → 旧逻辑不弹「已保存」IIFE silent=true 不弹 + shared manual
// 因 IIFE silent 被吞也不弹)
// 3. onSaveRequest silent=false 与 main.js confirmDiscardIfDirty 错误反馈
// 双重 toast
// 4. file:write catch 直回 e.message英文 + 路径泄露)
// Phase 2 audit 修 P1 #3STALE 不再等同「写盘失败」—— writeFile 在 STALE
// 检查前已经 resolvedata 可能已落 targetPath。ok=true 让关窗路径能正常退
// 出,但 toast 文案不变(用户视角的「文件已切换」提示总是合适)。
// 本文件覆盖决策矩阵bug 3 在调用方 silent=trueonSaveRequestbug 4
// 在 main/file-ops.js#friendlyWriteError 处测,见 file-ops.test.js。
import { describe, it, expect, vi } from 'vitest';
import { decideSaveToast } from '../../src/save-toast.js';
function makeShowToast() {
return vi.fn();
}
describe('decideSaveToast —— 基础合约', () => {
it('silent=false + ok=true无 STALE→ 弹「已保存」(success)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: true }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
it('silent=true + ok=true → 不弹auto-save 静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + reason=STALE → 不弹auto-save 静默,不打扰用户)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + 任何 reason → 都不弹', () => {
// auto-save 飞行期任何失败 IIFE 内已按 result.error 弹过错误 toast与 silent 无关),
// 这里 silent=true 再弹会导致 auto-save 飞行期错误反馈与 silent=false 路径混淆
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(true, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('result=null / undefined → 不抛、不弹', () => {
const showToast = makeShowToast();
expect(() => decideSaveToast(false, null, showToast)).not.toThrow();
expect(() => decideSaveToast(false, undefined, showToast)).not.toThrow();
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— STALE 反馈(核心 bug #1 修复 + Phase 2 P1 #3', () => {
it('silent=false + reason=STALE + ok=false → 弹「文件已切换,未保存到磁盘」(warning)', () => {
// 关键修复 #1飞行期用户切文件 → IIFE 静默 return {ok:false, reason:STALE} →
// silent=false manual caller 必须有反馈,告知「文件已切换,未保存到磁盘」。
// 旧逻辑 silent=false 但 result 不 ok → 不弹 → 用户按 Ctrl+S 什么反馈都没。
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
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 上。用户视角
// 仍然需要「文件已切换」反馈(编辑器和预期的不一致)。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('STALE 优先于 ok=truereason=STALE 时一律走 warning', () => {
// 防呆测试:即使 ok=truereason=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('silent=false + 其它失败 reasonCONFLICT_*/WRITE_FAILED/IPC_ERROR/...)→ 不弹', () => {
// 关键防呆IIFE 内部已按 result.error 弹过具体错误 toastFILE_CHANGED_EXTERNALLY
// → 弹冲突对话框 / FILE_NOT_FOUND → 弹「文件已被删除」/ errno → 弹「磁盘满 / 只读」等),
// 这里再弹「文件已切换...」会误导用户以为换文件了。
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(false, { ok: false, reason: 'CONFLICT_DISCARDED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'CONFLICT_KEPT' }, showToast);
decideSaveToast(false, { ok: false, reason: 'FILE_NOT_FOUND' }, showToast);
decideSaveToast(false, { ok: false, reason: 'PATH_NOT_ALLOWED' }, showToast);
decideSaveToast(false, { ok: false, reason: 'SYMLINK_NOT_ALLOWED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=false + ok=false 无 reason → 不弹(防御性)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— shared path 场景(核心 bug #2 修复)', () => {
it('auto-save silent=true 飞行期 + manual silent=false 写盘成功 → 弹「已保存」', () => {
// 关键修复:旧逻辑 IIFE silent=true 不弹 + shared manual silent=false 也
// 被吞(直接 return IIFE 结果)→ 用户按 Ctrl+S 成功写盘无任何反馈。
// 现在 shared path 调 decideSaveToastsilent=false manual 自己决定 → 弹。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
it('auto-save silent=true 飞行期 + manual silent=false STALE → 弹「文件已切换」', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('auto-save silent=true 飞行期 + manual silent=false STALE+ok=true → 弹「文件已切换」', () => {
// Phase 2 P1 #3writeFile 成功但用户已切走 → 关窗路径用 ok=true 退出,
// 但 silent=false manual caller 仍应弹 STALE 反馈。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
it('同 IIFE 结果auto-save silent=true 不弹 + manual silent=false 弹 → 不互相干扰', () => {
// 这是核心IIFE 写盘成功后auto-save 不弹、manual 弹「已保存」;
// 各自按 silent 决策,避免双重 toastforce_overwrite 递归 + manual 共享
// 的边缘 case 仍会有两次「已保存」,但这是 Phase N 之前已有的取舍)。
const sharedResult = { ok: true };
const autoSaveToast = makeShowToast();
const manualToast = makeShowToast();
decideSaveToast(true, sharedResult, autoSaveToast);
decideSaveToast(false, sharedResult, manualToast);
expect(autoSaveToast).not.toHaveBeenCalled();
expect(manualToast).toHaveBeenCalledTimes(1);
expect(manualToast).toHaveBeenCalledWith('已保存', 'success', 2500);
});
});
describe('decideSaveToast —— onSaveRequest silent=true核心 bug #3 修复)', () => {
it('silent=true + 任何失败 reason → 不弹', () => {
// 关键修复onSaveRequest 调 save({silent:true}),关闭路径反馈由主进程
// confirmDiscardIfDirty 原生框统一负责,避免双重 toastrenderer toast +
// main 原生「保存失败」框)。本 helper 在 silent=true 时一律不弹,
// 让 main.js 的原生对话框主导。
const showToast = makeShowToast();
decideSaveToast(true, { ok: false, reason: 'WRITE_FAILED' }, showToast);
decideSaveToast(true, { ok: false, reason: 'IPC_ERROR' }, showToast);
decideSaveToast(true, { ok: false, reason: 'STALE' }, showToast);
decideSaveToast(true, { ok: true }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + STALE ok=true → 不弹(关窗路径由 main.js 原生框主导)', () => {
// Phase 2 P1 #3 回归onSaveRequest silent=true + STALE ok=truewriteFile
// 成功但用户已切走save() outer 返回 true 让 main.js 顺利退出,
// 但 renderer 不应弹任何 toast关窗场景用户看不见且避免与 main 原生框重叠)。
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'STALE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
});
describe('decideSaveToast —— NO_CHANGES 反馈audit 2026-08早退路径用户感知', () => {
it('silent=false + reason=NO_CHANGES → 弹「已是最新」(info)', () => {
// 关键修复save() 的 !state.isDirty 早退路径手动调用 decideSaveToast
// 传 { ok:true, reason:'NO_CHANGES' }。旧逻辑早退 return true 不弹任何 toast
// auto-save 静默写盘后用户手动点保存 → 看不到反馈 → 「按钮坏了」类错觉。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
});
it('silent=true + reason=NO_CHANGES → 不弹auto-save 早退时静默)', () => {
// auto-save silent 路径不应被新分支打扰;头部 `if (silent) return` 一并拦下。
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('NO_CHANGES 优先于 ok=truereason=NO_CHANGES 时一律走 info', () => {
// 防呆:传 ok=true 但带 NO_CHANGES reason 必须走 info 分支,不弹 success。
// 否则手动保存「已是最新」会被误显示成「已保存」(success),语义错位。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, showToast);
expect(showToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
});
});
describe('decideSaveToast —— NO_FILE 反馈Round 7 S-3区分「没打开」与「已是最新」', () => {
it('silent=false + reason=NO_FILE → 弹「没有打开文件」(info)', () => {
// 关键修复save() 的 !state.currentFile 早退路径手动调用 decideSaveToast
// 传 { ok:true, reason:'NO_FILE' }。旧版共用 NO_CHANGES 文案「已是最新」,
// 但用户视角「我什么都没打开」与「有文件且未改动」语义不同 —— 改用准确
// 文案「没有打开文件」。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_FILE' }, showToast);
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('没有打开文件', 'info', 1500);
});
it('silent=true + reason=NO_FILE → 不弹auto-save 早退时静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'NO_FILE' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('NO_FILE 不被 NO_CHANGES 截胡:两个 reason 独立走各自分支', () => {
// 防呆NO_FILE 与 NO_CHANGES 是两种不同的早退语义,不能相互覆盖。
const fileToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_FILE' }, fileToast);
expect(fileToast).toHaveBeenCalledWith('没有打开文件', 'info', 1500);
expect(fileToast).not.toHaveBeenCalledWith('已是最新', 'info', 1500);
const changesToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'NO_CHANGES' }, changesToast);
expect(changesToast).toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(changesToast).not.toHaveBeenCalledWith('没有打开文件', 'info', 1500);
});
});
describe('decideSaveToast —— DELEGATED 反馈Round 8 S-1force_overwrite 委托 inner', () => {
// audit fix (Round 8 S-1)force_overwrite 把写盘委托给递归 inner save()
// inner 自己已经按 silent=false 弹过「已保存」或具体错误 toast。outer 只负责
// 把 ok 透传给调用方(关窗 / save_and_open 依赖它),不能再弹第二个 toast。
// 关键silent=false 也不弹,因为 inner 已经弹过silent=true 也不弹auto-save
it('silent=false + reason=DELEGATED + ok=true → 不弹inner 已弹过「已保存」)', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=false + reason=DELEGATED + ok=false → 不弹inner 已弹过错误 toast', () => {
const showToast = makeShowToast();
decideSaveToast(false, { ok: false, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('silent=true + reason=DELEGATED → 不弹auto-save 静默)', () => {
const showToast = makeShowToast();
decideSaveToast(true, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalled();
});
it('DELEGATED 不被 NO_FILE / NO_CHANGES / STALE / 通用 ok=true 截胡reason=DELEGATED 永远走 DELEGATED 分支', () => {
// 反例验证ok=true 时不能被「已保存」分支兜底再弹一个 toast。
const showToast = makeShowToast();
decideSaveToast(false, { ok: true, reason: 'DELEGATED' }, showToast);
expect(showToast).not.toHaveBeenCalledWith('已保存', 'success', 2500);
expect(showToast).not.toHaveBeenCalledWith('已是最新', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('没有打开文件', 'info', 1500);
expect(showToast).not.toHaveBeenCalledWith('文件已切换,未保存到磁盘', 'warning', 2500);
});
});

View File

@@ -0,0 +1,447 @@
// Stage 7 tests: shared/settings-schema.js
//
// 覆盖:
// - DEFAULT_SETTINGS导出 + 含必要字段
// - coerceLoadedSettings未知字段剥除 / 已知字段合并默认值
// - validateKeyenum / boolean / number clamp / nullable-path
// - validateAndSanitize批量校验遇到非法字段提前失败
//
// shared/ 是 CommonJS直接 require 即可(不像 main/ 需要 _setApp 注入)。
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = resolve(__dirname, '../../shared/settings-schema.js');
const schema = require('../../shared/settings-schema.js');
const {
DEFAULT_SETTINGS,
coerceLoadedSettings,
validateKey,
validateAndSanitize,
SETTINGS_SCHEMA,
} = schema;
describe('DEFAULT_SETTINGS', () => {
it('导出且包含核心字段', () => {
expect(DEFAULT_SETTINGS).toBeDefined();
expect(DEFAULT_SETTINGS.theme).toBeDefined();
expect(DEFAULT_SETTINGS.themePalette).toBeDefined();
expect(DEFAULT_SETTINGS.dataDir).toBeDefined();
expect(DEFAULT_SETTINGS.alwaysOnTop).toBe(false);
});
it('AI 默认值provider = openaibaseUrl / apiKey / model / systemPrompt 空串', () => {
expect(DEFAULT_SETTINGS.aiProvider).toBe('openai');
expect(DEFAULT_SETTINGS.aiBaseUrl).toBe('');
expect(DEFAULT_SETTINGS.aiApiKey).toBe('');
expect(DEFAULT_SETTINGS.aiModel).toBe('');
expect(DEFAULT_SETTINGS.aiSystemPrompt).toBe('');
});
});
describe('coerceLoadedSettings', () => {
it('非对象 → 默认值', () => {
expect(coerceLoadedSettings(null)).toEqual({ ...DEFAULT_SETTINGS });
expect(coerceLoadedSettings('str')).toEqual({ ...DEFAULT_SETTINGS });
});
it('已知字段被 raw 覆盖', () => {
const out = coerceLoadedSettings({ theme: 'light', readerFontSize: 19 });
expect(out.theme).toBe('light');
expect(out.readerFontSize).toBe(19);
});
it('未知字段被剥除', () => {
const out = coerceLoadedSettings({ theme: 'light', bogus: 'x' });
expect(out.theme).toBe('light');
expect(out).not.toHaveProperty('bogus');
});
it('缺省字段用默认值', () => {
const out = coerceLoadedSettings({ theme: 'light' });
expect(out.theme).toBe('light');
expect(out.themePalette).toBe(DEFAULT_SETTINGS.themePalette);
});
it('旧 autoSaveIntervalSec (0) → 迁移到 autoSaveDebounceMs (0)', () => {
// 旧版 0 = 关闭;新版 0 = 关闭 —— 直接 1:1 映射
const out = coerceLoadedSettings({ autoSaveIntervalSec: 0 });
expect(out.autoSaveDebounceMs).toBe(0);
expect(out).not.toHaveProperty('autoSaveIntervalSec');
});
it('旧 autoSaveIntervalSec (3 / 10) → 迁移到 autoSaveDebounceMs (500)', () => {
// 旧版「开启」档3 秒、10 秒轮询)→ 新版「500ms 防抖」
// 语义从「每隔 X 秒轮询」变成「停打 X ms 后保存」;统一映射到 500ms 默认值。
expect(coerceLoadedSettings({ autoSaveIntervalSec: 3 }).autoSaveDebounceMs).toBe(500);
expect(coerceLoadedSettings({ autoSaveIntervalSec: 10 }).autoSaveDebounceMs).toBe(500);
});
it('autoSaveDebounceMs 越界 → clamp 到边界', () => {
// 验证 number 类型规则生效:超界值被 clamp不被丢弃
expect(coerceLoadedSettings({ autoSaveDebounceMs: 99999 }).autoSaveDebounceMs).toBe(60000);
expect(coerceLoadedSettings({ autoSaveDebounceMs: -100 }).autoSaveDebounceMs).toBe(0);
});
it('string 字段超长 → 丢弃回默认(同步路径用 rule.max', () => {
// 同步路径coerceLoadedSettings / sanitizeSync一直用 rule.max本次 audit
// 修复的是 async validateKey 误写 opts.max 的问题。两条路径必须保持一致。
const out = coerceLoadedSettings({ aiModel: 'a'.repeat(10_000) });
expect(out.aiModel).toBe(DEFAULT_SETTINGS.aiModel);
});
it('Phase N 修复:下划线前缀的元数据键(如 _hasAiKey保留', () => {
// 主进程 get-settings / save-settings 都会注入 _hasAiKey 作为「是否配置过 API Key」
// 的只读元数据标记。旧实现只迭代 Object.keys(DEFAULT_SETTINGS),未知键全 drop
// 导致 renderer settingsStore.load() 后拿不到 _hasAiKey「显示已填 Key」+ reveal
// 流程全失效。下划线前缀约定为「只读元数据、不写盘」coerce 阶段需保留。
const out = coerceLoadedSettings({ theme: 'dark', _hasAiKey: true });
expect(out.theme).toBe('dark');
expect(out._hasAiKey).toBe(true);
});
it('非下划线前缀的未知键仍被剥除(确保下划线特判不会过度放行)', () => {
// 防御:下划线特判只放行 _ 前缀,普通 unknown 字段仍走原剥离路径,
// 不让攻击者用 _privateName 这种「看上去像元数据」的字段把脏数据灌进内存。
const out = coerceLoadedSettings({ theme: 'dark', _privateNote: 'secret', bogus: 42 });
expect(out).not.toHaveProperty('_privateNote');
expect(out).not.toHaveProperty('bogus');
});
// fix(audit 2026-08)同步路径sanitizeSync / coerceLoadedSettings之前
// 不校验 format 字段,导致手改 settings.json 写 aiBaseUrl: "ftp://x" /
// "not-a-url" 会被静默接受,渲染端拿到无效 URL真正 fetch 时才报 TypeError
// 错误链很难定位到 settings。
it('aiBaseUrl 非法 URL同步路径 → 丢弃回默认空串', () => {
expect(coerceLoadedSettings({ aiBaseUrl: 'ftp://x.y' }).aiBaseUrl).toBe('');
expect(coerceLoadedSettings({ aiBaseUrl: 'not-a-url' }).aiBaseUrl).toBe('');
expect(coerceLoadedSettings({ aiBaseUrl: 'javascript:alert(1)' }).aiBaseUrl).toBe('');
// http:/missing-slash.com —— URL 构造器能解析但 fetch 行为不一致,必须拒绝
expect(coerceLoadedSettings({ aiBaseUrl: 'http:/missing-slash.com' }).aiBaseUrl).toBe('');
});
it('aiBaseUrl 合法 http(s) URL同步路径 → 保留', () => {
expect(coerceLoadedSettings({ aiBaseUrl: 'https://api.openai.com/v1' }).aiBaseUrl).toBe(
'https://api.openai.com/v1',
);
expect(coerceLoadedSettings({ aiBaseUrl: 'http://10.0.0.1:11434/v1' }).aiBaseUrl).toBe(
'http://10.0.0.1:11434/v1',
);
});
// fix(audit 2026-08)Boolean 输入不应被 Number() 强转。
// Number(true) === 1、Number(false) === 0旧版会让 autoSaveDebounceMs: true
// 静默变 1ms激进到每个 keystroke 都存splitRatio: false 变 0无预览
it('number 字段 Boolean 输入 → 丢弃回默认', () => {
expect(coerceLoadedSettings({ autoSaveDebounceMs: true }).autoSaveDebounceMs)
.toBe(DEFAULT_SETTINGS.autoSaveDebounceMs);
expect(coerceLoadedSettings({ splitRatio: false }).splitRatio)
.toBe(DEFAULT_SETTINGS.splitRatio);
expect(coerceLoadedSettings({ readerFontSize: true }).readerFontSize)
.toBe(DEFAULT_SETTINGS.readerFontSize);
});
// fix(audit 2026-08)round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数round:2 才能保留用户的选择。
it('readerLineHeight 1.85 不会被四舍五入到 1.9', () => {
expect(coerceLoadedSettings({ readerLineHeight: 1.85 }).readerLineHeight).toBe(1.85);
expect(coerceLoadedSettings({ readerLineHeight: 1.7 }).readerLineHeight).toBe(1.7);
expect(coerceLoadedSettings({ readerLineHeight: 2.0 }).readerLineHeight).toBe(2.0);
});
});
describe('validateKey', () => {
it('enum 合法值 → ok', async () => {
const r = await validateKey('theme', 'light');
expect(r.ok).toBe(true);
expect(r.value).toBe('light');
});
it('enum 非法值 → 报错', async () => {
const r = await validateKey('theme', 'rainbow');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/theme 取值非法/);
});
it('boolean 非法类型 → 报错', async () => {
const r = await validateKey('alwaysOnTop', 'yes');
expect(r.ok).toBe(false);
});
it('number clamp 越界 → clamp 到边界', async () => {
const tooBig = await validateKey('readerFontSize', 99);
expect(tooBig.ok).toBe(true);
expect(tooBig.value).toBe(SETTINGS_SCHEMA.readerFontSize.max);
const tooSmall = await validateKey('readerFontSize', 1);
expect(tooSmall.ok).toBe(true);
expect(tooSmall.value).toBe(SETTINGS_SCHEMA.readerFontSize.min);
});
it('number 非数字 → 报错', async () => {
const r = await validateKey('readerFontSize', 'big');
expect(r.ok).toBe(false);
});
it('nullable-path 空字符串 → null', async () => {
const r = await validateKey('dataDir', ' ');
expect(r.ok).toBe(true);
expect(r.value).toBe(null);
});
// audit fix (Round 9)nullable-number 与 number 分支对称拒绝空串。
// 旧版 `case 'nullable-number'` 直接 `Number(raw)` → Number('') === 0 →
// clamp 到 minsidebarWidth='' 会被静默改成 200默认 min
it('nullable-number 空字符串 → 报错(不是 0', async () => {
const r = await validateKey('sidebarWidth', '');
expect(r.ok).toBe(false);
});
it('nullable-number 全空白字符串 → 报错', async () => {
const r = await validateKey('sidebarWidth', ' ');
expect(r.ok).toBe(false);
});
it('nullable-number null → null合法', async () => {
const r = await validateKey('sidebarWidth', null);
expect(r.ok).toBe(true);
expect(r.value).toBe(null);
});
it('nullable-number undefined → 视为 null与同步路径一致', async () => {
// 审计修复 (Round 11 deep-fix P2-3)nullable 字段的 undefined 与 null 同义。
// 旧版 async 路径把 undefined 当作错误(与同步 sanitizeSync 行为不一致),
// 导致 load() 接受 + update() 拒绝同一字段。
const r = await validateKey('sidebarWidth', undefined);
expect(r.ok).toBe(true);
expect(r.value).toBeNull();
});
it('nullable-path 非字符串 → 报错', async () => {
const r = await validateKey('dataDir', 123);
expect(r.ok).toBe(false);
});
it('未知字段 → 报错', async () => {
const r = await validateKey('unknown', 'anything');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/未知设置项/);
});
it('autoSaveDebounceMs 合法值 0 / 500 / 60000', async () => {
expect((await validateKey('autoSaveDebounceMs', 0)).ok).toBe(true);
expect((await validateKey('autoSaveDebounceMs', 500)).ok).toBe(true);
expect((await validateKey('autoSaveDebounceMs', 60000)).ok).toBe(true);
});
it('autoSaveDebounceMs 越界 → clamp', async () => {
// -1 → 099999 → 60000clamp 行为)
expect((await validateKey('autoSaveDebounceMs', -1)).value).toBe(0);
expect((await validateKey('autoSaveDebounceMs', 99999)).value).toBe(60000);
});
it('autoSaveDebounceMs 非数字 → 报错', async () => {
const r = await validateKey('autoSaveDebounceMs', 'soon');
expect(r.ok).toBe(false);
});
it('aiProvider openai / anthropic → ok', async () => {
expect((await validateKey('aiProvider', 'openai')).value).toBe('openai');
expect((await validateKey('aiProvider', 'anthropic')).value).toBe('anthropic');
});
it('aiProvider 非法值 → 报错', async () => {
const r = await validateKey('aiProvider', 'gemini');
expect(r.ok).toBe(false);
expect(r.error).toMatch(/aiProvider 取值非法/);
});
it('string 字段超过 rule.max → 报错', async () => {
// audit fix之前误用 opts.max导致 aiApiKey/aiBaseUrl/aiModel/aiSystemPrompt
// 声明的字符上限从未生效。这里锁定 rule.max 路径。
expect((await validateKey('aiModel', 'a'.repeat(257))).ok).toBe(false);
expect((await validateKey('aiBaseUrl', 'a'.repeat(4097))).ok).toBe(false);
expect((await validateKey('aiApiKey', 'k'.repeat(4097))).ok).toBe(false);
expect((await validateKey('aiSystemPrompt', 'x'.repeat(200_001))).ok).toBe(false);
});
it('string 字段在 rule.max 范围内 → ok', async () => {
expect((await validateKey('aiModel', 'a'.repeat(256))).ok).toBe(true);
// aiBaseUrl 现在带 url-https 格式校验 —— 用合法 URL 串代替 'a'.repeat
// 否则 URL 校验会拒绝非 URL 字符。最大长度限制的覆盖由上面的「超长」
// 测试用例保证。12 字符 scheme/host + 4084 字符 path = 4096 总长。
expect((await validateKey('aiBaseUrl', 'https://a.aa/' + 'a'.repeat(4083))).ok).toBe(true);
expect((await validateKey('aiApiKey', 'k'.repeat(4096))).ok).toBe(true);
expect((await validateKey('aiSystemPrompt', 'x'.repeat(200_000))).ok).toBe(true);
});
it('string 字段超长错误信息含上限值', async () => {
const r = await validateKey('aiModel', 'a'.repeat(257));
expect(r.error).toMatch(/超过 256 字符/);
});
});
describe('validateAndSanitize', () => {
it('合法 batch → 返回 sanitized 对象', async () => {
const r = await validateAndSanitize({ theme: 'light', readerFontSize: 18 });
expect(r.ok).toBe(true);
expect(r.sanitized.theme).toBe('light');
expect(r.sanitized.readerFontSize).toBe(18);
});
it('任一字段非法 → 整个失败', async () => {
const r = await validateAndSanitize({ theme: 'light', readerFontSize: 'huge' });
expect(r.ok).toBe(false);
});
it('非对象 → 报错', async () => {
expect((await validateAndSanitize(null)).ok).toBe(false);
expect((await validateAndSanitize('str')).ok).toBe(false);
expect((await validateAndSanitize([])).ok).toBe(false);
});
it('空对象 → 空 sanitized', async () => {
const r = await validateAndSanitize({});
expect(r.ok).toBe(true);
expect(r.sanitized).toEqual({});
});
});
// ------------------------------------------------------------------
// AI 字段的「空串是合法值」契约
//
// settings-dialog.js:303-326「清空 AI 配置」按钮依赖此契约:按钮设
// aiKeyInput.value = '',保存时 diff 检测 '' !== initial.aiApiKey → patch 含
// aiApiKey: '',走 settingsStore.update → settings-schema 校验 → 写盘。
// 若未来 schema 把 '' 改成 null / undefined 或拒绝空串,清空按钮会无声失效。
// ------------------------------------------------------------------
describe('AI 字段空串是合法值(清空按钮依赖此契约)', () => {
it('aiApiKey: "" → 接受且保留为空串', async () => {
const r = await validateAndSanitize({ aiApiKey: '' });
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('');
});
it('aiBaseUrl / aiModel / aiSystemPrompt 同时清空 → 全部接受', async () => {
const r = await validateAndSanitize({
aiApiKey: '',
aiBaseUrl: '',
aiModel: '',
aiSystemPrompt: '',
});
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('');
expect(r.sanitized.aiBaseUrl).toBe('');
expect(r.sanitized.aiModel).toBe('');
expect(r.sanitized.aiSystemPrompt).toBe('');
});
it('aiApiKey: 任意非空串 → 接受且原样保留', async () => {
const r = await validateAndSanitize({ aiApiKey: 'sk-abc-123' });
expect(r.ok).toBe(true);
expect(r.sanitized.aiApiKey).toBe('sk-abc-123');
});
});
// ------------------------------------------------------------------
// nullable-string 类型支持
//
// audit fixvalidateKey / sanitizeSync 的 switch 之前没有 nullable-string
// case遇到该类型的字符串值会落到 default → 「类型未定义」错误。
// 当前 schema 没有 nullable-string 字段aiApiKey 等用 string + 主进程判空),
// 这里用代码存在性测试锁定修复,避免未来回归。
// ------------------------------------------------------------------
describe('nullable-string 类型支持audit fix', () => {
const source = readFileSync(SCHEMA_PATH, 'utf8');
it('validateKey 的 switch 含 nullable-string case', () => {
// 提取 validateKey 函数体的 switch 段,断言其中含 case 'nullable-string'
const match = source.match(/async function validateKey[\s\S]*?^}/m);
expect(match, 'validateKey 函数必须存在').toBeTruthy();
expect(match[0]).toMatch(/case\s+['"]nullable-string['"]\s*:/);
});
it('sanitizeSync 的 switch 含 nullable-string case', () => {
// sanitizeSync 没有 async/function 关键字前缀 —— 直接匹配函数签名
const match = source.match(/function sanitizeSync[\s\S]*?^}/m);
expect(match, 'sanitizeSync 函数必须存在').toBeTruthy();
expect(match[0]).toMatch(/case\s+['"]nullable-string['"]\s*:/);
});
it('nullable-string 校验含 max 长度检查(与 string 一致)', () => {
const validateMatch = source.match(/async function validateKey[\s\S]*?^}/m);
expect(validateMatch).toBeTruthy();
// nullable-string case 内必须包含 rule.max 长度检查
const nullableCase = validateMatch[0].match(/case\s+['"]nullable-string['"]\s*:[\s\S]*?(?=\n\s+case\s|\n\s+default|\n\s+\})/);
expect(nullableCase, 'validateKey 必须有 nullable-string case 体').toBeTruthy();
expect(nullableCase[0]).toMatch(/rule\.max/);
});
});
// ------------------------------------------------------------------
// Round 4 P1-1nullable-path 同步路径空串归一为 null
//
// 之前 sanitizeSync 直接返回 '',与 validateKey 异步路径归一为 null 不同步。
// coerceLoadedSettings 把磁盘上残留的 "dataDir": "" 保留为 '',但 validateKey
// 会归一为 null → 任何依赖 dataDir === null 判断的代码失配。
// ------------------------------------------------------------------
describe('nullable-path 同步路径空串归一为 nullRound 4 P1-1', () => {
it('coerceLoadedSettings 把 dataDir: "" 归一为 null', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: '' });
expect(result.dataDir).toBe(null);
});
it('coerceLoadedSettings 把 dataDir: " "(纯空白)归一为 null', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: ' ' });
expect(result.dataDir).toBe(null);
});
it('coerceLoadedSettings 把 dataDir: "/some/path" 原样保留', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: '/some/path' });
expect(result.dataDir).toBe('/some/path');
});
it('dataDir: null 仍是 null不破坏既有契约', () => {
const result = coerceLoadedSettings({ ...DEFAULT_SETTINGS, dataDir: null });
expect(result.dataDir).toBe(null);
});
});
// ------------------------------------------------------------------
// Round 4 P2-3nullable-number typeof 守卫对称
//
// 原版无 typeof 守卫Number(true) === 1 隐式通过 isFinite。
// 当前 schema 用 nullable-number 的字段sidebarWidth / aiWidthmin 检查
// 会拦下 1但语义与 number 不一致 —— 加 typeof 守卫保持两条路径对称。
// ------------------------------------------------------------------
describe('nullable-number typeof 守卫对称Round 4 P2-3', () => {
it('sidebarWidth: true → 拒绝typeof 不匹配)', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: true });
// sidebarWidth 不在 coerce 结果里(或保留 default绝不能是 1
expect(r.sidebarWidth).not.toBe(1);
});
it('sidebarWidth: false → 拒绝', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: false });
expect(r.sidebarWidth).not.toBe(0);
});
it('sidebarWidth: 250 → 接受', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: 250 });
expect(r.sidebarWidth).toBe(250);
});
it('sidebarWidth: "300" → 接受(字符串数字合法)', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: '300' });
expect(r.sidebarWidth).toBe(300);
});
it('sidebarWidth: null → 仍是 null', () => {
const r = coerceLoadedSettings({ ...DEFAULT_SETTINGS, sidebarWidth: null });
expect(r.sidebarWidth).toBe(null);
});
});

View File

@@ -0,0 +1,373 @@
// src/shortcuts.js 测试
//
// 覆盖:
// - 8 个全局快捷键派发到对应 handler
// - 编辑器内(.cm-editor 内部)让位给 CM6Ctrl+F / Ctrl+S / Ctrl+Shift+A
// - 模态打开时吞掉特定快捷键(避免抢焦点 / 顶替 dialog
// - Esc 在搜索框内 → 清空搜索
// - non-modifier 按键不触发
//
// 用 jsdom 模拟 document.addEventListener('keydown')。
// modal-stack 用真实模块(之前 modal.test.js 已验证行为稳定)。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mountKeyboardShortcuts } from '../../src/shortcuts.js';
import { mountModal } from '../../src/modal.js';
import { isOpen as modalIsOpen } from '../../src/modal-stack.js';
function fireKey(opts) {
// 默认值Ctrl 修饰target = document.body
const init = {
key: 'f',
ctrlKey: true,
bubbles: true,
cancelable: true,
...opts,
};
const target = opts.target || document.body;
const ev = new KeyboardEvent('keydown', init);
target.dispatchEvent(ev);
return ev;
}
describe('shortcuts 基本派发', () => {
let handlers;
let detach;
beforeEach(() => {
document.body.innerHTML = '<input id="search-input" />';
handlers = {
onFocusSearch: vi.fn(),
onClearSearch: vi.fn(),
onSettings: vi.fn(),
onToggleTheme: vi.fn(),
onSave: vi.fn(),
onToggleEditorMode: vi.fn(),
onNewFile: vi.fn(),
onToggleFocusMode: vi.fn(),
onToggleAi: vi.fn(),
onReload: vi.fn(),
};
detach = mountKeyboardShortcuts(handlers);
});
afterEach(() => {
if (typeof detach === 'function') detach();
});
it('Ctrl+F → onFocusSearch不在编辑器内', () => {
fireKey({ key: 'f', ctrlKey: true });
expect(handlers.onFocusSearch).toHaveBeenCalledTimes(1);
expect(handlers.onFocusSearch.mock.invocationCallOrder[0])
.toBeLessThan(Number.MAX_SAFE_INTEGER);
});
it('Ctrl+, → onSettings', () => {
fireKey({ key: ',', ctrlKey: true });
expect(handlers.onSettings).toHaveBeenCalledTimes(1);
});
it('Ctrl+Shift+T → onToggleTheme', () => {
fireKey({ key: 'T', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleTheme).toHaveBeenCalledTimes(1);
fireKey({ key: 't', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleTheme).toHaveBeenCalledTimes(2);
});
it('Ctrl+S → onSave非编辑器内', () => {
fireKey({ key: 's', ctrlKey: true });
expect(handlers.onSave).toHaveBeenCalledTimes(1);
});
it('Ctrl+E → onToggleEditorMode', () => {
fireKey({ key: 'e', ctrlKey: true });
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
});
it('Ctrl+N → onNewFile', () => {
fireKey({ key: 'n', ctrlKey: true });
expect(handlers.onNewFile).toHaveBeenCalledTimes(1);
});
it('Ctrl+Shift+F → onToggleFocusMode', () => {
fireKey({ key: 'F', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleFocusMode).toHaveBeenCalledTimes(1);
});
it('Ctrl+Shift+A → onToggleAi', () => {
fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleAi).toHaveBeenCalledTimes(1);
});
// audit K1-H3README 提到 Ctrl+R 重新加载界面但之前未注册。
// 补:注册到 onReload handler与主菜单 reload 同源)。
it('Ctrl+R → onReload', () => {
fireKey({ key: 'r', ctrlKey: true });
expect(handlers.onReload).toHaveBeenCalledTimes(1);
// 大写 R 也应命中Shift 不会被按,但 key.toLowerCase() 仍可工作)
fireKey({ key: 'R', ctrlKey: true });
expect(handlers.onReload).toHaveBeenCalledTimes(2);
});
it('非修饰键 → 不触发任何 handler', () => {
fireKey({ key: 'a', ctrlKey: false, metaKey: false });
for (const [name, fn] of Object.entries(handlers)) {
expect(fn, name).not.toHaveBeenCalled();
}
});
it('Cmd+FmacOS → onFocusSearchmetaKey 视为 ctrl', () => {
fireKey({ key: 'f', ctrlKey: false, metaKey: true });
expect(handlers.onFocusSearch).toHaveBeenCalledTimes(1);
});
});
describe('shortcuts 编辑器内让位', () => {
let handlers;
beforeEach(() => {
// 构造编辑器内部 DOM.cm-editor 容器包裹 .cm-content
document.body.innerHTML = `
<div class="cm-editor">
<div class="cm-content" contenteditable="true">hello world</div>
</div>
`;
handlers = {
onFocusSearch: vi.fn(),
onSave: vi.fn(),
onToggleAi: vi.fn(),
onSettings: vi.fn(),
onToggleEditorMode: vi.fn(),
};
mountKeyboardShortcuts(handlers);
});
it('Ctrl+F 在编辑器内 → 不抢焦点(让位 CM6 搜索面板)', () => {
const cm = document.querySelector('.cm-content');
fireKey({ key: 'f', ctrlKey: true, target: cm });
expect(handlers.onFocusSearch).not.toHaveBeenCalled();
});
it('Ctrl+S 在编辑器内 → 不触发 onSaveCM6 已 save', () => {
const cm = document.querySelector('.cm-content');
fireKey({ key: 's', ctrlKey: true, target: cm });
expect(handlers.onSave).not.toHaveBeenCalled();
});
it('Ctrl+Shift+A 在编辑器内 → 不抢Shift+A 在编辑器里有内置语义)', () => {
const cm = document.querySelector('.cm-content');
fireKey({ key: 'A', ctrlKey: true, shiftKey: true, target: cm });
expect(handlers.onToggleAi).not.toHaveBeenCalled();
});
it('Ctrl+E 在编辑器内 → 仍然生效CM6 未占用)', () => {
const cm = document.querySelector('.cm-content');
fireKey({ key: 'e', ctrlKey: true, target: cm });
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
});
});
describe('shortcuts 模态打开时吞键', () => {
let handlers;
beforeEach(() => {
document.body.innerHTML = `
<div id="modal-root"></div>
<input id="search-input" />
`;
handlers = {
onFocusSearch: vi.fn(),
onSettings: vi.fn(),
onToggleTheme: vi.fn(),
onToggleEditorMode: vi.fn(),
onNewFile: vi.fn(),
onToggleFocusMode: vi.fn(),
onToggleAi: vi.fn(),
onSave: vi.fn(),
};
mountKeyboardShortcuts(handlers);
});
afterEach(() => {
// 关闭所有可能残留的 modal
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
document.body.innerHTML = '';
});
it('模态打开时 Ctrl+, → 不打开设置(避免顶替当前 dialog', () => {
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
expect(modalIsOpen()).toBe(true);
fireKey({ key: ',', ctrlKey: true });
expect(handlers.onSettings).not.toHaveBeenCalled();
});
it('模态打开时 Ctrl+F → 不抢焦点', () => {
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
fireKey({ key: 'f', ctrlKey: true });
expect(handlers.onFocusSearch).not.toHaveBeenCalled();
});
it('模态打开时 Ctrl+Shift+T → 不切主题', () => {
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
fireKey({ key: 'T', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleTheme).not.toHaveBeenCalled();
});
it('模态打开时 Ctrl+Shift+A → 不切 AI dock', () => {
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
expect(handlers.onToggleAi).not.toHaveBeenCalled();
});
it('模态打开时 Ctrl+S 仍允许(与编辑器保存一致)', () => {
// 验证「Ctrl+S 在模态内不禁用」这条契约;具体走哪个分支不重要,
// 关键是 onSave 仍会被调用(设置对话框 / 文件新建 dialog 关闭时不丢改动)。
mountModal({ title: 'mock-modal', body: '<p>x</p>' });
fireKey({ key: 's', ctrlKey: true });
expect(handlers.onSave).toHaveBeenCalledTimes(1);
});
});
describe('shortcuts Esc 清空搜索', () => {
let handlers;
beforeEach(() => {
document.body.innerHTML = '<input id="search-input" />';
handlers = {
onClearSearch: vi.fn(),
onFocusSearch: vi.fn(),
};
mountKeyboardShortcuts(handlers);
});
it('焦点在 search-input 时按 Esc → onClearSearch', () => {
const input = document.getElementById('search-input');
input.focus();
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(handlers.onClearSearch).toHaveBeenCalledTimes(1);
});
it('焦点不在搜索框时按 Esc → 不触发 onClearSearch', () => {
document.body.focus();
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(handlers.onClearSearch).not.toHaveBeenCalled();
});
});
describe('shortcuts handler 缺失时静默', () => {
it('不传 onSettings 也不应抛错', () => {
document.body.innerHTML = '';
mountKeyboardShortcuts({}); // 全空
expect(() => {
fireKey({ key: ',', ctrlKey: true });
fireKey({ key: 'f', ctrlKey: true });
fireKey({ key: 'T', ctrlKey: true, shiftKey: true });
fireKey({ key: 'F', ctrlKey: true, shiftKey: true });
fireKey({ key: 's', ctrlKey: true });
fireKey({ key: 'e', ctrlKey: true });
fireKey({ key: 'n', ctrlKey: true });
fireKey({ key: 'O', ctrlKey: true, shiftKey: true });
fireKey({ key: 'A', ctrlKey: true, shiftKey: true });
}).not.toThrow();
});
});
describe('shortcuts IME 合成期间C3 audit', () => {
let handlers;
let detach;
beforeEach(() => {
document.body.innerHTML = '';
handlers = {
onToggleEditorMode: vi.fn(),
onNewFile: vi.fn(),
onFocusSearch: vi.fn(),
onSave: vi.fn(),
};
detach = mountKeyboardShortcuts(handlers);
});
afterEach(() => {
if (detach && typeof detach.dispose === 'function') detach.dispose();
});
it('isComposing=true 时 Ctrl+E 不触发 onToggleEditorModeCJK 拼音中途保护)', () => {
fireKey({ key: 'e', ctrlKey: true, isComposing: true });
expect(handlers.onToggleEditorMode).not.toHaveBeenCalled();
});
it('keyCode=229Firefox / 旧 Chromium时 Ctrl+N 不触发 onNewFile', () => {
fireKey({ key: 'n', ctrlKey: true, keyCode: 229 });
expect(handlers.onNewFile).not.toHaveBeenCalled();
});
it('isComposing=true 时 Ctrl+F 也不抢焦点', () => {
fireKey({ key: 'f', ctrlKey: true, isComposing: true });
expect(handlers.onFocusSearch).not.toHaveBeenCalled();
});
it('isComposing=false 时(正常组合键)行为不变', () => {
fireKey({ key: 'e', ctrlKey: true, isComposing: false });
expect(handlers.onToggleEditorMode).toHaveBeenCalledTimes(1);
});
});
describe('shortcuts 模态打开时 Ctrl+W 抑制M1 audit', () => {
let handlers;
let detach;
let modalApi;
beforeEach(() => {
document.body.innerHTML = '<div id="modal-root"></div>';
handlers = { onSettings: vi.fn() };
detach = mountKeyboardShortcuts(handlers);
// 模拟有模态打开
modalApi = mountModal({ title: 't', body: '', footer: '', onBackdropClose: 'cancel' });
});
afterEach(() => {
if (detach && typeof detach.dispose === 'function') detach.dispose();
if (modalApi) modalApi.close('test-cleanup');
});
it('模态打开时 Ctrl+W 不触发默认行为preventDefault', () => {
const ev = fireKey({ key: 'w', ctrlKey: true });
expect(ev.defaultPrevented).toBe(true);
});
it('模态未开时 Ctrl+W 不在抑制列表里normal 行为:不被 preventDefault', () => {
if (modalApi) modalApi.close('test-no-modal');
modalApi = null;
const ev = fireKey({ key: 'w', ctrlKey: true });
// 不被任何 handler 拦截 → defaultPrevented 应该是 false由浏览器处理关窗口
expect(ev.defaultPrevented).toBe(false);
});
});
describe('shortcuts disposePhase O-M2 audit', () => {
it('mountKeyboardShortcuts 返回的 dispose 函数能 removeEventListener', () => {
document.body.innerHTML = '<input id="search-input" />';
const handler1 = vi.fn();
const detach1 = mountKeyboardShortcuts({
onSettings: handler1,
});
expect(typeof detach1).toBe('function');
// 销毁第一个 mount
detach1();
// 再 mount 第二个
const handler2 = vi.fn();
const detach2 = mountKeyboardShortcuts({
onSettings: handler2,
});
// 触发 Ctrl+,
fireKey({ key: ',', ctrlKey: true });
// 旧 handler 已经被 dispose → 不应被调用
expect(handler1).not.toHaveBeenCalled();
// 新 handler 应当被调用
expect(handler2).toHaveBeenCalledTimes(1);
detach2();
});
});

125
tests/unit/slug.test.js Normal file
View File

@@ -0,0 +1,125 @@
// shared/slug.js 的单测
//
// 这是 preload.js 与 src/outline.js 共用的 heading slug 算法唯一事实源。
// 改算法 = 同步改这里 + preload.js + src/outline.js + tests/unit/outline.test.js。
import { describe, it, expect } from 'vitest';
import {
slugifyHeadingBase,
slugifyHeading,
} from '../../shared/slug.js';
describe('slugifyHeadingBase', () => {
it('空字符串 → 空串slugifyHeading 才回退 section', () => {
expect(slugifyHeadingBase('')).toBe('');
});
it('空白字符串 → 空串', () => {
expect(slugifyHeadingBase(' ')).toBe('');
});
it('纯符号 → 空串(兜底由带去重的 slugifyHeading 提供)', () => {
// 这里只测「基础 slug」——空结果由调用方决定是跳过还是兜底为 'section'。
expect(slugifyHeadingBase('!@#$')).toBe('');
});
it('基础小写化 + 空格变 -', () => {
expect(slugifyHeadingBase('Hello World')).toBe('hello-world');
});
it('CJK 保留(基本平面 0x4E00-0x9FFF', () => {
expect(slugifyHeadingBase('你好世界')).toBe('你好世界');
});
it('下划线被洗掉_ 在 [`*_~] 字符集里,连同行内标点一起剥)', () => {
expect(slugifyHeadingBase('hello_world')).toBe('helloworld');
expect(slugifyHeadingBase('foo_bar_baz')).toBe('foobarbaz');
});
it('行内 HTML 标签被剥离', () => {
expect(slugifyHeadingBase('Hello <em>x</em>')).toBe('hello-x');
expect(slugifyHeadingBase('<span class="x">A</span> B')).toBe('a-b');
});
it('行内 markdown 标记符号被剥离', () => {
expect(slugifyHeadingBase('Hello *World*')).toBe('hello-world');
expect(slugifyHeadingBase('Hello `code`')).toBe('hello-code');
expect(slugifyHeadingBase('Hello _em_')).toBe('hello-em');
expect(slugifyHeadingBase('Hello ~~del~~')).toBe('hello-del');
});
it('连续空白合并为单个 -', () => {
expect(slugifyHeadingBase('foo bar baz')).toBe('foo-bar-baz');
expect(slugifyHeadingBase(' a b ')).toBe('a-b');
});
it('标点 / 引号被洗掉', () => {
expect(slugifyHeadingBase('A & B!')).toBe('a-b');
expect(slugifyHeadingBase('foo "bar" baz')).toBe('foo-bar-baz');
expect(slugifyHeadingBase('foo/bar')).toBe('foobar');
expect(slugifyHeadingBase('foo:bar')).toBe('foobar');
});
it('首尾 - 不裁剪(与 marked 旧默认一致)', () => {
expect(slugifyHeadingBase('--foo--bar--')).toBe('--foo--bar--');
});
it('数字开头保留', () => {
expect(slugifyHeadingBase('123 abc')).toBe('123-abc');
});
it('组合记号保留(拉丁语族 / 越南语等)', () => {
// \p{M} 应保留 combining marks
expect(slugifyHeadingBase('café')).toBe('café');
expect(slugifyHeadingBase('naïve résumé')).toBe('naïve-résumé');
});
it('非字符串输入用 toString 兜底(不抛错)', () => {
// 数字正常字符串化null/undefined 在 String() 后是 'null'/'undefined'
// 不会触发 section 兜底——因为这里只看 trim 后的非空判断。
expect(slugifyHeadingBase(123)).toBe('123');
expect(slugifyHeadingBase(null)).toBe('null');
expect(slugifyHeadingBase(undefined)).toBe('undefined');
});
});
describe('slugifyHeading 重复检测', () => {
it('同名 heading 加 -1 / -2 后缀', () => {
const seen = new Set();
expect(slugifyHeading('Same', seen)).toBe('same');
expect(slugifyHeading('Same', seen)).toBe('same-1');
expect(slugifyHeading('Same', seen)).toBe('same-2');
});
it('带标记的同名也会撞后缀(标记先剥)', () => {
const seen = new Set();
expect(slugifyHeading('Title', seen)).toBe('title');
// 同样算 'title' → 撞 'title'(已存在),得 'title-1'
expect(slugifyHeading('**Title**', seen)).toBe('title-1');
// 'Title *bold*' 剥标记 + 小写化后变 'title bold' → 'title-bold',与 'title' 不同
expect(slugifyHeading('Title *bold*', seen)).toBe('title-bold');
});
it('空 Set / 清空后重置计数', () => {
const seen = new Set();
slugifyHeading('foo', seen);
slugifyHeading('foo', seen);
seen.clear();
expect(slugifyHeading('foo', seen)).toBe('foo');
});
it('不同 slug 不互相干扰', () => {
const seen = new Set();
expect(slugifyHeading('A', seen)).toBe('a');
expect(slugifyHeading('B', seen)).toBe('b');
expect(slugifyHeading('A', seen)).toBe('a-1');
expect(slugifyHeading('B', seen)).toBe('b-1');
});
it('空 base 在带去重的 slugifyHeading 里回退为 section', () => {
// 纯符号 heading 走 slugifyHeading 也能得到非空 id
const seen = new Set();
expect(slugifyHeading('!@#$', seen)).toBe('section');
expect(slugifyHeading('!!!', seen)).toBe('section-1');
});
});

125
tests/unit/stats.test.js Normal file
View File

@@ -0,0 +1,125 @@
// Stage 7 tests: stats.js
//
// 覆盖:
// - countWordsCJK 单字 + 拉丁词 + 混合 + 边界
// - getTextStatschars / words / lines 三个字段 + 空文档边界
// - formatStatusStats中文 locale + 0/1/N 行
import { describe, it, expect } from 'vitest';
import { countWords, getTextStats, formatStatusStats } from '../../src/stats.js';
describe('countWords', () => {
it('空字符串 → 0', () => {
expect(countWords('')).toBe(0);
});
it('纯空白 → 0', () => {
expect(countWords(' \n \t')).toBe(0);
});
it('null / undefined → 0', () => {
expect(countWords(null)).toBe(0);
expect(countWords(undefined)).toBe(0);
});
it('非字符串 → 0', () => {
expect(countWords(42)).toBe(0);
expect(countWords({})).toBe(0);
});
it('纯拉丁词按空格分', () => {
expect(countWords('hello world foo bar')).toBe(4);
});
it('中文每个字算 1', () => {
expect(countWords('你好世界')).toBe(4);
});
it('中日韩混合', () => {
expect(countWords('Hello 你好 World 世界')).toBe(6); // 4 CJK + 2 latin
});
it('日文假名 + 韩文', () => {
expect(countWords('こんにちは 안녕하세요')).toBe(10); // 5 hiragana + 5 hangul
});
it('标点不计数', () => {
expect(countWords('!!! ??? ...')).toBe(0);
});
it('「hello-world」是一个词', () => {
expect(countWords('hello-world')).toBe(1);
});
it('多行', () => {
expect(countWords('first line\nsecond line\nthird line')).toBe(6);
});
});
describe('getTextStats', () => {
it('空字符串 → chars 0, words 0, lines 0', () => {
// audit L8空内容不再用 `|| 1` 兜底为 1 行。状态栏会显示「0 词」,
// 避免「刚打开一个空白文件就看到『1 行』误导」。
expect(getTextStats('')).toEqual({ chars: 0, words: 0, lines: 0 });
});
it('纯空白 → lines 0', () => {
const stats = getTextStats(' \n \n ');
expect(stats.lines).toBe(0);
expect(stats.words).toBe(0);
});
it('单行内容', () => {
const stats = getTextStats('hello world');
expect(stats).toEqual({ chars: 11, words: 2, lines: 1 });
});
it('多行非空内容', () => {
const stats = getTextStats('line one\nline two\nline three');
expect(stats.lines).toBe(3);
expect(stats.words).toBe(6);
expect(stats.chars).toBe(28); // 8+1+8+1+10
});
it('空行不计数', () => {
const stats = getTextStats('hello\n\n\nworld');
expect(stats.lines).toBe(2);
expect(stats.words).toBe(2);
});
it('null / 非字符串 → 默认值', () => {
expect(getTextStats(null)).toEqual({ chars: 0, words: 0, lines: 0 });
expect(getTextStats(42)).toEqual({ chars: 0, words: 0, lines: 0 });
});
it('CJK 内容', () => {
const stats = getTextStats('你好世界\n欢迎使用');
expect(stats.words).toBe(8);
expect(stats.lines).toBe(2);
expect(stats.chars).toBe('你好世界\n欢迎使用'.length);
});
});
describe('formatStatusStats', () => {
it('lines > 0 → 「N 词 · M 行」', () => {
expect(formatStatusStats({ words: 100, lines: 5 })).toBe('100 词 · 5 行');
});
it('lines === 0 → 「N 词」', () => {
expect(formatStatusStats({ words: 100, lines: 0 })).toBe('100 词');
});
it('千分位分隔zh-CN locale', () => {
expect(formatStatusStats({ words: 1234, lines: 5 })).toBe('1,234 词 · 5 行');
expect(formatStatusStats({ words: 12345, lines: 5 })).toBe('12,345 词 · 5 行');
});
it('0 词 → 「0 词」', () => {
expect(formatStatusStats({ words: 0, lines: 0 })).toBe('0 词');
});
it('缺字段时 fallback', () => {
expect(formatStatusStats({})).toBe('0 词');
expect(formatStatusStats({ words: 10 })).toBe('10 词');
});
});

366
tests/unit/utils.test.js Normal file
View File

@@ -0,0 +1,366 @@
// src/utils.js 的纯函数测试
// 当前覆盖 isMarkdownFile —— 这是 Stage 8 引入的工具,
// 决定一个文件是否能渲染 Markdown 预览(仅 .md / .markdown
import { describe, it, expect } from 'vitest';
import {
isMarkdownFile,
isPathInside,
pathsEqual,
formatSize,
formatDate,
formatRelativeTime,
escapeHtml,
escapeAttr,
dirnameOf,
basenameOf,
resolvePath,
toFileUrl,
safeRenderMarkdown,
} from '../../src/utils.js';
describe('isPathInside', () => {
it('基本包含关系 → true', () => {
expect(isPathInside('/data/notes/foo.md', '/data/notes')).toBe(true);
expect(isPathInside('/data/notes/sub/foo.md', '/data/notes')).toBe(true);
});
it('完全相等的路径 → true自身视为在内', () => {
expect(isPathInside('/data/notes', '/data/notes')).toBe(true);
expect(isPathInside('/data/notes/', '/data/notes')).toBe(true);
expect(isPathInside('/data/notes', '/data/notes/')).toBe(true);
});
it('前缀相同但不是子目录(避免 /foo 包含 /foobar → false', () => {
expect(isPathInside('/data/foobar.md', '/data/foo')).toBe(false);
});
it('完全不同的路径 → false', () => {
expect(isPathInside('/etc/passwd', '/data/notes')).toBe(false);
expect(isPathInside('/data/foo.md', '/var/log')).toBe(false);
});
it('parent 是 POSIX 根目录 / → 任何非空 child 都视为在内', () => {
// 修复点:旧版本 norm('/') → '',导致 a === '' 与任何 child 都不等、b.startsWith('/') 又为 false
// 误判「根目录下没有路径」。现在显式把空前缀视为「任何子路径都在内」。
expect(isPathInside('/foo/bar', '/')).toBe(true);
expect(isPathInside('/a', '/')).toBe(true);
expect(isPathInside('/notes/x.md', '/')).toBe(true);
});
it('parent 是 Windows 根盘符 C:\\ → 任何非空 child 都视为在内jsdom/Windows 测试场景)', () => {
// 这里没法在 node 里真实触发 isWindows(),但 norm 之后变成 'c:' 是确定的:
// 直接构造「模拟 Windows」路径。算法只看规范化结果不依赖平台检测。
// 这一档测试对应 norm(parent) === 'c:' 的分支。
expect(isPathInside('C:\\Users\\foo\\note.md', 'C:\\')).toBe(true);
});
it('空 child / 空 parent / null → false', () => {
expect(isPathInside('', '/data')).toBe(false);
expect(isPathInside('/data/foo.md', '')).toBe(false);
expect(isPathInside(null, '/data')).toBe(false);
expect(isPathInside('/data/foo.md', null)).toBe(false);
expect(isPathInside(undefined, undefined)).toBe(false);
});
it('child 仅有分隔符(规范化后为空)→ false', () => {
// 修复点:避免「//」或「/」被当成合法 child 漏进根目录
expect(isPathInside('/', '/data')).toBe(false);
expect(isPathInside('//', '/data')).toBe(false);
});
it('混合路径分隔符(正反斜杠)→ 视为相同', () => {
// norm 把所有 \\ → /,所以 'C:\\data' 与 'C:/data' 等价
// node 测试环境下 isWindows 返回 false不会 lowercase
// 这一档主要验证 norm 的归一化效果)
expect(isPathInside('/data/notes/foo.md', '\\data\\notes')).toBe(true);
});
});
describe('pathsEqual', () => {
it('两个相同字符串 → true', () => {
expect(pathsEqual('/a/b/c', '/a/b/c')).toBe(true);
expect(pathsEqual('C:\\Users\\foo', 'C:\\Users\\foo')).toBe(true);
});
it('空值 / null / undefined → false仅双空相等视为 false', () => {
expect(pathsEqual('', '/a')).toBe(false);
expect(pathsEqual('/a', '')).toBe(false);
expect(pathsEqual(null, '/a')).toBe(false);
expect(pathsEqual('/a', undefined)).toBe(false);
});
it('路径内容不同 → false', () => {
expect(pathsEqual('/a/b', '/a/c')).toBe(false);
expect(pathsEqual('C:\\foo', 'C:\\bar')).toBe(false);
});
// Windows case-fold 行为jsdom 25 默认 userAgent 是 'Mozilla/5.0 (win32)'
// → isWindows() 返回 true。所以两条 case 不同的路径在测试环境下应判等。
// 真实的运行环境Windows / POSIX效果以 isPathInside 的 Windows 段已经覆盖的
// 「跨斜杠 + 大小写不敏感」原则一致 —— 不为「假想平台」造用例。
it('Windows 路径大小写不敏感case-fold 后相等 → true', () => {
// jsdom 默认 mock 是 win32 userAgent → 这条断言测的是真实运行行为
expect(pathsEqual('C:\\Users\\Foo\\note.md', 'c:\\users\\foo\\note.md')).toBe(true);
expect(pathsEqual('/A/b.md', '/a/b.md')).toBe(true);
});
});
describe('isMarkdownFile', () => {
it('扩展名为 .md → true', () => {
expect(isMarkdownFile('note.md')).toBe(true);
expect(isMarkdownFile('/abs/path/to/note.md')).toBe(true);
expect(isMarkdownFile('C:\\Users\\foo\\notes\\note.md')).toBe(true);
});
it('扩展名为 .markdown → true', () => {
expect(isMarkdownFile('README.markdown')).toBe(true);
expect(isMarkdownFile('/path/to/README.markdown')).toBe(true);
});
it('大小写不敏感', () => {
expect(isMarkdownFile('Note.MD')).toBe(true);
expect(isMarkdownFile('Note.Md')).toBe(true);
expect(isMarkdownFile('Note.Markdown')).toBe(true);
});
it('其它可编辑扩展 → false仅支持编辑不支持预览', () => {
expect(isMarkdownFile('data.json')).toBe(false);
expect(isMarkdownFile('script.py')).toBe(false);
expect(isMarkdownFile('config.yaml')).toBe(false);
expect(isMarkdownFile('plain.txt')).toBe(false);
expect(isMarkdownFile('main.js')).toBe(false);
expect(isMarkdownFile('main.ts')).toBe(false);
expect(isMarkdownFile('styles.css')).toBe(false);
expect(isMarkdownFile('page.html')).toBe(false);
});
it('无扩展名 / 隐藏文件 / 空字符串 → false', () => {
expect(isMarkdownFile('')).toBe(false);
expect(isMarkdownFile('.gitignore')).toBe(false);
expect(isMarkdownFile('Makefile')).toBe(false);
expect(isMarkdownFile(null)).toBe(false);
expect(isMarkdownFile(undefined)).toBe(false);
expect(isMarkdownFile(42)).toBe(false);
});
it('以 .md 结尾但不是扩展名(如 .md5、.model→ false', () => {
// 防御:'foo.model' 的 lastIndexOf('.') 是 'model' 里的点,但我们按最后一个点判断,
// 应该是 'l',所以 false。'foo.md5' 最后一个点是 '.md5' 之前?还是…… 看实现:
// path.lastIndexOf('.') → 找到最后一个 '.' 的位置。对 'foo.md5'dot 在 3
// 取 dot+1 之后到末尾 'md5',不是集合里的 md。
expect(isMarkdownFile('photo.md5')).toBe(false);
expect(isMarkdownFile('archive.model')).toBe(false);
});
it('多点文件名note.v2.md、a.b.c.markdown→ true', () => {
// basename 末段的最后一个点才是扩展名分隔
expect(isMarkdownFile('note.v2.md')).toBe(true);
expect(isMarkdownFile('a.b.c.markdown')).toBe(true);
});
it('无扩展名的常见文件名 → false', () => {
expect(isMarkdownFile('README')).toBe(false);
expect(isMarkdownFile('LICENSE')).toBe(false);
expect(isMarkdownFile('/abs/path/Makefile')).toBe(false);
});
});
describe('formatSize', () => {
it('字节 / KB / MB 分级', () => {
expect(formatSize(0)).toBe('0 B');
expect(formatSize(512)).toBe('512 B');
expect(formatSize(1024)).toBe('1.0 KB');
expect(formatSize(1536)).toBe('1.5 KB');
expect(formatSize(1024 * 1024)).toBe('1.0 MB');
expect(formatSize(1024 * 1024 * 2.5)).toBe('2.5 MB');
});
it('非法输入 → 占位符', () => {
expect(formatSize(-1)).toBe('—');
expect(formatSize(NaN)).toBe('—');
expect(formatSize('100')).toBe('—');
expect(formatSize(null)).toBe('—');
});
});
describe('formatDate', () => {
it('YYYY-MM-DD HH:mm', () => {
// 用本地时区无关的方式构造:直接取 0 时刻附近
const d = new Date(2024, 0, 15, 9, 5); // 2024-01-15 09:05 本地
expect(formatDate(d.getTime())).toBe('2024-01-15 09:05');
});
it('非法输入 → 空串', () => {
expect(formatDate(NaN)).toBe('');
expect(formatDate('xxx')).toBe('');
expect(formatDate(null)).toBe('');
});
});
describe('formatRelativeTime', () => {
const now = new Date(2024, 5, 15, 12, 0, 0).getTime(); // 2024-06-15 12:00:00
it('< 60s → "刚刚"', () => {
expect(formatRelativeTime(now - 5_000, now)).toBe('刚刚');
expect(formatRelativeTime(now - 59_000, now)).toBe('刚刚');
});
it('< 60min → "N 分钟前"', () => {
expect(formatRelativeTime(now - 60_000, now)).toBe('1 分钟前');
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5 分钟前');
expect(formatRelativeTime(now - 59 * 60_000, now)).toBe('59 分钟前');
});
it('同日 < 24h → "N 小时前"', () => {
expect(formatRelativeTime(now - 60 * 60_000, now)).toBe('1 小时前');
expect(formatRelativeTime(now - 3 * 60 * 60_000, now)).toBe('3 小时前');
});
it('昨天 → "昨天"', () => {
const yesterday = now - 25 * 60 * 60_000; // 跨过 0 点
expect(formatRelativeTime(yesterday, now)).toBe('昨天');
});
it('< 7d → "N 天前"', () => {
expect(formatRelativeTime(now - 3 * 86400_000, now)).toBe('3 天前');
expect(formatRelativeTime(now - 6 * 86400_000, now)).toBe('6 天前');
});
it('≥ 7d → 回落到绝对时间', () => {
const old = new Date(2024, 5, 1, 12, 0, 0).getTime();
const got = formatRelativeTime(old, now);
// 应该跟 formatDate(old) 一致
expect(got).toBe(formatDate(old));
expect(got).toBe('2024-06-01 12:00');
});
it('未来时间 → 当成 0 差("刚刚"', () => {
expect(formatRelativeTime(now + 5_000, now)).toBe('刚刚');
});
it('非法输入 → 空串', () => {
expect(formatRelativeTime(NaN, now)).toBe('');
expect(formatRelativeTime('xxx', now)).toBe('');
expect(formatRelativeTime(null, now)).toBe('');
expect(formatRelativeTime(undefined, now)).toBe('');
});
it('非法 nowMs → 退回 Date.now() 不报错', () => {
// 不传 nowMsshould 不抛
expect(typeof formatRelativeTime(now - 1000)).toBe('string');
// 显式传非法 nowMs → 内部用 Date.now() 兜底
expect(typeof formatRelativeTime(now - 1000, NaN)).toBe('string');
});
});
describe('escapeHtml / escapeAttr', () => {
it('escapeHtml 转义五个特殊字符', () => {
expect(escapeHtml('<script>')).toBe('&lt;script&gt;');
expect(escapeHtml('a & b')).toBe('a &amp; b');
expect(escapeHtml('"x"')).toBe('&quot;x&quot;');
expect(escapeHtml("'x'")).toBe('&#39;x&#39;');
expect(escapeHtml('<a href="x">y</a>')).toBe(
'&lt;a href=&quot;x&quot;&gt;y&lt;/a&gt;'
);
});
it('null/undefined → 空串', () => {
expect(escapeHtml(null)).toBe('');
expect(escapeHtml(undefined)).toBe('');
});
it('escapeAttr 等价 escapeHtml语义标识而非行为差异', () => {
const inputs = ['<x>', 'a"b', "a'b", 'a&b'];
for (const s of inputs) {
expect(escapeAttr(s)).toBe(escapeHtml(s));
}
});
});
describe('dirnameOf / basenameOf', () => {
it('POSIX 路径', () => {
expect(dirnameOf('/data/notes/foo.md')).toBe('/data/notes');
expect(basenameOf('/data/notes/foo.md')).toBe('foo.md');
});
it('Windows 路径', () => {
expect(dirnameOf('C:\\Users\\foo\\bar.md')).toBe('C:\\Users\\foo');
expect(basenameOf('C:\\Users\\foo\\bar.md')).toBe('bar.md');
});
it('根目录下的文件', () => {
expect(dirnameOf('/foo.md')).toBe('/');
expect(basenameOf('/foo.md')).toBe('foo.md');
});
it('无分隔符的文件名', () => {
expect(dirnameOf('foo.md')).toBe('');
expect(basenameOf('foo.md')).toBe('foo.md');
});
it('非法输入 → 空串', () => {
expect(dirnameOf('')).toBe('');
expect(dirnameOf(null)).toBe('');
expect(basenameOf('')).toBe('');
expect(basenameOf(undefined)).toBe('');
});
});
describe('resolvePath', () => {
it('POSIX 相对路径解析', () => {
expect(resolvePath('/data/notes', 'foo.md')).toBe('/data/notes/foo.md');
expect(resolvePath('/data/notes', 'sub/foo.md')).toBe('/data/notes/sub/foo.md');
});
it('POSIX 绝对路径原样规范化', () => {
expect(resolvePath('/data/notes', '/etc/passwd')).toBe('/etc/passwd');
expect(resolvePath('/data/notes', '/a/./b/../c')).toBe('/a/c');
});
it('折叠 "." 和 ".."', () => {
expect(resolvePath('/data', './foo/./bar.md')).toBe('/data/foo/bar.md');
expect(resolvePath('/data/sub', '../foo.md')).toBe('/data/foo.md');
expect(resolvePath('/data/sub/deep', '../../foo.md')).toBe('/data/foo.md');
});
it('越过根目录 → null', () => {
expect(resolvePath('/data', '../../etc/passwd')).toBe(null);
expect(resolvePath('/', '../foo')).toBe(null);
});
it('Windows 盘符路径', () => {
expect(resolvePath('C:\\data', 'foo.md')).toBe('C:\\data\\foo.md');
expect(resolvePath('C:\\data', 'C:\\foo.md')).toBe('C:\\foo.md');
});
it('空 / 非法输入 → null', () => {
expect(resolvePath('/data', '')).toBe(null);
expect(resolvePath('/data', null)).toBe(null);
expect(resolvePath('/data', undefined)).toBe(null);
});
});
describe('toFileUrl', () => {
it('POSIX 绝对路径', () => {
expect(toFileUrl('/data/notes/foo.md')).toBe('file:///data/notes/foo.md');
});
it('Windows 盘符路径', () => {
expect(toFileUrl('C:\\data\\foo.md')).toBe('file:///C:/data/foo.md');
expect(toFileUrl('C:/data/foo.md')).toBe('file:///C:/data/foo.md');
});
it('文件名含 # / ? / 空格 → 编码但保留盘符冒号', () => {
expect(toFileUrl('/data/has space/foo.md')).toBe('file:///data/has%20space/foo.md');
expect(toFileUrl('/data/has#hash/foo.md')).toBe('file:///data/has%23hash/foo.md');
expect(toFileUrl('/data/has?q=1/foo.md')).toBe('file:///data/has%3Fq%3D1/foo.md');
});
it('空 / 非法输入 → 空串', () => {
expect(toFileUrl('')).toBe('');
expect(toFileUrl(null)).toBe('');
expect(toFileUrl(undefined)).toBe('');
});
});
describe('safeRenderMarkdown', () => {
// markdown-viewer / ai-diff-panel 共用入口:失败 / 不可用时必须
// 返回转义后的 <pre>不能泄漏未转义的原文XSS fail-closed
// node 环境无 window —— 这里跑的就是 "window 不可用" 的降级分支。
it('window 不可用 → 降级为 <pre>,转义危险字符', () => {
const html = safeRenderMarkdown('<script>alert(1)</script>');
expect(html).toContain('<pre');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>alert(1)</script>');
});
it('window 不可用 → 纯文本透传', () => {
const html = safeRenderMarkdown('safe text');
expect(html).toContain('<pre');
expect(html).toContain('safe text');
});
it('空 / 非法输入 → <pre> 包空内容', () => {
expect(safeRenderMarkdown('')).toContain('<pre');
expect(safeRenderMarkdown(null)).toContain('<pre');
expect(safeRenderMarkdown(undefined)).toContain('<pre');
});
});