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

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:
2026-08-09 12:31:14 +08:00
parent 64aedb9546
commit cb0caff4cf
24 changed files with 1122 additions and 129 deletions
+339
View File
@@ -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 / 分隔条隔离 ============
+8
View File
@@ -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
View File
@@ -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);
+87
View File
@@ -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('&quot; onclick');
});
test('脚注区 id 中的引号被转义', () => {
const html = parseMarkdown('x[^a" onload="x]\n\n[^a" onload="x]: note');
expect(html).not.toContain(' onload="');
expect(html).toContain('&quot; 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('&lt;u&gt;');
});
test('带属性的 <u onclick> 仍被转义', () => {
const html = parseMarkdown('<u onclick="alert(1)">x</u>');
expect(html).not.toContain('<u onclick');
expect(html).toContain('&lt;u');
});
test('行内代码中的 <u> 不被透传', () => {
const html = parseMarkdown('`<u>x</u>`');
expect(html).toContain('<code>&lt;u&gt;x&lt;/u&gt;</code>');
expect(html).not.toContain('<u>');
});
test('代码块中的 <u> 不被透传', () => {
const html = parseMarkdown('```\n<u>x</u>\n```');
expect(html).toContain('&lt;u&gt;');
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)');
+51
View File
@@ -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 = '';