feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,997 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { full as markdownItEmoji } from 'markdown-it-emoji'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { removeCustomComponents, setCustomComponents } from '../../src/utils/nodeComponents'
|
||||
import { flushAll } from '../setup/flush-all'
|
||||
|
||||
interface Scenario {
|
||||
name: string
|
||||
markdown: string
|
||||
expectedText?: string | string[]
|
||||
props?: Record<string, any>
|
||||
mountOptions?: Record<string, any>
|
||||
assert?: (wrapper: VueWrapper<any>) => void | Promise<void>
|
||||
skipSnapshot?: boolean
|
||||
}
|
||||
|
||||
let MarkdownRender: any
|
||||
|
||||
// Use shared flushAll from test/setup/flush-all
|
||||
|
||||
async function mountMarkdown(markdown: string, props: Record<string, any> = {}, mountOptions: Record<string, any> = {}) {
|
||||
const wrapper = mount(MarkdownRender, {
|
||||
props: {
|
||||
content: markdown,
|
||||
...props,
|
||||
customMarkdownIt: (md: any) => md.use(markdownItEmoji),
|
||||
},
|
||||
...mountOptions,
|
||||
})
|
||||
await flushAll()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function normalizeText(input: string) {
|
||||
return input.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function sanitizeSnapshotHtml(html: string, name: string) {
|
||||
html = html.replace(/class="([^"]*)"/g, (_, classNames: string) => {
|
||||
const normalized = classNames
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter(className => !/^typewriter-(?:enter|leave)(?:-(?:from|to|active))?$/.test(className))
|
||||
.join(' ')
|
||||
|
||||
return normalized ? `class="${normalized}"` : ''
|
||||
})
|
||||
|
||||
if (name.includes('admonition'))
|
||||
return html.replace(/admonition-[a-z0-9]+/gi, 'admonition-stable')
|
||||
return html
|
||||
}
|
||||
|
||||
describe('markdownRender node e2e coverage', () => {
|
||||
beforeAll(async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
||||
MarkdownRender = (await import('../../src/components/NodeRenderer')).default
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
; (Date.now as any).mockRestore?.()
|
||||
})
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: 'paragraph and text node',
|
||||
markdown: 'Simple paragraph content rendered as plain text.',
|
||||
expectedText: 'Simple paragraph content rendered as plain text.',
|
||||
assert: (wrapper) => {
|
||||
const paragraph = wrapper.find('p')
|
||||
expect(paragraph.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'heading node',
|
||||
markdown: '# Heading Node Title',
|
||||
expectedText: 'Heading Node Title',
|
||||
assert: (wrapper) => {
|
||||
const heading = wrapper.find('h1')
|
||||
expect(heading.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'blockquote node',
|
||||
markdown: '> Blockquote rendered content.',
|
||||
expectedText: 'Blockquote rendered content.',
|
||||
assert: (wrapper) => {
|
||||
const blockquote = wrapper.find('blockquote')
|
||||
expect(blockquote.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'unordered list node',
|
||||
markdown: '- Item one\n- Item two',
|
||||
expectedText: ['Item one', 'Item two'],
|
||||
assert: (wrapper) => {
|
||||
const list = wrapper.find('ul')
|
||||
expect(list.exists()).toBe(true)
|
||||
expect(list.findAll('li')).toHaveLength(2)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ordered list node',
|
||||
markdown: '1. First entry\n2. Second entry',
|
||||
expectedText: ['First entry', 'Second entry'],
|
||||
assert: (wrapper) => {
|
||||
const list = wrapper.find('ol')
|
||||
expect(list.exists()).toBe(true)
|
||||
expect(list.findAll('li')).toHaveLength(2)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'checkbox nodes',
|
||||
markdown: '- [x] Completed task\n- [ ] Pending task',
|
||||
expectedText: ['Completed task', 'Pending task'],
|
||||
assert: (wrapper) => {
|
||||
const checkboxes = wrapper.findAll('.checkbox-node')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
expect(checkboxes[0].find('.checkbox-checked').exists()).toBe(true)
|
||||
expect(checkboxes[1].find('.checkbox-unchecked').exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'link node',
|
||||
markdown: 'Visit [Vue](https://vuejs.org) now.',
|
||||
expectedText: ['Visit', 'Vue', 'now.'],
|
||||
assert: (wrapper) => {
|
||||
const link = wrapper.find('a[href="https://vuejs.org"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toBe('Vue')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image node',
|
||||
markdown: '',
|
||||
mountOptions: {
|
||||
global: {
|
||||
stubs: {
|
||||
'transition': false,
|
||||
'transition-group': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (wrapper) => {
|
||||
const paragraph = wrapper.find('p.paragraph-node')
|
||||
expect(paragraph.exists()).toBe(true)
|
||||
expect(paragraph.find('figure').exists()).toBe(false)
|
||||
expect(paragraph.findAll('img')).toHaveLength(1)
|
||||
const img = paragraph.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://example.com/vue.png')
|
||||
expect(img.attributes('title')).toBe('Vue Logo')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'single badge image with trailing newline stays inline',
|
||||
markdown: '\n',
|
||||
mountOptions: {
|
||||
global: {
|
||||
stubs: {
|
||||
'transition': false,
|
||||
'transition-group': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (wrapper) => {
|
||||
const paragraph = wrapper.find('p.paragraph-node')
|
||||
expect(paragraph.exists()).toBe(true)
|
||||
expect(paragraph.find('figure').exists()).toBe(false)
|
||||
expect(paragraph.findAll('img')).toHaveLength(1)
|
||||
const img = paragraph.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://img.shields.io/npm/l/markstream-vue')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image paragraph separated by blank lines stays inline',
|
||||
markdown: `Before
|
||||
|
||||

|
||||
|
||||
After`,
|
||||
expectedText: ['Before', 'After'],
|
||||
mountOptions: {
|
||||
global: {
|
||||
stubs: {
|
||||
'transition': false,
|
||||
'transition-group': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
skipSnapshot: true,
|
||||
assert: (wrapper) => {
|
||||
const paragraphs = wrapper.findAll('p.paragraph-node')
|
||||
expect(paragraphs).toHaveLength(3)
|
||||
const imageParagraph = paragraphs[1]
|
||||
expect(imageParagraph.find('figure').exists()).toBe(false)
|
||||
expect(imageParagraph.findAll('img')).toHaveLength(1)
|
||||
const img = imageParagraph.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://example.com/vue.png')
|
||||
expect(img.attributes('title')).toBe('Vue Logo')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inline image inside text stays inline',
|
||||
markdown: 'Before  after.',
|
||||
expectedText: ['Before', 'after.'],
|
||||
mountOptions: {
|
||||
global: {
|
||||
stubs: {
|
||||
'transition': false,
|
||||
'transition-group': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
skipSnapshot: true,
|
||||
assert: (wrapper) => {
|
||||
const paragraph = wrapper.find('p.paragraph-node')
|
||||
expect(paragraph.exists()).toBe(true)
|
||||
expect(paragraph.find('figure').exists()).toBe(false)
|
||||
expect(paragraph.findAll('img')).toHaveLength(1)
|
||||
const img = paragraph.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe('https://example.com/vue.png')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image links in same paragraph stay inline',
|
||||
markdown: `[](https://www.npmjs.com/package/markstream-vue)
|
||||
[](https://markstream-vue.simonhe.me/)
|
||||
[](https://github.com/Simon-He95/markstream-vue/actions/workflows/ci.yml)`,
|
||||
mountOptions: {
|
||||
global: {
|
||||
stubs: {
|
||||
'transition': false,
|
||||
'transition-group': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
skipSnapshot: true,
|
||||
assert: (wrapper) => {
|
||||
const paragraph = wrapper.find('p.paragraph-node')
|
||||
expect(paragraph.exists()).toBe(true)
|
||||
expect(paragraph.element.tagName).toBe('P')
|
||||
|
||||
const childTagNames = Array.from(paragraph.element.children).map(child => child.tagName)
|
||||
expect(childTagNames).toEqual(['A', 'A', 'A'])
|
||||
|
||||
const links = paragraph.findAll('a.link-node')
|
||||
expect(links).toHaveLength(3)
|
||||
|
||||
const images = paragraph.findAll('img')
|
||||
expect(images).toHaveLength(3)
|
||||
expect(paragraph.findAll('figure')).toHaveLength(0)
|
||||
// Each link should contain an img (possibly wrapped in a container span)
|
||||
const linksWithImages = links.filter(link => link.find('img').exists())
|
||||
expect(linksWithImages).toHaveLength(3)
|
||||
expect(images[0].attributes('src')).toContain('img.shields.io/npm/v/markstream-vue')
|
||||
expect(images[1].attributes('src')).toContain('img.shields.io/badge/playground-live-34c759')
|
||||
expect(images[2].attributes('src')).toContain('/badge.svg')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image links with relative target stay inline',
|
||||
markdown: `[](https://www.npmjs.com/package/markstream-vue)
|
||||
[](README.zh-CN.md)`,
|
||||
mountOptions: {
|
||||
global: {
|
||||
stubs: {
|
||||
'transition': false,
|
||||
'transition-group': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (wrapper) => {
|
||||
const paragraph = wrapper.find('p.paragraph-node')
|
||||
expect(paragraph.exists()).toBe(true)
|
||||
expect(paragraph.element.tagName).toBe('P')
|
||||
|
||||
const childTagNames = Array.from(paragraph.element.children).map(child => child.tagName)
|
||||
expect(childTagNames).toEqual(['A', 'A'])
|
||||
|
||||
const links = paragraph.findAll('a.link-node')
|
||||
expect(links).toHaveLength(2)
|
||||
expect(paragraph.findAll('span.text-node')).toHaveLength(0)
|
||||
|
||||
const images = paragraph.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(links[0].find('img').exists()).toBe(true)
|
||||
expect(links[1].find('img').exists()).toBe(true)
|
||||
expect(links[1].attributes('href')).toBe('README.zh-CN.md')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inline code node',
|
||||
markdown: 'Here is `const answer = 42` inline.',
|
||||
expectedText: ['Here is', 'const answer = 42', 'inline.'],
|
||||
assert: (wrapper) => {
|
||||
const code = wrapper.find('code')
|
||||
|
||||
expect(code.exists()).toBe(true)
|
||||
expect(code.text()).toBe('const answer = 42')
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'code block node',
|
||||
markdown: '```ts\nexport const sum = (a: number, b: number) => a + b\n```',
|
||||
props: { renderCodeBlocksAsPre: true },
|
||||
expectedText: ['export const sum = (a: number, b: number) => a + b'],
|
||||
assert: async (wrapper) => {
|
||||
await flushAll()
|
||||
const fallback = wrapper.find('pre code')
|
||||
expect(fallback.exists()).toBe(true)
|
||||
expect(fallback.text()).toContain('export const sum = (a: number, b: number) => a + b')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mermaid block node',
|
||||
markdown: '```mermaid\ngraph LR;A-->B;\n```',
|
||||
skipSnapshot: true,
|
||||
assert: async (wrapper) => {
|
||||
await flushAll()
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
const mermaid = wrapper.find('._mermaid')
|
||||
expect(mermaid.exists()).toBe(true)
|
||||
// do not rely on exact toolbar SVG markup in snapshots (icons may
|
||||
// render differently in different envs); just assert the wrapper
|
||||
// contains the mermaid container
|
||||
expect(mermaid.html()).toContain('_mermaid')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'table node',
|
||||
markdown: '| Name | Role |\n| --- | --- |\n| Alice | Developer |',
|
||||
expectedText: ['Name', 'Role', 'Alice', 'Developer'],
|
||||
assert: (wrapper) => {
|
||||
const table = wrapper.find('table')
|
||||
expect(table.exists()).toBe(true)
|
||||
expect(table.findAll('tbody tr')).toHaveLength(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'definition list node',
|
||||
markdown: '',
|
||||
props: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'definition_list',
|
||||
raw: 'Term: Definition details',
|
||||
items: [
|
||||
{
|
||||
type: 'definition_item',
|
||||
raw: 'Term: Definition details',
|
||||
term: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'Term',
|
||||
raw: 'Term',
|
||||
},
|
||||
],
|
||||
definition: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
raw: 'Definition details',
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'Definition details',
|
||||
raw: 'Definition details',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedText: ['Term', 'Definition details'],
|
||||
assert: (wrapper) => {
|
||||
const dl = wrapper.find('dl')
|
||||
expect(dl.exists()).toBe(true)
|
||||
expect(dl.findAll('dt')).toHaveLength(1)
|
||||
expect(dl.findAll('dd')).toHaveLength(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'footnote nodes',
|
||||
markdown: 'A footnote reference[^1].\n\n[^1]: Footnote explanation',
|
||||
expectedText: ['A footnote reference', 'Footnote explanation'],
|
||||
assert: (wrapper) => {
|
||||
const footnoteBlock = wrapper.find('[id="fnref-1"]')
|
||||
expect(footnoteBlock.exists()).toBe(true)
|
||||
expect(footnoteBlock.text()).toBe('[1]')
|
||||
const footerAnchor = wrapper.find('.footnote-anchor')
|
||||
expect(footerAnchor.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'footnote reference node',
|
||||
markdown: '',
|
||||
props: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'footnote_reference',
|
||||
id: '1',
|
||||
raw: '[^1]',
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedText: [],
|
||||
assert: (wrapper) => {
|
||||
const reference = wrapper.find('sup.footnote-reference')
|
||||
expect(reference.exists()).toBe(true)
|
||||
expect(reference.text()).toContain('1')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'admonition node',
|
||||
markdown: '::: warning\nAdmonition content\n:::',
|
||||
expectedText: 'Admonition content',
|
||||
assert: (wrapper) => {
|
||||
const admonition = wrapper.find('.admonition-warning')
|
||||
expect(admonition.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'thematic break node',
|
||||
markdown: 'First section.\n\n---\n\nSecond section.',
|
||||
expectedText: ['First section.', 'Second section.'],
|
||||
assert: (wrapper) => {
|
||||
const hr = wrapper.find('hr')
|
||||
expect(hr.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'hardbreak node',
|
||||
markdown: 'Line one \nLine two',
|
||||
expectedText: ['Line one', 'Line two'],
|
||||
assert: (wrapper) => {
|
||||
expect(wrapper.findAll('br.hard-break').length).toBeGreaterThan(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'strong node',
|
||||
markdown: 'This is **bold text**.',
|
||||
expectedText: ['This is', 'bold text'],
|
||||
assert: (wrapper) => {
|
||||
const strong = wrapper.find('strong')
|
||||
expect(strong.exists()).toBe(true)
|
||||
expect(strong.text()).toBe('bold text')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'emphasis node',
|
||||
markdown: 'This is *italic text*.',
|
||||
expectedText: ['This is', 'italic text'],
|
||||
assert: (wrapper) => {
|
||||
const em = wrapper.find('em')
|
||||
expect(em.exists()).toBe(true)
|
||||
expect(em.text()).toBe('italic text')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'strikethrough node',
|
||||
markdown: 'This is ~~deleted text~~.',
|
||||
expectedText: ['This is', 'deleted text'],
|
||||
assert: (wrapper) => {
|
||||
const del = wrapper.find('s, del')
|
||||
expect(del.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'highlight node',
|
||||
markdown: 'Use ==highlighted text== for emphasis.',
|
||||
expectedText: ['Use', 'highlighted text', 'for emphasis.'],
|
||||
assert: (wrapper) => {
|
||||
const mark = wrapper.find('mark.highlight-node')
|
||||
expect(mark.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'insert node',
|
||||
markdown: 'Here comes ++inserted text++ in the paragraph.',
|
||||
expectedText: ['Here comes', 'inserted text', 'in the paragraph.'],
|
||||
assert: (wrapper) => {
|
||||
const ins = wrapper.find('ins.insert-node')
|
||||
expect(ins.exists()).toBe(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'subscript node',
|
||||
markdown: 'Chemical formula H~2~O is familiar.',
|
||||
expectedText: ['Chemical formula', 'H2O', 'is familiar.'],
|
||||
assert: (wrapper) => {
|
||||
const sub = wrapper.find('sub')
|
||||
expect(sub.exists()).toBe(true)
|
||||
expect(sub.text()).toBe('2')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'superscript node',
|
||||
markdown: 'Math uses x^2^ frequently.',
|
||||
expectedText: ['Math uses', 'x2', 'frequently.'],
|
||||
assert: (wrapper) => {
|
||||
const sup = wrapper.find('sup')
|
||||
expect(sup.exists()).toBe(true)
|
||||
expect(sup.text()).toBe('2')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'emoji node',
|
||||
markdown: 'Smile with :smile: emoji.',
|
||||
expectedText: ['Smile with', 'emoji.'],
|
||||
assert: (wrapper) => {
|
||||
const emoji = wrapper.find('span.emoji-node')
|
||||
expect(emoji.exists()).toBe(true)
|
||||
expect(emoji.text()).toBe('😄')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'math inline node',
|
||||
markdown: 'Einstein wrote $E=mc^2$.',
|
||||
skipSnapshot: false,
|
||||
// Accept either raw Dollar-delimited text or rendered KaTeX output
|
||||
expectedText: ['Einstein wrote', 'E=mc^2'],
|
||||
assert: async (wrapper) => {
|
||||
await flushAll()
|
||||
await flushAll()
|
||||
const inline = wrapper.find('.math-inline')
|
||||
if (inline.exists())
|
||||
expect(wrapper.html()).toContain('katex')
|
||||
else
|
||||
expect(wrapper.text()).toContain('Einstein wrote $E=mc^2$.')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'math block node',
|
||||
markdown: '$$\na^2 + b^2 = c^2\n$$',
|
||||
skipSnapshot: true,
|
||||
// do not assert expectedText globally because math rendering is
|
||||
// optional and may be handled asynchronously or by KaTeX which
|
||||
// isn't guaranteed in the test env
|
||||
expectedText: [],
|
||||
assert: (wrapper) => {
|
||||
const block = wrapper.find('.math-block')
|
||||
// math rendering is optional (requires katex). Accept either
|
||||
// a rendered KaTeX block or the raw LaTeX fallback
|
||||
if (block.exists()) {
|
||||
// prefer KaTeX output, but tolerate absence
|
||||
const hasKaTeX = wrapper.html().includes('katex-display') || wrapper.html().includes('katex')
|
||||
if (!hasKaTeX) {
|
||||
expect(wrapper.text()).toContain('a^2 + b^2 = c^2')
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'reference node',
|
||||
markdown: 'Cite research [1] for details.',
|
||||
expectedText: ['Cite research', 'for details.'],
|
||||
assert: (wrapper) => {
|
||||
const reference = wrapper.find('span.reference-node')
|
||||
expect(reference.exists()).toBe(true)
|
||||
expect(reference.text()).toBe('1')
|
||||
expect(reference.classes()).toContain('reference-node')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'complex inline code with emphasis and multiple code spans',
|
||||
markdown: '1. **整合冗余条款:** 将原`1-(5)`、`3-(3)`、`3-(4)`中关于“缴纳费用”和“告知法律状态变化”的核心义务合并,统一放入`1-(4)`“法律状态维护与通知义务”中,使其逻辑更集中,避免了在不同部分重复提及相似义务。',
|
||||
expectedText: ['整合冗余条款', '1-(5)', '3-(3)', '3-(4)', '法律状态维护与通知义务'],
|
||||
// assert removed: rely on expectedText assertions to validate parsing
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
it(`renders ${scenario.name}`, async () => {
|
||||
const wrapper = await mountMarkdown(scenario.markdown, scenario.props, scenario.mountOptions)
|
||||
try {
|
||||
if (scenario.assert)
|
||||
await scenario.assert(wrapper)
|
||||
|
||||
const textContent = normalizeText(wrapper.text())
|
||||
if (Array.isArray(scenario.expectedText)) {
|
||||
for (const snippet of scenario.expectedText)
|
||||
expect(textContent).toContain(snippet)
|
||||
}
|
||||
else if (typeof scenario.expectedText === 'string' && scenario.expectedText.length > 0) {
|
||||
expect(textContent).toContain(scenario.expectedText)
|
||||
}
|
||||
|
||||
if (!scenario.skipSnapshot) {
|
||||
const snapshotHtml = sanitizeSnapshotHtml(wrapper.html(), scenario.name)
|
||||
expect(snapshotHtml).toMatchSnapshot()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
}, 20000)
|
||||
}
|
||||
|
||||
it('resizes table columns from header drag handles', async () => {
|
||||
const wrapper = await mountMarkdown('| Name | Role |\n| --- | --- |\n| Alice | Developer |')
|
||||
try {
|
||||
const headers = wrapper.findAll('thead th')
|
||||
expect(headers).toHaveLength(2)
|
||||
|
||||
const headerWidths = [120, 240]
|
||||
headers.forEach((header, index) => {
|
||||
Object.defineProperty(header.element, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: headerWidths[index],
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
const handle = headers[0].find('.table-node__resize-handle')
|
||||
expect(handle.exists()).toBe(true)
|
||||
|
||||
handle.element.dispatchEvent(new MouseEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 120,
|
||||
}))
|
||||
window.dispatchEvent(new MouseEvent('pointermove', {
|
||||
bubbles: true,
|
||||
clientX: 180,
|
||||
}))
|
||||
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
await flushAll()
|
||||
|
||||
const columns = wrapper.findAll('col')
|
||||
expect(columns).toHaveLength(2)
|
||||
expect(columns[0].attributes('style')).toContain('width: 180px')
|
||||
expect(columns[1].attributes('style')).toContain('width: 180px')
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows overriding `list_item` via customComponents', async () => {
|
||||
const scopeId = 'custom-components-list-item'
|
||||
const CustomListItem = defineComponent({
|
||||
name: 'CustomListItem',
|
||||
props: {
|
||||
node: { type: Object, required: true },
|
||||
indexKey: [String, Number],
|
||||
value: Number,
|
||||
customId: String,
|
||||
typewriter: Boolean,
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('li', { 'class': 'custom-list-item', 'data-value': props.value == null ? '' : String(props.value) }, [
|
||||
h(MarkdownRender, {
|
||||
nodes: (props.node as any).children || [],
|
||||
customId: props.customId,
|
||||
indexKey: `custom-list-item-${String(props.indexKey ?? '')}`,
|
||||
typewriter: props.typewriter,
|
||||
batchRendering: false,
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
setCustomComponents(scopeId, { list_item: CustomListItem })
|
||||
|
||||
try {
|
||||
const wrapper = await mountMarkdown('1. First entry\n2. Second entry', { customId: scopeId, final: true })
|
||||
try {
|
||||
const items = wrapper.findAll('li.custom-list-item')
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0].attributes('data-value')).toBe('1')
|
||||
expect(items[1].attributes('data-value')).toBe('2')
|
||||
expect(normalizeText(wrapper.text())).toMatch(/First entry\s*Second entry/)
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('replays a fade animation when a streamed non-code node updates in place', async () => {
|
||||
const wrapper = await mountMarkdown('Hello')
|
||||
try {
|
||||
const nodeContent = () => wrapper.find('[data-node-index="0"] .node-content')
|
||||
expect(nodeContent().attributes('style') ?? '').toBe('')
|
||||
expect(wrapper.find('.text-node-stream-delta').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ content: 'Hello world' })
|
||||
await flushAll()
|
||||
|
||||
expect(nodeContent().attributes('style') ?? '').toBe('')
|
||||
const firstDelta = wrapper.find('.text-node-stream-delta')
|
||||
expect(firstDelta.exists()).toBe(true)
|
||||
expect(firstDelta.text()).toBe('world')
|
||||
expect(normalizeText(wrapper.text())).toContain('Hello world')
|
||||
|
||||
await wrapper.setProps({ content: 'Hello world again' })
|
||||
await flushAll()
|
||||
|
||||
expect(nodeContent().attributes('style') ?? '').toBe('')
|
||||
const secondDelta = wrapper.find('.text-node-stream-delta')
|
||||
expect(secondDelta.exists()).toBe(true)
|
||||
expect(secondDelta.text()).toBe('again')
|
||||
expect(normalizeText(wrapper.text())).toContain('Hello world again')
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders repeated custom components without slot content reuse', async () => {
|
||||
const scopeId = 'custom-components-repeat'
|
||||
const NewQuestion = defineComponent({
|
||||
name: 'NewQuestion',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'new-question' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
const SubQuestion = defineComponent({
|
||||
name: 'SubQuestion',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'sub-question' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
setCustomComponents(scopeId, {
|
||||
'new-question': NewQuestion,
|
||||
'sub-question': SubQuestion,
|
||||
})
|
||||
|
||||
try {
|
||||
const markdown = `<new-question>A</new-question>
|
||||
<sub-question>
|
||||
B
|
||||
</sub-question>
|
||||
|
||||
<sub-question>
|
||||
C
|
||||
</sub-question>`
|
||||
const wrapper = await mountMarkdown(markdown, {
|
||||
customId: scopeId,
|
||||
final: true,
|
||||
})
|
||||
try {
|
||||
expect(normalizeText(wrapper.text())).toContain('A B C')
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders nested repeated custom components without content bleed', async () => {
|
||||
const scopeId = 'custom-components-nested-repeat'
|
||||
const NewQuestion = defineComponent({
|
||||
name: 'NewQuestion',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'new-question' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
const SubQuestion = defineComponent({
|
||||
name: 'SubQuestion',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'sub-question' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
setCustomComponents(scopeId, {
|
||||
'new-question': NewQuestion,
|
||||
'sub-question': SubQuestion,
|
||||
})
|
||||
|
||||
try {
|
||||
const markdown = `<sub-question>
|
||||
<new-question>A</new-question>
|
||||
<sub-question>Inner-1</sub-question>
|
||||
</sub-question>
|
||||
|
||||
<sub-question>
|
||||
<new-question>B</new-question>
|
||||
<sub-question>Inner-2</sub-question>
|
||||
</sub-question>`
|
||||
const wrapper = await mountMarkdown(markdown, { customId: scopeId, final: true })
|
||||
try {
|
||||
expect(normalizeText(wrapper.text())).toMatch(/A\s*Inner-1\s*B\s*Inner-2/)
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders repeated custom components with different attrs and explicit keys', async () => {
|
||||
const scopeId = 'custom-components-attrs-repeat'
|
||||
const SubQuestion = defineComponent({
|
||||
name: 'SubQuestion',
|
||||
setup(_, { slots, attrs }) {
|
||||
const id = attrs['data-id'] == null ? '' : String(attrs['data-id'])
|
||||
return () => h('div', { class: 'sub-question' }, [
|
||||
h('span', { class: 'sub-question__id' }, id),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
setCustomComponents(scopeId, {
|
||||
'sub-question': SubQuestion,
|
||||
})
|
||||
|
||||
try {
|
||||
const markdown = `<sub-question key="k1" data-id="1">B</sub-question>
|
||||
<sub-question key="k2" data-id="2">C</sub-question>`
|
||||
const wrapper = await mountMarkdown(markdown, { customId: scopeId, final: true })
|
||||
try {
|
||||
expect(normalizeText(wrapper.text())).toMatch(/1\s*B\s*2\s*C/)
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders custom components interleaved with markdown blocks', async () => {
|
||||
const scopeId = 'custom-components-mixed-markdown'
|
||||
const NewQuestion = defineComponent({
|
||||
name: 'NewQuestion',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'new-question' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
const SubQuestion = defineComponent({
|
||||
name: 'SubQuestion',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'sub-question' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
setCustomComponents(scopeId, {
|
||||
'new-question': NewQuestion,
|
||||
'sub-question': SubQuestion,
|
||||
})
|
||||
|
||||
try {
|
||||
const markdown = `<div><new-question>A</new-question></div>
|
||||
|
||||
Normal paragraph between.
|
||||
|
||||
- item-1
|
||||
- item-2
|
||||
|
||||
<div><sub-question>B</sub-question></div>
|
||||
|
||||
Another paragraph.
|
||||
|
||||
<div><sub-question>C</sub-question></div>`
|
||||
const wrapper = await mountMarkdown(markdown, { customId: scopeId, final: true })
|
||||
try {
|
||||
const text = normalizeText(wrapper.text())
|
||||
expect(text).toContain('A')
|
||||
expect(text).toContain('Normal paragraph between.')
|
||||
expect(text).toMatch(/item-1\s*item-2/)
|
||||
expect(text).toMatch(/A[^B]*B[\s\S]*C/)
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders repeated custom components mixed with standard HTML inside one block', async () => {
|
||||
const scopeId = 'custom-components-mixed-html'
|
||||
const SubQuestion = defineComponent({
|
||||
name: 'SubQuestion',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'sub-question' }, slots.default?.())
|
||||
},
|
||||
})
|
||||
|
||||
setCustomComponents(scopeId, {
|
||||
'sub-question': SubQuestion,
|
||||
})
|
||||
|
||||
try {
|
||||
const markdown = `<div>
|
||||
<sub-question>First <strong>X</strong></sub-question>
|
||||
<span>middle</span>
|
||||
<sub-question>Second <em>Y</em></sub-question>
|
||||
</div>`
|
||||
const wrapper = await mountMarkdown(markdown, { customId: scopeId, final: true })
|
||||
try {
|
||||
expect(normalizeText(wrapper.text())).toMatch(/First\s*X\s*middle\s*Second\s*Y/)
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('passes node props to custom components when using nodes prop with customHtmlTags', async () => {
|
||||
const scopeId = 'custom-nodes-prop-thinking'
|
||||
const ThinkingNode = defineComponent({
|
||||
name: 'ThinkingNode',
|
||||
props: {
|
||||
node: { type: Object, default: () => ({ loading: false, content: '' }) },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'thinking-node' }, props.node.content)
|
||||
},
|
||||
})
|
||||
|
||||
setCustomComponents(scopeId, {
|
||||
thinking: (props: any) => h(ThinkingNode, props),
|
||||
})
|
||||
|
||||
try {
|
||||
// Pre-parsed nodes as if parsed without customHtmlTags – the thinking
|
||||
// tag is still an html_block with tag:"thinking".
|
||||
const nodes = [
|
||||
{
|
||||
type: 'html_block',
|
||||
tag: 'thinking',
|
||||
content: '<thinking>I am thinking</thinking>',
|
||||
raw: '<thinking>I am thinking</thinking>',
|
||||
loading: false,
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', content: 'Hello', raw: 'Hello' }],
|
||||
},
|
||||
]
|
||||
|
||||
const wrapper = mount(MarkdownRender, {
|
||||
props: {
|
||||
nodes,
|
||||
final: true,
|
||||
customId: scopeId,
|
||||
customHtmlTags: ['thinking'],
|
||||
},
|
||||
})
|
||||
await flushAll()
|
||||
|
||||
try {
|
||||
const text = normalizeText(wrapper.text())
|
||||
expect(text).toContain('I am thinking')
|
||||
expect(text).toContain('Hello')
|
||||
// The custom component should be rendered (not the default HtmlBlockNode)
|
||||
expect(wrapper.find('.thinking-node').exists()).toBe(true)
|
||||
}
|
||||
finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user