fix(audit): 审计问题全量修复(docs/12)
高危逻辑 A1-A10:saved 写盘快照、重载卸载回写闸门与期望值校验、 ?file 无条件打开、偏好读后才写+防抖、depth=0 守卫、查找命中随编辑 作废、自动保存避让冲突标签、搜索防链接环、重 IO 命令异步化+上限。 样式:--panel 更正为 --bg-panel、新增 .input 基础样式、文档主题 纸面/字色成对、打印隐藏清单补全、paperTone 驱动源码明暗。 性能:状态栏/大纲按正文 memo、大纲延后提取、Sidebar memo、专注 装饰映射复用、查找防抖、图片 base64 过 IPC。 中危一批:关窗落盘后放行、回收站去向明示、GBK 搜索、create_new 落盘、Mermaid 渲染代次守卫、多行粘贴分段等。 回归:新增 tabkey/audit-fix 集成测试(E1 Tab 抢先定性排除), vitest 71 通过 + 2 跳过。
This commit is contained in:
102
tests/audit-fix.test.ts
Normal file
102
tests/audit-fix.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// 审计修复回归测试(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 } 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)
|
||||
})
|
||||
})
|
||||
@@ -91,7 +91,7 @@ describe('快捷键注册表', () => {
|
||||
|
||||
it('事件组合键归一化与展示', () => {
|
||||
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: 'S' })).toBe('ctrl+shift+s')
|
||||
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: '+' })).toBe('ctrl+shift+=')
|
||||
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: '+' })).toBe('ctrl+=')
|
||||
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: false, key: 'Control' })).toBeNull()
|
||||
expect(prettyCombo('ctrl+shift+s')).toBe('Ctrl+Shift+S')
|
||||
expect(prettyCombo('')).toBe('(未绑定)')
|
||||
|
||||
80
tests/tabkey.test.ts
Normal file
80
tests/tabkey.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Tab 键集成测试(审计 E1 定性):向组装好的编辑器派发真实 keydown 事件,
|
||||
// 验证表格跳格 / 代码块行首缩进到底由哪个插件处理——此前的测试都是直调函数,
|
||||
// 从未验证过 Milkdown 自带 indent 插件(对 Tab 无条件插两空格)是否抢先。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
|
||||
const MD = [
|
||||
'普通段落文字。',
|
||||
'',
|
||||
'| A | B |',
|
||||
'| --- | --- |',
|
||||
'| 甲一 | 乙二 |',
|
||||
'',
|
||||
'```ts',
|
||||
'const x = 1',
|
||||
'```',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
function dispatchTab(host: HTMLElement, editor: ReturnType<typeof createMarkdownEditor>, shift = false) {
|
||||
const view = editor.action((ctx) => ctx.get(editorViewCtx))
|
||||
const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: shift, bubbles: true, cancelable: true })
|
||||
view.dom.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
function setCursorIn(editor: ReturnType<typeof createMarkdownEditor>, needle: string, offsetInText = 1) {
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
let target = -1
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (target < 0 && node.isText && node.text?.includes(needle)) {
|
||||
target = pos + node.text.indexOf(needle) + offsetInText
|
||||
}
|
||||
return target < 0
|
||||
})
|
||||
if (target < 0) throw new Error(`找不到文本 ${needle}`)
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, target)))
|
||||
view.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function cursorCellText(editor: ReturnType<typeof createMarkdownEditor>): string {
|
||||
return editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
return view.state.selection.$from.parent.textContent
|
||||
})
|
||||
}
|
||||
|
||||
describe('Tab 键真实派发', () => {
|
||||
it('表格内 Tab 跳到下一个单元格而不是插入空格', async () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const editor = createMarkdownEditor(host, MD)
|
||||
await editor.create()
|
||||
setCursorIn(editor, '甲一')
|
||||
const before = getMarkdown(editor)
|
||||
dispatchTab(host, editor)
|
||||
expect(cursorCellText(editor)).toBe('乙二')
|
||||
expect(getMarkdown(editor)).toBe(before) // 文档未被插入空格改动
|
||||
await editor.destroy()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('代码块内 Tab 在行首缩进(光标在行中时)', async () => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const editor = createMarkdownEditor(host, MD)
|
||||
await editor.create()
|
||||
setCursorIn(editor, 'const x = 1', 4) // 光标停在 "con|st" 处
|
||||
dispatchTab(host, editor)
|
||||
const md = getMarkdown(editor)
|
||||
expect(md).toContain(' const x = 1') // 行首多了两空格
|
||||
expect(md).not.toContain('con st') // 不是在光标处插空格
|
||||
await editor.destroy()
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
@@ -24,11 +24,21 @@ describe('标签页会话模型', () => {
|
||||
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '原文' })
|
||||
expect(isDirty(s.tabs[0])).toBe(false)
|
||||
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '新版' })
|
||||
s = tabsReducer(s, { type: 'saved', id: t.id })
|
||||
s = tabsReducer(s, { type: 'saved', id: t.id, savedContent: '新版' })
|
||||
expect(isDirty(s.tabs[0])).toBe(false)
|
||||
expect(s.tabs[0].savedContent).toBe('新版')
|
||||
})
|
||||
|
||||
it('保存竞态:写盘期间的新输入不被 saved 误标(审计回归)', () => {
|
||||
const t = createTab({ content: '快照' })
|
||||
let s: TabsState = { tabs: [t], activeId: t.id }
|
||||
// 写盘用的是「快照」;await 期间用户又输入了新内容
|
||||
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '快照+新输入' })
|
||||
s = tabsReducer(s, { type: 'saved', id: t.id, savedContent: '快照' })
|
||||
expect(s.tabs[0].savedContent).toBe('快照')
|
||||
expect(isDirty(s.tabs[0])).toBe(true) // 新输入仍是脏的,等待下一次保存
|
||||
})
|
||||
|
||||
it('关闭活动标签后激活相邻标签;关闭其他/右侧范围正确', () => {
|
||||
let s = stateWith(['a', 'b', 'c'])
|
||||
const [t1, t2, t3] = s.tabs
|
||||
|
||||
Reference in New Issue
Block a user