This commit is contained in:
2026-10-18 04:39:37 +08:00
parent ff366a4daf
commit e686763c35
21 changed files with 951 additions and 339 deletions

View File

@@ -734,4 +734,64 @@ describe('coerceLoadedSettings(schema 兜底)', () => {
expect(coerceLoadedSettings(undefined)).toEqual({ ...DEFAULT_CONFIG });
expect(coerceLoadedSettings('garbage')).toEqual({ ...DEFAULT_CONFIG });
});
});
// aiApiKey 包络降级路径(loadConfig 解密失败 → pendingAiKeyEnvelope)。
// 单测环境没有 Electron safeStorage → secretStore.decrypt 对 enc:v1: 包络
// 恒返回 ''(「不可用」契约),天然进入降级分支,无需额外 mock。
describe('aiApiKey 包络降级(decrypt 失败)', () => {
const ENVELOPE = 'enc:v1:Zm9vYmFy';
it('解密失败:内存 aiApiKey 置空(明文契约)+ hasPendingAiKeyEnvelope 为 true', () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE, theme: 'dark' }), 'utf-8');
configStore.init();
expect(configStore.getConfig().aiApiKey).toBe('');
expect(configStore.hasPendingAiKeyEnvelope()).toBe(true);
});
it('saveConfig 未触碰 aiApiKey:包络原样写回磁盘,不被空串覆盖', async () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE, theme: 'dark' }), 'utf-8');
configStore.init();
const res = await configStore.saveConfig({ theme: 'light' });
expect(res.ok).toBe(true);
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
expect(raw.aiApiKey).toBe(ENVELOPE);
expect(raw.theme).toBe('light');
// 内存侧仍守「aiApiKey 是明文或 ''」契约:不把包络塞回去
expect(configStore.getConfig().aiApiKey).toBe('');
});
it('saveConfig 显式重填 Key:写新值、清掉旧包络', async () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE }), 'utf-8');
configStore.init();
const res = await configStore.saveConfig({ aiApiKey: 'sk-new' });
expect(res.ok).toBe(true);
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
// 测试环境 safeStorage 不可用 → encrypt 降级为明文透传
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
expect(raw.aiApiKey).toBe('sk-new');
expect(configStore.getConfig().aiApiKey).toBe('sk-new');
});
it('saveConfig 显式清空 Key:写空串,包络不复活', async () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE }), 'utf-8');
configStore.init();
const res = await configStore.saveConfig({ aiApiKey: '' });
expect(res.ok).toBe(true);
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
expect(raw.aiApiKey).toBe('');
});
it('明文 passthrough(无前缀)正常解密:不留包络', () => {
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: 'sk-plain' }), 'utf-8');
configStore.init();
expect(configStore.getConfig().aiApiKey).toBe('sk-plain');
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
});
it('首次启动(无 config.json)不残留包络状态', () => {
configStore.init();
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
});
});

View File

