e0a5f57793
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.
247 lines
6.9 KiB
TypeScript
247 lines
6.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
renderMarkdownNodeToHtml,
|
|
renderNestedMarkdownToHtml,
|
|
} from '../packages/markstream-vue2/src/utils/nestedHtml'
|
|
|
|
describe('vue2 nested html helper', () => {
|
|
it('renders nested custom nodes and markdown children into stable html', () => {
|
|
const html = renderNestedMarkdownToHtml(
|
|
{
|
|
nodes: [
|
|
{
|
|
type: 'paragraph',
|
|
raw: '',
|
|
children: [
|
|
{ type: 'text', raw: '', content: 'Before' },
|
|
],
|
|
},
|
|
{
|
|
type: 'thinking',
|
|
tag: 'thinking',
|
|
raw: '',
|
|
attrs: [['class', 'preset']],
|
|
children: [
|
|
{
|
|
type: 'paragraph',
|
|
raw: '',
|
|
children: [
|
|
{ type: 'text', raw: '', content: 'Inner ' },
|
|
{
|
|
type: 'strong',
|
|
raw: '',
|
|
children: [
|
|
{ type: 'text', raw: '', content: 'bold' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'list',
|
|
raw: '',
|
|
ordered: false,
|
|
items: [
|
|
{
|
|
type: 'list_item',
|
|
raw: '',
|
|
children: [
|
|
{ type: 'text', raw: '', content: 'item' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
customHtmlTags: ['thinking'],
|
|
customNodeClass(node) {
|
|
return node.type === 'thinking' ? 'thinking-node__nested' : ''
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(html).toContain('<p>Before</p>')
|
|
expect(html).toContain('<strong>bold</strong>')
|
|
expect(html).toContain('<ul><li>item</li></ul>')
|
|
expect(html).toContain('data-markstream-custom-tag="thinking"')
|
|
expect(html).toContain('class="markstream-nested-custom markstream-nested-custom--thinking thinking-node__nested preset"')
|
|
})
|
|
|
|
it('falls back to markdown rendering for streaming content fragments', () => {
|
|
const html = renderNestedMarkdownToHtml(
|
|
{
|
|
node: {
|
|
type: 'thinking',
|
|
tag: 'thinking',
|
|
raw: '',
|
|
content: 'Streamed intro\n\n- first\n- second',
|
|
} as any,
|
|
},
|
|
{
|
|
cacheKey: 'vue2-nested-html-streaming',
|
|
customHtmlTags: ['thinking'],
|
|
},
|
|
)
|
|
|
|
expect(html).toContain('<p>Streamed intro</p>')
|
|
expect(html).toContain('<ul>')
|
|
expect(html).toContain('<li>first</li>')
|
|
expect(html).toContain('<li>second</li>')
|
|
})
|
|
|
|
it('sanitizes unsafe attrs while preserving merged classes for custom nodes', () => {
|
|
const html = renderMarkdownNodeToHtml(
|
|
{
|
|
type: 'hint',
|
|
tag: 'hint',
|
|
raw: '',
|
|
attrs: [
|
|
['onclick', 'alert(1)'],
|
|
['data-role', 'inner'],
|
|
['class', 'preset'],
|
|
],
|
|
children: [
|
|
{ type: 'text', raw: '', content: 'Safe body' },
|
|
],
|
|
} as any,
|
|
{
|
|
customNodeClass: 'extra-class',
|
|
},
|
|
)
|
|
|
|
expect(html).toContain('data-role="inner"')
|
|
expect(html).toContain('class="markstream-nested-custom markstream-nested-custom--hint extra-class preset"')
|
|
expect(html).not.toContain('onclick=')
|
|
expect(html).toContain('Safe body')
|
|
})
|
|
|
|
it('escapes incomplete html nodes instead of trusting partial markup', () => {
|
|
const html = renderMarkdownNodeToHtml(
|
|
{
|
|
type: 'html_inline',
|
|
raw: '',
|
|
content: '<span>partial',
|
|
loading: true,
|
|
autoClosed: false,
|
|
} as any,
|
|
)
|
|
|
|
expect(html).toBe('<span>partial')
|
|
})
|
|
|
|
it('renders structured html wrappers while leaving blocked tags unstructured', () => {
|
|
const structuredHtml = renderMarkdownNodeToHtml(
|
|
{
|
|
type: 'html_block',
|
|
tag: 'span',
|
|
raw: '<span style="font-size: 12px;"></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: 'text', raw: '', content: 'alpha' }],
|
|
},
|
|
{
|
|
type: 'list_item',
|
|
raw: '',
|
|
children: [{ type: 'text', raw: '', content: 'beta' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as any,
|
|
)
|
|
|
|
expect(structuredHtml).toContain('<span style="font-size: 12px;">')
|
|
expect(structuredHtml).toContain('<ul><li>alpha</li><li>beta</li></ul>')
|
|
|
|
const blockedHtml = renderMarkdownNodeToHtml(
|
|
{
|
|
type: 'html_block',
|
|
tag: 'script',
|
|
raw: '<script>\n\n- alpha\n\n</script>',
|
|
content: '<script>\n\n- alpha\n\n</script>',
|
|
children: [
|
|
{
|
|
type: 'list',
|
|
raw: '',
|
|
ordered: false,
|
|
items: [
|
|
{
|
|
type: 'list_item',
|
|
raw: '',
|
|
children: [{ type: 'text', raw: '', content: 'alpha' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as any,
|
|
)
|
|
|
|
expect(blockedHtml).toBe('<script>\n\n- alpha\n\n</script>')
|
|
expect(blockedHtml).not.toContain('<ul>')
|
|
|
|
const literalHtml = renderMarkdownNodeToHtml(
|
|
{
|
|
type: 'html_block',
|
|
tag: 'pre',
|
|
raw: '<pre>\n\n- alpha\n\n</pre>',
|
|
content: '<pre>\n\n- alpha\n\n</pre>',
|
|
children: [
|
|
{
|
|
type: 'list',
|
|
raw: '',
|
|
ordered: false,
|
|
items: [
|
|
{
|
|
type: 'list_item',
|
|
raw: '',
|
|
children: [{ type: 'text', raw: '', content: 'alpha' }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as any,
|
|
)
|
|
|
|
expect(literalHtml).toBe('<pre>\n\n- alpha\n\n</pre>')
|
|
})
|
|
|
|
it('sanitizes dangerous attrs on structured html wrappers', () => {
|
|
const html = renderMarkdownNodeToHtml(
|
|
{
|
|
type: 'html_block',
|
|
tag: 'a',
|
|
raw: '<a href="javascript:alert(1)" onclick="alert(1)" data-safe="ok"></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: 'safe child',
|
|
children: [{ type: 'text', raw: 'safe child', content: 'safe child' }],
|
|
},
|
|
],
|
|
} as any,
|
|
)
|
|
|
|
expect(html).toContain('<a data-safe="ok">')
|
|
expect(html).toContain('<p>safe child</p>')
|
|
expect(html).not.toContain('onclick=')
|
|
expect(html).not.toContain('javascript:')
|
|
})
|
|
})
|