Files
new-markdown/tests/audit-fix.test.ts
Muse b2d36b7397 fix(acceptance-r2): 二轮验收 4 项回修(docs/15)
C5 粘贴:handleDOMEvents.paste 前置拦截无 HTML/无文件的多行纯文本按段落
插入(真机菜单粘贴走 execCommand 兜底、与 Ctrl+V 同中 clipboard 插件的
Markdown 解析);插入改开放 Slice,消除段中空段。
E3 相对导出:先选保存位置,再按导出目录重写图片相对路径。
E4 复制为 HTML:剪贴板变体公式只留 MathML、Mermaid 栅格化 PNG。
E6 HTML 导入:DOM 预处理剥 head/脚本/SVG、自家 KaTeX 还原 $ 源码、
GFM 插件加载失败降级。vitest 79 通过 + 2 跳过。
2026-11-14 11:17:12 +08:00

161 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 审计修复回归测试(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<typeof createMarkdownEditor>) {
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<HTMLInputElement>('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('<h1>标题</h1><table><thead><tr><th>列一</th><th>列二</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>')
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('<br />')
await editor.destroy()
host.remove()
})
})