This commit is contained in:
2026-10-16 16:34:15 +08:00
parent 7a3769670b
commit ff366a4daf
80 changed files with 9961 additions and 888 deletions

View File

@@ -0,0 +1,132 @@
// P2-23(audit):AI 面板 placeholder 区分「无文件」vs「未配置」。
//
// 设计:纯函数 pickPlaceholder 决策三态文案,
// - !hasFile → 「请先打开一个 Markdown 文件」
// - hasFile + !configured → 「AI 未配置 — 点「⚙ 设置」填 Base URL 与 API Key」
// - hasFile + configured → 「描述你想如何修改当前文档...」
//
// 把决策抽成纯函数方便单测(避免起 jsdom 跑 DOM 副作用)。panel 内部
// setEnabled / setConfigured 走它。
//
// 另测:setEnabled / setConfigured 在 jsdom 下同步 DOM 状态(input.placeholder +
// .ai-chat-configure-link.hidden),保证「内联设置链」只在该显示时显示。
// @vitest-environment jsdom
import { describe, it, expect, beforeEach } from 'vitest';
import { pickPlaceholder, AiChatPanel } from '../../src/ai/ai-chat-panel.js';
describe('pickPlaceholder —— P2-23 三态文案', () => {
it('无文件 → 提示先打开文件', () => {
expect(pickPlaceholder({ hasFile: false, configured: true }))
.toBe('请先打开一个 Markdown 文件');
});
it('有文件 + 未配置 → 提示去设置里配置', () => {
const text = pickPlaceholder({ hasFile: true, configured: false });
expect(text).toContain('AI 未配置');
expect(text).toContain('设置');
});
it('有文件 + 已配置 → 默认描述型 placeholder', () => {
expect(pickPlaceholder({ hasFile: true, configured: true }))
.toBe('描述你想如何修改当前文档...');
});
it('优先级:!hasFile 永远盖过 !configured(即便都没配)', () => {
expect(pickPlaceholder({ hasFile: false, configured: false }))
.toBe('请先打开一个 Markdown 文件');
});
});
describe('AiChatPanel.setEnabled / setConfigured —— DOM 同步', () => {
/** @returns {HTMLElement} 重建一份 index.html 里的 ai-chat-dock 骨架 */
function buildDock() {
const dock = document.createElement('div');
dock.className = 'ai-chat-dock';
dock.innerHTML = `
<div class="ai-chat-form-row">
<button class="ai-chat-toggle" type="button">×</button>
<div class="ai-chat-input-wrap">
<input type="text" class="ai-chat-input" />
<button class="ai-chat-configure-link" type="button" hidden>⚙ 设置</button>
</div>
<button class="ai-chat-submit" type="button" data-mode="submit" disabled>生成修改</button>
</div>
`;
return dock;
}
let dock;
beforeEach(() => {
dock = buildDock();
});
it('未初始化时默认 configured=true(避免中间态被误判)', () => {
const panel = new AiChatPanel({
element: dock,
onSubmit: () => {},
onCancel: () => {},
});
// 默认 _configured !== false === true
panel.setEnabled(false); // 无文件
expect(dock.querySelector('.ai-chat-input').placeholder)
.toBe('请先打开一个 Markdown 文件');
});
it('hasFile + !configured → placeholder 切到「未配置」+ 内联链显示', () => {
const panel = new AiChatPanel({
element: dock,
onSubmit: () => {},
onCancel: () => {},
});
panel.setEnabled(true); // 有文件
panel.setConfigured(false); // 未配置
const input = dock.querySelector('.ai-chat-input');
const link = dock.querySelector('.ai-chat-configure-link');
expect(input.placeholder).toContain('AI 未配置');
expect(link.hidden).toBe(false);
});
it('hasFile + configured → placeholder 切回「描述修改」+ 内联链隐藏', () => {
const panel = new AiChatPanel({
element: dock,
onSubmit: () => {},
onCancel: () => {},
});
panel.setEnabled(true);
panel.setConfigured(false);
panel.setConfigured(true); // 配置完成
const input = dock.querySelector('.ai-chat-input');
const link = dock.querySelector('.ai-chat-configure-link');
expect(input.placeholder).toBe('描述你想如何修改当前文档...');
expect(link.hidden).toBe(true);
});
it('!hasFile 永远隐藏内联链(不论配置与否)', () => {
const panel = new AiChatPanel({
element: dock,
onSubmit: () => {},
onCancel: () => {},
});
panel.setEnabled(false); // 无文件
panel.setConfigured(false); // 未配置
expect(dock.querySelector('.ai-chat-configure-link').hidden).toBe(true);
});
it('点击内联链调 onOpenSettings 回调(如果传了)', () => {
let called = 0;
const panel = new AiChatPanel({
element: dock,
onSubmit: () => {},
onCancel: () => {},
onOpenSettings: () => { called += 1; },
});
panel.setEnabled(true);
panel.setConfigured(false);
const link = dock.querySelector('.ai-chat-configure-link');
link.click();
expect(called).toBe(1);
});
});

View File

@@ -0,0 +1,56 @@
// P2-9(audit C-4):app.js 顶层静态 import 不拉 CM6。
//
// 背景:main.js 用 show:false + ready-to-show,窗口出现时机 ≈ renderer 首绘。
// app.js 是 <script type="module">,模块图全部 fetch+eval 完才执行 —— 顶层
// 静态 import './markdown-editor.js'(basicSetup 拖整个 23 条 importmap 的
// CM6 图)意味着「窗口出现」要等 CM6 全部下载解析完。
//
// 契约(本测试锁死):
// 1. app.js 顶层不得静态 import 'codemirror' / '@codemirror/*' /
// './markdown-editor.js'(CM6 只能经 dynamic import 进 renderer)
// 2. bootstrap 路径里必须有 dynamic import('./markdown-editor.js')
// 3. editor 不得是模块级 const new MarkdownEditor(...) —— 必须是 let,
// 由 bootstrap 在 dynamic import 完成后实例化(模块 eval 时不建编辑器)
//
// 与 tests/unit/csp-importmap-hash.test.js 同为「源码静态分析契约测试」:
// jsdom 无法 import app.js(模块级副作用 + window.api 前置),只能读源码断言。
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const appPath = join(dirname(fileURLToPath(import.meta.url)), '../../src/app.js');
const src = readFileSync(appPath, 'utf8');
// 顶层静态 import 行(行首无缩进的 import ... from '...')
function staticImportSpecifiers(source) {
const specs = [];
for (const line of source.split('\n')) {
const m = line.match(/^import\s.*?\sfrom\s+'([^']+)'/);
if (m) specs.push(m[1]);
}
return specs;
}
describe('app.js 顶层不静态拉 CM6(audit P2-9)', () => {
it("无静态 import 'codemirror' / '@codemirror/*' / './markdown-editor.js'", () => {
const heavy = staticImportSpecifiers(src).filter((s) =>
s === 'codemirror' || s.startsWith('@codemirror/') || s.startsWith('@lezer/') || s === './markdown-editor.js'
);
expect(heavy).toEqual([]);
});
it("bootstrap 路径存在 dynamic import('./markdown-editor.js')", () => {
expect(src).toMatch(/import\('\.\/markdown-editor\.js'\)/);
});
it('editor 是模块级 let(bootstrap 内实例化),不是顶层 const new', () => {
expect(src).not.toMatch(/^const editor = new MarkdownEditor/m);
expect(src).toMatch(/^let editor/m);
});
it("无静态 import { EditorView } from '@codemirror/view'(facet 访问走 bootstrap 注入)", () => {
expect(src).not.toMatch(/^import\s*\{\s*EditorView\s*\}\s*from\s*'@codemirror\/view'/m);
});
});

View File

@@ -0,0 +1,59 @@
// P2-7(audit):app:open-config-dir 高频调用检测。
//
// 设计:main/audit-rate.js 提供 createAuditRateTracker({ windowMs, threshold, now })
// 返回 hit(key) → { count, exceeded }。滑动窗口按 key 计数:
// - 窗口内 count ≤ threshold → exceeded=false
// - 窗口内 count > threshold → exceeded=true(调用方据此 console.warn)
// - 超过 windowMs 的旧记录自动过期
//
// now 可注入(Date.now 默认),方便单测推进时间。
import { describe, it, expect } from 'vitest';
const { createAuditRateTracker } = require('../../main/audit-rate.js');
describe('createAuditRateTracker —— P2-7 高频调用检测', () => {
it('首次调用 count=1,不 exceeded', () => {
const t = createAuditRateTracker({ windowMs: 60_000, threshold: 5 });
expect(t.hit('k')).toEqual({ count: 1, exceeded: false });
});
it('窗口内累计到 threshold 不 exceeded,超过则 exceeded', () => {
let now = 1000;
const t = createAuditRateTracker({ windowMs: 60_000, threshold: 3, now: () => now });
expect(t.hit('k').exceeded).toBe(false); // 1
expect(t.hit('k').exceeded).toBe(false); // 2
expect(t.hit('k').exceeded).toBe(false); // 3
const fourth = t.hit('k'); // 4 > 3
expect(fourth.count).toBe(4);
expect(fourth.exceeded).toBe(true);
});
it('不同 key 独立计数', () => {
const t = createAuditRateTracker({ windowMs: 60_000, threshold: 1 });
t.hit('a');
expect(t.hit('b')).toEqual({ count: 1, exceeded: false });
});
it('超过 windowMs 的旧记录过期,重新计数', () => {
let now = 0;
const t = createAuditRateTracker({ windowMs: 1000, threshold: 2, now: () => now });
t.hit('k'); // t=0
t.hit('k'); // t=0, count=2
expect(t.hit('k').exceeded).toBe(true); // t=0, count=3
now = 1500; // 滑出窗口
expect(t.hit('k')).toEqual({ count: 1, exceeded: false });
});
it('边界:恰好 windowMs 的时间点视为过期([now-window, now) 语义)', () => {
let now = 0;
const t = createAuditRateTracker({ windowMs: 1000, threshold: 1, now: () => now });
t.hit('k'); // t=0
now = 1000; // 恰好 1000ms 后
expect(t.hit('k')).toEqual({ count: 1, exceeded: false });
});
it('默认 now=Date.now,不抛错', () => {
const t = createAuditRateTracker({ windowMs: 1000, threshold: 1 });
expect(() => t.hit('k')).not.toThrow();
});
});

View File

@@ -0,0 +1,85 @@
// main/ai.js cancelAll —— 顺序竞争 epoch 闸(audit P1-5)
//
// 背景:cancelAll 内部三步
// 1) for (controller of pending.values()) controller.abort();
// 2) pending.clear();
// 3) inFlightRequestIds.clear();
// 三步非原子。竞态:cancelAll 走到 step 1 刚 abort 完,runEdit 同步阶段
// 已经把「新 requestId + 新 controller」加进 pending + inFlightRequestIds →
// step 2/3 把这条新 entry 也清掉,runEdit 自以为还在跑,await 不到结果,
// 任何同 requestId 重试都找不到 controller。
//
// 修复:抽 makeCleanupEpoch() 工厂 → { snapshot, invalidate, current, isCurrent }。
// - cancelAll 入口 invalidate() → 序号 + 1(任何已 snapshot 的 in-flight 任务
// 之后 isCurrent() === false → 自行 bail)
// - runEdit 入口 snapshot() 拿当前序号;await 后 isCurrent() === false
// (说明本任务期间发生过 cancelAll)→ 不再 apply 任何副作用、直接 return
//
// 测试策略:纯函数工厂,无 fs / network / Electron 依赖。
import { describe, it, expect } from 'vitest';
import { makeCleanupEpoch } from '../../main/cleanup-epoch.js';
describe('cleanup epoch(audit P1-5)', () => {
it('初始:current = 0,snapshot(0) isCurrent 成立', () => {
const ep = makeCleanupEpoch();
expect(ep.current()).toBe(0);
expect(ep.snapshot()).toBe(0);
expect(ep.isCurrent(ep.snapshot())).toBe(true);
});
it('invalidate() → current 自增,isCurrent 旧 snapshot 返 false', () => {
const ep = makeCleanupEpoch();
const oldSnap = ep.snapshot();
ep.invalidate();
expect(ep.current()).toBeGreaterThan(oldSnap);
expect(ep.isCurrent(oldSnap)).toBe(false);
});
it('invalidate 之后再 snapshot 拿新序号,isCurrent 成立', () => {
const ep = makeCleanupEpoch();
ep.invalidate();
const newSnap = ep.snapshot();
expect(ep.isCurrent(newSnap)).toBe(true);
});
it('多次 invalidate 序号单调递增', () => {
const ep = makeCleanupEpoch();
const seqs = Array.from({ length: 5 }, () => {
const before = ep.current();
ep.invalidate();
return ep.current() - before;
});
// 每次都增 1
expect(seqs).toEqual([1, 1, 1, 1, 1]);
});
it('runEdit 模式:A 拿快照 → cancelAll → A 的 isCurrent 返 false', () => {
// 模拟 P1-5 竞态:A 在 cancelAll 之前 snapshot,cancelAll 之后 A 检查 → 失效
const ep = makeCleanupEpoch();
const aSnap = ep.snapshot();
// ... await 期间 ...
ep.invalidate(); // cancelAll 触发
expect(ep.isCurrent(aSnap)).toBe(false);
});
it('runEdit 模式:B 在 cancelAll 之后 snapshot → isCurrent 成立', () => {
// 新请求在 cancelAll 之后进入,snapshot 拿新序号 → isCurrent true
const ep = makeCleanupEpoch();
ep.invalidate();
const bSnap = ep.snapshot();
expect(ep.isCurrent(bSnap)).toBe(true);
});
it('cancelAll 期间 burst:B1 → cancelAll → B2 都拿自己的快照,互不影响', () => {
const ep = makeCleanupEpoch();
const a1 = ep.snapshot();
ep.invalidate();
const b1 = ep.snapshot();
ep.invalidate();
const b2 = ep.snapshot();
expect(ep.isCurrent(a1)).toBe(false); // A1 被 cancelAll 失效
expect(ep.isCurrent(b1)).toBe(false); // B1 也被第二次 cancelAll 失效
expect(ep.isCurrent(b2)).toBe(true); // B2 仍有效
});
});

View File

@@ -0,0 +1,156 @@
// src/markdown-editor.js —— CodeMirror history depth 上限(audit P0-3)
//
// 背景:basicSetup 默认带 history(),无 maxDepth 上限。打开 5MB 文件 + 长
// 时间编辑 → history stack 几百 MB,OOM 风险。
//
// 修复:extensions 列表里 basicSetup 之后加 Prec.high(history({ minEventTimeout: 200, maxDepth: 500 }))。
// 覆盖 basicSetup 的默认 history 配置:maxDepth 限制 undo 步数,minEventTimeout
// 把 200ms 内的连续 keystroke 合并成 1 步(避免每个键 1 步、3 千字输入塞 3 千
// 条 history)。
//
// 测试策略:
// - 1MB 文档 + 1000 次独立 transaction → undoDepth ≤ 500(maxDepth)
// - 500 次 transaction → undoDepth = 500(恰好满载)
// - undo 一次 → undoDepth 减 1,redoDepth 加 1
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { undoDepth, redoDepth, undo, isolateHistory } from '@codemirror/commands';
import { EditorView } from '@codemirror/view';
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(),
});
}
describe('CodeMirror history depth 上限(audit P0-3)', () => {
let editor;
beforeEach(() => {
editor = createEditor();
});
it('加载空 doc → undoDepth = 0', () => {
// MarkdownEditor 懒加载 view —— 必须先 loadFile
editor.loadFile({ path: '/empty.md', name: 'empty.md' }, '');
expect(undoDepth(editor.view.state)).toBe(0);
});
it('1000 次独立 transaction → undoDepth ≤ 520(minDepth 500 + updateBranch +20 buffer)', () => {
// 注:audit P0-3 描述「1MB 文档 + 1000 次 transaction」—— 这里用 1KB
// 文档,理由:audit 关心的是 history 长度上限(minDepth 截断),不关心
// 文档大小本身。1KB 文档下跑 1000 次 dispatch 几秒内出结果,1MB 文档
// 要 46 秒(每次 dispatch 都做 change mapping + state 克隆)。
editor.loadFile({ path: '/big.md', name: 'big.md' }, 'x'.repeat(1024));
const view = editor.view;
// 1000 次独立 transaction:用 userEvent: 'test.step' 跳出 joinableUserEvent
// 正则(^(input\.type|delete)($|\.))—— 不匹配的 userEvent 不会与上一个
// event 合并,确保每个 transaction 各自成为 1 个 history step。
// 同步加 isolateHistory 标注,双重保险。
for (let i = 0; i < 1000; i++) {
view.dispatch({
changes: { from: 0, to: 1, insert: String.fromCharCode(65 + (i % 26)) },
userEvent: 'test.step',
annotations: [isolateHistory.of('test')],
});
}
// 关键断言:undoDepth 不会无限增长,超 minDepth(500) 即被截断
// updateBranch 内部 maxLen + 20 buffer,所以上限是 520
expect(undoDepth(view.state)).toBeLessThanOrEqual(520);
expect(undoDepth(view.state)).toBeGreaterThanOrEqual(500);
});
it('500 次 transaction → undoDepth = 500(恰好满载,未截断)', () => {
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'hello world');
const view = editor.view;
for (let i = 0; i < 500; i++) {
view.dispatch({
changes: { from: 0, to: 1, insert: String.fromCharCode(65 + (i % 26)) },
userEvent: 'test.step',
annotations: [isolateHistory.of('test')],
});
}
expect(undoDepth(view.state)).toBe(500);
});
it('undo 一次 → undoDepth 减 1,redoDepth 加 1(minDepth 限制下 history 仍可用)', () => {
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'hello');
const view = editor.view;
for (let i = 0; i < 3; i++) {
view.dispatch({
changes: { from: 0, to: 0, insert: 'a' },
userEvent: 'test.step',
annotations: [isolateHistory.of('test')],
});
}
expect(undoDepth(view.state)).toBe(3);
expect(redoDepth(view.state)).toBe(0);
undo(view);
expect(undoDepth(view.state)).toBe(2);
expect(redoDepth(view.state)).toBe(1);
});
it('超 500 步后 undo 老的 history 被丢弃,只剩最新的 ~500 步可撤销', () => {
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'x');
const view = editor.view;
// 600 步独立 transaction
for (let i = 0; i < 600; i++) {
view.dispatch({
changes: { from: 0, to: 0, insert: 'a' },
userEvent: 'test.step',
annotations: [isolateHistory.of('test')],
});
}
// 600 步后只剩 minDepth(500) + 20 buffer = 520
expect(undoDepth(view.state)).toBeLessThanOrEqual(520);
expect(undoDepth(view.state)).toBeGreaterThanOrEqual(500);
// 连续 undo 直到 history 空
while (undoDepth(view.state) > 0) {
undo(view);
}
expect(undoDepth(view.state)).toBe(0);
// redo 应有 ~500+ 条
expect(redoDepth(view.state)).toBeLessThanOrEqual(520);
expect(redoDepth(view.state)).toBeGreaterThanOrEqual(500);
// 再 undo:应失败(已无 history 可撤销)
const beforeUndo = undoDepth(view.state);
undo(view);
expect(undoDepth(view.state)).toBe(beforeUndo); // 没变化
});
});

View File

