Files
BoosAPI/desktop/libs/markstream-vue/test/vue2-html-nodes.test.ts
T
boos e0a5f57793 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.
2026-06-22 11:32:57 +08:00

460 lines
13 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
// Import Vue 2 components
import HtmlBlockNode from '../packages/markstream-vue2/src/components/HtmlBlockNode/HtmlBlockNode.vue'
import HtmlInlineNode from '../packages/markstream-vue2/src/components/HtmlInlineNode/HtmlInlineNode.vue'
import { setCustomComponents } from '../packages/markstream-vue2/src/utils/nodeComponents'
import { flushAll } from './setup/flush-all'
// Mock custom components
const TestComponent = defineComponent({
name: 'TestComponent',
props: ['dataType', 'active'],
setup(props, { slots }) {
return () => h(
'div',
{
'class': 'test-component',
'data-type': props.dataType,
'data-active': props.active,
},
slots.default?.() || 'Test Component',
)
},
})
const NestedComponent = defineComponent({
name: 'NestedComponent',
setup(_, { slots }) {
return () => h('div', { class: 'nested-component' }, slots.default?.())
},
})
describe('vue 2 - HtmlBlockNode Custom Components Integration', () => {
const testId = 'test-vue2-html-block'
beforeEach(() => {
setCustomComponents(testId, {
testcomp: TestComponent,
nestedcomp: NestedComponent,
})
})
it('should render custom component in HTML block', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp data-type="block">Content</testcomp>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('.test-component').attributes('data-type')).toBe('block')
expect(wrapper.html()).toContain('Content')
})
it('should render nested custom components', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp data-type="outer">Outer <nestedcomp>Nested</nestedcomp> End</testcomp>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('.nested-component').exists()).toBe(true)
expect(wrapper.html()).toContain('Outer')
expect(wrapper.html()).toContain('Nested')
expect(wrapper.html()).toContain('End')
})
it('should render deeply nested custom components', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: `
<testcomp data-type="level1">
Level 1
<nestedcomp>
Level 2
<testcomp data-type="level3">Level 3</testcomp>
</nestedcomp>
</testcomp>
`,
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.findAll('.test-component').length).toBeGreaterThan(0)
expect(wrapper.findAll('.nested-component').length).toBeGreaterThan(0)
expect(wrapper.html()).toContain('Level 1')
expect(wrapper.html()).toContain('Level 2')
expect(wrapper.html()).toContain('Level 3')
})
it('should render mixed standard HTML and custom components', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<div class="standard"><testcomp data-type="mixed">Component</testcomp><p>Paragraph</p></div>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.standard').exists()).toBe(true)
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('p').exists()).toBe(true)
})
it('should use v-html when no custom components are present', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<div class="standard">Pure HTML</div>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.standard').exists()).toBe(true)
expect(wrapper.html()).toContain('Pure HTML')
})
it('should sanitize raw HTML fallback content in blocks', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<div><img src="x" onerror="alert(1)"><a href="javascript:alert(1)" title="ok">Link</a><script>alert(1)</script></div>',
loading: false,
},
customId: testId,
},
})
await flushAll()
const img = wrapper.find('img')
const link = wrapper.find('a')
expect(img.exists()).toBe(true)
expect(img.attributes('onerror')).toBeUndefined()
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toBeUndefined()
expect(link.attributes('title')).toBe('ok')
expect(wrapper.html()).not.toContain('<script')
expect(wrapper.html()).not.toContain('alert(1)')
})
it('should pass props correctly to custom components', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp data-type="test" active="true">With Props</testcomp>',
loading: false,
},
customId: testId,
},
})
const comp = wrapper.find('.test-component')
expect(comp.attributes('data-type')).toBe('test')
expect(comp.attributes('data-active')).toBe('true')
})
it('should sanitize dangerous attributes', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp onclick="alert(1)" data-type="safe">Content</testcomp>',
loading: false,
},
customId: testId,
},
})
const comp = wrapper.find('.test-component')
expect(comp.attributes('onclick')).toBeUndefined()
expect(comp.attributes('data-type')).toBe('safe')
})
it('should render placeholder when loading is true', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp>Content</testcomp>',
loading: true,
},
customId: testId,
},
})
// Should show placeholder initially
expect(wrapper.find('.html-block-node__placeholder').exists()).toBe(true)
})
it('should render structured markdown children inside standard html wrappers once', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
tag: 'span',
content: '<span style="font-size: 12px;"></span>',
attrs: [['style', 'font-size: 12px;']],
children: [
{
type: 'list',
raw: '',
ordered: false,
items: [
{
type: 'list_item',
raw: '',
children: [
{
type: 'paragraph',
raw: '',
children: [{ type: 'text', raw: '', content: 'alpha' }],
},
],
},
{
type: 'list_item',
raw: '',
children: [
{
type: 'paragraph',
raw: '',
children: [{ type: 'text', raw: '', content: 'beta' }],
},
],
},
],
},
],
loading: false,
},
customId: testId,
},
})
await flushAll()
const root = wrapper.find('.html-block-node')
expect(root.element.tagName).toBe('SPAN')
expect(root.attributes('style')).toContain('font-size: 12px;')
expect(wrapper.findAll('ul')).toHaveLength(1)
expect(wrapper.findAll('li')).toHaveLength(2)
expect((wrapper.text().match(/alpha/g) || []).length).toBe(1)
expect((wrapper.text().match(/beta/g) || []).length).toBe(1)
})
it('should sanitize dangerous attrs on structured wrapper roots', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
tag: 'a',
content: '<a href="javascript:alert(1)" onclick="alert(1)" data-safe="ok"></a>',
attrs: [
['href', 'javascript:alert(1)'],
['onclick', 'alert(1)'],
['data-safe', 'ok'],
],
children: [
{
type: 'paragraph',
raw: '',
children: [{ type: 'text', raw: '', content: 'safe child' }],
},
],
loading: false,
},
customId: testId,
},
})
await flushAll()
const root = wrapper.find('.html-block-node')
expect(root.element.tagName).toBe('A')
expect(root.attributes('data-safe')).toBe('ok')
expect(root.attributes('href')).toBeUndefined()
expect(root.attributes('onclick')).toBeUndefined()
expect(wrapper.text()).toContain('safe child')
})
})
describe('vue 2 - HtmlInlineNode Custom Components Integration', () => {
const testId = 'test-vue2-html-inline'
beforeEach(() => {
setCustomComponents(testId, {
testcomp: TestComponent,
nestedcomp: NestedComponent,
})
})
it('should render custom component inline', () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: '<testcomp data-type="inline">Content</testcomp>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.html()).toContain('Content')
})
it('should render nested custom components inline', () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: 'Text <testcomp data-type="outer"><nestedcomp>Nested</nestedcomp></testcomp> more',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('.nested-component').exists()).toBe(true)
expect(wrapper.html()).toContain('Text')
expect(wrapper.html()).toContain('more')
})
it('should use DOM rendering for pure HTML', () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: '<span class="standard">Pure HTML</span>',
loading: false,
},
customId: testId,
},
})
// HtmlInlineNode uses DOM manipulation, check that the container exists
expect(wrapper.find('.html-inline-node').exists()).toBe(true)
})
it('should sanitize raw HTML fallback content inline', async () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: 'Before <img src="x" onerror="alert(1)"><a href="javascript:alert(1)" title="ok">Link</a> After',
loading: false,
},
customId: testId,
},
})
await flushAll()
const img = wrapper.find('img')
const link = wrapper.find('a')
expect(img.exists()).toBe(true)
expect(img.attributes('onerror')).toBeUndefined()
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toBeUndefined()
expect(link.attributes('title')).toBe('ok')
expect(wrapper.html()).not.toContain('alert(1)')
})
it('should handle mixed inline content', () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: 'Before <testcomp data-type="test">Component</testcomp> After',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.html()).toContain('Before')
expect(wrapper.html()).toContain('After')
})
it('should handle loading state', () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: '<testcomp>Loading Content</testcomp>',
loading: true,
autoClosed: false,
},
customId: testId,
},
})
// Should show loading text
expect(wrapper.find('.html-inline-node--loading').exists()).toBe(true)
expect(wrapper.html()).toContain('Loading Content')
})
})
describe('vue 2 - Component Behavior', () => {
const testId = 'test-vue2-behavior'
beforeEach(() => {
setCustomComponents(testId, {
testcomp: TestComponent,
})
})
it('should handle empty content', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '',
loading: false,
},
customId: testId,
},
})
// Empty content should render empty container
expect(wrapper.find('.html-block-node').exists()).toBe(true)
})
it('should handle content with only whitespace', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: ' \n\t ',
loading: false,
},
customId: testId,
},
})
// Whitespace-only content should still render container
expect(wrapper.find('.html-block-node').exists()).toBe(true)
})
})