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,321 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
/* eslint-disable antfu/no-import-node-modules-by-path */
|
||||
import katex from 'katex'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import React, { act } from '../packages/markstream-react/node_modules/react'
|
||||
import { createRoot } from '../packages/markstream-react/node_modules/react-dom/client'
|
||||
import { renderToStaticMarkup } from '../packages/markstream-react/node_modules/react-dom/server'
|
||||
import { NodeRenderer } from '../packages/markstream-react/src/components/NodeRenderer'
|
||||
import { removeCustomComponents, setCustomComponents, withMarkstreamComponentDisplay } from '../packages/markstream-react/src/customComponents'
|
||||
import { NodeRenderer as ServerNodeRenderer } from '../packages/markstream-react/src/server'
|
||||
import { clearKaTeXCache, clearKaTeXWorker, setKaTeXWorker } from '../packages/markstream-react/src/workers/katexWorkerClient'
|
||||
|
||||
const REAL_WORLD_MULTILINE_INPUT = `$2.897771955 times 10^{-3}text{m·K}$^[1]^
|
||||
测试<sup>[3]</sup>。
|
||||
$x$^[1]^
|
||||
$x$ ^[1]^
|
||||
测试^[1]^
|
||||
$2.897771955 \\times 10^{-3}\\text{m·K}$^[1]^
|
||||
<sup>[1]</sup>
|
||||
测试<sup>[12]</sup>结束
|
||||
A<sup>[3]</sup>B
|
||||
$x$^[1]^
|
||||
测试^[1]^
|
||||
<sup>[3]</sup>
|
||||
测试<sup>[12]</sup>结束`
|
||||
|
||||
class FakeKaTeXWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onerror: ((event: ErrorEvent) => void) | null = null
|
||||
|
||||
postMessage(data: { id: string, content: string, displayMode: boolean }) {
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
const html = katex.renderToString(data.content, {
|
||||
throwOnError: false,
|
||||
displayMode: data.displayMode,
|
||||
output: 'html',
|
||||
strict: 'ignore',
|
||||
})
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
id: data.id,
|
||||
html,
|
||||
content: data.content,
|
||||
displayMode: data.displayMode,
|
||||
},
|
||||
} as MessageEvent)
|
||||
}
|
||||
catch (error: any) {
|
||||
this.onmessage?.({
|
||||
data: {
|
||||
id: data.id,
|
||||
error: error?.message || String(error),
|
||||
content: data.content,
|
||||
displayMode: data.displayMode,
|
||||
},
|
||||
} as MessageEvent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
terminate() {}
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function renderMarkdown(content: string, extraProps: Record<string, unknown> = {}) {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
|
||||
const rerender = async (nextContent: string, nextProps: Record<string, unknown> = {}) => {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(NodeRenderer as any, {
|
||||
content: nextContent,
|
||||
viewportPriority: false,
|
||||
deferNodesUntilVisible: false,
|
||||
maxLiveNodes: 0,
|
||||
...extraProps,
|
||||
...nextProps,
|
||||
}))
|
||||
})
|
||||
await flushReact()
|
||||
}
|
||||
|
||||
await rerender(content)
|
||||
|
||||
return {
|
||||
host,
|
||||
rerender,
|
||||
unmount: async () => {
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||||
setKaTeXWorker(new FakeKaTeXWorker() as unknown as Worker)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearKaTeXWorker()
|
||||
clearKaTeXCache()
|
||||
document.body.innerHTML = ''
|
||||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false
|
||||
})
|
||||
|
||||
describe('markstream-react issue #386 renderer regressions', () => {
|
||||
it('renders bracketed superscript syntax from markdown content', async () => {
|
||||
const view = await renderMarkdown('测试^[1]^')
|
||||
|
||||
expect(view.host.querySelector('sup.superscript-node')?.textContent).toBe('[1]')
|
||||
expect(view.host.textContent).not.toContain('^[1]^')
|
||||
|
||||
await view.unmount()
|
||||
})
|
||||
|
||||
it('renders superscript syntax immediately after inline math', async () => {
|
||||
const view = await renderMarkdown('$x$^[1]^')
|
||||
|
||||
expect(view.host.querySelector('.katex')).toBeTruthy()
|
||||
expect(view.host.querySelector('sup.superscript-node')?.textContent).toBe('[1]')
|
||||
expect(view.host.textContent).not.toContain('^[1]^')
|
||||
|
||||
await view.unmount()
|
||||
})
|
||||
|
||||
it('renders superscript syntax after inline math even with separating whitespace', async () => {
|
||||
const view = await renderMarkdown('$x$ ^[1]^')
|
||||
|
||||
expect(view.host.querySelector('.katex')).toBeTruthy()
|
||||
expect(view.host.querySelector('sup.superscript-node')?.textContent).toBe('[1]')
|
||||
expect(view.host.textContent).not.toContain('^[1]^')
|
||||
|
||||
await view.unmount()
|
||||
})
|
||||
|
||||
it('preserves brackets inside standard inline html tags', async () => {
|
||||
const view = await renderMarkdown('测试<sup>[3]</sup>。')
|
||||
|
||||
expect(view.host.querySelector('.html-inline-node sup')?.textContent).toBe('[3]')
|
||||
expect(view.host.querySelectorAll('p.paragraph-node')).toHaveLength(1)
|
||||
expect(view.host.querySelector('p.paragraph-node .html-inline-node sup')?.textContent).toBe('[3]')
|
||||
expect(view.host.querySelector('p.paragraph-node')?.textContent).toBe('测试[3]。')
|
||||
|
||||
await view.unmount()
|
||||
})
|
||||
|
||||
it('keeps inline html embedded in the same paragraph on the client', async () => {
|
||||
const view = await renderMarkdown('A<sup>[3]</sup>B')
|
||||
|
||||
expect(view.host.querySelectorAll('p.paragraph-node')).toHaveLength(1)
|
||||
expect(view.host.querySelector('p.paragraph-node .html-inline-node sup')?.textContent).toBe('[3]')
|
||||
expect(view.host.querySelector('p.paragraph-node')?.textContent).toBe('A[3]B')
|
||||
|
||||
await view.unmount()
|
||||
})
|
||||
|
||||
it('keeps inline html embedded in the same paragraph during SSR', () => {
|
||||
const html = renderToStaticMarkup(React.createElement(ServerNodeRenderer as any, {
|
||||
content: 'A<sup>[3]</sup>B',
|
||||
typewriter: false,
|
||||
}))
|
||||
|
||||
expect(html).toContain('<p')
|
||||
expect(html).toContain('A')
|
||||
expect(html).toContain('<sup>[3]</sup>')
|
||||
expect(html).toContain('B')
|
||||
expect(html).not.toContain('</p><span class="html-inline-node"')
|
||||
expect(html).not.toContain('</sup></div></div></div><div class="node-slot"')
|
||||
})
|
||||
|
||||
it('recovers incomplete inline html during streaming updates without breaking the paragraph', async () => {
|
||||
const view = await renderMarkdown('A<sup>[3]', { final: false })
|
||||
|
||||
expect(view.host.querySelectorAll('p.paragraph-node')).toHaveLength(1)
|
||||
expect(view.host.querySelector('p.paragraph-node .html-inline-node sup')?.textContent).toBe('[3]')
|
||||
expect(view.host.querySelector('p.paragraph-node')?.textContent).toBe('A[3]')
|
||||
expect(view.host.textContent).not.toContain('<sup>[3]')
|
||||
|
||||
await view.rerender('A<sup>[3]</sup>B', { final: false })
|
||||
|
||||
expect(view.host.querySelectorAll('p.paragraph-node')).toHaveLength(1)
|
||||
expect(view.host.querySelector('p.paragraph-node .html-inline-node sup')?.textContent).toBe('[3]')
|
||||
expect(view.host.querySelector('p.paragraph-node')?.textContent).toBe('A[3]B')
|
||||
expect(view.host.textContent).not.toContain('<sup>[3]')
|
||||
|
||||
await view.unmount()
|
||||
})
|
||||
|
||||
it('keeps whitelisted custom inline tags embedded in the same paragraph', async () => {
|
||||
const scopeId = 'react-inline-ref-regression'
|
||||
const InlineRef: React.FC<{ node: { content?: string } }> = ({ node }) => (
|
||||
<sup className="inline-ref-node">{node.content || ''}</sup>
|
||||
)
|
||||
setCustomComponents(scopeId, { 'inline-ref': InlineRef })
|
||||
|
||||
try {
|
||||
const view = await renderMarkdown('A<inline-ref>[7]</inline-ref>B', {
|
||||
customId: scopeId,
|
||||
customHtmlTags: ['inline-ref'],
|
||||
final: true,
|
||||
})
|
||||
|
||||
expect(view.host.querySelectorAll('p.paragraph-node')).toHaveLength(1)
|
||||
expect(view.host.querySelector('p.paragraph-node .inline-ref-node')?.textContent).toBe('[7]')
|
||||
expect(view.host.querySelector('p.paragraph-node')?.textContent).toBe('A[7]B')
|
||||
|
||||
await view.unmount()
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps whitelisted custom inline tags embedded in the same paragraph during SSR', () => {
|
||||
const scopeId = 'react-inline-ref-ssr-regression'
|
||||
const InlineRef: React.FC<{ node: { content?: string } }> = ({ node }) => (
|
||||
<sup className="inline-ref-node">{node.content || ''}</sup>
|
||||
)
|
||||
setCustomComponents(scopeId, { 'inline-ref': InlineRef })
|
||||
|
||||
try {
|
||||
const html = renderToStaticMarkup(React.createElement(ServerNodeRenderer as any, {
|
||||
content: 'A<inline-ref>[7]</inline-ref>B',
|
||||
customId: scopeId,
|
||||
customHtmlTags: ['inline-ref'],
|
||||
typewriter: false,
|
||||
}))
|
||||
|
||||
expect(html).toContain('<p')
|
||||
expect(html).toContain('A')
|
||||
expect(html).toContain('<sup class="inline-ref-node">[7]</sup>')
|
||||
expect(html).toContain('B')
|
||||
expect(html).not.toContain('</p><sup class="inline-ref-node">[7]</sup>')
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('lets block-marked custom html tags break out of paragraph wrappers on the client', async () => {
|
||||
const scopeId = 'react-blockish-inline-tag-client'
|
||||
const BlockRef = withMarkstreamComponentDisplay(({ node }: { node: { content?: string } }) => (
|
||||
<div className="block-ref-node">{node.content || ''}</div>
|
||||
), 'block')
|
||||
|
||||
setCustomComponents(scopeId, { 'inline-ref': BlockRef })
|
||||
|
||||
try {
|
||||
const view = await renderMarkdown('A<inline-ref>[7]</inline-ref>B', {
|
||||
customId: scopeId,
|
||||
customHtmlTags: ['inline-ref'],
|
||||
final: true,
|
||||
})
|
||||
|
||||
expect(view.host.querySelectorAll('p.paragraph-node')).toHaveLength(2)
|
||||
expect(view.host.querySelector('p.paragraph-node')?.textContent).toBe('A')
|
||||
expect(view.host.querySelector('.block-ref-node')?.textContent).toBe('[7]')
|
||||
expect(Array.from(view.host.querySelectorAll('p.paragraph-node')).at(1)?.textContent).toBe('B')
|
||||
expect(view.host.innerHTML).not.toContain('<p dir="auto" class="paragraph-node">A<div')
|
||||
|
||||
await view.unmount()
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('lets block-marked custom html tags break out of paragraph wrappers during SSR', () => {
|
||||
const scopeId = 'react-blockish-inline-tag-ssr'
|
||||
const BlockRef = withMarkstreamComponentDisplay(({ node }: { node: { content?: string } }) => (
|
||||
<div className="block-ref-node">{node.content || ''}</div>
|
||||
), 'block')
|
||||
|
||||
setCustomComponents(scopeId, { 'inline-ref': BlockRef })
|
||||
|
||||
try {
|
||||
const html = renderToStaticMarkup(React.createElement(ServerNodeRenderer as any, {
|
||||
content: 'A<inline-ref>[7]</inline-ref>B',
|
||||
customId: scopeId,
|
||||
customHtmlTags: ['inline-ref'],
|
||||
typewriter: false,
|
||||
}))
|
||||
|
||||
expect(html).toContain('<div class="block-ref-node">[7]</div>')
|
||||
expect(html).not.toContain('<p dir="auto" class="paragraph-node">A<div class="block-ref-node">[7]</div>B</p>')
|
||||
expect(html).toContain('<p dir="auto" class="paragraph-node"><span class="text-node whitespace-pre-wrap break-words">A</span></p>')
|
||||
expect(html).toContain('<p dir="auto" class="paragraph-node"><span class="text-node whitespace-pre-wrap break-words">B</span></p>')
|
||||
}
|
||||
finally {
|
||||
removeCustomComponents(scopeId)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the real multiline issue-386 input in streaming mode without leaking raw superscript syntax', async () => {
|
||||
const view = await renderMarkdown(REAL_WORLD_MULTILINE_INPUT, { final: false })
|
||||
|
||||
const superscripts = Array.from(view.host.querySelectorAll('sup.superscript-node')).map(node => node.textContent)
|
||||
const inlineHtmlSup = Array.from(view.host.querySelectorAll('.html-inline-node sup')).map(node => node.textContent)
|
||||
|
||||
expect(view.host.querySelector('.katex')).toBeTruthy()
|
||||
expect(superscripts).toEqual(['[1]', '[1]', '[1]', '[1]', '[1]', '[1]', '[1]'])
|
||||
expect(inlineHtmlSup).toEqual(['[3]', '[1]', '[12]', '[3]', '[3]', '[12]'])
|
||||
expect(view.host.textContent).not.toContain('^[1]^')
|
||||
|
||||
await view.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user