@@ -129,6 +129,44 @@ describe('FileList 拖拽投放', () => {
expect(fileLi.classList.contains('is-dragging')).toBe(false);
expect(list._dragEntry).toBeNull();
});
it('dragstart 设 effectAllowed=all(拖行中补按 Ctrl 仍可 copy 落地)', () => {
// Chromium 会把 dragover 的 dropEffect 夹进 effectAllowed:dragstart 若按
// 当时 Ctrl 态锁成 'move',途中补按 Ctrl 要 copy 时 'copy' ⊄ 'move',
// drop 被静默吞掉(Chromium 实测)。意图实时判定由 dragover/drop 的
// ctrlKey 分支负责。
const ev = fire(liFor('/d/note.md'), 'dragstart');
expect(ev.dataTransfer.effectAllowed).toBe('all');
});
it('_isInvalidDropTarget:源在目标目录内 → 拒绝(原地移动会变成 name (2) 静默改名)', () => {
// 方向回归守卫:此前条件写反成「target 在 src 之下」,该守卫从未真正生效。
expect(list._isInvalidDropTarget(
{ path: '/d/sub/x.md', entryType: 'file' },
{ path: '/d/sub', entryType: 'folder' },
)).toBe(true);
// 兄弟目录的正常拖放不受影响
expect(list._isInvalidDropTarget(
{ path: '/d/note.md', entryType: 'file' },
{ path: '/d/sub', entryType: 'folder' },
)).toBe(false);
});
it('setClipboardCut 高亮跨 setFiles 重渲染保留,传 null 清除', () => {
list.setClipboardCut('/d/note.md');
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(true);
// fs-watcher 推新快照触发重渲染:命令式加的 class 会被 _fillItem 的
// wantClass 重赋值抹掉 —— 高亮必须由渲染期从 clipboardCutPath 补回。
list.setFiles([
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
{ name: 'other.md', path: '/d/other.md', entryType: 'file' },
]);
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(true);
expect(liFor('/d/sub').classList.contains('is-clipboard-cut')).toBe(false);
list.setClipboardCut(null);
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(false);
});
});
describe('FileList 空白区域右键菜单', () => {

View File

@@ -150,6 +150,89 @@ describe('moveEntry / copyEntry', () => {
expect(ctx.state.currentFile.path).toBe('/d/note.md');
});
it('moveEntry 飞行中设置 state.movingPath,成功后 finally 清空(fs-watcher 竞态守卫)', async () => {
const ctx = makeCtx();
let seenDuringFlight;
ctx.api.moveFile = vi.fn(async () => {
seenDuringFlight = ctx.state.movingPath;
return { ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10, relDir: 'sub' };
});
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/note.md', name: 'note.md', entryType: 'file' }, '/d/sub');
expect(seenDuringFlight).toBe('/d/note.md');
expect(ctx.state.movingPath).toBeNull();
// 移动文件本身 → currentFileRelDir 同步为 result.relDir
expect(ctx.state.currentFileRelDir).toBe('sub');
});
it('moveEntry 失败同样清空 movingPath 守卫', async () => {
const ctx = makeCtx();
ctx.api.moveFile = vi.fn(async () => ({ ok: false, code: 'EACCES', message: '没有访问权限' }));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/note.md', name: 'note.md', entryType: 'file' }, '/d/sub');
expect(ctx.state.movingPath).toBeNull();
});
it('moveEntry 祖先文件夹被移动(重名避让改名):新路径用 result.path 重拼、relDir 指向文件所在目录', async () => {
const ctx = makeCtx();
ctx.state.currentFile = { path: '/d/A/sub/note.md', name: 'note.md', mtimeMs: 7, size: 42 };
ctx.state.currentFileRelDir = 'A/sub';
// 移动 /d/A → /d/B,重名避让后实际落点 /d/B/A (2)
ctx.api.moveFile = vi.fn(async () => ({
ok: true, path: '/d/B/A (2)', name: 'A (2)', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
}));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
// 旧实现:destDir + remainder → 丢掉文件夹自身段,拼成 /d/B/sub/note.md(错)
expect(ctx.state.currentFile.path).toBe('/d/B/A (2)/sub/note.md');
expect(ctx.state.currentFile.name).toBe('note.md');
// 旧实现:直接用 result.relDir='B'(被移动文件夹的新父目录,错)
expect(ctx.state.currentFileRelDir).toBe('B/A (2)/sub');
expect(ctx.controls.fileList.setActive).toHaveBeenCalledWith('/d/B/A (2)/sub/note.md');
});
it('moveEntry 祖先文件夹被移动(打开文件直接在文件夹下):relDir = 新父/新名', async () => {
const ctx = makeCtx();
ctx.state.currentFile = { path: '/d/A/note.md', name: 'note.md', mtimeMs: 7, size: 42 };
ctx.api.moveFile = vi.fn(async () => ({
ok: true, path: '/d/B/A', name: 'A', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
}));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
expect(ctx.state.currentFile.path).toBe('/d/B/A/note.md');
expect(ctx.state.currentFileRelDir).toBe('B/A');
});
it('moveEntry 祖先判定带分隔符边界:/d/A 移动不误伤打开文件 /d/Abc.md', async () => {
const ctx = makeCtx();
ctx.state.currentFile = { path: '/d/Abc.md', name: 'Abc.md', mtimeMs: 7, size: 42 };
ctx.state.currentFileRelDir = '';
// result.entryType folder 且 cfPath 与被移动文件夹共享前缀但不同目录
ctx.api.moveFile = vi.fn(async () => ({
ok: true, path: '/d/B/A', name: 'A', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
}));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
// 旧实现裸 startsWith('/d/A') 会误命中 '/d/Abc.md' 并把状态改坏
expect(ctx.state.currentFile.path).toBe('/d/Abc.md');
expect(ctx.state.currentFileRelDir).toBe('');
});
it('moveEntry / copyEntry 入参缺字段时直接返回 null', async () => {
const ctx = makeCtx();
const ops = createFileOps(ctx);

View File

@@ -0,0 +1,129 @@
// 2026-10 撤销 / 重做按钮(#btn-undo / #btn-redo)背后的 MarkdownEditor 门面:
// - canUndo / canRedo 跟随 CodeMirror history 深度
// - undo / redo 执行命令并返回是否生效
// - loadFile 复用 view 切文件时 history 重置(每文件一份干净 undo/redo 栈,
// 按钮不会在刚打开的文件上误导「有可撤销」)
// app.js 顶层不静态 import CM6(audit P2-9),按钮接线测不到这里,
// 语义正确性全部收敛在本文件的 MarkdownEditor 方法层。
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MarkdownEditor } from '../../src/markdown-editor.js';
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
if (typeof window.requestAnimationFrame === 'undefined') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
if (typeof Range.prototype.getClientRects !== 'function') {
Range.prototype.getClientRects = function () {
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() {
const el = document.createElement('div');
document.body.appendChild(el);
return new MarkdownEditor({
element: el,
onChange: vi.fn(),
onSave: vi.fn(),
});
}
// 模拟一次用户输入(独立 history 事件:userEvent 不同不会被 newGroupDelay 合并)
function type(editor, text) {
editor.view.dispatch({
changes: { from: editor.view.state.doc.length, insert: text },
userEvent: 'input.type',
});
}
describe('MarkdownEditor 撤销/重做门面(2026-10)', () => {
let editor;
beforeEach(() => {
editor = createEditor();
});
it('view 未创建(还没 loadFile)→ canUndo / canRedo 均 false,undo/redo 返回 false', () => {
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(false);
expect(editor.undo()).toBe(false);
expect(editor.redo()).toBe(false);
});
it('首次 loadFile 后(fresh view)→ 无可撤无可重做', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello');
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(false);
});
it('输入 → canUndo true;undo() 回退 doc 且 canRedo true;redo() 再前滚', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello');
type(editor, ' world');
expect(editor.view.state.doc.toString()).toBe('hello world');
expect(editor.canUndo()).toBe(true);
expect(editor.canRedo()).toBe(false);
expect(editor.undo()).toBe(true);
expect(editor.view.state.doc.toString()).toBe('hello');
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(true);
expect(editor.redo()).toBe(true);
expect(editor.view.state.doc.toString()).toBe('hello world');
expect(editor.canRedo()).toBe(false);
});
it('历史耗尽时 undo 空操作返回 false,不吞事件', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'x');
expect(editor.undo()).toBe(false);
expect(editor.view.state.doc.toString()).toBe('x');
});
it('切文件(复用 view)→ 前一文件的编辑记录被清空,doc 是新文件内容', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA');
type(editor, '!'); // A 有可撤的一步
expect(editor.canUndo()).toBe(true);
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB');
expect(editor.view.state.doc.toString()).toBe('BBB');
// 关键断言:不能把 A 的编辑 history 带进 B(否则 ↶ 在新文件上亮着,
// 按下去会退回 A 的内容 —— 旧实现的跨文件撤销 bug)
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(false);
});
it('切文件后在新文件里编辑 → 撤销只回到新文件初始内容', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA');
type(editor, '!!!');
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB');
type(editor, '?');
editor.undo();
expect(editor.view.state.doc.toString()).toBe('BBB');
});
it('loadFile 切文件不触发 onChange(setState 非 transaction,不 fire updateListener)', () => {
// 2026-10 语义变更:旧实现「dispatch 替换 doc」每次切文件都会伪报一次
// onChange(触发多余的 live preview / auto-save 空转)。setState 重建 state
// 不派发事务 → onChange 不跑;脏态复位由 openFile 尾部显式完成。
const onChange = vi.fn();
const el = document.createElement('div');
document.body.appendChild(el);
const ed = new MarkdownEditor({ element: el, onChange, onSave: vi.fn() });
ed.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA'); // 首次建 view:0 次
ed.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB'); // setState 复用:0 次
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -306,11 +306,16 @@ describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
}
});
it('loadFile 重复同一文件:onChange 触发次数精确等于 dispatch 次数(listener 不重复挂)', () => {
it('loadFile 重复同一文件:不触发 onChange(setState 语义),listener 不重复挂', () => {
// 回归锚点:旧实现每次 loadFile 都 addEventListener 三次(compositionstart
// / end / cancel),但 dispose 才 remove —— contentDOM 上 listener 数量
// 线性增长。N 次切文件 → onChange 被触发 3N 次。新实现下 view 不重建,
// listener 只一份,N 次 loadFile 正好 N 次 onChange。
// 线性增长,N 次切文件 → onChange 被触发 3N 次。单实例化后 listener 只一份,
// 当时的断言是「N 次 loadFile 正好 N 次 onChange」。
// 2026-10 撤销/重做引入时复用路径再进化:从「dispatch 替换 doc」改为
// 「setState 重建 state」——把上一个文件的编辑 history 清零(否则切到 B 后
// Ctrl+Z 会退回 A 的内容,#btn-undo 也会在刚打开的文件上误亮)。
// setState 不派发 transaction → 一次 onChange 都不该有:加载 = 导航不是编辑,
// 脏态复位由 openFile 尾部显式完成。
const { editor, onChange } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const baseCount = onChange.mock.calls.length;
@@ -318,7 +323,7 @@ describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
editor.loadFile({ path: '/a.md', name: 'a.md' }, `A${i}`);
}
const delta = onChange.mock.calls.length - baseCount;
expect(delta).toBe(5); // 精确 5,不是 10 / 15 / ...
expect(delta).toBe(0); // 不是 5 / 10 / 15 —— setState 完全不触发
});
it('A→B→A 场景下 lastSavedDoc 跟踪最终内容', () => {

View File

@@ -93,6 +93,12 @@ describe('ALLOWED_URI_REGEXP 单测', () => {
['image:001.jpg', true],
['screenshot:1.png', true],
['dir/sub:dir/file.png', true],
// 回归(37664cb 重构引入):分支 B 段字符类曾排除 `#`,把「文件#锚点」
// 跨文件跳转形态整类误拒(app.js resolveLocalFile 消费 # 后缀)。
['foo/bar#baz', true],
['page.md#chapter', true],
['sprite.svg#icon-home', true],
['notes/page.md#sec', true],
// 即使危险 scheme 后只接字母(无 `/`),正则仍拒绝:顶层 `(?!)` 拦掉。
['javascript:foo', false],
['vbscript:foo', false],
@@ -101,6 +107,13 @@ describe('ALLOWED_URI_REGEXP 单测', () => {
['vbscript:msgbox(1)', false],
['data:text/html,xxx', false],
['chrome-extension://x/y', false],
// 分支 A 收紧:旧版不要求「冒号后带点扩展名」,`scheme:无点token` 整类
// 放行(blob: / chrome: / ms-msdt: …),白名单被稀释成 4 scheme 黑名单。
// 现在恢复默认拒绝;仅剩主进程导航守卫兜底的状态不复存在。
['blob:x', false],
['chrome:x', false],
['ms-msdt:x', false],
['view-source:x', false],
// 协议相对 URL:// 或 \\ 开头会被浏览器解析为「继承当前 scheme」,
// 攻击面是「静默导航到外站 / 把用户隐私发给 evil.com」。
// 必须与绝对路径 /abs/x.png 区分开 —— 单 / 放行,双 / 拒绝。

View File

@@ -220,12 +220,13 @@ describe('formatRelativeTime', () => {
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('≥ 7d 且同年 → 紧凑 "MM-DD HH:mm"', () => {
const old = new Date(2024, 5, 1, 12, 0, 0).getTime(); // now 也在 2024-06
expect(formatRelativeTime(old, now)).toBe('06-01 12:00');
});
it('≥ 7d 且跨年 → "YYYY-MM-DD"(保年份,舍时分)', () => {
const old = new Date(2023, 5, 1, 12, 0, 0).getTime();
expect(formatRelativeTime(old, now)).toBe('2023-06-01');
});
it('未来时间 → 当成 0 差("刚刚")', () => {
expect(formatRelativeTime(now + 5_000, now)).toBe('刚刚');