157 lines
5.8 KiB
JavaScript
157 lines
5.8 KiB
JavaScript
// 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); // 没变化
|
||
});
|
||
});
|