feat: v0.4.0 — 安全修复×6 + 增量性能×3 + Playwright E2E + 实例级 i18n
CI / test-parser (push) Successful in 9m27s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Failing after 5m15s
CI / verify (20.x) (push) Successful in 9m54s
CI / verify (18.x) (push) Successful in 9m56s
CI / verify (24.x) (push) Successful in 9m48s
CI / test-parser (push) Successful in 9m27s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Failing after 5m15s
CI / verify (20.x) (push) Successful in 9m54s
CI / verify (18.x) (push) Successful in 9m56s
CI / verify (24.x) (push) Successful in 9m48s
0.3.0 修复版: - 脚注 id 属性注入 XSS 防护(行内引用 + 脚注区) - 全局插件注入移至 afterCreate(searchReplace 等依赖 textarea 的插件真正生效)+ use() 同名防重 - 行内渲染缓存附加 refs 指纹,防跨文档引用链接串数据 - 白名单裸标签 <u>/</u> 透传,underline(Ctrl+U)预览可见 - getStatus()/渲染 env 使用实例 locale;replaceAll 替换文本按字面处理 - replaceAllRegex 保留 $1 捕获组语义 0.3.1 性能版: - 高亮规则按语言缓存(registerLanguage 覆盖失效),bench +33% - 统计增量计算:字数/词数/行数差异区间,击键零全量扫描 - outline 树构建 O(n²) → 迭代栈 O(n) - 拖放图片 >500KB 拦截、scrollToLine 真实行高、undo/redo 光标恢复 - selectionChange/cursorMove 与浮动工具栏解耦、unregisterShortcut 大小写归一 - toast 接入 7 种 ANIMATIONS、实例主题订阅随 destroy 断开(dispose) 0.4.0 工程版: - Playwright 真实浏览器冒烟测试(e2e/,22 项断言)+ CI e2e job - sideEffects: false 便于 tree-shaking - MeEditor.destroy() 全面复位(全局钩子清理 + 内部注入钩子重建) - 实例 locale 统一作用于全部 UI 文案(状态栏/工具栏/右键菜单/大纲) 测试 782 → 826,全绿;typecheck/lint/build 通过
This commit is contained in:
@@ -461,6 +461,29 @@ describe('MarkdownEditor - 插件', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('同名插件重复 use 只安装一次', () => {
|
||||
let installs = 0;
|
||||
const p = { name: 'dup-plugin', install: () => { installs++; } };
|
||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
ed.use(p);
|
||||
ed.use(p);
|
||||
ed.use(p);
|
||||
expect(installs).toBe(1);
|
||||
expect(ed.getPlugins().length).toBe(1);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('unuse 后同名插件可重新安装', () => {
|
||||
let installs = 0;
|
||||
const p = { name: 're-install', install: () => { installs++; }, destroy() {} };
|
||||
ed.use(p);
|
||||
ed.unuse('re-install');
|
||||
ed.use(p);
|
||||
expect(installs).toBe(2);
|
||||
expect(ed.getPlugins().length).toBe(1);
|
||||
});
|
||||
|
||||
test('addToolbarButton 追加按钮', () => {
|
||||
ed.addToolbarButton({ action: 'custom', title: 'Custom' });
|
||||
const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom');
|
||||
@@ -1705,6 +1728,44 @@ describe('MarkdownEditor - v0.2.0 outline 构建', () => {
|
||||
}
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('outline 树结构完整(嵌套 ul 正确闭合)', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {
|
||||
value: '# A\n## A1\n### A1a\n# B\n## B1\n### B1a\n#### B1a1\n## B2',
|
||||
outline: true, mode: 'split',
|
||||
});
|
||||
ed._buildOutline();
|
||||
const panel = ed.el.querySelector('.me-outline');
|
||||
const html = panel.innerHTML;
|
||||
const ulOpen = (html.match(/<ul>/g) || []).length;
|
||||
const ulClose = (html.match(/<\/ul>/g) || []).length;
|
||||
expect(ulOpen).toBe(ulClose);
|
||||
const liOpen = (html.match(/<li /g) || []).length;
|
||||
const liClose = (html.match(/<\/li>/g) || []).length;
|
||||
expect(liOpen).toBe(liClose);
|
||||
expect(liOpen).toBe(8);
|
||||
expect(panel.querySelectorAll('a').length).toBe(8);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('outline 长文档线性构建不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const lines = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const lvl = (i % 4) + 1;
|
||||
lines.push('#'.repeat(lvl) + ' Heading ' + i);
|
||||
}
|
||||
const ed = new MarkdownEditor(c, { value: lines.join('\n'), outline: true, mode: 'split' });
|
||||
ed._buildOutline();
|
||||
const panel = ed.el.querySelector('.me-outline');
|
||||
expect(panel.querySelectorAll('a').length).toBe(200);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.0 gutter 更新', () => {
|
||||
@@ -2098,6 +2159,260 @@ describe('MarkdownEditor - v0.2.3 光标/选区 API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.3.1 增量统计 ============
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 增量统计', () => {
|
||||
let ed;
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
ed = new MarkdownEditor(c, { value: '' });
|
||||
});
|
||||
afterEach(() => ed.destroy());
|
||||
|
||||
const fullStats = (v: string) => {
|
||||
const cnChars = (v.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const enWords = (v.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||
return { characters: v.length, chineseChars: cnChars, englishWords: enWords, words: cnChars + enWords, lines: v ? v.split('\n').length : 0 };
|
||||
};
|
||||
|
||||
test('增量结果与全量一致(逐步输入)', () => {
|
||||
const inputs = ['hello world', 'hello world foo', 'hello world foobar', '你好世界 hello', '你好世界\nhello\n世界', '中文 x 混合 test\n第二行', ''];
|
||||
for (const v of inputs) {
|
||||
ed.setValue(v, { silent: true });
|
||||
const stats = ed.getStats();
|
||||
const expectStats = fullStats(v);
|
||||
expect(stats.characters).toBe(expectStats.characters);
|
||||
expect(stats.chineseChars).toBe(expectStats.chineseChars);
|
||||
expect(stats.englishWords).toBe(expectStats.englishWords);
|
||||
expect(stats.words).toBe(expectStats.words);
|
||||
expect(stats.lines).toBe(expectStats.lines);
|
||||
}
|
||||
});
|
||||
|
||||
test('单词中间删除字符后统计正确', () => {
|
||||
ed.setValue('hello world', { silent: true });
|
||||
expect(ed.getStats().englishWords).toBe(2);
|
||||
ed.setValue('helloworld', { silent: true });
|
||||
expect(ed.getStats().englishWords).toBe(1);
|
||||
ed.setValue('hello', { silent: true });
|
||||
expect(ed.getStats().englishWords).toBe(1);
|
||||
});
|
||||
|
||||
test('追加单词与删除行后统计正确', () => {
|
||||
ed.setValue('hello world', { silent: true });
|
||||
ed.setValue('hello world extra', { silent: true });
|
||||
expect(ed.getStats().englishWords).toBe(3);
|
||||
ed.setValue('hello world extra\nnew line', { silent: true });
|
||||
expect(ed.getStats().lines).toBe(2);
|
||||
ed.setValue('single', { silent: true });
|
||||
expect(ed.getStats().lines).toBe(1);
|
||||
});
|
||||
|
||||
test('连续追加 50 次后与全量一致', () => {
|
||||
ed.setValue('初始文本 with words', { silent: true });
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ed.setValue(ed.getValue() + ' 追加' + i + 'x', { silent: true });
|
||||
}
|
||||
const v = ed.getValue();
|
||||
const stats = ed.getStats();
|
||||
const expectStats = fullStats(v);
|
||||
expect(stats.chineseChars).toBe(expectStats.chineseChars);
|
||||
expect(stats.englishWords).toBe(expectStats.englishWords);
|
||||
expect(stats.lines).toBe(expectStats.lines);
|
||||
});
|
||||
|
||||
test('gutter 行数跟随统计缓存', () => {
|
||||
ed.setValue('a\nb\nc', { silent: true });
|
||||
expect(ed.gutter.children.length).toBe(3);
|
||||
ed.setValue('a\nb', { silent: true });
|
||||
expect(ed.gutter.children.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.3.1 P2 体验一致性 ============
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 undo 光标恢复', () => {
|
||||
test('undo 后光标位置被 clamp 恢复', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
ed.setValue('hello world');
|
||||
ed.textarea.setSelectionRange(11, 11);
|
||||
ed.setValue('hello world foo');
|
||||
ed.textarea.setSelectionRange(15, 15);
|
||||
ed.undo();
|
||||
expect(ed.getValue()).toBe('hello world');
|
||||
expect(ed.textarea.selectionStart).toBe(11);
|
||||
expect(ed.textarea.selectionEnd).toBe(11);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('redo 后光标不越界', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
ed.setValue('ab');
|
||||
ed.undo();
|
||||
ed.redo();
|
||||
expect(ed.getValue()).toBe('ab');
|
||||
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 selectionChange 与浮动工具栏解耦', () => {
|
||||
test('floatingToolbar: false 时 selectionChange 仍触发', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'hello world', floatingToolbar: false });
|
||||
const handler = jest.fn();
|
||||
ed.on('selectionChange', handler);
|
||||
ed.textarea.setSelectionRange(0, 5);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(handler.mock.calls[0][0]).toHaveProperty('start', 0);
|
||||
expect(handler.mock.calls[0][0]).toHaveProperty('end', 5);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('floatingToolbar: false 时 cursorMove 仍触发', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'ab', floatingToolbar: false });
|
||||
const handler = jest.fn();
|
||||
ed.on('cursorMove', handler);
|
||||
ed.textarea.setSelectionRange(1, 1);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(handler).toHaveBeenCalled();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 unregisterShortcut 大小写归一', () => {
|
||||
test('注册 Ctrl+B 后可用 ctrl+b 注销', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
ed.registerShortcut('Ctrl+B', 'bold');
|
||||
expect(ed.getShortcuts().length).toBe(1);
|
||||
ed.unregisterShortcut('ctrl+b');
|
||||
expect(ed.getShortcuts().length).toBe(0);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 toast 动画接入', () => {
|
||||
test('所有内置动画类型不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
['fade', 'slide', 'scale', 'bounce', 'flip', 'rotate', 'zoom'].forEach((a) => {
|
||||
ed.toast('msg', { animation: a, duration: 0 });
|
||||
const t = ed.el.querySelector('.me-toast');
|
||||
expect(t).not.toBeNull();
|
||||
expect(t.className).toContain(`me-toast-${a}`);
|
||||
t.remove();
|
||||
});
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('toast 应用动画进入样式', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
ed.toast('hello', { animation: 'slide', duration: 0 });
|
||||
const t = ed.el.querySelector('.me-toast') as HTMLElement;
|
||||
expect(t).not.toBeNull();
|
||||
expect(t.style.transition).toContain('400ms');
|
||||
expect(t.style.opacity).toBe('0');
|
||||
t.remove();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.3.1 拖放图片大小限制', () => {
|
||||
test('超大图片拖入时 toast 警告且不插入', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed);
|
||||
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||
const file = new File([new ArrayBuffer(1024 * 1024)], 'big.png', { type: 'image/png' });
|
||||
const dt = { files: [file] };
|
||||
const evt = new Event('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(evt, 'dataTransfer', { value: dt });
|
||||
ed.textarea.dispatchEvent(evt as DragEvent);
|
||||
expect(toastSpy).toHaveBeenCalled();
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
toastSpy.mockRestore();
|
||||
insertSpy.mockRestore();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('小图片拖入正常插入', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
|
||||
const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' });
|
||||
const dt = { files: [file] };
|
||||
const evt = new Event('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(evt, 'dataTransfer', { value: dt });
|
||||
ed.textarea.dispatchEvent(evt as DragEvent);
|
||||
expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅校验
|
||||
insertSpy.mockRestore();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.4.0 实例 locale 决定 UI 文案', () => {
|
||||
test('实例 locale 为 en-US 时状态栏与按钮为英文', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'hello', locale: 'en-US', wordCount: true });
|
||||
expect(ed.statusEl.textContent).toContain('Characters');
|
||||
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
|
||||
expect(boldBtn.title).toBe('Bold');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('实例 locale 为 zh-CN 时状态栏与按钮为中文(不受浏览器默认影响)', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '你好', locale: 'zh-CN', wordCount: true });
|
||||
expect(ed.statusEl.textContent).toContain('字符');
|
||||
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
|
||||
expect(boldBtn.title).toBe('粗体');
|
||||
expect(ed.textarea.getAttribute('aria-label')).toBe('编辑');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('右键菜单标签跟随实例 locale', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'text', locale: 'en-US' });
|
||||
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 10, clientY: 10, bubbles: true }));
|
||||
const menu = document.querySelector('.me-context-menu');
|
||||
expect(menu.textContent).toContain('Undo');
|
||||
if (menu) menu.remove();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.3 内容操作 API ============
|
||||
|
||||
describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
|
||||
@@ -2151,6 +2466,19 @@ describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('replaceAll 替换文本中的 $ 按字面处理', () => {
|
||||
ed.setValue('a b a');
|
||||
const count = ed.replaceAll('a', '$1');
|
||||
expect(count).toBe(2);
|
||||
expect(ed.getValue()).toBe('$1 b $1');
|
||||
});
|
||||
|
||||
test('replaceAllRegex 保留正则捕获组语义', () => {
|
||||
ed.setValue('hello 2024');
|
||||
ed.replaceAllRegex(/(\d{4})/, '$1!');
|
||||
expect(ed.getValue()).toBe('hello 2024!');
|
||||
});
|
||||
|
||||
test('lineCount 返回行数', () => {
|
||||
expect(ed.lineCount()).toBe(1);
|
||||
ed.setValue('a\nb\nc');
|
||||
@@ -2300,6 +2628,17 @@ describe('MarkdownEditor - v0.2.3 getStatus', () => {
|
||||
expect(Array.isArray(s.plugins)).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('getStatus locale 跟随实例语言', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { locale: 'en-US' });
|
||||
ed.setLocale('de');
|
||||
expect(ed.getStatus().locale).toBe('de');
|
||||
expect(ed.getLocale()).toBe('de');
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============
|
||||
|
||||
@@ -91,6 +91,14 @@ describe('highlight - 语言注册', () => {
|
||||
expect(out).toContain('<span class="me-hl-keyword">frobnicate</span>');
|
||||
});
|
||||
|
||||
test('覆盖已有语言定义后规则缓存失效', () => {
|
||||
registerLanguage('langA', { keywords: ['one'], builtins: [] });
|
||||
expect(highlight('one', 'langA')).toContain('me-hl-keyword');
|
||||
registerLanguage('langA', { keywords: ['two'], builtins: [] });
|
||||
expect(highlight('two', 'langA')).toContain('me-hl-keyword');
|
||||
expect(highlight('one', 'langA')).not.toContain('me-hl-keyword');
|
||||
});
|
||||
|
||||
test('getSupportedLanguages 返回语言列表', () => {
|
||||
const langs = getSupportedLanguages();
|
||||
expect(Array.isArray(langs)).toBe(true);
|
||||
|
||||
+39
-2
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
|
||||
|
||||
test('VERSION 是字符串', () => {
|
||||
expect(typeof VERSION).toBe('string');
|
||||
expect(VERSION).toBe('0.2.5');
|
||||
expect(VERSION).toBe('0.4.0');
|
||||
});
|
||||
|
||||
test('api.default 是 api 本身', () => {
|
||||
@@ -81,6 +81,24 @@ describe('index.ts - 全局 API', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('全局 searchReplace 在新实例上可用(DOM 已就绪)', () => {
|
||||
destroy();
|
||||
use('searchReplace');
|
||||
const ed = create(container, { value: 'foo bar' });
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
|
||||
expect(ed.el.querySelector('.me-search')).not.toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('全局插件与 config.plugins 同名时只安装一次', () => {
|
||||
destroy();
|
||||
use('autoSave');
|
||||
const ed = create(container, { value: 'test', plugins: ['autoSave'] });
|
||||
const count = ed.getPlugins().filter((p: any) => p.name === 'autoSave').length;
|
||||
expect(count).toBe(1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('on / off 全局钩子', () => {
|
||||
const fn = jest.fn();
|
||||
const unsub = on('afterCreate', fn);
|
||||
@@ -111,11 +129,30 @@ describe('index.ts - 全局 API', () => {
|
||||
});
|
||||
|
||||
test('destroy 清理全局资源', () => {
|
||||
expect(() => destroy()).not.toThrow();
|
||||
destroy();
|
||||
const status = getStatus();
|
||||
expect(status.globalPlugins.length).toBe(0);
|
||||
});
|
||||
|
||||
test('destroy 清理用户全局钩子但保留内部注入钩子', () => {
|
||||
destroy();
|
||||
const fn = jest.fn();
|
||||
on('afterCreate', fn);
|
||||
expect(MarkdownEditor._hooks.get('afterCreate')!.length).toBeGreaterThan(0);
|
||||
destroy();
|
||||
const hooks = MarkdownEditor._hooks.get('afterCreate') || [];
|
||||
expect(hooks.length).toBe(1); // 仅剩内部 injectGlobalPlugins
|
||||
expect(hooks[0]).not.toBe(fn);
|
||||
});
|
||||
|
||||
test('destroy 后 MeEditor.use 全局插件仍可用', () => {
|
||||
destroy();
|
||||
use('autoSave');
|
||||
const ed = create(container, { value: 'x' });
|
||||
expect(ed.getPlugins().some((p: any) => p.name === 'autoSave')).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('window.MeEditor 被设置', () => {
|
||||
expect((window as any).MeEditor).toBeDefined();
|
||||
expect((window as any).MeEditor.VERSION).toBe(VERSION);
|
||||
|
||||
@@ -1329,6 +1329,93 @@ describe('parseMarkdown - v0.2.5 title 转义', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMarkdown - v0.2.5 脚注 id 属性注入防护', () => {
|
||||
test('脚注引用 id 中的引号不能注入属性', () => {
|
||||
const html = parseMarkdown('[^a" onclick="alert(1)]\n\n[^a" onclick="alert(1)]: note');
|
||||
expect(html).not.toContain(' onclick="');
|
||||
expect(html).not.toContain('onclick="alert(1)"');
|
||||
expect(html).toContain('" onclick');
|
||||
});
|
||||
|
||||
test('脚注区 id 中的引号被转义', () => {
|
||||
const html = parseMarkdown('x[^a" onload="x]\n\n[^a" onload="x]: note');
|
||||
expect(html).not.toContain(' onload="');
|
||||
expect(html).toContain('" onload');
|
||||
});
|
||||
|
||||
test('正常脚注 id 不受影响', () => {
|
||||
const html = parseMarkdown('text[^1]\n\n[^1]: note');
|
||||
expect(html).toContain('id="fnref-1"');
|
||||
expect(html).toContain('id="fn-1"');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.3.0 白名单裸标签透传(underline) ============
|
||||
|
||||
describe('parseMarkdown - v0.3.0 underline 渲染', () => {
|
||||
test('裸 <u> 标签透传为下划线', () => {
|
||||
const html = parseMarkdown('<u>underlined</u>');
|
||||
expect(html).toContain('<p><u>underlined</u></p>');
|
||||
});
|
||||
|
||||
test('Ctrl+U 命令输出在预览中可见', () => {
|
||||
const html = parseMarkdown('<u>hello</u> world');
|
||||
expect(html).toContain('<u>hello</u>');
|
||||
expect(html).not.toContain('<u>');
|
||||
});
|
||||
|
||||
test('带属性的 <u onclick> 仍被转义', () => {
|
||||
const html = parseMarkdown('<u onclick="alert(1)">x</u>');
|
||||
expect(html).not.toContain('<u onclick');
|
||||
expect(html).toContain('<u');
|
||||
});
|
||||
|
||||
test('行内代码中的 <u> 不被透传', () => {
|
||||
const html = parseMarkdown('`<u>x</u>`');
|
||||
expect(html).toContain('<code><u>x</u></code>');
|
||||
expect(html).not.toContain('<u>');
|
||||
});
|
||||
|
||||
test('代码块中的 <u> 不被透传', () => {
|
||||
const html = parseMarkdown('```\n<u>x</u>\n```');
|
||||
expect(html).toContain('<u>');
|
||||
expect(html).not.toContain('<u>');
|
||||
});
|
||||
|
||||
test('<script> 仍被转义(白名单外)', () => {
|
||||
const html = parseMarkdown('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.3.0 渲染缓存 refs 指纹 ============
|
||||
|
||||
describe('parseMarkdown - v0.3.0 渲染缓存 refs 指纹', () => {
|
||||
test('相同文本不同引用定义互不串数据', () => {
|
||||
clearRenderCache();
|
||||
const htmlA = parseMarkdown('[x][r]\n\n[r]: https://a.com');
|
||||
const htmlB = parseMarkdown('[x][r]\n\n[r]: https://b.com');
|
||||
expect(htmlA).toContain('href="https://a.com"');
|
||||
expect(htmlB).toContain('href="https://b.com"');
|
||||
expect(htmlA).not.toContain('href="https://b.com"');
|
||||
expect(htmlB).not.toContain('href="https://a.com"');
|
||||
});
|
||||
|
||||
test('无引用定义的文本仍可命中缓存', () => {
|
||||
clearRenderCache();
|
||||
expect(parseMarkdown('**bold** text')).toBe(parseMarkdown('**bold** text'));
|
||||
});
|
||||
|
||||
test('引用链接与无引用文本混合时各自正确', () => {
|
||||
clearRenderCache();
|
||||
const htmlA = parseMarkdown('[x][r] and **plain**\n\n[r]: https://a.com');
|
||||
const htmlB = parseMarkdown('[x][r] and **plain**\n\n[r]: https://b.com');
|
||||
expect(htmlA).toContain('https://a.com');
|
||||
expect(htmlB).toContain('https://b.com');
|
||||
expect(htmlB).toContain('<strong>plain</strong>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMarkdown - v0.2.5 URL 属性无双重转义', () => {
|
||||
test('链接 URL 中的 & 不双重转义', () => {
|
||||
const html = parseMarkdown('[x](https://example.com/?a=1&b=2)');
|
||||
|
||||
@@ -758,6 +758,57 @@ describe('v0.1.5 watch 外部主题源', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.3.1 实例主题 dispose', () => {
|
||||
test('dispose 断开外部跟随的 MutationObserver', () => {
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('data-theme', 'dark');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
container: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
|
||||
ctx.syncWithElement(div);
|
||||
ctx.dispose();
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
disconnectSpy.mockRestore();
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('dispose 后可重复调用且不抛错', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
container: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
expect(() => { ctx.dispose(); ctx.dispose(); }).not.toThrow();
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('MarkdownEditor destroy 时断开实例主题订阅', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
c.setAttribute('data-theme', 'dark');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
const ctx = ed.getThemeContext();
|
||||
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
|
||||
ctx.adopt();
|
||||
ed.destroy();
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
disconnectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 MarkdownEditor 实例主题 API', () => {
|
||||
test('setTheme / getTheme 实例方法', () => {
|
||||
document.body.innerHTML = '';
|
||||
|
||||
Reference in New Issue
Block a user