// 审计修复回归测试(2026-11-13,对应 docs/11 的高危/中危项) import { describe, it, expect } from 'vitest' import { createMarkdownEditor, cursorFromSelection } from '../src/editor/editor' import { editorViewCtx } from '@milkdown/kit/core' import { AllSelection, NodeSelection, TextSelection } from '@milkdown/kit/prose/state' import { buildTextIndex, findInIndex } from '../src/editor/search' import { insertPlainText } from '../src/editor/paste' import { findBlockIndex } from '../src/editor/cursorSync' import { getMarkdown } from '../src/editor/editor' async function makeEditor(markdown: string) { const host = document.createElement('div') document.body.appendChild(host) const editor = createMarkdownEditor(host, markdown) await editor.create() return { host, editor } } function docOf(editor: ReturnType) { return editor.action((ctx) => ctx.get(editorViewCtx).state.doc) } describe('cursorFromSelection 的 depth=0 选区(审计高危 6)', () => { it('全选时不抛 RangeError', async () => { const { host, editor } = await makeEditor('第一段\n\n第二段') editor.action((ctx) => { const view = ctx.get(editorViewCtx) expect(() => cursorFromSelection(new AllSelection(view.state.doc))).not.toThrow() }) await editor.destroy() host.remove() }) it('选中公式块(NodeSelection)时不抛 RangeError', async () => { const { host, editor } = await makeEditor('前文\n\n$$\nx=1\n$$\n\n后文') editor.action((ctx) => { const view = ctx.get(editorViewCtx) let mathPos = -1 view.state.doc.descendants((node, pos) => { if (node.type.name === 'math_block') mathPos = pos return mathPos < 0 }) expect(mathPos).toBeGreaterThanOrEqual(0) const info = cursorFromSelection(new NodeSelection(view.state.doc.resolve(mathPos))) expect(info.line).toBeGreaterThanOrEqual(1) }) await editor.destroy() host.remove() }) }) describe('查找索引修复(审计)', () => { it('正则命中横跨块间分隔符时被跳过', async () => { const { host, editor } = await makeEditor('foo\n\nbar baz') const index = buildTextIndex(docOf(editor) as never) const cross = findInIndex(index, 'foo\\s+bar', { caseSensitive: false, wholeWord: false, regex: true }) expect(cross).toHaveLength(0) const within = findInIndex(index, 'bar', { caseSensitive: false, wholeWord: false, regex: false }) expect(within).toHaveLength(1) await editor.destroy() host.remove() }) it('整词对正则模式同样生效', async () => { const { host, editor } = await makeEditor('hello world hello shell') const index = buildTextIndex(docOf(editor) as never) expect(findInIndex(index, 'hello', { caseSensitive: true, wholeWord: true, regex: true })).toHaveLength(2) expect(findInIndex(index, 'hell', { caseSensitive: true, wholeWord: true, regex: true })).toHaveLength(0) await editor.destroy() host.remove() }) it('含 İ 的文档不区分大小写查找位置不偏移', async () => { const { host, editor } = await makeEditor('İ x hello') const index = buildTextIndex(docOf(editor) as never) const hits = findInIndex(index, 'hello', { caseSensitive: false, wholeWord: false, regex: false }) expect(hits).toHaveLength(1) // 段落内容在文档中从位置 1 开始,'İ x ' 占 4 个字符,hello 应落在 5 expect(hits[0].from).toBe(5) await editor.destroy() host.remove() }) }) describe('粘贴与锚点修复(审计)', () => { it('多行纯文本粘贴按段落插入,不塞进单个文本节点', async () => { const { host, editor } = await makeEditor('开头') insertPlainText(editor, '第一行\n第二行') const md = getMarkdown(editor) expect(md).toContain('第一行') expect(md).toContain('第二行') // 两行应成为两个段落(序列化里是空行分隔),而不是一行里的字面换行 expect(md.replace(/\n+$/, '')).toMatch(/第一行\n\n/) await editor.destroy() host.remove() }) it('锚点兜底匹配不被空段落劫持', () => { const blocks = ['', '目标文本 abc', '其他'] expect(findBlockIndex(blocks, { blockText: '目标文本', offsetInBlock: 0 })).toBe(1) }) }) describe('验收回修回归', () => { it('任务复选框点击后视觉 checked 与文档同步(C3)', async () => { const { host, editor } = await makeEditor('- [ ] 待办一\n- [x] 已办') const box = host.querySelector('input.task-checkbox') expect(box).not.toBeNull() expect(box!.checked).toBe(false) box!.click() expect(box!.checked).toBe(true) expect(getMarkdown(editor)).toContain('- [x] 待办一') await editor.destroy() host.remove() }) it('HTML 导入保留 GFM 表格(E6)', async () => { const { htmlToMarkdown } = await import('../src/services/exportHtml') const md = await htmlToMarkdown('

标题

列一列二
12
') expect(md).toContain('# 标题') expect(md).toContain('| 列一 | 列二 |') expect(md).toMatch(/\| -+ \| -+ \|/) expect(md).toContain('| 1 | 2 |') }) }) describe('二轮验收回归', () => { it('Ctrl+V 粘贴记事本三行纯文本分成三段(C5)', async () => { const { host, editor } = await makeEditor('已有文字') editor.action((ctx) => { const view = ctx.get(editorViewCtx) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 5))) }) const view = editor.action((ctx) => ctx.get(editorViewCtx)) const event = new Event('paste', { bubbles: true, cancelable: true }) Object.defineProperty(event, 'clipboardData', { value: { files: [], getData: (type: string) => (type === 'text/plain' ? '第一行\r\n第二行\r\n第三行' : '') }, }) view.dom.dispatchEvent(event) const md = getMarkdown(editor) expect(md).toContain('第一行\n\n第二行\n\n第三行') await editor.destroy() host.remove() }) it('段落中间粘贴多行不产生空段落(C5)', async () => { const { host, editor } = await makeEditor('前后文') editor.action((ctx) => { const view = ctx.get(editorViewCtx) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2))) }) const { insertPlainText } = await import('../src/editor/paste') insertPlainText(editor, '第一行\n第二行') const md = getMarkdown(editor) expect(md).toContain('前第一行\n\n第二行后文') expect(md).not.toContain('
') await editor.destroy() host.remove() }) })