@@ -0,0 +1,120 @@
// main/config-store.js —— saveConfig isQuitting 闸(audit P1-2)
//
// 背景:renderer 错误风暴(如 settingsStore.update 触发循环)会建无界
// Promise 链;进程退出时 IPC handler `await configStore.saveConfig(...)`
// 被卡在最末一个未完成 task,before-quit 等不到 save 全部完成 → quit 阻塞。
//
// 修复:加 isQuitting 闸。before-quit 触发时把闸置 true,新 saveConfig
// 立即返回 { ok: false, error: 'APP_QUITTING' }(同步值,不是 Promise,
// 避免 IPC await 永不 resolve)。in-flight 任务照常完成(已 fsync 的
// 文件 rename 完才返回 ok:true,强制 abort 会丢正在写的 tmp)—— 「
// 进程退出」语意下「最新一个 save 完成」即可接受。
//
// 测试策略:直接 import config-store 模块,用 _setIsQuitting 控制闸。
// saveConfig 内部写 tmp + rename 到真文件 → 测试用真 fs + tmpdir 隔离。
// 注意:每次测试前 _reset 清状态 + _setIsQuitting(false),避免 case 串扰。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as configStore from '../../main/config-store.js';
let mockHomeDir;
let mockUserDataDir;
let home;
function makeIsolatedHome() {
const dir = mkdtempSync(join(tmpdir(), 'notes-config-quit-'));
// 写入一个最小可用的 config.json(init 需要它存在)
writeFileSync(join(dir, 'config.json'), JSON.stringify({ alwaysOnTop: false }, null, 2), 'utf-8');
return dir;
}
describe('config-store saveConfig isQuitting 闸(audit P1-2)', () => {
beforeEach(async () => {
mockHomeDir = mkdtempSync(join(tmpdir(), 'notes-cq-home-'));
mockUserDataDir = mkdtempSync(join(tmpdir(), 'notes-cq-ud-'));
configStore._setApp({
getPath: (name) => {
if (name === 'home') return mockHomeDir;
if (name === 'userData') return mockUserDataDir;
throw new Error(`unexpected getPath(${name})`);
},
});
configStore._reset();
configStore._setIsQuitting(false);
home = makeIsolatedHome();
await configStore.init({ homeDir: home });
});
it('默认状态:isQuitting = false', () => {
expect(configStore._isQuitting()).toBe(false);
});
it('_setIsQuitting(true) 后 isQuitting 反映为 true', () => {
configStore._setIsQuitting(true);
expect(configStore._isQuitting()).toBe(true);
});
it('isQuitting=true 时 saveConfig 同步返 { ok:false, error:APP_QUITTING },不写盘', async () => {
configStore._setIsQuitting(true);
const before = readFileSync(join(home, 'config.json'), 'utf-8');
const result = await configStore.saveConfig({ alwaysOnTop: true });
expect(result).toEqual({ ok: false, error: 'APP_QUITTING' });
// 文件未被改写(before vs after 内容一致)
const after = readFileSync(join(home, 'config.json'), 'utf-8');
expect(after).toBe(before);
});
it('isQuitting=true 时 saveConfig 返的 Promise 已 resolve(不等链尾)', async () => {
// 关键:闸开时返回的必须是「已 resolve 的值」,否则 IPC handler
// `await saveConfig(...)` 永远挂在链尾 → before-quit 阻塞。
configStore._setIsQuitting(true);
// 10 次并发调用 → 全部应立即 resolve
const results = await Promise.all(
Array.from({ length: 10 }, () => configStore.saveConfig({ alwaysOnTop: true }))
);
expect(results).toHaveLength(10);
for (const r of results) {
expect(r).toEqual({ ok: false, error: 'APP_QUITTING' });
}
});
it('isQuitting=true 时 in-flight 之前的 save 仍正常完成(不强制 abort)', async () => {
// 第一个 save 在闸开启前排进 queue,应正常完成
const p1 = configStore.saveConfig({ alwaysOnTop: true });
configStore._setIsQuitting(true);
const r1 = await p1;
expect(r1.ok).toBe(true);
expect(r1.value.alwaysOnTop).toBe(true);
// 闸开后后续 save 被拒
const r2 = await configStore.saveConfig({ alwaysOnTop: false });
expect(r2).toEqual({ ok: false, error: 'APP_QUITTING' });
});
it('isQuitting=true → _setIsQuitting(false) 后 saveConfig 恢复工作', async () => {
configStore._setIsQuitting(true);
const r1 = await configStore.saveConfig({ alwaysOnTop: true });
expect(r1).toEqual({ ok: false, error: 'APP_QUITTING' });
configStore._setIsQuitting(false);
const r2 = await configStore.saveConfig({ alwaysOnTop: true });
expect(r2.ok).toBe(true);
});
afterEach(() => {
if (home && existsSync(home)) {
rmSync(home, { recursive: true, force: true });
}
if (mockHomeDir) rmSync(mockHomeDir, { recursive: true, force: true });
if (mockUserDataDir) rmSync(mockUserDataDir, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,123 @@
// index.html —— CSP 收紧(audit P0-2)
//
// 背景:原 CSP `script-src 'self' 'unsafe-inline'` —— 注释承认仅 importmap
// 需要 inline,但 'unsafe-inline' 留下 XSS 后续路径(任何位置注入的 inline
// script 都被放行)。
//
// 修复:给 importmap 内联块生成 SHA-256 hash;CSP 改
// script-src 'self' 'sha256-<hash>='
// 把 'unsafe-inline' 从 script-src 移除。hash 必须与 importmap 当前实际内容
// 匹配(任何 importmap 调整后必须重算 + 同步 CSP,否则 importmap 被 CSP
// 拦截,renderer 启动会「Failed to resolve module specifier」)。
//
// 注:style-src 的 'unsafe-inline' 保留 —— marked 渲染的代码块用到 inline
// style(CSP 收紧会破坏阅读视图)。本测试只盯 script-src。
//
// 测试策略:直接读 index.html 文件,解析 <meta http-equiv="Content-Security-Policy">
// 和 <script type="importmap"> 块,独立用 Node crypto 重算 hash,断言:
// - script-src 不含 'unsafe-inline'
// - script-src 含 'sha256-...' 形式
// - CSP 里的 hash == importmap inner content 的 SHA-256
// - importmap 改动 → hash 自动失效(防 drift)
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const INDEX_HTML = resolve(__dirname, '../../index.html');
function readIndexHtml() {
return readFileSync(INDEX_HTML, 'utf8');
}
function extractCsp(html) {
// content 属性里含单引号 ('self') + 哈希等号,不能用 [^"']+ 简单排除;
// 用更稳的「非贪婪 + 显式终止」:content="..." 直到遇到 " 后接空白/>/换行。
const m = html.match(/<meta\s+http-equiv=["']Content-Security-Policy["']\s+content=(["'])([\s\S]*?)\1\s*>/i);
if (!m) throw new Error('未找到 <meta http-equiv="Content-Security-Policy">');
return m[2];
}
function parseCspDirectives(csp) {
// 简单解析:split by ';',每个 directive 按空格 split key / values。
// 值可能带单/双引号(CSP source-list 语法),解析时去掉外层引号便于后续比对。
/** @type {Record<string, string[]>} */
const out = {};
for (const raw of csp.split(';')) {
const trimmed = raw.trim();
if (!trimmed) continue;
const parts = trimmed.split(/\s+/);
const key = parts.shift();
out[key] = parts.map((v) => {
// 去外层 ' 或 "(CSP value 内部不含嵌套引号,simple trim 安全)
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
return v.slice(1, -1);
}
return v;
});
}
return out;
}
function extractImportmapContent(html) {
const m = html.match(/<script\s+type=["']importmap["']\s*>([\s\S]*?)<\/script>/i);
if (!m) throw new Error('未找到 <script type="importmap">');
return m[1];
}
function sha256Base64(content) {
// 浏览器 HTML tokenizer 在算 CSP inline-script hash 前,会把脚本内容里的
// \r\n / \r 归一化成 \n(这是 2026-10-16 importmap 被 CSP 拦截的真因:仓库
// 源文件是 CRLF,但 CSP 必须放 LF 归一化后的 hash)。这里先归一化再算,
// 与浏览器行为一致,否则测试会在「文件 CRLF」时算出错的 hash 还判定通过。
const normalized = content.replace(/\r\n?/g, '\n');
return createHash('sha256').update(normalized).digest('base64');
}
describe('index.html CSP —— script-src 不含 unsafe-inline(audit P0-2)', () => {
it('CSP meta 标签存在', () => {
const html = readIndexHtml();
const csp = extractCsp(html);
expect(csp).toBeTruthy();
});
it("script-src 不含 'unsafe-inline'", () => {
// parseCspDirectives 已剥外层引号 → 直接比对 'unsafe-inline'(无引号)。
// 用 .some 而非 .toContain:避免未来 CSP 拼成 host-source-list 误判。
const html = readIndexHtml();
const directives = parseCspDirectives(extractCsp(html));
expect(directives['script-src']).toBeDefined();
expect(directives['script-src'].some((v) => v === 'unsafe-inline')).toBe(false);
});
it('script-src 含 self + 至少一个 sha256-XXX=', () => {
const html = readIndexHtml();
const directives = parseCspDirectives(extractCsp(html));
const scriptSrc = directives['script-src'];
expect(scriptSrc).toContain('self');
const hashEntries = scriptSrc.filter((v) => /^sha256-/.test(v));
expect(hashEntries.length).toBeGreaterThanOrEqual(1);
});
it('CSP 里的 sha256 hash == importmap inner content 的 SHA-256', () => {
const html = readIndexHtml();
const directives = parseCspDirectives(extractCsp(html));
const importmapContent = extractImportmapContent(html);
const expectedHash = sha256Base64(importmapContent);
const expectedEntry = `sha256-${expectedHash}`;
const scriptSrc = directives['script-src'];
expect(scriptSrc).toContain(expectedEntry);
});
it('importmap 内容变更 → hash 自动失效(防 drift,文档化契约)', () => {
// 这条不是失败用例,是「设计意图」文档:
// 任何 importmap 调整(新增/删除/改路径)后必须重算 SHA-256 并同步 CSP。
// 改完 importmap 不重算 hash → 上面那条测试失败,提示开发者。
// 不需要断言(已由上一条隐含覆盖),存在仅为了让读者明白这条测试的意图。
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,77 @@
// src/editor-mode-reset.js —— splitRatio 重置决策的纯函数测试
//
// 背景(audit P0-4 + Memory-2):
// - 编辑双栏(splitRatio)每次「打开」都默认对半分,drag 比例仅本次会话内
// 有效,不跨应用重启保留。
// - 旧实现:applyEditorMode 内部用 `document.body.dataset.editorMode !== 'split'`
// 做闸 —— 但默认 editorMode='split' 冷启动时,dataset 已是 'split',闸永远为
// 假,上次拖的 70% 在冷启动被恢复。
// - 修复:applyEditorMode 内部无条件按 visualMode 决定 —— visualMode='split'
// 时调 settingsStore.update({ splitRatio: 0.5 }),其它不动。
// - 顺带:mountSplitter.onDragEnd 改为 noop(drag 不再持久化)。
//
// 这里只测纯函数;mountSplitter 那个 onDragEnd noop 改动是单行「删除
// settingsStore.update 调用」,没分支可测,留在 app.js commit 里人工 review。
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { applySplitRatioResetIfEnteringSplit } from '../../src/editor-mode-reset.js';
describe('applySplitRatioResetIfEnteringSplit', () => {
let updateMock;
let settingsStore;
beforeEach(() => {
updateMock = vi.fn().mockResolvedValue({ ok: true });
settingsStore = { update: updateMock };
});
it('visualMode = split → 调 settingsStore.update({ splitRatio: 0.5 })', () => {
applySplitRatioResetIfEnteringSplit('split', settingsStore);
expect(updateMock).toHaveBeenCalledTimes(1);
expect(updateMock).toHaveBeenCalledWith({ splitRatio: 0.5 });
});
it('visualMode = edit → 不调 update', () => {
applySplitRatioResetIfEnteringSplit('edit', settingsStore);
expect(updateMock).not.toHaveBeenCalled();
});
it('visualMode = preview → 不调 update', () => {
applySplitRatioResetIfEnteringSplit('preview', settingsStore);
expect(updateMock).not.toHaveBeenCalled();
});
it('visualMode 是未知字符串(防御性)→ 不调 update', () => {
// 防止未来 schema 加新模式时这里误触 reset
applySplitRatioResetIfEnteringSplit('unknown', settingsStore);
expect(updateMock).not.toHaveBeenCalled();
});
it('update reject → onError 被调,函数不抛', async () => {
updateMock.mockRejectedValue(new Error('disk full'));
const onError = vi.fn();
// 不 await:函数本身 fire-and-forget;测试用 microtask flush 等待 catch 跑完
applySplitRatioResetIfEnteringSplit('split', settingsStore, onError);
await new Promise((r) => setTimeout(r, 0));
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0]).toMatch(/disk full/);
});
it('update reject + 未传 onError → 不抛(用 console.warn 兜底,不让布局重置炸掉 app)', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
updateMock.mockRejectedValue(new Error('disk full'));
expect(() => applySplitRatioResetIfEnteringSplit('split', settingsStore)).not.toThrow();
await new Promise((r) => setTimeout(r, 0));
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('update 成功 → onError 不被调(成功路径不打扰用户)', async () => {
const onError = vi.fn();
applySplitRatioResetIfEnteringSplit('split', settingsStore, onError);
await new Promise((r) => setTimeout(r, 0));
expect(onError).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,172 @@
// main/file-list-cache.js 单测(audit R7a P1-20)
//
// 覆盖:
// - 首次 getOrBuild → 调 scanFn,结果缓存
// - 同 dir 二次 getOrBuild → 命中缓存,不调 scanFn
// - invalidate → 下次 getOrBuild 重扫
// - dir 变化 → 重扫
// - scanFn 返回 {ok:false} → 返回 {error} 不缓存
// - files 字段只含非文件夹条目(兼容 state.files)
// - peek / _setForTest 测试钩子
import { describe, it, expect, beforeEach } from 'vitest';
const cache = require('../../main/file-list-cache.js');
const ENTRIES_500 = Array.from({ length: 500 }, (_, i) => ({
name: `f-${i}.md`,
path: `/data/f-${i}.md`,
entryType: 'editable',
isFolder: false,
size: 100,
mtimeMs: 1000 + i,
}));
function scanFnMock(entries = ENTRIES_500) {
return async (dir) => ({ ok: true, dir, entries });
}
beforeEach(() => {
cache.invalidate();
cache._setForTest(null);
});
describe('file-list-cache: 缓存命中 / 失效', () => {
it('首次 getOrBuild 调 scanFn,结果缓存', async () => {
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
const r = await cache.getOrBuild('/data', scan);
expect(calls).toBe(1);
expect(r.entries).toHaveLength(500);
expect(r.total).toBe(500);
expect(r.files).toHaveLength(500);
expect(r.dir).toBe('/data');
});
it('同 dir 二次 getOrBuild 命中缓存,不调 scanFn', async () => {
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
await cache.getOrBuild('/data', scan);
await cache.getOrBuild('/data', scan);
expect(calls).toBe(1);
});
it('invalidate → 下次 getOrBuild 重扫', async () => {
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
await cache.getOrBuild('/data', scan);
expect(calls).toBe(1);
cache.invalidate();
await cache.getOrBuild('/data', scan);
expect(calls).toBe(2);
});
it('dir 变化 → 重扫', async () => {
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: ENTRIES_500 }; };
await cache.getOrBuild('/data', scan);
await cache.getOrBuild('/other', scan);
expect(calls).toBe(2);
});
it('scanFn 返回 {ok:false} → 返回 {error},不缓存', async () => {
let calls = 0;
const scan = async () => {
calls += 1;
return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT' };
};
const r = await cache.getOrBuild('/missing', scan);
expect(r.error).toBeTruthy();
expect(r.error.ok).toBe(false);
expect(r.error.code).toBe('ENOENT');
expect(cache.peek()).toBeNull();
// 下次再 getOrBuild 同 dir 仍会重扫(没缓存可命中)
await cache.getOrBuild('/missing', scan);
expect(calls).toBe(2);
});
});
describe('file-list-cache: files 字段派生', () => {
it('files 只含非文件夹条目(state.files 兼容)', async () => {
const mixed = [
{ name: 'a.md', path: '/a', entryType: 'editable', isFolder: false, size: 1, mtimeMs: 1 },
{ name: 'sub', path: '/sub', entryType: 'folder', isFolder: true },
{ name: 'b.png', path: '/b', entryType: 'binary', isFolder: false, size: 2, mtimeMs: 2 },
];
const scan = async (dir) => ({ ok: true, dir, entries: mixed });
const r = await cache.getOrBuild('/data', scan);
expect(r.entries).toHaveLength(3);
expect(r.files).toHaveLength(2); // a.md + b.png
expect(r.files.every((f) => !f.isFolder)).toBe(true);
});
it('files 字段投影 shape 与 renderer state.files 一致', async () => {
const scan = scanFnMock();
const r = await cache.getOrBuild('/data', scan);
const sample = r.files[0];
expect(sample).toHaveProperty('name');
expect(sample).toHaveProperty('path');
expect(sample).toHaveProperty('size');
expect(sample).toHaveProperty('mtimeMs');
expect(sample).toHaveProperty('entryType');
// isFolder 故意不进 files(filter 已经移除)
expect(sample).not.toHaveProperty('isFolder');
});
});
describe('file-list-cache: peek / _setForTest', () => {
it('peek 拿当前缓存(不改)', async () => {
const scan = scanFnMock();
await cache.getOrBuild('/data', scan);
const snap = cache.peek();
expect(snap.dir).toBe('/data');
expect(snap.total).toBe(500);
// 二次 peek 同一引用
expect(cache.peek()).toBe(snap);
});
it('_setForTest 直接设缓存,getOrBuild 同 dir 命中', async () => {
cache._setForTest({ dir: '/preset', entries: [], files: [], total: 0, at: 0 });
let called = false;
const scan = async () => { called = true; return { ok: true, dir: '/preset', entries: [] }; };
const r = await cache.getOrBuild('/preset', scan);
expect(called).toBe(false);
expect(r.dir).toBe('/preset');
expect(r.total).toBe(0);
});
});
describe('file-list-cache: 分页一致性', () => {
// 模拟 file:list handler 的使用方式:
// 1) 首次 getOrBuild → 拿 entries
// 2) slice(offset, offset+limit) → 返回第一页
// 3) loadMore → 再次 slice(off+limit) → 不重扫(命中缓存)
it('分页多次 slice 共享一次 scan(10000 文件 50 页)', async () => {
const big = Array.from({ length: 10000 }, (_, i) => ({
name: `n-${String(i).padStart(5, '0')}.md`,
path: `/data/n-${i}.md`,
entryType: 'editable',
isFolder: false,
size: 100,
mtimeMs: 1000 + i,
}));
let calls = 0;
const scan = async (dir) => { calls += 1; return { ok: true, dir, entries: big }; };
// 首屏
const r = await cache.getOrBuild('/data', scan);
expect(r.total).toBe(10000);
const limit = 200;
const seen = [];
for (let offset = 0; offset < r.total; offset += limit) {
// 每次分页只 slice 缓存,不重扫
const page = r.entries.slice(offset, offset + limit);
seen.push(...page);
}
expect(calls).toBe(1); // 一次 scan,50 次 slice
expect(seen).toHaveLength(10000);
// 顺序保持(slice 不会重排)
expect(seen[0].name).toBe('n-00000.md');
expect(seen[9999].name).toBe('n-09999.md');
});
});

View File

@@ -94,7 +94,9 @@ describe('showFileListContextMenu 集成', () => {
function fakeShowFileListContextMenu(entry, x, y, contextMenu, callbacks) {
const isFolder = entry && entry.entryType === 'folder';
const items = [];
if (!isFolder) items.push({ label: '重命名', value: 'rename' });
// 历史:folder 原本隐藏「重命名」—— file:rename 主进程强绑 .md 后缀。
// 现在用 folder:create / folder:rename / folder:delete 三个新 IPC 解锁文件夹重命名/删除。
items.push({ label: '重命名', value: 'rename' });
items.push({ label: '在文件夹中显示', value: 'reveal' });
items.push({ separator: true, value: '' });
items.push({ label: '删除', value: 'delete' });
@@ -119,17 +121,36 @@ describe('showFileListContextMenu 集成', () => {
]);
});
it('folder 条目 → 菜单不包含 rename', () => {
it('folder 条目 → 菜单包含 rename / reveal / delete(folder:rename 解锁)', () => {
// 历史注释:folder 条目原本隐藏「重命名」 —— file:rename 主进程强绑 .md 后缀。
// 现在用独立的 folder:create / folder:rename / folder:delete IPC,folder 也允许重命名。
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('folder 条目点「重命名」 → onRename 被调(分支到 folder rename 而非 file rename)', () => {
// 验证 onSelect 回调:根据 entryType 把「重命名」分支到对应 handler。
// 真实现里 showFileListContextMenu 直接调 fileOps.renameFolder / renameCurrentOrFile,
// fakeShowFileListContextMenu 这里只能验证 onRename 被调用(具体 handler 是生产代码的
// 内部调度的,由 file-ops.test.js / 集成覆盖)。
const onRename = vi.fn();
fakeShowFileListContextMenu(
{ name: 'subdir', path: '/d/subdir', entryType: 'folder' }, 10, 10, menu,
{ onRename, onReveal: vi.fn(), onDelete: vi.fn() }
);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[0].click(); // 「重命名」
expect(onRename).toHaveBeenCalledTimes(1);
expect(onRename.mock.calls[0][0].name).toBe('subdir');
expect(onRename.mock.calls[0][0].entryType).toBe('folder');
});
it('点击「重命名」 → onRename 回调被调,且菜单关闭', () => {
const onRename = vi.fn();
const onReveal = vi.fn();

View File

@@ -0,0 +1,188 @@
// src/file-list.js —— 拖拽投放(drag → dragover → drop)事件委托单测
//
// 覆盖:
// - 普通拖拽文件到文件夹:onDropIntoFolder(entry, folderEntry, { copy:false })
// - 按住 Ctrl/Cmd 拖拽到文件夹:onDropIntoFolder(entry, folderEntry, { copy:true })
// - 拖到非文件夹(文件)不放:不触发
// - 文件夹拖到自己的子孙目录:视为非法目标,不触发
//
// jsdom 不原生支持 DragEvent 的 dataTransfer,这里手动给事件挂一个 dataTransfer 桩。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as FileListMod from '../../src/file-list.js';
const { FileList } = FileListMod;
function makeDataTransfer() {
return {
data: {},
effectAllowed: '',
dropEffect: '',
setData(type, val) { this.data[type] = String(val); },
getData(type) { return this.data[type] || ''; },
};
}
function fire(el, type, { ctrlKey = false, metaKey = false } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true });
ev.dataTransfer = makeDataTransfer();
// ctrlKey / metaKey 在 jsdom 的 Event 上只读,用 defineProperty 注入
Object.defineProperty(ev, 'ctrlKey', { value: ctrlKey });
Object.defineProperty(ev, 'metaKey', { value: metaKey });
el.dispatchEvent(ev);
return ev;
}
describe('FileList 拖拽投放', () => {
let element;
let list;
let onDrop;
beforeEach(() => {
document.body.innerHTML = `<ul id="files"></ul>`;
element = document.getElementById('files');
onDrop = vi.fn();
list = new FileList({ element, onSelect: vi.fn(), onDropIntoFolder: onDrop });
list.setFiles([
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
]);
});
function liFor(path) {
// querySelector 里路径含 / 与中文,用属性选择器 + 转义;jsdom 无 CSS.escape,
// 直接用 [data-path="..."](路径不含引号,安全)并兜底到 find。
return element.querySelector(`.file-item[data-path="${path}"]`)
|| Array.from(element.querySelectorAll('.file-item')).find((li) => li.dataset.path === path);
}
it('普通拖拽文件到文件夹 → 触发 move(copy:false)', () => {
const fileLi = liFor('/d/note.md');
const folderLi = liFor('/d/sub');
fire(fileLi, 'dragstart');
fire(folderLi, 'dragover');
expect(folderLi.classList.contains('is-drop-target')).toBe(true);
fire(folderLi, 'drop');
expect(onDrop).toHaveBeenCalledTimes(1);
const [dragged, target, opts] = onDrop.mock.calls[0];
expect(dragged.path).toBe('/d/note.md');
expect(target.path).toBe('/d/sub');
expect(target.entryType).toBe('folder');
expect(opts).toEqual({ copy: false });
});
it('按住 Ctrl 拖拽文件到文件夹 → 触发 copy(copy:true)', () => {
const fileLi = liFor('/d/note.md');
const folderLi = liFor('/d/sub');
fire(fileLi, 'dragstart');
fire(folderLi, 'dragover', { ctrlKey: true });
fire(folderLi, 'drop', { ctrlKey: true });
expect(onDrop).toHaveBeenCalledTimes(1);
const [dragged, target, opts] = onDrop.mock.calls[0];
expect(dragged.path).toBe('/d/note.md');
expect(target.path).toBe('/d/sub');
expect(opts).toEqual({ copy: true });
});
it('拖到非文件夹(文件)不放 → 不触发', () => {
const fileLi = liFor('/d/note.md');
fire(fileLi, 'dragstart');
fire(fileLi, 'dragover'); // 拖到自己身上
fire(fileLi, 'drop');
expect(onDrop).not.toHaveBeenCalled();
expect(fileLi.classList.contains('is-drop-target')).toBe(false);
});
it('文件夹拖到自身 → 非法目标,不触发', () => {
const folderLi = liFor('/d/sub');
fire(folderLi, 'dragstart');
fire(folderLi, 'dragover');
fire(folderLi, 'drop');
expect(onDrop).not.toHaveBeenCalled();
});
it('文件夹拖到自己的子孙目录 → 非法目标,不触发', () => {
list.setFiles([
{ name: 'A', path: '/d/A', entryType: 'folder' },
{ name: 'B', path: '/d/A/B', entryType: 'folder' },
]);
const aLi = liFor('/d/A');
const bLi = liFor('/d/A/B');
fire(aLi, 'dragstart');
fire(bLi, 'dragover');
fire(bLi, 'drop');
expect(onDrop).not.toHaveBeenCalled();
});
it('dragend 清空拖拽源状态', () => {
const fileLi = liFor('/d/note.md');
fire(fileLi, 'dragstart');
expect(fileLi.classList.contains('is-dragging')).toBe(true);
fire(fileLi, 'dragend');
expect(fileLi.classList.contains('is-dragging')).toBe(false);
expect(list._dragEntry).toBeNull();
});
});
describe('FileList 空白区域右键菜单', () => {
let element;
let list;
let onItem;
let onEmpty;
beforeEach(() => {
document.body.innerHTML = `<ul id="files"></ul>`;
element = document.getElementById('files');
onItem = vi.fn();
onEmpty = vi.fn();
list = new FileList({
element,
onSelect: vi.fn(),
onContextMenu: onItem,
onContextMenuEmpty: onEmpty,
});
list.setFiles([
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
]);
});
function rightClick(target, { x = 4, y = 6 } = {}) {
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y });
target.dispatchEvent(ev);
}
function liFor(path) {
return element.querySelector(`.file-item[data-path="${path}"]`)
|| Array.from(element.querySelectorAll('.file-item')).find((li) => li.dataset.path === path);
}
it('右键空白区域(ul 本身)→ 触发 onContextMenuEmpty,不触发 onContextMenu', () => {
rightClick(element);
expect(onEmpty).toHaveBeenCalledTimes(1);
expect(onEmpty.mock.calls[0][0]).toBe(4);
expect(onEmpty.mock.calls[0][1]).toBe(6);
expect(onItem).not.toHaveBeenCalled();
});
it('右键具体条目 → 触发 onContextMenu,不触发 onContextMenuEmpty', () => {
rightClick(liFor('/d/note.md'));
expect(onItem).toHaveBeenCalledTimes(1);
expect(onEmpty).not.toHaveBeenCalled();
});
it('未提供 onContextMenuEmpty 时,空白区域右键不报错也不弹菜单', () => {
const ul = document.createElement('ul');
document.body.appendChild(ul);
const bare = new FileList({ element: ul, onSelect: vi.fn(), onContextMenu: vi.fn() });
bare.setFiles([{ name: 'a.md', path: '/d/a.md', entryType: 'file' }]);
expect(() => rightClick(ul)).not.toThrow();
});
});

View File

@@ -3,7 +3,7 @@
// 覆盖:
// - fuzzyScore 评分函数(纯函数):子串 vs 子序列、单词边界、相邻匹配、空 query
// - setFiles:外部删除通知 (audit 2.1)
// - applyFilter:排序模式(name / mtime-desc)、搜索 vs sortMode 优先级
// - applyFilter:排序模式(name / name-desc / mtime-desc / mtime-asc)、搜索 vs sortMode 优先级
// - moveActive:边界(空列表、首/尾)+ 键盘导航
// - dispose 后 listener 失效
//
@@ -65,6 +65,40 @@ describe('FileList 搜索 / 排序行为', () => {
expect(renderedNames()).toEqual(['new.md', 'mid.md', 'old.md']);
});
it('mtime-asc 模式按修改时间正序(旧 → 新)', () => {
// 2026-10 加的下拉排序项之一,与 mtime-desc 方向相反。
// 验证升序比较器不写反(避免与降序共享同一个 sort 函数只是顺序反了)。
list.setSort('mtime-asc');
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(['old.md', 'mid.md', 'new.md']);
});
it('name-desc 模式按文件名倒序(Z → A,zh-CN locale)', () => {
// 2026-10 加的下拉排序项之一,与默认 name 方向相反。
list.setSort('name-desc');
feed([
{ name: 'a.md', path: '/d/a.md' },
{ name: 'c.md', path: '/d/c.md' },
{ name: 'b.md', path: '/d/b.md' },
]);
expect(renderedNames()).toEqual(['c.md', 'b.md', 'a.md']);
});
it('setSort 未知 mode 退回到 name 而非抛错', () => {
// normalizeSortMode 的契约测试 —— UI / settings 传入拼错或过期值时,
// 不该抛错或保留上一次未知状态;回到默认 'name' 是更安全的失败模式。
feed([
{ name: 'b.md', path: '/d/b.md' },
{ name: 'a.md', path: '/d/a.md' },
]);
expect(() => list.setSort('not-a-real-mode')).not.toThrow();
expect(renderedNames()).toEqual(['a.md', 'b.md']);
});
it('无 mtime 时 mtime-desc 模式按 name 兜底(不抛错)', () => {
list.setSort('mtime-desc');
feed([
@@ -345,4 +379,177 @@ describe('FileList dispose lifecycle', () => {
await new Promise((r) => setTimeout(r, 150));
// 没有任何报错(监听已解绑,flushSearch 不会跑)
});
});
});
// ============================================================================
// audit R7a P1-15:windowed 虚拟滚动
// ============================================================================
//
// 关键约束:jsdom 不做布局,clientHeight 永远是 0 → render() 自动走 full 模式。
// 测试需要主动 mock clientHeight(+ scrollTop / scrollHeight)才能触发
// windowed 分支。所有测试都在 beforeEach 里 defineProperty,afterEach 还原,
// 避免污染其它 describe 块。
describe('FileList windowed 虚拟滚动 (audit R7a P1-15)', () => {
let element;
let originalDescriptors;
const VIEWPORT_H = 500; // 10 个可见项
const ITEM_H = 50; // 与 .file-item CSS 同步
/**
* jsdom 不做布局,clientHeight/scrollHeight/scrollTop 都是 0。
* 用 Object.defineProperty 直接覆盖实例属性,触发 windowed 分支。
* configurable:true 是必须的(afterEach 要 delete 还原)。
*/
function mockLayout(el, { clientHeight = VIEWPORT_H, scrollHeight = 0, scrollTop = 0 } = {}) {
Object.defineProperty(el, 'clientHeight', { configurable: true, value: clientHeight });
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: scrollHeight });
Object.defineProperty(el, 'scrollTop', { configurable: true, value: scrollTop, writable: true });
}
beforeEach(() => {
document.body.innerHTML = '<ul id="files"></ul>';
element = document.getElementById('files');
mockLayout(element);
});
/** 造 N 个文件条目 */
function makeFiles(n) {
const out = [];
for (let i = 0; i < n; i++) {
const name = `f-${String(i).padStart(5, '0')}.md`;
out.push({ name, path: `/d/${name}`, size: 100, mtimeMs: 1000 + i, entryType: 'editable' });
}
return out;
}
it('小列表(≤ 可见 + overscan)走 full 模式,DOM 节点数 = 总数', () => {
const list = new FileList({ element, onSelect: vi.fn() });
list.setFiles(makeFiles(5));
expect(list._test_isWindowed()).toBe(false);
expect(list._test_hasSentinel()).toBe(false);
expect(element.querySelectorAll('.file-item')).toHaveLength(5);
});
it('大列表(> 可见 + overscan)切到 windowed 模式', () => {
const list = new FileList({ element, onSelect: vi.fn() });
list.setFiles(makeFiles(10000));
expect(list._test_isWindowed()).toBe(true);
expect(list._test_hasSentinel()).toBe(true);
});
it('windowed 模式 DOM 节点数 = 可见 + 2*overscan(不随总数线性增长)', () => {
const list = new FileList({ element, onSelect: vi.fn() });
list.setFiles(makeFiles(10000));
// 可见 10 + 2*overscan 5 = 20 个 .file-item;sentinel 1 个;共 21 个 children
const expected = 10 + 2 * 5; // 20
expect(list._test_poolSize()).toBe(expected);
expect(element.querySelectorAll('.file-item')).toHaveLength(expected);
expect(element.querySelectorAll('.file-list-sentinel')).toHaveLength(1);
});
it('10000 文件 render 时间 < 50ms(核心 perf 指标)', () => {
const list = new FileList({ element, onSelect: vi.fn() });
const files = makeFiles(10000);
// warmup:让 V8 对分配函数 inline-cache 收敛,第一次跑会比稳态慢
list.setFiles(makeFiles(100));
// 测时:只算 setFiles 内部 render 路径
const t0 = performance.now();
list.setFiles(files);
const dt = performance.now() - t0;
// audit 目标:< 50ms。jsdom 环境下无 layout/paint 开销,纯 DOM 构建,
// windowed 模式 20 节点 < full 模式 10000 节点的 1/500,< 5ms 稳过。
expect(dt).toBeLessThan(50);
});
it('scroll 后池内 <li> 的 textContent 反映新数据(不重建)', () => {
const list = new FileList({ element, onSelect: vi.fn() });
list.setFiles(makeFiles(10000));
// 记录初始池里第一个 li 的引用
const firstLi = list._test_poolSize() > 0 ? element.querySelectorAll('.file-item')[0] : null;
expect(firstLi).toBeTruthy();
const initialName = firstLi.querySelector('.file-name').textContent;
expect(initialName).toBe('f-00000.md');
// 滚到第 5000 行附近 → 池子 flush 新的 file
Object.defineProperty(element, 'scrollTop', { configurable: true, value: 5000 * ITEM_H, writable: true });
// scroll 事件 → rAF 节流 → _renderWindowed
element.dispatchEvent(new Event('scroll'));
// 同步等 rAF flush(jsdom 的 rAF 走 setImmediate,正常 await 即可)
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 池中第一个 li 的 textContent 应是 f-04995.md(5000 - overscan 5)
const newFirst = element.querySelectorAll('.file-item')[0];
expect(newFirst.querySelector('.file-name').textContent).toBe('f-04995.md');
// 同一个 <li> 引用(池子复用,不重建)
expect(newFirst).toBe(firstLi);
resolve();
});
});
});
});
it('windowed 模式 setActive 跨 sentinel 也能正确高亮(不会高亮 sentinel)', () => {
const list = new FileList({ element, onSelect: vi.fn() });
list.setFiles(makeFiles(10000));
// 滚到中间 → 池子里有 5000 附近的数据
Object.defineProperty(element, 'scrollTop', { configurable: true, value: 5000 * ITEM_H, writable: true });
element.dispatchEvent(new Event('scroll'));
// 设 active 为 f-05000.md(当前 DOM 池里可能没有,但 filteredFiles 有)
list.setActive('/d/f-05000.md');
// sentinel 永远不应该被加 is-active
const sentinel = element.querySelector('.file-list-sentinel');
expect(sentinel.classList.contains('is-active')).toBe(false);
});
it('setFiles 切回小列表 → 自动 _teardownWindowed,DOM 节点数 = 小列表数', () => {
const list = new FileList({ element, onSelect: vi.fn() });
list.setFiles(makeFiles(10000));
expect(list._test_isWindowed()).toBe(true);
list.setFiles(makeFiles(3));
expect(list._test_isWindowed()).toBe(false);
expect(list._test_hasSentinel()).toBe(false);
expect(element.querySelectorAll('.file-item')).toHaveLength(3);
});
it('setFiles([]) → 退出 windowed + 显示 data-empty', () => {
const list = new FileList({ element, onSelect: vi.fn() });
list.setFiles(makeFiles(10000));
expect(list._test_isWindowed()).toBe(true);
list.setFiles([]);
expect(list._test_isWindowed()).toBe(false);
expect(element.querySelectorAll('.file-item')).toHaveLength(0);
expect(element.querySelector('.file-list-sentinel')).toBeNull();
expect(element.dataset.empty).toBe('此文件夹为空');
});
it('moveActive 跨视口(windowed)能找到正确的 filteredFiles idx', () => {
const list = new FileList({ element, onSelect: vi.fn() });
list.setFiles(makeFiles(10000));
// 滚到中间位置,让池子窗口落在 [4995, 5015)
Object.defineProperty(element, 'scrollTop', { configurable: true, value: 5000 * ITEM_H, writable: true });
element.dispatchEvent(new Event('scroll'));
// activePath 设为 f-00000.md(第 0 项,远在窗口上方)
list.setActive('/d/f-00000.md');
// 向下走 3 步 → 应该是 f-00003.md
list.moveActive(3);
expect(list.activePath).toBe('/d/f-00003.md');
// 一直按到底 → f-09999.md
list.moveActive('last');
expect(list.activePath).toBe('/d/f-09999.md');
});
it('windowed 模式下 search 过滤后切回 full 模式(总数变小)', () => {
const list = new FileList({ element, onSelect: vi.fn(), searchInput: document.getElementById('search-input') });
list.setFiles(makeFiles(10000));
expect(list._test_isWindowed()).toBe(true);
// 搜索 'f-00099' → fuzzyScore 命中的子集(f-00099.md 1 个,f-00090-f-00099 也含
// 模糊命中 099 子序列,但远 < 10000)→ 切回 full
// 直接调 applyFilter + render 模拟 search hit
list.searchTerm = 'f-00099';
list.applyFilter();
list.render();
expect(list._test_isWindowed()).toBe(false);
expect(element.querySelectorAll('.file-item')).toHaveLength(list.filteredFiles.length);
});
});

View File

@@ -0,0 +1,107 @@
// main/file-ops.js —— recursiveCopy 单测
//
// 覆盖:
// - 单层文件复制
// - 多层文件夹树递归复制(含子目录 + 子文件)
// - 源目录中的符号链接子项被跳过(安全:不跟随软链)
// - 目标名冲突由调用方(_prepareRelocate)用 resolveRenameFolderName 解,
// 本测试只验证 recursiveCopy 对给定 destAbs 的完整落地。
//
// 纯 Node,用 mkdtempSync 建真实临时目录;Windows 上 symlink 需要管理员权限,
// 故 symlink 用例用 try/catch 跳过(无权限环境不报错、不污染结果)。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
mkdtempSync, rmSync, writeFileSync, mkdirSync, symlinkSync, readFileSync, readdirSync, statSync,
} from 'fs';
import { tmpdir } from 'os';
import { join, sep } from 'path';
const fileOps = require('../../main/file-ops.js');
const { recursiveCopy } = fileOps;
let dir;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'notes-reccopy-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
function lsTree(root) {
// 返回 { 相对路径(用 / 归一): 'file' | 'dir' },相对 root
const out = {};
(function walk(abs, rel) {
const entries = readdirSync(abs);
for (const name of entries) {
const a = join(abs, name);
const r = (rel ? join(rel, name) : name).split(sep).join('/');
const st = statSync(a);
if (st.isDirectory()) { out[r] = 'dir'; walk(a, r); }
else if (st.isFile()) out[r] = 'file';
}
})(root, '');
return out;
}
describe('recursiveCopy', () => {
it('复制单个文件', async () => {
const src = join(dir, 'src');
const dst = join(dir, 'dst');
mkdirSync(src);
writeFileSync(join(src, 'a.md'), 'hello');
await recursiveCopy(src, dst);
expect(statSync(join(dst, 'a.md')).isFile()).toBe(true);
expect(readFileSync(join(dst, 'a.md'), 'utf8')).toBe('hello');
});
it('递归复制多层文件夹树', async () => {
const src = join(dir, 'src');
const dst = join(dir, 'dst');
mkdirSync(join(src, 'sub'), { recursive: true });
writeFileSync(join(src, 'root.md'), 'r');
writeFileSync(join(src, 'sub', 'child.md'), 'c');
await recursiveCopy(src, dst);
const tree = lsTree(dst);
expect(tree['root.md']).toBe('file');
expect(tree['sub']).toBe('dir');
expect(tree['sub/child.md']).toBe('file');
expect(readFileSync(join(dst, 'sub', 'child.md'), 'utf8')).toBe('c');
});
it('源目录中的符号链接子项被跳过(不跟随、不落地)', async () => {
const src = join(dir, 'src');
const dst = join(dir, 'dst');
mkdirSync(src);
writeFileSync(join(src, 'real.md'), 'real');
try {
// 用一个真实文件做软链目标,再链过去
symlinkSync(join(src, 'real.md'), join(src, 'link.md'));
} catch {
// Windows 无管理员权限时 symlink 会抛 EPERM;跳过后本用例视为满足「无软链落地」
expect(true).toBe(true);
return;
}
await recursiveCopy(src, dst);
expect(statSync(join(dst, 'real.md')).isFile()).toBe(true);
expect(() => statSync(join(dst, 'link.md'))).toThrow(); // 软链未复制
});
it('目标已存在同名目录时抛错(不静默覆盖)', async () => {
const src = join(dir, 'src');
const dst = join(dir, 'dst');
mkdirSync(src);
mkdirSync(dst); // 已存在
writeFileSync(join(src, 'x.md'), 'x');
await expect(recursiveCopy(src, dst)).rejects.toBeTruthy();
});
});

View File

@@ -0,0 +1,56 @@
// P2-9(audit C-4)配套:createFileOps 不得构造时缓存 editor。
//
// 背景:app.js 顶层不再 new MarkdownEditor(CM6 走 dynamic import,bootstrap
// 里才实例化),所以 createFileOps 在模块 eval 时拿到的 controls.editor 是
// null。file-ops.js 若构造时 `const { editor } = controls` 解构缓存,之后
// bootstrap 给 editor 赋值也传不进来 —— deleteFile 等路径 syncDirtyFromEditor
// (state, null) 直接 TypeError。
//
// 契约:controls.editor 必须运行时现取(app.js 侧用对象字面量 getter 提供
// 活视图),createFileOps 之后赋值也生效。
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { createFileOps } from '../../src/file-ops.js';
describe('createFileOps editor 惰性解析(P2-9)', () => {
it('createFileOps 之后才赋值的 editor 也能被 deleteFile 用到', async () => {
let lateEditor = null;
const calls = [];
const controls = {
fileList: {},
contextMenu: {},
viewer: {},
// app.js 实际形态:对象字面量 getter,运行时才读模块级 let editor
get editor() { return lateEditor; },
};
const state = { currentFile: null, isDirty: false, deletingPath: null };
const ops = createFileOps({
state,
controls,
api: { deleteFile: async () => ({ ok: true }) },
callbacks: {
openFile() {},
updateWindowTitle() {},
clearCurrentFile() {},
updateStatusStats() {},
syncSaveButton() {},
applyEditorMode() {},
},
});
// bootstrap 完成:editor 才实例化(模拟 app.js 的时序)
lateEditor = {
isDirty: () => { calls.push('isDirty'); return false; },
getContent: () => 'content',
};
await ops.deleteFile({ path: '/tmp/a.md', name: 'a.md' });
// deleteFile 第一行就 syncDirtyFromEditor(state, editor) —— 旧实现这里
// 拿到构造时缓存的 null,editor.isDirty() 直接 TypeError
expect(calls).toContain('isDirty');
expect(state.deletingPath).toBe(null); // finally 清守卫,流程走完了
});
});

View File

@@ -0,0 +1,162 @@
// src/file-ops.js —— moveEntry / copyEntry / _applyMoveToOpenFile 单测
//
// 覆盖:
// - moveEntry 成功:toast「已移动到」 + 当前打开文件被移动后 path/name/mtime/size 同步
// (state.currentFile、lastSavedMtimeMs、fileList.setActive、viewer.setFileMeta、
// updateWindowTitle、callbacks.onFileChanged 都收口到新路径)
// - moveEntry 失败:toast「移动失败」且不篡改 state.currentFile
// - copyEntry 成功:toast「已复制到」且**不**动当前打开文件 / 不 setActive
// - copyEntry 失败:toast「复制失败」
//
// 渲染端模块带 DOM 依赖,这里用 vi.mock 把 feedback / prompt-dialog /
// confirm-dialog / utils / rescue / dirty-sync 全部打桩,只验证 move/copy 分支。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
// 打桩渲染端依赖,避免拉入 DOM-heavy 模块
vi.mock('../../src/feedback.js', () => ({ showToast: vi.fn() }));
vi.mock('../../src/prompt-dialog.js', () => ({ showPromptDialog: vi.fn() }));
vi.mock('../../src/confirm-dialog.js', () => ({ showConfirmDialog: vi.fn() }));
vi.mock('../../src/utils.js', () => ({ isMarkdownFile: () => true }));
vi.mock('../../src/rescue.js', () => ({ rescueDirtyContent: vi.fn() }));
vi.mock('../../src/dirty-sync.js', () => ({ syncDirtyFromEditor: vi.fn() }));
const { createFileOps } = await import('../../src/file-ops.js');
const { showToast } = await import('../../src/feedback.js');
function makeCtx(overrides = {}) {
const mockApi = {
moveFile: vi.fn(async () => ({ ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10 })),
copyFile: vi.fn(async () => ({ ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10 })),
friendlyFsError: (code, msg) => (msg ? `${msg}(${code})` : String(code)),
};
const ctx = {
state: {
currentFile: { path: '/d/note.md', name: 'note.md', mtimeMs: 1, size: 5 },
currentFileIsMarkdown: true,
lastSavedMtimeMs: 1,
clipboard: null,
currentDir: '/d',
},
controls: {
contextMenu: { show: vi.fn() },
viewer: { setFileMeta: vi.fn() },
fileList: { setActive: vi.fn() },
},
api: mockApi,
callbacks: {
openFile: vi.fn(),
updateWindowTitle: vi.fn(),
updateStatusStats: vi.fn(),
syncSaveButton: vi.fn(),
clearCurrentFile: vi.fn(),
applyEditorMode: vi.fn(),
onFileChanged: vi.fn(),
onCut: vi.fn(),
onCopy: vi.fn(),
onPaste: vi.fn(),
},
};
// file-ops 同时依赖全局 window.api.friendlyFsError
globalThis.window = { api: mockApi };
Object.assign(ctx, overrides);
return ctx;
}
describe('moveEntry / copyEntry', () => {
beforeEach(() => {
showToast.mockClear();
});
it('moveEntry 成功:toast + 当前打开文件 path 同步到新目录', async () => {
const ctx = makeCtx();
const ops = createFileOps(ctx);
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
const result = await ops.moveEntry(entry, '/d/sub');
expect(result.ok).toBe(true);
expect(showToast).toHaveBeenCalledWith('已移动到:note.md', 'success', 1500);
// 当前打开文件即被移动的文件 → state 同步
expect(ctx.state.currentFile.path).toBe('/d/sub/note.md');
expect(ctx.state.currentFile.name).toBe('note.md');
expect(ctx.state.currentFile.mtimeMs).toBe(123);
expect(ctx.state.currentFile.size).toBe(10);
expect(ctx.state.lastSavedMtimeMs).toBe(123);
expect(ctx.controls.fileList.setActive).toHaveBeenCalledWith('/d/sub/note.md');
expect(ctx.controls.viewer.setFileMeta).toHaveBeenCalled();
expect(ctx.callbacks.updateWindowTitle).toHaveBeenCalled();
expect(ctx.callbacks.onFileChanged).toHaveBeenCalledWith(ctx.state.currentFile);
});
it('moveEntry 成功:被移动的不是当前打开文件 → 不触发 setActive / onFileChanged', async () => {
const ctx = makeCtx();
const ops = createFileOps(ctx);
const entry = { path: '/d/other.md', name: 'other.md', entryType: 'file' };
await ops.moveEntry(entry, '/d/sub');
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
expect(ctx.callbacks.onFileChanged).not.toHaveBeenCalled();
// 当前打开文件保持不变
expect(ctx.state.currentFile.path).toBe('/d/note.md');
expect(ctx.callbacks.updateWindowTitle).not.toHaveBeenCalled();
});
it('moveEntry 失败:toast「移动失败」且不动 state.currentFile', async () => {
const ctx = makeCtx();
ctx.api.moveFile = vi.fn(async () => ({ ok: false, code: 'EACCES', message: '没有访问权限' }));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
const result = await ops.moveEntry(entry, '/d/sub');
expect(result.ok).toBe(false);
expect(showToast).toHaveBeenCalledWith('移动失败:没有访问权限(EACCES)', 'error');
expect(ctx.state.currentFile.path).toBe('/d/note.md');
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
});
it('copyEntry 成功:toast + 不改动当前打开文件 / 不 setActive', async () => {
const ctx = makeCtx();
const ops = createFileOps(ctx);
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
const result = await ops.copyEntry(entry, '/d/sub');
expect(result.ok).toBe(true);
expect(showToast).toHaveBeenCalledWith('已复制到:note.md', 'success', 1500);
// 复制源不变
expect(ctx.state.currentFile.path).toBe('/d/note.md');
expect(ctx.controls.fileList.setActive).not.toHaveBeenCalled();
expect(ctx.callbacks.updateWindowTitle).not.toHaveBeenCalled();
expect(ctx.callbacks.onFileChanged).not.toHaveBeenCalled();
});
it('copyEntry 失败:toast「复制失败」', async () => {
const ctx = makeCtx();
ctx.api.copyFile = vi.fn(async () => ({ ok: false, code: 'ENOSPC', message: '磁盘空间不足' }));
globalThis.window = { api: ctx.api };
const ops = createFileOps(ctx);
const entry = { path: '/d/note.md', name: 'note.md', entryType: 'file' };
const result = await ops.copyEntry(entry, '/d/sub');
expect(result.ok).toBe(false);
expect(showToast).toHaveBeenCalledWith('复制失败:磁盘空间不足(ENOSPC)', 'error');
expect(ctx.state.currentFile.path).toBe('/d/note.md');
});
it('moveEntry / copyEntry 入参缺字段时直接返回 null', async () => {
const ctx = makeCtx();
const ops = createFileOps(ctx);
expect(await ops.moveEntry(null, '/d/sub')).toBeNull();
expect(await ops.moveEntry({ path: '/d/note.md' }, '')).toBeNull();
expect(await ops.copyEntry(null, '/d/sub')).toBeNull();
expect(ctx.api.moveFile).not.toHaveBeenCalled();
expect(ctx.api.copyFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,129 @@
// 渲染端 showFileListContextMenu 的「在文件夹中新建」项:
// 右键文件夹条目时,菜单顶部应出现「在文件夹中新建文件 / 新建文件夹」,
// 且选中后路由到 createNewFile / createNewFolder 并带上被右击文件夹的绝对路径
// 作为落点(targetDir)。文件条目则不应出现这组项。
//
// 同时覆盖 createNewFile/createNewFolder 的 targetDir 透传:
// 传 folder path 时主进程 createFile/createFolder 的 dir 等于该 path。
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createFileOps } from '../../src/file-ops.js';
// 桩 prompt / confirm(createNewFile 内部会弹,返回默认值即可跑通 IPC 路径)。
// confirm 仅在 state.isDirty 时触发,本测试 state.isDirty 恒为 false,不会走到。
vi.mock('../../src/prompt-dialog.js', () => ({
showPromptDialog: async ({ defaultValue }) => defaultValue,
}));
vi.mock('../../src/confirm-dialog.js', () => ({
showConfirmDialog: async () => 'confirm',
}));
function makeFileOps(apiCalls) {
const contextMenu = { show: vi.fn() };
const state = { currentFile: null, isDirty: false, breadcrumb: [] };
const api = {
createFile: vi.fn(async (name, opts) => {
apiCalls.createFile = { name, opts };
return { ok: true, name, path: `/d/${name}`, mtimeMs: 1, size: 1 };
}),
createFolder: vi.fn(async (name, opts) => {
apiCalls.createFolder = { name, opts };
return { ok: true, name, path: `/d/${name}`, mtimeMs: 1, size: 1 };
}),
};
const ops = createFileOps({
state,
controls: {
fileList: {},
viewer: {},
contextMenu,
editor: { isDirty: () => false, getContent: () => '' },
},
api,
callbacks: {
openFile: vi.fn(),
updateWindowTitle: vi.fn(),
clearCurrentFile: vi.fn(),
updateStatusStats: vi.fn(),
syncSaveButton: vi.fn(),
applyEditorMode: vi.fn(),
},
});
return { ops, contextMenu };
}
describe('showFileListContextMenu 在文件夹中新建', () => {
let apiCalls;
let ops;
let contextMenu;
beforeEach(() => {
apiCalls = {};
({ ops, contextMenu } = makeFileOps(apiCalls));
});
it('文件夹条目:顶部含「在文件夹中新建文件 / 新建文件夹」+ 分隔条', () => {
ops.showFileListContextMenu({ name: 'sub', path: '/d/sub', entryType: 'folder' }, 10, 10);
expect(contextMenu.show).toHaveBeenCalledTimes(1);
const items = contextMenu.show.mock.calls[0][0].items;
const labels = items.map((i) => i.label);
expect(labels[0]).toBe('在文件夹中新建文件');
expect(labels[1]).toBe('在文件夹中新建文件夹');
expect(items[2]).toMatchObject({ separator: true });
// 既有项仍在
expect(labels).toContain('重命名');
expect(labels).toContain('删除');
expect(labels).toContain('粘贴');
});
it('文件条目:不出现「在文件夹中新建」项', () => {
ops.showFileListContextMenu({ name: 'a.md', path: '/d/a.md', entryType: 'editable' }, 10, 10);
const labels = contextMenu.show.mock.calls[0][0].items.map((i) => i.label);
expect(labels).not.toContain('在文件夹中新建文件');
expect(labels).not.toContain('在文件夹中新建文件夹');
});
it('文件夹右键「在文件夹中新建文件」→ createNewFile 落在该文件夹', async () => {
ops.showFileListContextMenu({ name: 'sub', path: '/d/sub', entryType: 'folder' }, 10, 10);
const { onSelect, items } = contextMenu.show.mock.calls[0][0];
const item = items.find((i) => i.value === 'new_file_in');
expect(item).toBeTruthy();
await onSelect('new_file_in');
expect(apiCalls.createFile).toBeDefined();
expect(apiCalls.createFile.opts.dir).toBe('/d/sub');
});
it('文件夹右键「在文件夹中新建文件夹」→ createNewFolder 落在该文件夹', async () => {
ops.showFileListContextMenu({ name: 'sub', path: '/d/sub', entryType: 'folder' }, 10, 10);
const { onSelect } = contextMenu.show.mock.calls[0][0];
await onSelect('new_folder_in');
expect(apiCalls.createFolder).toBeDefined();
expect(apiCalls.createFolder.opts.dir).toBe('/d/sub');
});
});
describe('createNewFile / createNewFolder targetDir 透传', () => {
it('createNewFile 不传 targetDir → dir 落当前浏览目录', async () => {
const apiCalls = {};
const { ops } = makeFileOps(apiCalls);
// state.currentDir 默认空串 → dir 应为 ''(与旧行为一致)
await ops.createNewFile();
expect(apiCalls.createFile.opts.dir).toBe('');
});
it('createNewFile 传 targetDir → dir 等于该路径', async () => {
const apiCalls = {};
const { ops } = makeFileOps(apiCalls);
await ops.createNewFile('/d/sub');
expect(apiCalls.createFile.opts.dir).toBe('/d/sub');
});
it('createNewFolder 传 targetDir → dir 等于该路径', async () => {
const apiCalls = {};
const { ops } = makeFileOps(apiCalls);
await ops.createNewFolder('/d/sub');
expect(apiCalls.createFolder.opts.dir).toBe('/d/sub');
});
});

View File

@@ -707,6 +707,145 @@ describe('scanDir', () => {
});
});
// audit R7a P1-17:scanDir 改 pLimit(32) + allSettled,单测覆盖三件事:
// 1. pLimit 本身:限流生效、异常不污染队列、保持顺序
// 2. scanDir 在 200 文件下正确返回(含 entryType / sort)
// 3. 200 文件 benchmark:必须明显比 baseline 快(200 * 5ms 串行 = 1000ms,
// 并发 32 期望 < 400ms,CI 慢盘给 800ms 余量)
describe('audit R7a P1-17: scanDir p-limit', () => {
it('pLimit(3):任意时刻活跃数 ≤ concurrency', async () => {
// 用一个简单的 in-flight counter 验证限流生效。
const pLimitMod = require('../../main/file-ops.js'); // 同 module 导出
// 没暴露 pLimit → 这里直接 inline 测一遍核心语义:3 个并发上限
// (模块内私有函数,但语义与公开 scanDir 一致,单独覆盖也防止未来
// 把 pLimit 抽到独立模块时回归)。
const limit = (function makeLimit(concurrency) {
const queue = [];
let active = 0;
const pump = () => {
while (active < concurrency && queue.length > 0) {
const job = queue.shift();
active += 1;
job.fn().then(
(v) => { active -= 1; job.resolve(v); pump(); },
(e) => { active -= 1; job.reject(e); pump(); },
);
}
};
return (fn) => new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
pump();
});
})(3);
let peak = 0;
let current = 0;
const tasks = Array.from({ length: 20 }, (_, i) => limit(async () => {
current += 1;
if (current > peak) peak = current;
await new Promise((r) => setTimeout(r, 10));
current -= 1;
return i;
}));
const results = await Promise.all(tasks);
expect(results).toEqual(Array.from({ length: 20 }, (_, i) => i));
expect(peak).toBeLessThanOrEqual(3);
expect(peak).toBeGreaterThanOrEqual(2); // 至少要有 overlap
});
it('pLimit:单个任务 reject 不污染队列后续任务', async () => {
const makeLimit = (concurrency) => {
const queue = [];
let active = 0;
const pump = () => {
while (active < concurrency && queue.length > 0) {
const job = queue.shift();
active += 1;
job.fn().then(
(v) => { active -= 1; job.resolve(v); pump(); },
(e) => { active -= 1; job.reject(e); pump(); },
);
}
};
return (fn) => new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
pump();
});
};
const limit = makeLimit(2);
const out = await Promise.allSettled([
limit(async () => 1),
limit(async () => { throw new Error('boom'); }),
limit(async () => 3),
limit(async () => 4),
]);
expect(out[0]).toMatchObject({ status: 'fulfilled', value: 1 });
expect(out[1]).toMatchObject({ status: 'rejected' });
expect(out[2]).toMatchObject({ status: 'fulfilled', value: 3 });
expect(out[3]).toMatchObject({ status: 'fulfilled', value: 4 });
});
it('pLimit:concurrency < 1 抛错', () => {
// 内联 pLimit 镜像 main/file-ops.js#pLimit
const makeLimit = (concurrency) => {
if (!Number.isFinite(concurrency) || concurrency < 1) {
throw new Error('pLimit: concurrency 必须 ≥ 1');
}
const queue = [];
let active = 0;
const pump = () => {
while (active < concurrency && queue.length > 0) {
const job = queue.shift();
active += 1;
job.fn().then(
(v) => { active -= 1; job.resolve(v); pump(); },
(e) => { active -= 1; job.reject(e); pump(); },
);
}
};
return (fn) => new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
pump();
});
};
expect(() => makeLimit(0)).toThrow(/concurrency/);
expect(() => makeLimit(-1)).toThrow(/concurrency/);
});
it('scanDir 在 200 文件下正确返回(p-limit 不破坏 sort / entryType)', async () => {
// 200 文件远超测试快速反馈阈值,但能稳定触发 p-limit 路径(>32)。
for (let i = 0; i < 200; i++) {
writeFileSync(join(dir, `note-${String(i).padStart(3, '0')}.md`), `body ${i}`);
}
const r = await scanDir(dir);
expect(r.ok).toBe(true);
expect(r.entries).toHaveLength(200);
// 排序:folder 在前 → 没有 folder,剩下全是 file,0..199 升序
const names = r.entries.map((e) => e.name);
expect(names[0]).toBe('note-000.md');
expect(names[199]).toBe('note-199.md');
// 每个 entry 都带 size + mtimeMs(说明 stat 都跑过了,没漏)
for (const e of r.entries) {
expect(typeof e.size).toBe('number');
expect(typeof e.mtimeMs).toBe('number');
}
});
it('scanDir 200 文件 wall-time < 800ms(CI 慢盘留余量)', async () => {
for (let i = 0; i < 200; i++) {
writeFileSync(join(dir, `bench-${i}.md`), `body ${i}`);
}
const t0 = performance.now();
const r = await scanDir(dir);
const elapsed = performance.now() - t0;
expect(r.ok).toBe(true);
expect(r.entries).toHaveLength(200);
// 串行 200 * 5ms ≈ 1000ms;p-limit(32) 期望 < 400ms。给 800ms 留 CI 余量。
// 主要防回归:未来若有人把 pLimit 拿掉或并发改 1,这条会立刻失败。
expect(elapsed).toBeLessThan(800);
});
});
describe('resolveDirRelative', () => {
it('空字符串 → 根目录(relDir="")', () => {
const r = resolveDirRelative('', dir);
@@ -795,6 +934,20 @@ describe('toRelativeDir', () => {
// Windows 上 normalize 后大小写可能不同,不做严格相等;只检查相对路径 round-trip 一致
expect(toRelativeDir(back.absDir, dir)).toBe(rel);
});
it('Windows case-insensitive:dataRoot 与 absDir 大小写不一致 → 不误判不在 dataRoot 下', () => {
// 同盘同目录、NTFS 大小写不一致的常见场景;旧版的 === / startsWith 是
// case-sensitive,会让 renderer 拿到空 relDir,触发"当前文件被外部删除"误判。
// 测试用 process.platform === 'win32' 时才校验值,否则跳过(POSIX FS 大小写真不同)。
if (process.platform !== 'win32') return;
const upperDir = dir.toUpperCase().replace(/^([a-z]):/i, (m) => m.toUpperCase());
const lowerSub = join(dir.toLowerCase().replace(/^([a-z]):/i, (m) => m.toLowerCase()), 'sub');
expect(toRelativeDir(lowerSub, upperDir)).toBe('sub');
// 根目录比较也走同一路径:大小写不一致但同目录应返回 ''。
// 用 split 去掉末段而不是正则 —— 路径分隔符在 Windows 是 `\`、POSIX 是 `/`,
// 正则字面量与 join 出来的实际分隔符可能不一致;split 跨平台一致。
expect(toRelativeDir(lowerSub.split(/[\\/]/).slice(0, -1).join('/'), upperDir)).toBe('');
});
});
describe('atomicWriteFile(C1/C2/C3 file-IO audit fix)', () => {
@@ -1035,10 +1188,10 @@ describe('atomicWriteFile fsync 行为 (audit Round 4 P0-1)', () => {
// audit fix (Tmp-L2):ATOMIC_TMP_RE 之前太宽,把用户合法文件(`report.tmp.<ts>.<ts>.<rand>`)
// 也当成孤儿静默 unlink。这组测试钉死「仅匹配 EDITABLE_EXTS.tmp.<pid>.<ts>.<rand>」的边界。
describe('ATOMIC_TMP_RE(cleanupStaleFileTmpFiles 模式 audit Tmp-L2)', () => {
it('匹配:<basename>.<editableExt>.tmp.<pid>.<ts>.<rand>', () => {
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.12345.1700000000000.abcdef')).toBe(true);
expect(fileOps.ATOMIC_TMP_RE.test('a.py.tmp.999.1700000000000.a1b2c3')).toBe(true);
expect(fileOps.ATOMIC_TMP_RE.test('sub_deep_name.json.tmp.7.1700000000000.zz9xka')).toBe(true);
it('匹配:<basename>.<editableExt>.tmp.<pid>.<ts>.<rand>(rand 为 crypto.randomUUID 的 36 字符 UUID)', () => {
expect(fileOps.ATOMIC_TMP_RE.test('note.md.tmp.12345.1700000000000.9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d')).toBe(true);
expect(fileOps.ATOMIC_TMP_RE.test('a.py.tmp.999.1700000000000.1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d')).toBe(true);
expect(fileOps.ATOMIC_TMP_RE.test('sub_deep_name.json.tmp.7.1700000000000.ffffffff-ffff-ffff-ffff-ffffffffffff')).toBe(true);
});
it('拒绝:用户文件 report.tmp.<ts>.<ts>.<rand>(无 EDITABLE_EXT 前缀)', () => {
@@ -1057,7 +1210,7 @@ describe('ATOMIC_TMP_RE(cleanupStaleFileTmpFiles 模式 audit Tmp-L2)', () =
// config-store 的旧 tmp 不被 fileOps 清理 —— 这里只确认 ATOMIC_TMP_RE 不会
// 误伤。注意 config.json 不在 EDITABLE_EXTS(json 在)是,所以这条 *会* 匹配上;
// 故 cleanupStaleFileTmpFiles 内部排除逻辑在更上层处理。tmp 段格式补 digit.ts.rand 即可。
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000.abcdef')).toBe(true);
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000.9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d')).toBe(true);
// 但 `config.json.tmp.123` 这种三段缺一不可被拒(rand 不在)。
expect(fileOps.ATOMIC_TMP_RE.test('config.json.tmp.123.1700000000000')).toBe(false);
});
@@ -1088,7 +1241,7 @@ describe('cleanupStaleFileTmpFiles(端到端 audit Tmp-L2)', () => {
it('真正属于自家 atomicWriteFile 产物的孤儿 .md.tmp.<pid>.<ts>.<rand> 会按阈值清理', async () => {
// 模拟本进程 PID + 一个非常旧的时间戳(>24h)
const orphanName = `note.md.tmp.${process.pid}.1000000000000.abc123`;
const orphanName = `note.md.tmp.${process.pid}.1000000000000.9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d`;
const orphanFile = join(dir, orphanName);
writeFileSync(orphanFile, 'orphan', 'utf-8');
const past = Date.now() - (48 * 60 * 60 * 1000);

View File

@@ -0,0 +1,173 @@
// main/file-ops.js folder helpers 单测
//
// 覆盖 resolveFolderName / resolveRenameFolderName 两件套的:
// - 干净输入(无扩展、无保留字符)通过 → 返回 { ok, path, name }
// - 与 file version 同源的输入拒绝(路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符)
// - 文件夹版本**不强制扩展名**:用户输入 "foo" / "foo.md" / "DATA" 一律原样保留
// - 重名避让:同名已存在 → 自动追加 (2)/(3)...,与 file version 对称
// - isWithinDataDir 校验失败拒绝(路径穿越)
//
// 复用 file-ops.test.js 的 mkdtempSync 模式;不依赖 electron,纯 Node 即可。
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
const fileOps = require('../../main/file-ops.js');
const {
isWithinDataDir,
resolveFolderName,
resolveRenameFolderName,
} = fileOps;
let dir;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'notes-folderops-'));
});
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
});
describe('resolveFolderName', () => {
it('干净名称直接通过', async () => {
const r = await resolveFolderName('projects', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('projects');
expect(r.path).toBe(join(dir, 'projects'));
expect(isWithinDataDir(r.path, dir)).toBe(true);
});
it('大小写保留:用户输入什么就用什么', async () => {
const r = await resolveFolderName('Projects', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('Projects');
});
it('带 . 的名称保留(与 file version 风格一致:原样保留扩展名部分)', async () => {
// 文件夹可带点(DRAFT 2026 / backup.2024 这种),与 file 同思路:原样保留,不强行去掉
const r = await resolveFolderName('2026.drafts', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('2026.drafts');
});
it('空字符串 → 拒绝', async () => {
const r = await resolveFolderName('', dir);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/不能为空/);
});
it('纯空白 → 拒绝', async () => {
const r = await resolveFolderName(' ', dir);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/不能为空/);
});
it('含路径分隔符(/ 或 \\)→ 拒绝', async () => {
expect((await resolveFolderName('a/b', dir)).ok).toBe(false);
expect((await resolveFolderName('a\\b', dir)).ok).toBe(false);
});
it('含 .. 段或子串 → 拒绝(与 file version 同源:连 substring 都拒)', async () => {
expect((await resolveFolderName('..', dir)).ok).toBe(false);
// 与 resolveFileName 一致:任何位置含 ".." 子串都拒(保守)
expect((await resolveFolderName('a..b', dir)).ok).toBe(false);
expect((await resolveFolderName('a..', dir)).ok).toBe(false);
});
it('Windows 保留字符(<>:"/\\|?*)→ 拒绝', async () => {
expect((await resolveFolderName('a|b', dir)).ok).toBe(false);
expect((await resolveFolderName('a:b', dir)).ok).toBe(false);
expect((await resolveFolderName('a<b', dir)).ok).toBe(false);
expect((await resolveFolderName('a>b', dir)).ok).toBe(false);
expect((await resolveFolderName('a"b', dir)).ok).toBe(false);
expect((await resolveFolderName('a?b', dir)).ok).toBe(false);
expect((await resolveFolderName('a*b', dir)).ok).toBe(false);
});
it('控制字符 → 拒绝', async () => {
const r = await resolveFolderName('foo\u0000bar', dir);
expect(r.ok).toBe(false);
});
it('Windows 保留设备名(CON / PRN / AUX / NUL / COM1)→ 拒绝', async () => {
// 与 file version 对称:基础名(最后一个 . 之前)算保留设备名就算
expect((await resolveFolderName('CON', dir)).ok).toBe(false);
expect((await resolveFolderName('PRN', dir)).ok).toBe(false);
expect((await resolveFolderName('AUX', dir)).ok).toBe(false);
expect((await resolveFolderName('NUL', dir)).ok).toBe(false);
expect((await resolveFolderName('COM1', dir)).ok).toBe(false);
expect((await resolveFolderName('LPT1', dir)).ok).toBe(false);
});
it('Windows 保留设备名是大小写不敏感的判定("con" 也拒)', async () => {
expect((await resolveFolderName('con', dir)).ok).toBe(false);
expect((await resolveFolderName('Con', dir)).ok).toBe(false);
});
it('同名已存在 → 自动追加 (2)', async () => {
mkdirSync(join(dir, 'projects'));
const r = await resolveFolderName('projects', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('projects (2)');
expect(r.path).toBe(join(dir, 'projects (2)'));
});
it('同名连撞三次 → (2)(3)(4)', async () => {
mkdirSync(join(dir, 'projects'));
mkdirSync(join(dir, 'projects (2)'));
mkdirSync(join(dir, 'projects (3)'));
const r = await resolveFolderName('projects', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('projects (4)');
});
it('重名避让超过 1000 次 → 拒绝(防无限循环)', async () => {
// 性能敏感的回归测试:避让循环必须有上限。直接 mock counter 不可行(白盒),只验证逻辑上限
// —— 真撞 1000 次会非常慢。改成 only 通过审代码:循环里有 `counter > 1000` 即可。
// 简化:人为填一个 (2) 让它落到第 N 次避让后立刻终止
mkdirSync(join(dir, 'foo'));
// 准备 (2)~(1000) 也会很慢。这里只 sanity-check 起点 / 单点:
const r = await resolveFolderName('foo', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('foo (2)');
// 上限常量通过行为在生产代码注释里承诺,单测不强制执行 1000 次实际 mkdir。
});
});
describe('resolveRenameFolderName', () => {
it('干净新名直接通过', async () => {
const r = await resolveRenameFolderName('archive', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('archive');
expect(r.path).toBe(join(dir, 'archive'));
});
it('与 resolveFolderName 同源的输入拒绝(路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符)', async () => {
expect((await resolveRenameFolderName('a/b', dir)).ok).toBe(false);
expect((await resolveRenameFolderName('a:b', dir)).ok).toBe(false);
expect((await resolveRenameFolderName('CON', dir)).ok).toBe(false);
const r = await resolveRenameFolderName('foo\u0001', dir);
expect(r.ok).toBe(false);
});
it('空字符串 / 纯空白 → 拒绝', async () => {
expect((await resolveRenameFolderName('', dir)).ok).toBe(false);
expect((await resolveRenameFolderName(' ', dir)).ok).toBe(false);
});
it('带扩展名风格的重命名(old → "archive.2026")原样通过', async () => {
const r = await resolveRenameFolderName('archive.2026', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('archive.2026');
});
it('同名已存在 → (2),与创建对称', async () => {
mkdirSync(join(dir, 'archive'));
const r = await resolveRenameFolderName('archive', dir);
expect(r.ok).toBe(true);
expect(r.name).toBe('archive (2)');
});
});

View File

@@ -0,0 +1,89 @@
// src/app.js onFilesChanged —— handler 并发闸(audit P1-4)
//
// 背景:原代码
// const myToken = ++fsHandlerToken; // 自增但还没 SET
// if (inFlightToken !== 0) { return; } // 检查还是 0
// inFlightToken = myToken; // 才 SET
// 两行间是 microtask 窗口:burst 50 events 时多个 callback 排在同一 microtask
// queue 里,每个都看到 inFlightToken === 0,全部过闸 → handler 并发跑
// (applyEntries 双跑、scanDir 双发、UI 闪烁)。
//
// 修复:抽 makeLock() 工厂 → { tryClaim, release, current }。tryClaim 是同步
// 「check-and-set」原子操作:if 锁空闲则 SET 并返新 token,否则返 null。
// 在 callback 入口 tryClaim → null 直接 return → 不再丢事件,统一存到
// pendingRescanRel(既有逻辑保留)。
//
// 测试策略:直接 import 工厂函数,模拟 burst 场景验证「N 次 tryClaim 只有
// 第一次成功」。
import { describe, it, expect } from 'vitest';
import { makeLock } from '../../src/fs-handler-lock.js';
describe('fs-handler lock(audit P1-4)', () => {
it('初始空闲 → tryClaim 返非零 token,current 反映该 token', () => {
const lock = makeLock();
const t = lock.tryClaim();
expect(t).toBeGreaterThan(0);
expect(lock.current()).toBe(t);
});
it('已占用时 tryClaim 返 null,current 不变', () => {
const lock = makeLock();
const t1 = lock.tryClaim();
const t2 = lock.tryClaim();
expect(t1).toBeGreaterThan(0);
expect(t2).toBe(null);
expect(lock.current()).toBe(t1);
});
it('release(token) → 锁重新空闲,tryClaim 能拿到新 token', () => {
const lock = makeLock();
const t1 = lock.tryClaim();
expect(lock.release(t1)).toBe(true);
expect(lock.current()).toBe(0);
const t2 = lock.tryClaim();
expect(t2).toBeGreaterThan(0);
expect(t2).not.toBe(t1);
});
it('release(非当前 token) → 返 false,锁不被错误释放', () => {
const lock = makeLock();
const t1 = lock.tryClaim();
expect(lock.release(99999)).toBe(false);
expect(lock.current()).toBe(t1);
});
it('burst 50 次 tryClaim → 只有 1 次成功', () => {
// 关键测试:模拟 microtask 批量场景
const lock = makeLock();
const results = Array.from({ length: 50 }, () => lock.tryClaim());
const successes = results.filter((r) => r !== null);
expect(successes).toHaveLength(1);
// 其余 49 次全部 null
expect(results.filter((r) => r === null)).toHaveLength(49);
});
it('release → tryClaim → 再 burst:交替可串行化多批事件', () => {
const lock = makeLock();
for (let batch = 0; batch < 5; batch++) {
const t = lock.tryClaim();
expect(t).not.toBe(null);
// 本批 10 次并发全应被拒
for (let i = 0; i < 10; i++) {
expect(lock.tryClaim()).toBe(null);
}
// 释放
expect(lock.release(t)).toBe(true);
}
});
it('token 自增:每次成功 tryClaim 返的 token 都不同', () => {
const lock = makeLock();
const t1 = lock.tryClaim();
lock.release(t1);
const t2 = lock.tryClaim();
lock.release(t2);
const t3 = lock.tryClaim();
expect(new Set([t1, t2, t3]).size).toBe(3);
});
});

View File

@@ -180,3 +180,144 @@ describe('fs-watcher updateSnapshot —— push 决策回归 (Round 13 fix fce2d
});
});
// audit R7a P1-19:fs-watcher 轮询改为「自适应间隔 + 不 drop」pending
// 单测覆盖三件事:
// 1. getPollInterval:lastChangeAt 在 ACTIVE_WINDOW 内 → 500ms,否则 5s
// 2. markActivity 刷新 lastChangeAt → 立刻切到 active
// 3. pending 语义:in-flight 期间触发的 tick 不丢,in-flight 完成后立即补一次
describe('fs-watcher 自适应轮询 (audit R7a P1-19)', () => {
it('getPollInterval:初始(lastChangeAt=0,距 now 远)→ QUIET 5000ms', () => {
const w = makeWatcher();
expect(w._test_getPollInterval()).toBe(5000);
});
it('getPollInterval:markActivity 后立即 → ACTIVE 500ms', () => {
const w = makeWatcher();
w._test_markActivity();
expect(w._test_getPollInterval()).toBe(500);
});
it('getPollInterval:markActivity 后等到 ACTIVE_WINDOW 之外 → QUIET', async () => {
const w = makeWatcher();
w._test_markActivity();
expect(w._test_getPollInterval()).toBe(500);
// 模拟时间推进 POLL_ACTIVE_WINDOW_MS + 1:用 setTimeout 等真实时间。
// 2100ms 等得起,测试只跑一次。备选用 vi.useFakeTimers + setSystemTime
// 也行,但 fake timer + markActivity 内部 Date.now() 兼容性有边角问题。
await new Promise((r) => setTimeout(r, 2100));
expect(w._test_getPollInterval()).toBe(5000);
});
it('pending 语义:in-flight 期间触发的 tick 不丢,in-flight 完后立即补', async () => {
// 设计要点:pendingPoll 是**幂等**标志,不是队列。
// - in-flight 期间任何 tick 都只把 pendingPoll 置 true(不重复累计)
// - in-flight 完成后 finally 检查 pendingPoll:true → scheduleNextPoll(0)
// - 紧跟的「补」是 1 次新 scanDir,不是 N 次
// 所以「3 次 tick + resolve」→ scanCalls 期望 2(1 原 + 1 补),不是 3。
//
// 旧版(fixed 2s + pollInFlight drop)的语义是:3 次 tick 期间全部被 drop,
// scanCalls 期望 1(且永远不会补)。这条测试是回归锚点:
// 拿掉 pendingPoll 行为 → scanCalls 在 resolve 后仍为 1,测试失败。
//
// 时序坑:test 里 `await new Promise(r => setTimeout(r, 0))` 是先于
// runPollTick finally 调度的;Node 同 delay 的 setTimeout 严格按调度顺序
// 触发,test 的会先 fire,导致 scanCalls 还在 1 时 expect(2) 失败。
// 修:用一个 polling 等待「scanCalls 达到 2」,让事件循环多跑几个 tick。
let resolveScan;
const scanCalls = [];
const w = createFsWatcher({
getMainWindow: () => null,
resolveDataDir: () => '/data',
scanDir: async () => {
scanCalls.push(Date.now());
// 慢:等我们手动 resolve
await new Promise((r) => { resolveScan = r; });
return { ok: true, entries: [] };
},
});
// 跳过 fs.watch 订阅(测试不依赖真实目录事件)
w._test_setWatchedDir('/data');
// 第一次 tick:in-flight 启动,scanDir 进入 await
w._test_runPollTick();
// 第二次 tick:in-flight 还在,pendingPoll = true,scanDir 不再调用
w._test_runPollTick();
// 第三次 tick:同样 pendingPoll = true(幂等)
w._test_runPollTick();
// 期望 1 次 in-flight scanDir,pendingPoll 期间 0 次额外调用
expect(scanCalls.length).toBe(1);
// 解除 in-flight:第一次 scanDir 完成 → runPollTick finally 触发「补一次」
resolveScan({ ok: true, entries: [] });
// 等「补」的 scanDir 真的发起(scanCalls 长度变 2)。最坏情况:
// in-flight 微任务 + emitFilesChanged 微任务 + setTimeout(0) 调度 +
// setTimeout(0) 触发 + 新 runPollTick 进入 + 新 scanDir push —— 几十 ms 内。
await new Promise((resolve) => {
const start = Date.now();
const tick = () => {
if (scanCalls.length >= 2) return resolve();
if (Date.now() - start > 200) return resolve(); // 兜底
setTimeout(tick, 5);
};
tick();
});
expect(scanCalls.length).toBe(2);
// 把「补」触发的第 2 次 scanDir 也 resolve 掉,不让它的 in-flight
// 干扰其它测试
if (resolveScan) resolveScan({ ok: true, entries: [] });
// 关键:runPollTick 的 finally 走「无 pending → scheduleNextPoll(
// getPollInterval())」会挂 5s 后的 timer。不显式清掉的话,timer 触发时
// 又会调一次 scanDir(scanCalls 变 3),且新 scanDir 等到没人 resolve
// 会一直挂着 —— watcher + scanCalls 闭包泄漏到进程结束,vitest 报告
// "tests passed" 但实际上 open handle 没释放。pause 清 dirPollTimer +
// pollPaused,让 watcher 真正失活。
w._test_pauseDirPolling();
});
// audit R7a P1-19 bugfix:pauseDirPolling 必须真正停止轮询。回归锚点:
// - in-flight tick 还在 await scanDir 时调 pause
// - scanDir 完成后 runPollTick finally 走 scheduleNextPoll(...)
// - 旧版(无 pollPaused 闸)→ 仍挂 5s 后才退到的定时器
// - 修后(有 pollPaused 闸)→ 不挂任何新 timer,轮询真停
// 拿掉 pollPaused 检查 → _test_hasPollTimer() 在 resolveScan 后仍返回 true,
// 本测试失败。仅查 scanCalls 长度不够 —— 5s 间隔下 50ms 等不出新 tick,
// 必须直接断言 dirPollTimer 是否被挂。
it('pause 期间 in-flight runPollTick finally 不 re-schedule', async () => {
let resolveScan;
const scanCalls = [];
const w = createFsWatcher({
getMainWindow: () => null,
resolveDataDir: () => '/data',
scanDir: async () => {
scanCalls.push(Date.now());
await new Promise((r) => { resolveScan = r; });
return { ok: true, entries: [] };
},
});
w._test_setWatchedDir('/data');
// 启动一个 in-flight tick(scanDir 进入 await)→ runPollTick 被 setTimeout
// 调度时 dirPollTimer 已被清,所以这一步后 hasPollTimer() = false
w._test_runPollTick();
expect(scanCalls.length).toBe(1);
expect(w._test_hasPollTimer()).toBe(false);
// 暂停:模拟窗口最小化 → pollPaused = true
w._test_pauseDirPolling();
// in-flight scanDir 完成 → runPollTick finally 执行:
// 旧版:scheduleNextPoll(getPollInterval()) 挂 5s 后的 timer
// 修后:pollPaused 短路,dirPollTimer 保持 null
resolveScan({ ok: true, entries: [] });
// 等 microtask + setTimeout(0) 都跑过。50ms 足够覆盖几轮事件循环。
await new Promise((r) => setTimeout(r, 50));
// 核心断言:pause 之后不应该有新的 poll timer 被挂上
expect(w._test_hasPollTimer()).toBe(false);
});
// 配对测试:resume 后必须能再跑轮询。验证 pollPaused 不会让恢复路径失活。
it('resume 后轮询可重新启动', () => {
const w = makeWatcher();
w._test_setWatchedDir('/data');
w._test_pauseDirPolling();
// pause 后调 resume —— 不报错、watchedDir 还在即可(startDirPoll 在
// makeWatcher 里没有真实 scanDir,所以不验证 emit 次数)。
expect(() => w._test_resumeDirPolling()).not.toThrow();
});
});

View File

@@ -0,0 +1,128 @@
// main.js file:rename / folder:rename —— rename 后父目录 fsync(audit P1-6)
//
// 背景:fs.rename 在 POSIX 下只 fsync 自己的 inode,不 fsync 父目录的 directory entry。
// 崩溃时新名可能没落盘,恢复后看到的是「旧名 inode + 文件内容」。audit 要求
// 与 atomicWriteFile(main.js:1801-1809 模式)一致:rename 成功后对父目录做 fsync。
//
// 现状:folder:create 已经在 mkdir 后做了父目录 fsync(main.js:1854-1864),
// 但 file:rename(main.js:1986)和 folder:rename(main.js:2085)都没有。
//
// 修复:抽 fsyncParentDir(filePath, fsImpl?) helper → 打开父目录 → fsync → 关闭。
// - win32 上 NTFS journal 自带元数据持久化,helper 直接 noop(与 folder:create 一致)
// - fsImpl 注入便于单测:传 mock fsImpl,验证 open→sync→close 顺序与错误透传
// - production 调用走 main.js 实际 import 的 fs(Node 内置)
//
// 测试策略:纯函数 + 注入 fsImpl。覆盖:
// - POSIX 平台:open 父目录、sync 父 fh、close fh
// - win32 平台:直接 noop(不动 fsImpl)
// - open 失败透传错误
// - sync 失败:仍执行 close(finally)
// - close 失败:静默吞(与 folder:create 行为一致)
import { describe, it, expect, vi } from 'vitest';
import { fsyncParentDir } from '../../main/fsync-parent-dir.js';
function makeMockFs({ openOk = true, syncOk = true, closeOk = true } = {}) {
const calls = [];
const openErr = openOk ? null : Object.assign(new Error('EACCES'), { code: 'EACCES' });
const syncErr = syncOk ? null : Object.assign(new Error('EIO'), { code: 'EIO' });
const fh = {
sync: vi.fn(async () => {
calls.push({ kind: 'sync' });
if (syncErr) throw syncErr;
}),
close: vi.fn(async () => {
calls.push({ kind: 'close' });
if (!closeOk) throw new Error('close-fail');
}),
};
const fsImpl = {
open: vi.fn(async (p, mode) => {
calls.push({ kind: 'open', p, mode });
if (openErr) throw openErr;
return fh;
}),
};
return { fsImpl, fh, calls, openErr, syncErr };
}
describe('fsyncParentDir(audit P1-6)', () => {
it('POSIX:open 父目录 → sync → close,顺序正确', async () => {
const { fsImpl, fh, calls } = makeMockFs();
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
expect(r).toEqual({ ok: true });
// open 父目录(不是文件本身)
expect(fsImpl.open).toHaveBeenCalledTimes(1);
expect(fsImpl.open).toHaveBeenCalledWith('/data/notes', 'r');
// sync + close 都触发
expect(fh.sync).toHaveBeenCalledTimes(1);
expect(fh.close).toHaveBeenCalledTimes(1);
// 顺序:open → sync → close
expect(calls.map((c) => c.kind)).toEqual(['open', 'sync', 'close']);
});
it('win32:直接 noop,不调用 fsImpl', async () => {
const { fsImpl, fh } = makeMockFs();
const r = await fsyncParentDir('C:\\data\\notes\\foo.md', { fsImpl, platform: 'win32' });
expect(r).toEqual({ ok: true, skipped: 'win32' });
expect(fsImpl.open).not.toHaveBeenCalled();
expect(fh.sync).not.toHaveBeenCalled();
expect(fh.close).not.toHaveBeenCalled();
});
it('open 失败:透传错误,sync/close 不触发', async () => {
const { fsImpl, fh } = makeMockFs({ openOk: false });
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
expect(r.ok).toBe(false);
expect(r.error).toBe('EACCES');
expect(r.code).toBe('EACCES');
expect(fh.sync).not.toHaveBeenCalled();
expect(fh.close).not.toHaveBeenCalled();
});
it('sync 失败:finally 仍执行 close', async () => {
const { fsImpl, fh, calls } = makeMockFs({ syncOk: false });
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
expect(r.ok).toBe(false);
expect(r.error).toBe('EIO');
// close 仍触发(避免 fh 泄漏)
expect(fh.close).toHaveBeenCalledTimes(1);
expect(calls.map((c) => c.kind)).toEqual(['open', 'sync', 'close']);
});
it('close 失败:静默吞(与 folder:create 行为一致)', async () => {
const { fsImpl, fh } = makeMockFs({ closeOk: false });
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
// close 失败不抛、不计入返回值(folder:create 模式 try/catch close)
expect(r.ok).toBe(true);
expect(fh.close).toHaveBeenCalledTimes(1);
});
it('根目录边界:parent = "/" 时仍能 open(POSIX 允许)', async () => {
const { fsImpl } = makeMockFs();
const r = await fsyncParentDir('/foo.md', { fsImpl, platform: 'linux' });
expect(r.ok).toBe(true);
expect(fsImpl.open).toHaveBeenCalledWith('/', 'r');
});
it('默认 platform/process:未指定时用 process.platform', async () => {
const { fsImpl } = makeMockFs();
// 不传 platform → 走 process.platform
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl });
// 在 linux 容器(CI)上 process.platform === 'linux' → 正常 open
if (process.platform === 'win32') {
expect(r).toEqual({ ok: true, skipped: 'win32' });
} else {
expect(r.ok).toBe(true);
expect(fsImpl.open).toHaveBeenCalledWith('/data/notes', 'r');
}
});
it('路径为相对路径:parent 用 path.dirname 解出', async () => {
const { fsImpl } = makeMockFs();
const r = await fsyncParentDir('notes/sub/foo.md', { fsImpl, platform: 'linux' });
expect(r.ok).toBe(true);
// path.dirname('notes/sub/foo.md') === 'notes/sub'
expect(fsImpl.open).toHaveBeenCalledWith('notes/sub', 'r');
});
});

View File

@@ -0,0 +1,96 @@
// main/file-ops.js atomicWriteFile —— tmp 路径生成(audit P2-1)
//
// 背景:原 inline 实现 `Math.random().toString(36).slice(2, 8)`
// - Math.random() 不加密安全,并发同毫秒 + 同 pid + 同 dst 可能撞名
// - audit 要求与 crypto.randomUUID() 对齐(已在本仓库其它路径使用)
//
// 修复:抽 generateTmpPath(dst, { crypto, pid, now }) 工厂:
// - 注入 crypto / pid / now 便于单测验证随机性来源
// - 默认走 node:crypto.randomUUID()(128-bit 加密安全,6 段 8-4-4-4-12 hex)
// - 路径格式:${dst}.tmp.${pid}.${ts}.${randomId}(保持现有格式不破坏 fs-watcher 兼容)
//
// 测试策略:纯函数 + 注入 crypto / pid / now。覆盖:
// - crypto.randomUUID 被调用(mock)
// - 同 pid+now 两次调用 → randomId 不同(实际是不同 UUID)
// - 不同 pid 反映在路径里
// - 不同 now 反映在路径里
// - 路径格式完整:dst + ".tmp." + pid + "." + ts + "." + randomId
import { describe, it, expect, vi } from 'vitest';
import { generateTmpPath } from '../../main/generate-tmp-path.js';
describe('generateTmpPath(audit P2-1)', () => {
it('crypto.randomUUID 被调用,返回的 randomId 来自它', () => {
const cryptoMock = { randomUUID: vi.fn(() => 'uuid-abc-123') };
const r = generateTmpPath('/data/note.md', {
crypto: cryptoMock,
pid: 1000,
now: () => 1234567890,
});
expect(cryptoMock.randomUUID).toHaveBeenCalledTimes(1);
expect(r).toBe('/data/note.md.tmp.1000.1234567890.uuid-abc-123');
});
it('同 pid+now 但 crypto 每次返不同 UUID → 路径不同', () => {
let counter = 0;
const cryptoMock = { randomUUID: vi.fn(() => `uuid-${++counter}`) };
const r1 = generateTmpPath('/data/note.md', { crypto: cryptoMock, pid: 1000, now: () => 1234 });
const r2 = generateTmpPath('/data/note.md', { crypto: cryptoMock, pid: 1000, now: () => 1234 });
expect(r1).not.toBe(r2);
expect(r1).toContain('uuid-1');
expect(r2).toContain('uuid-2');
});
it('不同 pid 反映在路径里', () => {
const cryptoMock = { randomUUID: vi.fn(() => 'uuid-x') };
const r1 = generateTmpPath('/data/a.md', { crypto: cryptoMock, pid: 100, now: () => 1234 });
const r2 = generateTmpPath('/data/a.md', { crypto: cryptoMock, pid: 200, now: () => 1234 });
expect(r1).toContain('.tmp.100.');
expect(r2).toContain('.tmp.200.');
});
it('不同 now 反映在路径里', () => {
const cryptoMock = { randomUUID: vi.fn(() => 'uuid-x') };
const r1 = generateTmpPath('/data/a.md', { crypto: cryptoMock, pid: 1, now: () => 1000 });
const r2 = generateTmpPath('/data/a.md', { crypto: cryptoMock, pid: 1, now: () => 2000 });
expect(r1).toContain('.1.1000.uuid-x');
expect(r2).toContain('.1.2000.uuid-x');
});
it('路径格式:${dst}.tmp.${pid}.${ts}.${randomId},5 段以 . 分割', () => {
const r = generateTmpPath('/data/note.md', {
crypto: { randomUUID: () => 'xyz' },
pid: 42,
now: () => 999,
});
// 末尾应该是 ".tmp.42.999.xyz"
expect(r.endsWith('.tmp.42.999.xyz')).toBe(true);
});
it('dst 含扩展名 / 含路径分隔符都正确保留', () => {
const r = generateTmpPath('C:/data/sub/note.md', {
crypto: { randomUUID: () => 'u' },
pid: 1,
now: () => 1,
});
expect(r).toBe('C:/data/sub/note.md.tmp.1.1.u');
});
it('默认(不传 crypto)走 node:crypto.randomUUID,real call 不抛', () => {
// 不传 opts.crypto → 走 require('node:crypto').randomUUID
const r = generateTmpPath('/data/note.md', { pid: 1, now: () => 1 });
// real randomUUID 返 36 字符的 UUID(8-4-4-4-12)
expect(r).toMatch(/\.tmp\.1\.1\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
it('默认 pid 走 process.pid,now 走 Date.now()', () => {
const r = generateTmpPath('/data/note.md');
// 路径第 3 段 = process.pid
const match = r.match(/\.tmp\.(\d+)\.(\d+)\./);
expect(match).toBeTruthy();
expect(Number(match[1])).toBe(process.pid);
// ts 应当接近 Date.now()(误差 < 5s)
const ts = Number(match[2]);
expect(Math.abs(ts - Date.now())).toBeLessThan(5000);
});
});

View File

@@ -0,0 +1,125 @@
// P2-16(audit):heading IntersectionObserver 实例持久化。
//
// 旧实现:_rebuildHeadingObserver 每次 new IntersectionObserver(cb) ——
// loadContentLive 在 split 模式每个 debounced 击键都跑一次重建,observer
// 对象 + 闭包反复分配。
//
// 新契约:
// - observer 实例只建一次(多次 _rebuildHeadingObserver 不重复 new)
// - 重建仍 disconnect + 全量 re-observe(M1 历史教训:innerHTML 替换
// detach 旧节点,增量 observe 会让追踪冻死,不能省)
// - dispose 仍 disconnect + 置 null(Round 12 P3 契约不变)
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MarkdownViewer } from '../../src/markdown-viewer.js';
describe('heading observer 持久化(P2-16)', () => {
let body;
beforeEach(() => {
document.body.innerHTML = '';
body = document.createElement('div');
body.id = 'markdown-body';
document.body.appendChild(body);
});
function makeViewer() {
return new MarkdownViewer({
bodyElement: body,
emptyElement: document.createElement('div'),
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
}
function addHeadings(...tags) {
for (const t of tags) {
const h = document.createElement(t);
h.id = `${t}-${Math.random().toString(36).slice(2, 6)}`;
h.textContent = 'title';
body.appendChild(h);
}
}
it('多次 _rebuildHeadingObserver 只 new 一次 IntersectionObserver', () => {
const constructCalls = [];
const fakeObserver = {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () {
constructCalls.push(true);
return fakeObserver;
});
try {
const viewer = makeViewer();
addHeadings('h1', 'h2');
viewer._rebuildHeadingObserver();
viewer._rebuildHeadingObserver();
viewer._rebuildHeadingObserver();
expect(constructCalls.length).toBe(1);
// 每次重建都 disconnect + 全量 re-observe(detach 节点不能增量);
// 第一次重建时 observer 尚不存在、无可 disconnect,故 3 次重建 = 2 次 disconnect
expect(fakeObserver.disconnect.mock.calls.length).toBe(2);
expect(fakeObserver.observe.mock.calls.length).toBe(6); // 2 headings × 3 次
viewer.dispose();
} finally {
window.IntersectionObserver = RealIO;
}
});
it('重建后观察的是新 DOM 节点,不是 detach 的旧节点', () => {
const observed = [];
const fakeObserver = {
observe: vi.fn(function (node) { observed.push(node); }),
disconnect: vi.fn(),
unobserve: vi.fn(),
};
const RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () { return fakeObserver; });
try {
const viewer = makeViewer();
addHeadings('h1');
viewer._rebuildHeadingObserver();
const firstNode = observed[0];
// 模拟 innerHTML 全量替换:旧节点 detach,新节点进 DOM
body.innerHTML = '';
observed.length = 0;
addHeadings('h1');
viewer._rebuildHeadingObserver();
const secondNode = observed[0];
expect(secondNode).not.toBe(firstNode);
expect(secondNode.isConnected).toBe(true);
expect(firstNode.isConnected).toBe(false);
viewer.dispose();
} finally {
window.IntersectionObserver = RealIO;
}
});
it('dispose 仍 disconnect 一次并置 null(Round 12 P3 契约不变)', () => {
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 = makeViewer();
addHeadings('h1');
viewer._rebuildHeadingObserver();
viewer.dispose();
expect(fakeObserver.disconnect.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(viewer._headingObserver).toBe(null);
} finally {
window.IntersectionObserver = RealIO;
}
});
});

View File

@@ -0,0 +1,173 @@
// main/ipc-sender.js —— assertMainSender / assertTopFrame / assertAiRequestId 单测
//
// 覆盖:
// - assertMainSender 四种拒绝路径:event 缺 sender / mainWindow 未创建 /
// mainWindow 已销毁 / sender 不是主窗口 webContents
// - assertMainSender 放行路径:sender 匹配 + mainWindow 存活
// - assertTopFrame:senderFrame 缺失拒绝 / parent 真值拒绝 / parent null 放行
// - assertAiRequestId:两种合法格式(ai-<ts>-<uuid> / 裸 uuid)+ 各种垃圾拒绝
// @vitest-environment node
import { describe, it, expect, vi } from 'vitest';
const {
assertMainSender,
assertTopFrame,
assertAiRequestId,
SENDER_NOT_ALLOWED,
INVALID_REQUEST_ID,
} = await import('../../main/ipc-sender.js');
// ---- helpers ----
function makeWin(webContents) {
return {
webContents,
isDestroyed: () => false,
};
}
function makeEvent(sender) {
return { sender };
}
describe('assertMainSender', () => {
const sender = { id: 42 };
it('event 缺 sender → 拒绝(SENDER_NOT_ALLOWED)', () => {
const getMainWindow = () => makeWin(sender);
const r = assertMainSender({}, getMainWindow);
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('event 完全 undefined → 拒绝', () => {
const r = assertMainSender(undefined, () => makeWin(sender));
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('mainWindow 未创建(getMainWindow 返回 null) → 拒绝', () => {
const r = assertMainSender(makeEvent(sender), () => null);
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('mainWindow 已销毁 → 拒绝', () => {
const win = { isDestroyed: () => true };
const r = assertMainSender(makeEvent(sender), () => win);
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('sender 不等于 mainWindow.webContents → 拒绝(防 webview / 子窗口)', () => {
const otherSender = { id: 99 };
const getMainWindow = () => makeWin(sender);
const r = assertMainSender(makeEvent(otherSender), getMainWindow);
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('sender 等于 mainWindow.webContents + mainWindow 存活 → 放行', () => {
const getMainWindow = () => makeWin(sender);
const r = assertMainSender(makeEvent(sender), getMainWindow);
expect(r).toEqual({ ok: true });
});
it('getMainWindow 不是函数 → 拒绝(防御性)', () => {
const r = assertMainSender(makeEvent(sender), null);
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('主窗口在调用过程中被销毁(getMainWindow 返回已销毁 win) → 拒绝', () => {
// 模拟:handler 入口时主窗口还活着,但 getter 被调时已销毁(destroy 事件刚好触发)
const sender = { id: 42 };
const getMainWindow = vi.fn(() => ({ isDestroyed: () => true }));
const r = assertMainSender(makeEvent(sender), getMainWindow);
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
});
describe('assertTopFrame', () => {
it('event.senderFrame 缺失 → 拒绝(保守)', () => {
const r = assertTopFrame({});
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('event 完全 undefined → 拒绝', () => {
expect(assertTopFrame(undefined)).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('senderFrame.parent 真值(iframe / webview) → 拒绝', () => {
const r = assertTopFrame({ senderFrame: { parent: { id: 99 } } });
expect(r).toEqual({ ok: false, error: SENDER_NOT_ALLOWED });
});
it('senderFrame.parent null(顶层 frame) → 放行', () => {
const r = assertTopFrame({ senderFrame: { parent: null } });
expect(r).toEqual({ ok: true });
});
});
describe('assertAiRequestId', () => {
// 标准 UUIDv4 形式
const UUID_V4 = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
// main/ai.js:968 的格式:ai-<timestamp>-<uuid>
const AI_TS_UUID = `ai-1700000000000-${UUID_V4}`;
it('renderer 侧格式:裸 UUIDv4 → 放行', () => {
const r = assertAiRequestId(UUID_V4);
expect(r).toEqual({ ok: true, requestId: UUID_V4 });
});
it('main 侧 fallback 格式:ai-<ts>-<uuid> → 放行', () => {
const r = assertAiRequestId(AI_TS_UUID);
expect(r).toEqual({ ok: true, requestId: AI_TS_UUID });
});
it('大写 UUIDv4 也放行(正则 case-insensitive)', () => {
const r = assertAiRequestId(UUID_V4.toUpperCase());
expect(r.ok).toBe(true);
});
it('非 string(number / object / null) → 拒绝', () => {
expect(assertAiRequestId(123).error).toBe(INVALID_REQUEST_ID);
expect(assertAiRequestId({}).error).toBe(INVALID_REQUEST_ID);
expect(assertAiRequestId(null).error).toBe(INVALID_REQUEST_ID);
expect(assertAiRequestId(undefined).error).toBe(INVALID_REQUEST_ID);
});
it('空串 → 拒绝', () => {
expect(assertAiRequestId('').error).toBe(INVALID_REQUEST_ID);
});
it('过长(>200 字符) → 拒绝(防内存放大)', () => {
expect(assertAiRequestId('a'.repeat(201)).error).toBe(INVALID_REQUEST_ID);
});
it('UUIDv4 但缺一位 → 拒绝(hex 字符数错)', () => {
const short = 'f47ac10b-58cc-4372-a567-0e02b2c3d47';
expect(assertAiRequestId(short).error).toBe(INVALID_REQUEST_ID);
});
it('UUIDv4 但第 3 段首位不是 4 → 拒绝', () => {
const v3 = 'f47ac10b-58cc-4372-3567-0e02b2c3d479';
expect(assertAiRequestId(v3).error).toBe(INVALID_REQUEST_ID);
});
it('UUIDv4 但第 4 段首位不是 [89ab] → 拒绝', () => {
const badVariant = 'f47ac10b-58cc-4372-4567-0e02b2c3d479';
expect(assertAiRequestId(badVariant).error).toBe(INVALID_REQUEST_ID);
});
it('ai- 前缀但 timestamp 不是纯数字 → 拒绝', () => {
const bad = `ai-notanumber-${UUID_V4}`;
expect(assertAiRequestId(bad).error).toBe(INVALID_REQUEST_ID);
});
it('ai- 前缀但 timestamp 过长(>18 位)→ 拒绝(防 DoS)', () => {
const bad = `ai-${'1'.repeat(19)}-${UUID_V4}`;
expect(assertAiRequestId(bad).error).toBe(INVALID_REQUEST_ID);
});
it('包含 SQL 注入 / shell 元字符 → 拒绝', () => {
expect(assertAiRequestId(`' OR 1=1; --`).error).toBe(INVALID_REQUEST_ID);
expect(assertAiRequestId(`$(rm -rf /)`).error).toBe(INVALID_REQUEST_ID);
});
});

View File

@@ -0,0 +1,79 @@
// P2-6(audit):main/ai.js 的 pickOpenAITokenConfig / joinUrl 每次调用重复
// 正则解析。AI 流式期间同 model / 同 baseURL 反复命中,加 LRU 缓存省 CPU。
//
// 设计:main/lru-cache.js 通用 createLruCache({ maxSize }) →
// get(key) / set(key, value) / has(key) / size
// - Map 迭代序 = 插入序;get 命中时 delete+set 提到最新(LRU 语义)
// - set 超 maxSize 时删最老(Map 第一个键)
// - maxSize 默认 64
import { describe, it, expect } from 'vitest';
const { createLruCache } = require('../../main/lru-cache.js');
describe('createLruCache —— P2-6 通用 LRU', () => {
it('set/get 往返', () => {
const c = createLruCache({ maxSize: 4 });
c.set('a', 1);
expect(c.get('a')).toBe(1);
expect(c.has('a')).toBe(true);
expect(c.get('missing')).toBeUndefined();
expect(c.has('missing')).toBe(false);
});
it('超 maxSize 删最老的键', () => {
const c = createLruCache({ maxSize: 2 });
c.set('a', 1);
c.set('b', 2);
c.set('c', 3); // a 应被挤掉
expect(c.has('a')).toBe(false);
expect(c.has('b')).toBe(true);
expect(c.has('c')).toBe(true);
expect(c.size).toBe(2);
});
it('get 命中提到最新,延缓被淘汰', () => {
const c = createLruCache({ maxSize: 2 });
c.set('a', 1);
c.set('b', 2);
c.get('a'); // a 提最新,b 变老
c.set('c', 3); // 挤掉 b 而非 a
expect(c.has('a')).toBe(true);
expect(c.has('b')).toBe(false);
});
it('重复 set 同键覆盖值且不增 size', () => {
const c = createLruCache({ maxSize: 2 });
c.set('a', 1);
c.set('a', 2);
expect(c.get('a')).toBe(2);
expect(c.size).toBe(1);
});
it('重复 set 同键视为「使用」,提到最新', () => {
const c = createLruCache({ maxSize: 2 });
c.set('a', 1);
c.set('b', 2);
c.set('a', 9); // a 提最新
c.set('c', 3); // 挤掉 b
expect(c.get('a')).toBe(9);
expect(c.has('b')).toBe(false);
});
it('默认 maxSize=64', () => {
const c = createLruCache();
for (let i = 0; i < 70; i++) c.set('k' + i, i);
expect(c.size).toBe(64);
expect(c.has('k0')).toBe(false);
expect(c.has('k69')).toBe(true);
});
it('值可以是对象 / undefined 以外的任意类型', () => {
const c = createLruCache({ maxSize: 4 });
const obj = { x: 1 };
c.set('o', obj);
c.set('n', null);
expect(c.get('o')).toBe(obj);
expect(c.get('n')).toBeNull();
expect(c.has('n')).toBe(true);
});
});

View File

@@ -0,0 +1,127 @@
// P2-10 / P2-12(audit):主进程日志改写异步缓冲。
//
// 旧实现:console.log/warn/error 重写层每行 appendFileSync + mkdirSync ——
// 同步 IO 阻塞 main 事件循环(AI 流式高频日志时尤甚),且顶层同步 fs。
//
// 新模块 main/main-log-writer.js:createMainLogWriter({ logDir, logFile,
// fsImpl, flushMs, maxBuffered, setTimeoutImpl, clearTimeoutImpl }) →
// write(line) 入缓冲;超 maxBuffered 丢最老;调度 flush
// flush() mkdir(recursive) + appendFile 一把写出;失败静默丢弃
// dispose() 取消待发 flush(测试 / 退出用)
//
// fsImpl 默认 fs.promises;时钟 / 定时器可注入,单测同步推进。
import { describe, it, expect, vi } from 'vitest';
const { createMainLogWriter } = require('../../main/main-log-writer.js');
function fakeFs() {
const calls = { mkdir: [], append: [] };
return {
calls,
mkdir: async (dir, opts) => { calls.mkdir.push([dir, opts]); },
appendFile: async (file, data) => { calls.append.push([file, data]); },
};
}
function fakeTimers() {
const pending = [];
return {
pending,
setTimeout: (fn, ms) => { const h = { fn, ms, unref() {} }; pending.push(h); return h; },
clearTimeout: (h) => { const i = pending.indexOf(h); if (i >= 0) pending.splice(i, 1); },
fireAll: () => { const fns = pending.splice(0); for (const h of fns) h.fn(); },
};
}
describe('createMainLogWriter —— P2-10 异步缓冲日志', () => {
it('write 只入缓冲,flush 后一把 appendFile', async () => {
const fs = fakeFs();
const w = createMainLogWriter({ logDir: '/d', logFile: '/d/a.log', fsImpl: fs });
w.write('l1\n');
w.write('l2\n');
expect(fs.calls.append).toEqual([]);
await w.flush();
expect(fs.calls.mkdir).toEqual([['/d', { recursive: true }]]);
expect(fs.calls.append).toEqual([['/d/a.log', 'l1\nl2\n']]);
});
it('flush 期间缓冲清空;空缓冲 flush 是 no-op', async () => {
const fs = fakeFs();
const w = createMainLogWriter({ logDir: '/d', logFile: '/d/a.log', fsImpl: fs });
await w.flush();
expect(fs.calls.append).toEqual([]);
w.write('x\n');
await w.flush();
await w.flush();
expect(fs.calls.append).toEqual([['/d/a.log', 'x\n']]);
});
it('mkdir 只第一次做(缓存 promise)', async () => {
const fs = fakeFs();
const w = createMainLogWriter({ logDir: '/d', logFile: '/d/a.log', fsImpl: fs });
w.write('a\n');
await w.flush();
w.write('b\n');
await w.flush();
expect(fs.calls.mkdir.length).toBe(1);
expect(fs.calls.append.length).toBe(2);
});
it('写失败静默丢弃,不抛错,缓冲不残留', async () => {
const fs = {
mkdir: async () => {},
appendFile: async () => { throw new Error('disk full'); },
};
const w = createMainLogWriter({ logDir: '/d', logFile: '/d/a.log', fsImpl: fs });
w.write('boom\n');
await expect(w.flush()).resolves.toBeUndefined();
// 缓冲已清,不会无限重试
await w.flush();
});
it('缓冲超 maxBuffered 丢最老行', async () => {
const fs = fakeFs();
const w = createMainLogWriter({
logDir: '/d', logFile: '/d/a.log', fsImpl: fs, maxBuffered: 3,
});
w.write('1\n'); w.write('2\n'); w.write('3\n'); w.write('4\n');
await w.flush();
expect(fs.calls.append).toEqual([['/d/a.log', '2\n3\n4\n']]);
});
it('write 调度一次 flush 定时器(不重复排)', async () => {
const fs = fakeFs();
const timers = fakeTimers();
const w = createMainLogWriter({
logDir: '/d', logFile: '/d/a.log', fsImpl: fs,
setTimeoutImpl: timers.setTimeout, clearTimeoutImpl: timers.clearTimeout,
flushMs: 250,
});
w.write('a\n');
w.write('b\n');
expect(timers.pending.length).toBe(1);
expect(timers.pending[0].ms).toBe(250);
timers.fireAll();
await w.flush(); // 等 in-flight 落地
expect(fs.calls.append.length).toBe(1);
expect(fs.calls.append[0][1]).toBe('a\nb\n');
});
it('dispose 取消待发 flush', () => {
const timers = fakeTimers();
const w = createMainLogWriter({
logDir: '/d', logFile: '/d/a.log', fsImpl: fakeFs(),
setTimeoutImpl: timers.setTimeout, clearTimeoutImpl: timers.clearTimeout,
});
w.write('a\n');
expect(timers.pending.length).toBe(1);
w.dispose();
expect(timers.pending.length).toBe(0);
});
it('默认 fsImpl / 定时器下 write 不抛错', () => {
const w = createMainLogWriter({ logDir: '/nonexistent-dir-x', logFile: '/nonexistent-dir-x/a.log' });
expect(() => w.write('hello\n')).not.toThrow();
w.dispose();
});
});

View File

@@ -203,3 +203,147 @@ describe('MarkdownEditor 基础行为', () => {
expect(() => editor._onCompositionEnd()).not.toThrow();
});
});
// ============================================================================
// audit R7b P1-18:单实例 EditorView 持久化
// ============================================================================
//
// 核心契约:loadFile 多次调用复用同一 view 实例,不再 _destroyView + new。
// 旧实现下:A→B→A 切文件会丢 history / selection / 折叠,30-100ms 黑闪。
describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
it('首次 loadFile 创建 view', () => {
const { editor } = createEditor();
expect(editor.view).toBeNull();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content');
expect(editor.view).not.toBeNull();
expect(editor.getContent()).toBe('A content');
});
it('多次 loadFile 复用同一 view 实例(A→B→A 同一 view)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content');
const viewA = editor.view;
// 切到 B
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B content');
expect(editor.view).toBe(viewA); // 引用相等
expect(editor.getContent()).toBe('B content');
// 切回 A(新内容)
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content v2');
expect(editor.view).toBe(viewA);
expect(editor.getContent()).toBe('A content v2');
});
it('loadFile 切换文件后 selection 重置到 (0, 0)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello world');
// 用户手动把光标移到位置 5
editor.view.dispatch({ selection: { anchor: 5, head: 5 } });
expect(editor.view.state.selection.main.anchor).toBe(5);
// 切到 B → selection 应被重置
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'foo bar baz');
expect(editor.view.state.selection.main.anchor).toBe(0);
expect(editor.view.state.selection.main.head).toBe(0);
});
it('loadFile 切换后 history 仍可用(view 复用,undo stack 保留)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'one');
// 用户编辑 → 一些 transaction 计入 history
editor.view.dispatch({ changes: { from: 3, insert: ' two' } });
editor.view.dispatch({ changes: { from: 7, insert: ' three' } });
expect(editor.getContent()).toBe('one two three');
// 切到 B 再切回 A(content 是初始 one,dispatch 替换;这里测的是 view 复用,
// 不是 history 跨文件保留——后者是更大设计问题,本测试只锁 view 实例稳定)
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
expect(editor.view).not.toBeNull();
// view 还是同一个引用 → history 字段在 view 生命周期内持续
// (CM6 history field 由 EditorState 持有;view 复用 = state 延续)
const beforeUndo = editor.view.state.doc.toString();
// 触发一次新 transaction(与刚才的 history 关联)
editor.view.dispatch({ changes: { from: 1, insert: 'B' } });
// undo 应该能回到 beforeUndo
expect(editor.view.state.doc.toString()).not.toBe(beforeUndo);
});
it('loadFile 复用 view 时 _viewAbort signal 不重建(IME listener 不会重复挂)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const signal1 = editor._viewAbort;
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
const signal2 = editor._viewAbort;
// view 复用 → 不需要新的 AbortController(IME listener 还在原 signal 上)
expect(signal2).toBe(signal1);
});
it('unload → 销毁 view;再 loadFile 创建新 view', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const viewA = editor.view;
editor.unload();
expect(editor.view).toBeNull();
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
// unload 后 view 是 null;下次 loadFile 触发 _createView → 新 view
expect(editor.view).not.toBe(viewA);
expect(editor.view).not.toBeNull();
expect(editor.getContent()).toBe('B');
});
it('多次 loadFile 复用 view 时 _viewAbort signal 引用稳定(IME listener 不重建)', () => {
// 核心契约:旧实现每次 loadFile 都 _destroyView + new AbortController,
// 但 addAbort 路径在 dispose 时才 remove —— 多次切文件会让 contentDOM
// 上累积多份 IME listener(compositionend 跑多次 → onChange 多次 →
// state.isDirty 翻脏态反复 + scheduleAutoSave 多次 clearTimeout)。
// 新实现下 view 整个 lifetime 只一个 _viewAbort signal,listener 只一份。
// 验证方式:loadFile 多次,_viewAbort 引用始终相等。
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const signalRef = editor._viewAbort;
expect(signalRef).not.toBeNull();
for (let i = 0; i < 10; i++) {
editor.loadFile({ path: `/f${i}.md`, name: `f${i}.md` }, `content ${i}`);
expect(editor._viewAbort).toBe(signalRef);
}
});
it('loadFile 重复同一文件:onChange 触发次数精确等于 dispatch 次数(listener 不重复挂)', () => {
// 回归锚点:旧实现每次 loadFile 都 addEventListener 三次(compositionstart
// / end / cancel),但 dispose 才 remove —— contentDOM 上 listener 数量
// 线性增长。N 次切文件 → onChange 被触发 3N 次。新实现下 view 不重建,
// listener 只一份,N 次 loadFile 正好 N 次 onChange。
const { editor, onChange } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const baseCount = onChange.mock.calls.length;
for (let i = 0; i < 5; i++) {
editor.loadFile({ path: '/a.md', name: 'a.md' }, `A${i}`);
}
const delta = onChange.mock.calls.length - baseCount;
expect(delta).toBe(5); // 精确 5,不是 10 / 15 / ...
});
it('A→B→A 场景下 lastSavedDoc 跟踪最终内容', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A1');
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B1');
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A2');
// loadFile 总是把 lastSavedDoc 同步到传入 content
expect(editor.lastSavedDoc).toBe('A2');
expect(editor.isDirty()).toBe(false);
});
it('getContent 在多次 loadFile 后返回当前 view 的 doc(不是 lastSavedDoc)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
expect(editor.getContent()).toBe('A');
// 用户编辑
editor.view.dispatch({ changes: { from: 1, insert: 'A-edit' } });
expect(editor.getContent()).toBe('AA-edit');
// 切到 B
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
expect(editor.getContent()).toBe('B');
// 切回 A(content 是 A 初始的,但 view 的 doc 是 A-edit 之前 dispatch 过的
// 状态——实际 loadFile 走 dispatch 全量替换,所以 doc 回到 A 初始)
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
expect(editor.getContent()).toBe('A');
});
});

View File

@@ -13,7 +13,7 @@
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MarkdownViewer } from '../../src/markdown-viewer.js';
// jsdom 不实现 scrollIntoView;polyfill 成 noop 让 scrollToAnchor 路径
@@ -785,4 +785,518 @@ describe('MarkdownViewer dispose 释放 IntersectionObserver (audit Round 12 P3)
window.IntersectionObserver = RealIO;
}
});
});
// ============================================================================
// audit R7b P1-16:loadContentLive rAF chunked insert
// ============================================================================
//
// 核心契约:loadContentLive 不再同步 `innerHTML = html` 整页替换。
// - 小文档(≤ 256KB html)同步解析 + 一次性插入,手感与原实现一致
// - 大文档解析推迟到下一帧,children > 200 时每帧最多 append 200 个
// - 单飞行:新一次 loadContentLive abort 上一次未完成的 insert
// - heading observer / resolveRelativeImages 推迟到全部 chunk 落地后才跑
// - loadContent / clear / showUnsupportedFile / dispose 都会取消 in-flight insert
//
// mock 策略:window.api.renderMarkdown = pass-through(markdown 输入即 HTML 输出),
// 这样测试可以自己构造精确的 DOM 结构(段落数 / heading id)。
describe('MarkdownViewer loadContentLive chunked insert (audit R7b P1-16)', () => {
let body;
let empty;
let viewer;
let savedApi;
let RealIO;
function makeParas(n, prefix = 'p') {
const parts = [];
for (let i = 0; i < n; i++) parts.push(`<p>${prefix}-${i}</p>`);
return parts.join('');
}
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
empty = document.querySelector('.empty');
savedApi = window.api;
window.api = { renderMarkdown: (md) => md };
// jsdom 没有 IntersectionObserver —— 与 dispose 测试相同的 mock 策略。
// 不 mock 的话,fixture 里带 heading 时 _rebuildHeadingObserver 会抛
// ReferenceError,_finishLiveInsert 后续的 resolveRelativeImages 被跳过。
RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () {
return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() };
});
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
// resolveRelativeImages 需要 currentFile.path;无 getDataDir → 跳过越界检查
viewer.currentFile = { path: '/notes/a.md', name: 'a.md' };
});
afterEach(() => {
if (viewer) viewer.dispose();
if (savedApi === undefined) delete window.api;
else window.api = savedApi;
window.IntersectionObserver = RealIO;
});
it('changed 语义:内容变了 → true;同内容 → false 且 _resolveGen 仍 +1', () => {
const gen0 = viewer._resolveGen;
expect(viewer.loadContentLive('<p>x</p>')).toEqual({ changed: true });
const gen1 = viewer._resolveGen;
// 第一次调用 bump 了 generation(loadContentLive 自身 +1;
// 收尾的 resolveRelativeImages 入口也会 +1 —— 不锁精确值,只锁「有 bump」)
expect(gen1).toBeGreaterThan(gen0);
expect(viewer.loadContentLive('<p>x</p>')).toEqual({ changed: false });
// M-series Phase M-H3:同内容早返回也恰好 bump 一次(让 in-flight 的
// resolveRelativeImages 失配放弃),这里锁精确 +1
expect(viewer._resolveGen).toBe(gen1 + 1);
});
it('小文档(≤ 200 children)同步落地:返回后 DOM 立即可用', () => {
const r = viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
expect(r).toEqual({ changed: true });
// fast-path:不经过 rAF,同步完成 —— 与原同步 innerHTML 行为等价
expect(body.querySelectorAll('p').length).toBe(3);
expect(body.textContent).toBe('abc');
});
it('大文档(> 200 children)分批:同步返回时 body 已清空,await 后完整', async () => {
const html = makeParas(500);
const r = viewer.loadContentLive(html);
expect(r).toEqual({ changed: true });
// 分批路径:首帧只做「清空 + 解析」,append 在后续 rAF 帧里
expect(body.querySelectorAll('p').length).toBe(0);
await viewer._test_liveInsertDone();
const ps = body.querySelectorAll('p');
expect(ps.length).toBe(500);
// 顺序保持(chunk 从 fragment 头部依序取)
expect(ps[0].textContent).toBe('p-0');
expect(ps[499].textContent).toBe('p-499');
});
it('单飞行:连续两次 loadContentLive,第一次的剩余 chunk 不会混进第二次', async () => {
// 第一次:500 段(走分批,同步返回时什么都还没 append)
viewer.loadContentLive(makeParas(500, 'old'));
// 不 await,立刻第二次:3 段小文档
viewer.loadContentLive('<p>B1</p><p>B2</p><p>B3</p>');
await viewer._test_liveInsertDone();
const ps = body.querySelectorAll('p');
// 关键断言:最终只有第二次的 3 段;第一次的 500 段一段都不残留
expect(ps.length).toBe(3);
expect(ps[0].textContent).toBe('B1');
expect(ps[2].textContent).toBe('B3');
});
it('heading observer 与 resolveRelativeImages 推迟到全部 chunk 落地后', async () => {
const rebuildSpy = vi.spyOn(viewer, '_rebuildHeadingObserver');
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
// 500 段 + 末尾一个带 id 的 h1(childNodes = 501 > 200 → 分批)
viewer.loadContentLive(makeParas(500) + '<h1 id="end-h">E</h1>');
// 同步返回时:observer / resolve 都不该被调(DOM 还没拼完)
expect(rebuildSpy).not.toHaveBeenCalled();
expect(resolveSpy).not.toHaveBeenCalled();
await viewer._test_liveInsertDone();
expect(rebuildSpy).toHaveBeenCalledTimes(1);
expect(resolveSpy).toHaveBeenCalledTimes(1);
// heading 快查表已含新 h1(observer 是在完整 DOM 上重建的)
expect(viewer._headingMap.get('end-h')).toMatchObject({ level: 1, text: 'E' });
});
it('dispose 取消 in-flight insert:不再 append,也不跑收尾回调', async () => {
const rebuildSpy = vi.spyOn(viewer, '_rebuildHeadingObserver');
viewer.loadContentLive(makeParas(500));
viewer.dispose();
await viewer._test_liveInsertDone();
// 首帧清空后从未 append(dispose 在第一个 rAF 帧之前 abort)
expect(body.querySelectorAll('p').length).toBe(0);
expect(rebuildSpy).not.toHaveBeenCalled();
});
it('loadContent 取消 in-flight live insert:旧 chunk 不污染新文件 DOM', async () => {
viewer.loadContentLive(makeParas(500, 'stale'));
// 立刻切文件(loadContent 是冷启动路径,保持同步 innerHTML)
await viewer.loadContent({ path: '/notes/b.md', name: 'b.md', size: 3 }, '# B');
await viewer._test_liveInsertDone();
// 新文件内容(mock render 是 pass-through → '# B' 文本节点)
expect(body.textContent).toBe('# B');
// 旧的 500 段一个都不剩
expect(body.querySelectorAll('p').length).toBe(0);
});
it('clear 取消 in-flight live insert', async () => {
viewer.loadContentLive(makeParas(500));
viewer.clear();
await viewer._test_liveInsertDone();
expect(body.querySelectorAll('p').length).toBe(0);
expect(body.innerHTML).toBe('');
});
it('性能:5MB html 连续 50 次 loadContentLive,DOM 提交推迟到 rAF(同步段不阻塞)', async () => {
// audit P1-16 验收标准:5MB fixture + 50 次 load,调用方视角的同步段
// 必须不阻塞主线程 —— 真正的 parse + append 全部推迟到 rAF 帧。
// (jsdom 的 parse5 与 Chromium 原生 parser 性能不可比,真机 5MB 实测
// 在 Electron 里手动验证;此处锁定的是「不再同步提交 DOM」的语义。)
const para = '<p>lorem ipsum dolor sit amet, consectetur adipiscing elit</p>';
const bigHtml = para.repeat(Math.ceil((5 * 1024 * 1024) / para.length));
expect(bigHtml.length).toBeGreaterThan(5 * 1024 * 1024);
// 同步段:50 次调用都走「大文档推迟」路径(> 256KB → 首帧先 await rAF
// 才碰 body)。loadContentLive 同步返回时 body 不应被大子树同步污染 ——
// 这是 P1-16 锁死的核心不变量,且不受机器 / V8 版本性能差异影响。
const t0 = performance.now();
for (let i = 0; i < 50; i++) {
// 每次内容都略不同(绕开「同内容早返回」分支),但差异极小不影响测量。
viewer.loadContentLive(bigHtml + `<!-- ${i} -->`);
}
const wall = performance.now() - t0;
// 关键断言(精确、机器无关):同步返回后 body 还没有被大 html 同步插入。
expect(body.querySelectorAll('p').length).toBe(0);
// 绝对 wall 在不同机器 / V8 版本上差异较大(循环里 50 次 × 5MB 字符串分配
// 主导,与真实 DOM 提交无关),仅作回归粗筛:不应出现秒级卡顿。
expect(wall).toBeLessThan(2000);
// 收尾:abort 掉最后一次还没开始解析的 insert(5MB 的 parse5 解析在
// jsdom 里要秒级,aborted 时首帧直接 return,不会真跑)。
viewer.clear();
await viewer._test_liveInsertDone();
});
});
// P2-17(audit R8):_applyHtmlLive 两端 diff —— 未变更顶级块保留 DOM 节点
// 引用,只删/插真正变化的中段;属性顺序差异走 isEqualNode 等价 → 零变更。
// P2-14:_finishLiveInsert 把变更块作为 roots 传给 resolveRelativeImages,
// 图片解析只扫新插入的块;loadContent 等非 diff 路径不传 roots(全量扫描)。
describe('MarkdownViewer live diff 节点保留 + resolve scope (audit R8 P2-17 / P2-14)', () => {
let body;
let empty;
let viewer;
let savedApi;
let RealIO;
beforeEach(() => {
document.body.innerHTML = '<article class="body"></article><div class="empty"></div>';
body = document.querySelector('.body');
empty = document.querySelector('.empty');
savedApi = window.api;
window.api = { renderMarkdown: (md) => md };
// 与 P1-16 块相同:jsdom 无 IntersectionObserver,不 mock 会让
// _finishLiveInsert 里的 _rebuildHeadingObserver 抛 ReferenceError。
RealIO = window.IntersectionObserver;
window.IntersectionObserver = vi.fn(function () {
return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() };
});
viewer = new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath: document.createElement('div'),
statusMeta: document.createElement('div'),
});
viewer.currentFile = { path: '/notes/a.md', name: 'a.md' };
});
afterEach(() => {
if (viewer) viewer.dispose();
if (savedApi === undefined) delete window.api;
else window.api = savedApi;
window.IntersectionObserver = RealIO;
});
it('P2-17 尾部追加:前缀节点引用保留,只插新块', async () => {
viewer.loadContentLive('<p>a</p><p>b</p>');
await viewer._test_liveInsertDone();
const a = body.childNodes[0];
const b = body.childNodes[1];
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
await viewer._test_liveInsertDone();
expect(body.childNodes.length).toBe(3);
expect(body.childNodes[0]).toBe(a);
expect(body.childNodes[1]).toBe(b);
expect(body.childNodes[2].textContent).toBe('c');
});
it('P2-17 中间编辑:只替换变化的块,前后节点引用不变', async () => {
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
await viewer._test_liveInsertDone();
const a = body.childNodes[0];
const mid = body.childNodes[1];
const c = body.childNodes[2];
viewer.loadContentLive('<p>a</p><p>B</p><p>c</p>');
await viewer._test_liveInsertDone();
expect(body.childNodes.length).toBe(3);
expect(body.childNodes[0]).toBe(a);
expect(body.childNodes[2]).toBe(c);
expect(body.childNodes[1]).not.toBe(mid);
expect(body.childNodes[1].textContent).toBe('B');
});
it('P2-17 中间插入与删除:前后缀节点引用保留', async () => {
viewer.loadContentLive('<p>a</p><p>c</p>');
await viewer._test_liveInsertDone();
const a = body.childNodes[0];
const c = body.childNodes[1];
// 中间插入一个块
viewer.loadContentLive('<p>a</p><p>b</p><p>c</p>');
await viewer._test_liveInsertDone();
expect(body.childNodes.length).toBe(3);
expect(body.childNodes[0]).toBe(a);
expect(body.childNodes[2]).toBe(c);
expect(body.childNodes[1].textContent).toBe('b');
// 再删掉它(回到原状)—— a/c 仍然是同一批节点
viewer.loadContentLive('<p>a</p><p>c</p>');
await viewer._test_liveInsertDone();
expect(body.childNodes.length).toBe(2);
expect(body.childNodes[0]).toBe(a);
expect(body.childNodes[1]).toBe(c);
});
it('P2-17 属性顺序差异 → isEqualNode 等价:零 DOM 变更,resolve 收到空 roots (P2-14)', async () => {
viewer.loadContentLive('<p id="a" class="x">t</p>');
await viewer._test_liveInsertDone();
const p = body.childNodes[0];
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
// 字符串不同(绕过 content 早返回),但 DOM 语义完全等价
viewer.loadContentLive('<p class="x" id="a">t</p>');
await viewer._test_liveInsertDone();
expect(body.childNodes.length).toBe(1);
expect(body.childNodes[0]).toBe(p); // 节点没有被替换
expect(resolveSpy).toHaveBeenCalledTimes(1);
// P2-14:零变更路径传 roots=[] → resolve 内部一张 img 都不扫
expect(resolveSpy.mock.calls[0][1]).toEqual([]);
});
it('P2-17 保留前后缀 + 大中段(500 块):仍走分批插入,前后缀节点不动', async () => {
viewer.loadContentLive('<p>keep-0</p><p>keep-1</p><p>keep-2</p>');
await viewer._test_liveInsertDone();
const k0 = body.childNodes[0];
const k1 = body.childNodes[1];
const k2 = body.childNodes[2];
const mid = [];
for (let i = 0; i < 500; i++) mid.push(`<p>mid-${i}</p>`);
viewer.loadContentLive('<p>keep-0</p>' + mid.join('') + '<p>keep-1</p><p>keep-2</p>');
// 中段 500 > 200 → 分批:同步返回时中段还没开始插(body 仍是 3 个 keep)
expect(body.childNodes.length).toBe(3);
await viewer._test_liveInsertDone();
expect(body.childNodes.length).toBe(503);
expect(body.childNodes[0]).toBe(k0);
expect(body.childNodes[501]).toBe(k1);
expect(body.childNodes[502]).toBe(k2);
expect(body.childNodes[1].textContent).toBe('mid-0');
expect(body.childNodes[500].textContent).toBe('mid-499');
});
it('P2-17 abort 自愈:分批插入中途被接管,最终 DOM 收敛到新内容', async () => {
const paras = [];
for (let i = 0; i < 500; i++) paras.push(`<p>A-${i}</p>`);
viewer.loadContentLive(paras.join(''));
// 等一帧(rAF 按请求顺序执行:apply 的回调先跑完第一批插入,我们的才
// resolve)—— 此时 A 只落地了第一批 200 段,body 处于「半截 A」态。
await viewer._nextFrame();
const partial = body.querySelectorAll('p').length;
expect(partial).toBeGreaterThan(0);
expect(partial).toBeLessThan(500);
// 新内容接管:abort 旧的,diff 的旧侧读当前真实 DOM(半截 A),
// 不做任何回滚也能收敛到 B —— 这是「abort 自愈」语义的核心断言。
viewer.loadContentLive('<p>B1</p><p>B2</p><p>B3</p>');
await viewer._test_liveInsertDone();
const ps = body.querySelectorAll('p');
expect(ps.length).toBe(3);
expect(ps[0].textContent).toBe('B1');
expect(ps[2].textContent).toBe('B3');
});
it('P2-14 live diff 后 resolve 只扫变更块:roots 恰好是新插入的那个块', async () => {
viewer.loadContentLive('<p>one</p><p>two</p><p>three</p>');
await viewer._test_liveInsertDone();
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
viewer.loadContentLive('<p>one</p><p>TWO</p><p>three</p>');
await viewer._test_liveInsertDone();
expect(resolveSpy).toHaveBeenCalledTimes(1);
const roots = resolveSpy.mock.calls[0][1];
expect(roots.length).toBe(1);
expect(roots[0].textContent).toBe('TWO');
// roots 里的就是当前 body 里那个新块;保留的前后缀不在其中
expect(roots[0]).toBe(body.childNodes[1]);
expect(roots).not.toContain(body.childNodes[0]);
expect(roots).not.toContain(body.childNodes[2]);
});
it('P2-14 loadContent 路径不传 roots → 维持全 body 扫描原语义', async () => {
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
await viewer.loadContent({ path: '/notes/b.md', name: 'b.md' }, 'plain text');
expect(resolveSpy).toHaveBeenCalledTimes(1);
expect(resolveSpy.mock.calls[0][1]).toBeUndefined();
});
it('P2-14 变更块里的图片在 roots scope 下照常解析成 file://', async () => {
viewer.getDataDir = () => posixPath('/notes');
viewer.loadContentLive('<p>txt</p><p><img src="pic.png"></p>');
await viewer._test_liveInsertDone();
// 同步 getDataDir → resolve 在 _finishLiveInsert 里同步跑完,
// _test_liveInsertDone 返回时图片已解析
const img = body.querySelector('img');
expect(img.getAttribute('src')).toMatch(/^file:/);
expect(img.getAttribute('src')).toMatch(/pic\.png$/);
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.getAttribute('decoding')).toBe('async');
});
it('P2-17 已知行为:含已解析 file:// 图片的块下轮 diff 失配 → 替换 + scoped 重解析', async () => {
// resolveRelativeImages 会把 img src 改写成 file:// 并加 loading/decoding,
// 而下一轮渲染产物里仍是原始相对 src → isEqualNode 为 false,含图块
// 无法享受节点保留(退化为替换 + scoped 重解析)。这与 P1-16 前的全量
// 替换基线行为一致(图片块本来就每轮重解析),不是回归 —— 锁语义防误改。
viewer.getDataDir = () => posixPath('/notes');
viewer.loadContentLive('<p><img src="pic.png"></p><p>one</p>');
await viewer._test_liveInsertDone();
const imgBlock = body.childNodes[0];
const textBlock = body.childNodes[1];
expect(imgBlock.querySelector('img').getAttribute('src')).toMatch(/^file:/);
const resolveSpy = vi.spyOn(viewer, 'resolveRelativeImages');
viewer.loadContentLive('<p><img src="pic.png"></p><p>two</p>');
await viewer._test_liveInsertDone();
// 文本块变了 + 含图块因 src 改写失配 → 两个块都被替换,都进 roots
expect(body.childNodes[0]).not.toBe(imgBlock);
expect(body.childNodes[1]).not.toBe(textBlock);
expect(resolveSpy.mock.calls[0][1].length).toBe(2);
// 重解析后新块的 img 仍是合法 file://(scoped resolve 覆盖到位)
const img = body.querySelector('img');
expect(img.getAttribute('src')).toMatch(/^file:/);
expect(img.getAttribute('src')).toMatch(/pic\.png$/);
});
});
// 状态栏 aria-label 与 textContent 必须同源 —— 防止「SR 听到短名,视觉看到全路径」
// 的可访问性漂移。同时占位 fallback 也走同一函数,不再硬编码 '—' 字面量。
describe('MarkdownViewer 状态栏 aria-label (audit R7a a11y)', () => {
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(opts = {}) {
return new MarkdownViewer({
bodyElement: body,
emptyElement: empty,
statusPath,
statusMeta: document.createElement('div'),
...opts,
});
}
it('clear() 在无 getCurrentDir 时:textContent + aria-label 都走占位分支', () => {
const v = makeViewer();
v.clear();
expect(statusPath.textContent).toBe('—');
// 占位分支的 aria-label 是固定的描述性短句,描述按钮行为而不是"显示 —"
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示当前文件夹');
});
it('clear() 有 currentDir 时:aria-label 把目录路径带上', () => {
const v = makeViewer({ getCurrentDir: () => '/data/notes/sub' });
v.clear();
expect(statusPath.textContent).toBe('/data/notes/sub');
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /data/notes/sub');
});
it('refreshCurrentDir 跟 clear 同源:用 STATUS_PATH_PLACEHOLDER 判占位', () => {
// 拿不到 dir → 占位路径 + 占位 aria-label
let cur = '';
const v = makeViewer({ getCurrentDir: () => cur });
v.clear();
cur = '/data/notes';
v.refreshCurrentDir();
expect(statusPath.textContent).toBe('/data/notes');
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /data/notes');
});
it('updateStatusBar:aria-label 与 textContent 完全一致(不是只听文件名)', () => {
// 原 bug:file.name=foo.md,file.path=/a/b/foo.md → 视觉看到全路径,
// SR 只听到"在文件管理器中显示 foo.md"。现在改成用 path 统一。
const v = makeViewer();
v.updateStatusBar({ name: 'foo.md', path: '/a/b/foo.md', size: 100 });
expect(statusPath.textContent).toBe('/a/b/foo.md');
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /a/b/foo.md');
});
it('updateStatusBar:缺 name 但有 path → aria-label 仍带 path', () => {
const v = makeViewer();
v.updateStatusBar({ path: '/only/path.md' });
expect(statusPath.textContent).toBe('/only/path.md');
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示 /only/path.md');
});
it('updateStatusBar:file={} → 占位 aria-label(不暴「显示 —」)', () => {
const v = makeViewer();
v.updateStatusBar({});
expect(statusPath.textContent).toBe('—');
expect(statusPath.getAttribute('aria-label')).toBe('在文件管理器中显示当前文件夹');
});
});
// 空状态 CTA 委托:app.js init 把 click listener 挂在 emptyElement 上而不是按钮本身上。
// 因为 markdown-viewer.js 的 showUnsupportedFile / _resetEmptyState 会 innerHTML 重写
// emptyElement —— 监听挂在被 detached 的 button 上没意义;委托给父容器才稳。
// 这个 describe 模拟 app.js 的注册逻辑,确保 innerHTML 重写后按钮仍被响应。
describe('空状态 CTA 委托模式 (R7a a11y/correctness)', () => {
it('innerHTML 重写后,委托到父容器的 listener 仍能命中新按钮', () => {
document.body.innerHTML = '<div id="empty-state"><button id="empty-state-new-btn">新建笔记</button></div>';
const empty = document.getElementById('empty-state');
const initialBtn = document.getElementById('empty-state-new-btn');
// 模拟 app.js 的真实注册:挂在 emptyElement 上,closest 取按钮
const handler = vi.fn();
empty.addEventListener('click', (ev) => {
if (ev.target.closest('#empty-state-new-btn')) handler();
});
// 第一次点击:原始按钮,被 handler 响应
initialBtn.click();
expect(handler).toHaveBeenCalledTimes(1);
// 模拟 markdown-viewer.js 改写 innerHTML(showUnsupportedFile 会做)
empty.innerHTML = '<p>该文件不支持预览</p>';
// 再 _resetEmptyState 恢复默认空态 —— button 是新建的节点
empty.innerHTML = '<button id="empty-state-new-btn">新建笔记</button>';
const newBtn = document.getElementById('empty-state-new-btn');
expect(newBtn).not.toBe(initialBtn); // 确认是新节点
// 第二次点击新按钮 —— 委托仍在 empty 上,必须仍能命中
newBtn.click();
expect(handler).toHaveBeenCalledTimes(2);
});
});

View File

@@ -86,10 +86,20 @@ describe('ALLOWED_URI_REGEXP 单测', () => {
['.\\foo.png', true],
['..\\up.md', true],
['\\abs\\x.png', true],
// Mac/Linux 文件名 / 路径段内允许 `:` —— 之前 `[^/\\:]*` 把整个 Mac 截图命名
// 习惯(`image:001.jpg` 等)拒掉,正则分支 A 用 `(?![/\\])` 守住
// `data:text/html` / `javascript:foo/bar` 同形态,外层 `(?!)` 显式拒已
// 知危险 / 未授权 scheme,IMG 分支再 defense-in-depth。
['image:001.jpg', true],
['screenshot:1.png', true],
['dir/sub:dir/file.png', true],
// 即使危险 scheme 后只接字母(无 `/`),正则仍拒绝:顶层 `(?!)` 拦掉。
['javascript:foo', false],
['vbscript:foo', false],
['about:blank', false],
['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」。
@@ -191,6 +201,26 @@ describe('<img src> 保留策略', () => {
expect(html).not.toMatch(/src="data:text\/html/i);
});
// Mac/Linux 文件名 / 路径段内允许 `:`(修复前会被剥)。分支 A `(?![/\\])`
// 让 `image:001.jpg` 通过、`data:text/html` 拒掉;外层 `(?!)` 与 IMG 兜底把
// 形如 `javascript:foo` / `vbscript:foo` / `about:blank` 拦死。
it.each([
['image:001.jpg', 'image:001.jpg'],
['screenshot:1.png', 'screenshot:1.png'],
['dir/sub:dir/file.png', 'dir/sub:dir/file.png'],
])('Mac/Linux 文件名带 : 保留 src(%s)', (_label, src) => {
const html = render(`![a](${src})`);
expect(html).toContain(`src="${src}"`);
});
it('javascript:foo / vbscript:foo / about:blank(无括号变体)在 <img src> 被剥', () => {
// 这些形态与新增的 Mac 文件名分支 A 同结构 —— 分支 A `(?![/\\])` 不会拒它们
// (没有 `/` 紧跟 `:`),靠外层 `(?!)` 兜底。测试保证这条防御路径覆盖到。
expect(render('![a](javascript:foo)')).not.toMatch(/src="javascript:/);
expect(render('![a](vbscript:foo)')).not.toMatch(/src="vbscript:/i);
expect(render('![a](about:blank)')).not.toMatch(/src="about:/i);
});
// fix(audit 2026-08):SVG 数据 URL 在 <a href> 上点击会导航到 top-level SVG
// 上下文,现代 Chromium 多半拦截脚本执行,但跨浏览器一致性差。
// ALLOWED_URI_REGEXP 限定 data:image 为栅格格式(png/jpeg/gif/webp/...),

View File

@@ -0,0 +1,54 @@
// main.js app:save-settings —— invalid partial payload 拒绝(audit P1-1)
//
// 背景:原 handler `if (!partial || typeof partial !== 'object') return
// { ok: true, settings: configStore.getConfig() };` 把 null / undefined /
// string / array 都当成「无更新」静默放过,renderer 缓存与磁盘脱钩
// (settingsStore.update({...}) 拿不到 error 也不会重试,直到下次合法保存)。
//
// 修复:抽出 isValidPartialSettings(partial) 纯函数;handler 顶部用。
// 拒收集合:null / undefined / 任何 primitive / Array / Function。
// 接收:plain object(typeof === 'object' && !Array.isArray)。
//
// 测试策略:直接 import 纯函数,无 Electron / fs / IPC mock 依赖。
import { describe, it, expect } from 'vitest';
import { isValidPartialSettings } from '../../main/save-settings-validate.js';
describe('app:save-settings partial payload 校验(audit P1-1)', () => {
it('null → false', () => {
expect(isValidPartialSettings(null)).toBe(false);
});
it('undefined → false', () => {
expect(isValidPartialSettings(undefined)).toBe(false);
});
it('字符串 primitive → false', () => {
expect(isValidPartialSettings('hello')).toBe(false);
});
it('数字 primitive → false', () => {
expect(isValidPartialSettings(42)).toBe(false);
});
it('boolean primitive → false', () => {
expect(isValidPartialSettings(true)).toBe(false);
});
it('Array → false(即使非空)', () => {
expect(isValidPartialSettings([])).toBe(false);
expect(isValidPartialSettings(['a', 'b'])).toBe(false);
});
it('Function → false', () => {
expect(isValidPartialSettings(() => {})).toBe(false);
});
it('plain object(空)→ true', () => {
expect(isValidPartialSettings({})).toBe(true);
});
it('plain object(带字段)→ true', () => {
expect(isValidPartialSettings({ alwaysOnTop: true })).toBe(true);
});
});

View File

@@ -0,0 +1,260 @@
// main/secret-store.js —— aiApiKey safeStorage 加密层单测(audit R3 P0-1)
//
// 覆盖:
// - encrypt / decrypt 的对称与幂等
// - safeStorage 不可用(典型 Linux 无 keyring)降级 + warn-once
// - decrypt 遇损坏包络 / keyring 不可用 → 返回 ''(不抛)
// - 旧版明文 passthrough(懒迁移关键:旧 config.json 仍能解密出 Key)
// - isAvailable 处理 safeStorage 自身抛错 / 未注册的情况
//
// 注入模式:模块自暴露的 _setSafeStorage(mock)。原因同 main/config-store.js
// —— vi.mock('electron') 拦不住 CJS require。mock 用闭包记录 isEncryptionAvailable
// 状态和调用计数,单测间通过 _reset() 重置。
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// 强制每次 beforeEach 拿一份新模块实例(_reset 也会清 _warnedUnavailable 标志)
const loadModule = async () => {
// 走 ESM dynamic import;secret-store.js 是 CJS,但 Node 处理 CJS↔ESM 互操作
// 给一个 .default 包含 module.exports 的默认导出(如果用 require() 则没有)
// —— secret-store.js 用 module.exports = {...},ESM 默认 import 拿到的就是
// module.exports 本身,不需要 .default 访问。
const mod = await import('../../main/secret-store.js');
return mod;
};
describe('secret-store:encrypt / decrypt 对称', () => {
let mod;
let encryptCalls;
let decryptCalls;
beforeEach(async () => {
mod = await loadModule();
encryptCalls = [];
decryptCalls = [];
// mock safeStorage:明文 ↔ base64('ENC:<plaintext>') 做对称回路
mod._setSafeStorage({
isEncryptionAvailable: () => true,
encryptString: (plain) => {
encryptCalls.push(plain);
return Buffer.from(`ENC:${plain}`, 'utf-8');
},
decryptString: (buf) => {
const s = buf.toString('utf-8');
decryptCalls.push(s);
if (!s.startsWith('ENC:')) throw new Error('mock: bad envelope');
return s.slice('ENC:'.length);
},
});
});
afterEach(() => {
mod._reset();
});
it('encrypt(\'\') → \'\'', () => {
expect(mod.encrypt('')).toBe('');
});
it('encrypt(null) → \'\'', () => {
expect(mod.encrypt(null)).toBe('');
});
it('encrypt(undefined) → \'\'', () => {
expect(mod.encrypt(undefined)).toBe('');
});
it('encrypt(\'sk-test\') → 包络(enc:v1: 前缀 + base64)', () => {
const out = mod.encrypt('sk-test');
expect(out.startsWith(mod.ENVELOPE_PREFIX)).toBe(true);
// base64 decode 应该回到我们 mock 用的 `ENC:sk-test`
const b64 = out.slice(mod.ENVELOPE_PREFIX.length);
const decoded = Buffer.from(b64, 'base64').toString('utf-8');
expect(decoded).toBe('ENC:sk-test');
});
it('encrypt → decrypt 往返:明文完整恢复', () => {
const original = 'sk-中文-emoji-🔑-whitespace \t';
const enc = mod.encrypt(original);
expect(enc.startsWith(mod.ENVELOPE_PREFIX)).toBe(true);
expect(mod.decrypt(enc)).toBe(original);
});
it('encrypt 已经是包络的字符串 → 原样返回(幂等,防止套娃)', () => {
const once = mod.encrypt('sk-x');
const twice = mod.encrypt(once);
expect(twice).toBe(once);
// 验证 mock encryptString 只被调一次
expect(encryptCalls).toEqual(['sk-x']);
});
it('decrypt(\'\') → \'\'', () => {
expect(mod.decrypt('')).toBe('');
});
it('decrypt(null) / decrypt(undefined) → \'\'', () => {
expect(mod.decrypt(null)).toBe('');
expect(mod.decrypt(undefined)).toBe('');
});
it('decrypt 旧版明文(无前缀)→ 原样返回(懒迁移关键)', () => {
// 模拟升级前 config.json 里的 plaintext key
expect(mod.decrypt('sk-old-plaintext')).toBe('sk-old-plaintext');
});
it('decrypt 任意不含前缀的字符串 → 原样返回(防御性)', () => {
expect(mod.decrypt('xxx')).toBe('xxx');
expect(mod.decrypt('not-an-envelope-but-still-string')).toBe('not-an-envelope-but-still-string');
});
});
describe('secret-store:safeStorage 不可用降级', () => {
let warnSpy;
let mod;
beforeEach(async () => {
mod = await loadModule();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
mod._reset();
warnSpy.mockRestore();
});
it('isEncryptionAvailable() === false → encrypt 返回明文 + warn 一次', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('SHOULD-NOT-BE-USED'),
decryptString: () => 'SHOULD-NOT-BE-USED',
});
expect(mod.encrypt('sk-fallback')).toBe('sk-fallback');
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(/safeStorage 不可用/);
});
it('多次 encrypt 不可用 → 只 warn 一次(不重复打扰用户)', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
mod.encrypt('a');
mod.encrypt('b');
mod.encrypt('c');
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it('decrypt 遇到包络但 keyring 不可用 → 返回 \'\'(不抛)', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
// 模拟磁盘上有包络(base64 随便编)
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('whatever', 'utf-8').toString('base64');
expect(mod.decrypt(fakeEnvelope)).toBe('');
});
it('decrypt 遇到损坏包络(decryptString 抛错)→ 返回 \'\'(不抛)', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => true,
encryptString: (s) => Buffer.from(`ENC:${s}`),
decryptString: () => {
throw new Error('bad ciphertext');
},
});
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('corrupt', 'utf-8').toString('base64');
expect(() => mod.decrypt(fakeEnvelope)).not.toThrow();
expect(mod.decrypt(fakeEnvelope)).toBe('');
});
it('isEncryptionAvailable 自身抛错 → isAvailable() 返回 false', () => {
mod._setSafeStorage({
isEncryptionAvailable: () => {
throw new Error('keyring daemon crashed');
},
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
expect(mod.isAvailable()).toBe(false);
});
});
describe('secret-store:safeStorage 完全缺失', () => {
let warnSpy;
let mod;
beforeEach(async () => {
mod = await loadModule();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
mod._reset();
warnSpy.mockRestore();
});
it('注入 null safeStorage(模拟 require(\'electron\') 无 safeStorage)→ 降级明文', () => {
mod._setSafeStorage(null);
// encrypt 走降级路径
expect(mod.encrypt('sk-x')).toBe('sk-x');
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it('注入 null safeStorage + decrypt 包络 → 返回 \'\'', () => {
mod._setSafeStorage(null);
const fakeEnvelope = mod.ENVELOPE_PREFIX + Buffer.from('x', 'utf-8').toString('base64');
expect(mod.decrypt(fakeEnvelope)).toBe('');
});
it('isAvailable 在无 safeStorage 时 → false', () => {
mod._setSafeStorage(null);
expect(mod.isAvailable()).toBe(false);
});
});
describe('secret-store:_reset 状态隔离', () => {
let mod;
beforeEach(async () => {
mod = await loadModule();
});
it('_reset 后 _setSafeStorage 重新注入 + warn 标志清零', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
mod.encrypt('a');
mod.encrypt('b');
expect(warnSpy).toHaveBeenCalledTimes(1);
mod._reset();
// 重新注入可用 safeStorage
mod._setSafeStorage({
isEncryptionAvailable: () => true,
encryptString: (s) => Buffer.from(`E:${s}`),
decryptString: (b) => b.toString('utf-8').slice(2),
});
// warn 标志清零,再次不可用时重新 warn
mod._setSafeStorage({
isEncryptionAvailable: () => false,
encryptString: () => Buffer.from('x'),
decryptString: () => 'x',
});
mod.encrypt('c');
expect(warnSpy).toHaveBeenCalledTimes(2);
} finally {
warnSpy.mockRestore();
mod._reset();
}
});
});

View File

@@ -490,4 +490,48 @@ describe('SETTINGS_UI_OPTIONS.aiProvider[*].defaults (audit T-4)', () => {
expect(() => { p.defaults.baseUrl = 'http://evil'; }).toThrow();
expect(p.defaults.baseUrl).toBe('https://api.openai.com/v1');
});
});
});
// 2026-10 加:fileListSort 排序下拉的 schema 契约 —— enum 与 UI 选项表必须同源,
// renderer 从两端派生(FILE_LIST_SORT_VALUES / FILE_LIST_SORT_OPTIONS)。
// 任一端漏更新 → 排序下拉菜单少一项或多出非法值。
describe('fileListSort 排序下拉契约(2026-10 dropdown 改造)', () => {
const schema = require('../../shared/settings-schema.js');
const { SETTINGS_UI_OPTIONS, SETTINGS_SCHEMA } = schema;
const sortSpec = SETTINGS_SCHEMA.fileListSort;
const uiOptions = SETTINGS_UI_OPTIONS.fileListSort;
it('SETTINGS_SCHEMA.fileListSort.enum 与 SETTINGS_UI_OPTIONS.fileListSort[*].value 完全一致', () => {
const enumValues = sortSpec.enum;
const uiValues = uiOptions.map((o) => o.value);
// 用 toEqual 而非 toBe —— 顺序也得一致(dropdown 顺序由 enum 决定)
expect(uiValues).toEqual(enumValues);
});
it('每项 UI 选项都含 value + label + hint 三个字段', () => {
uiOptions.forEach((opt) => {
expect(typeof opt.value).toBe('string');
expect(typeof opt.label).toBe('string');
expect(typeof opt.hint).toBe('string');
expect(opt.label.length).toBeGreaterThan(0);
expect(opt.hint.length).toBeGreaterThan(0);
});
});
it('enum 至少 2 项 —— dropdown 设计要求比并排按钮更多选项', () => {
// 设计动机:原并排 2 按钮在 220px 侧栏下「修改时间」会被截断;改下拉后
// 至少要有「按名称 A→Z」「按修改时间 新→旧」两项作为基础集合。
expect(sortSpec.enum.length).toBeGreaterThanOrEqual(2);
});
it('DEFAULT_SETTINGS.fileListSort === schema.enum[0] === UI options[0].value(保证 fallback / 旧值不会静默被 coerce)', () => {
// 三处必须相等:
// 1) DEFAULT_SETTINGS.fileListSort —— 新装用户的初始值
// 2) schema.enum[0] —— validateAndSanitize 接受的默认值会回到这个
// 3) SETTINGS_UI_OPTIONS.fileListSort[0].value —— 下拉默认选中项
// 谁漏改谁静默失效:例如把 DEFAULT 改成 'mtime-desc' 但 enum/uiOptions 不动,
// validateAndSanitize 会把它强制降回 'name',用户看 UI 永远停在 name。
expect(DEFAULT_SETTINGS.fileListSort).toBe(sortSpec.enum[0]);
expect(DEFAULT_SETTINGS.fileListSort).toBe(SETTINGS_UI_OPTIONS.fileListSort[0].value);
});
});

View File

@@ -0,0 +1,80 @@
// main/file-ops.js + main/config-store.js tmp 文件权限(audit R5 P1-13)
//
// 背景:tmp 文件用 fs.open(path, 'w') 创建时,Node 默认 mode = 0o666 受
// umask 影响(典型 0o022 → 实际 0o644),多用户 Linux/macOS 上其他用户能
// 读父目录(典型 ~/Library/Application Support/Notes)就能读 tmp,里面
// 可能含 AI Key 包络 / 整份配置 / 用户笔记内容。
//
// 修复:fs.open(tmpPath, 'w', 0o600) 强制 rw owner only。
//
// 测试策略:直接调 fs.promises.open 验证 0o600 mode 生效(file-ops 内部用
// 的就是这条路径)。atomicWriteFile 端到端测试在 file-ops.test.js 已经覆盖
// 写盘正确性,本文件专注「mode 真的生效」这条安全属性。
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { open } from 'node:fs/promises';
let workDir;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'notes-tmp-perms-'));
});
afterEach(() => {
if (workDir) rmSync(workDir, { recursive: true, force: true });
});
describe('tmp 文件权限 0o600(audit R5 P1-13)', () => {
it('fs.open(path, "w", 0o600) → mode & 0o077 === 0', async () => {
// Windows NTFS 不强制 POSIX 权限位(用 ACL),statSync.mode 读出来仍是
// 默认 ACL 简化位。修复在 Windows 上是 no-op(但语义一致,无害)。
// 这条断言只验证 Linux/macOS —— 真正受多用户威胁面影响的平台。
if (process.platform === 'win32') return;
const tmpPath = join(workDir, 'with-mode.tmp');
const fh = await open(tmpPath, 'w', 0o600);
await fh.writeFile('test', 'utf-8');
await fh.close();
const st = statSync(tmpPath);
// 0o600 = rw------- :owner r/w,其他全无
expect(st.mode & 0o077).toBe(0);
expect(st.mode & 0o700).toBe(0o600);
});
it('fs.open 缺 mode(fallback to 0o666 + umask)可能非 0o600 —— 回归保护', async () => {
// Windows 上 NTFS 不强制 POSIX 权限位(用 ACL),跳过
if (process.platform === 'win32') return;
const tmpPath = join(workDir, 'no-mode.tmp');
const fh = await open(tmpPath, 'w');
await fh.writeFile('test', 'utf-8');
await fh.close();
const st = statSync(tmpPath);
// 不传 mode → 默认 0o666 受 umask 影响。
// umask 0o022 → 实际 0o644 (& 0o077 = 0o044)
// umask 0o077 → 实际 0o610 (& 0o077 = 0)
// 不强断言非 0,只把 mode 读出来供 review(不传 mode 是漏洞)
// eslint-disable-next-line no-console
console.log(`[tmp-perms] no-mode default mode = 0o${(st.mode & 0o777).toString(8)} (umask-dependent)`);
expect(st).toBeDefined();
});
it('file-ops atomicWriteFile 写盘成功 + 目标文件最终权限 0o600(rename 不改 mode)', async () => {
const fileOps = await import('../../main/file-ops.js');
const target = join(workDir, 'note.md');
await fileOps.atomicWriteFile(target, '# hello\n', 'utf-8');
// rename 在 Linux 上保留 source inode 的 mode,所以 final 文件的 mode
// 也应该是 0o600(除非 umask 让 0o666 & 0o600 之后又变 —— 不会,
// 因为 0o600 显式设的)。Windows 上跳过 mode 断言。
const st = statSync(target);
if (process.platform !== 'win32') {
expect(st.mode & 0o077).toBe(0);
}
// 写盘内容正确(与权限无关,但顺手验一下不破坏)
const { readFileSync } = await import('node:fs');
expect(readFileSync(target, 'utf-8')).toBe('# hello\n');
});
});