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.
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
/**
|
||
* @vitest-environment jsdom
|
||
*/
|
||
|
||
import { afterEach, describe, expect, it } from 'vitest'
|
||
/* eslint-disable antfu/no-import-node-modules-by-path */
|
||
import React, { act, StrictMode } from '../packages/markstream-react/node_modules/react'
|
||
import { createRoot } from '../packages/markstream-react/node_modules/react-dom/client'
|
||
import { LinkNode } from '../packages/markstream-react/src/components/LinkNode/LinkNode'
|
||
import { NodeRenderer } from '../packages/markstream-react/src/components/NodeRenderer'
|
||
import { TextNode } from '../packages/markstream-react/src/components/TextNode/TextNode'
|
||
|
||
async function flushReact() {
|
||
await act(async () => {
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
})
|
||
}
|
||
|
||
afterEach(() => {
|
||
document.body.innerHTML = ''
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false
|
||
})
|
||
|
||
describe('markstream-react text streaming fade', () => {
|
||
it('keeps only the latest appended suffix animating during rapid streaming updates, like vue3', async () => {
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const root = createRoot(host)
|
||
const textStreamState = new Map<string, string>()
|
||
const renderText = (content: string) =>
|
||
React.createElement(StrictMode, null, React.createElement(TextNode as any, {
|
||
node: {
|
||
type: 'text',
|
||
content,
|
||
},
|
||
ctx: {
|
||
typewriter: true,
|
||
textStreamState,
|
||
},
|
||
indexKey: 'stream-0',
|
||
}))
|
||
|
||
await act(async () => {
|
||
root.render(renderText('Hello'))
|
||
})
|
||
await flushReact()
|
||
|
||
expect(host.textContent).toBe('Hello')
|
||
expect(host.querySelectorAll('.text-node-stream-delta')).toHaveLength(0)
|
||
|
||
await act(async () => {
|
||
root.render(renderText('HelloWorld'))
|
||
})
|
||
await flushReact()
|
||
|
||
let deltas = Array.from(host.querySelectorAll('.text-node-stream-delta'))
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas.map(delta => delta.textContent)).toEqual(['World'])
|
||
expect(host.textContent).toBe('HelloWorld')
|
||
|
||
await act(async () => {
|
||
root.render(renderText('HelloWorld'))
|
||
})
|
||
await flushReact()
|
||
|
||
deltas = Array.from(host.querySelectorAll('.text-node-stream-delta'))
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas.map(delta => delta.textContent)).toEqual(['World'])
|
||
|
||
await act(async () => {
|
||
root.render(renderText('HelloWorldAgain'))
|
||
})
|
||
await flushReact()
|
||
|
||
deltas = Array.from(host.querySelectorAll('.text-node-stream-delta'))
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas.map(delta => delta.textContent)).toEqual(['Again'])
|
||
expect(host.textContent).toBe('HelloWorldAgain')
|
||
|
||
await act(async () => {
|
||
deltas[0]?.dispatchEvent(new Event('animationend', { bubbles: true }))
|
||
})
|
||
await flushReact()
|
||
|
||
expect(host.querySelectorAll('.text-node-stream-delta')).toHaveLength(0)
|
||
expect(host.textContent).toBe('HelloWorldAgain')
|
||
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
})
|
||
|
||
it('preserves appended-text fade spans through the full NodeRenderer streaming path', async () => {
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const root = createRoot(host)
|
||
const renderMarkdown = (content: string) =>
|
||
React.createElement(StrictMode, null, React.createElement(NodeRenderer as any, {
|
||
content,
|
||
deferNodesUntilVisible: false,
|
||
viewportPriority: false,
|
||
}))
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('Hello'))
|
||
})
|
||
await flushReact()
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('HelloWorld'))
|
||
})
|
||
await flushReact()
|
||
|
||
let deltas = Array.from(host.querySelectorAll('.text-node-stream-delta'))
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas.map(delta => delta.textContent)).toEqual(['World'])
|
||
expect(host.textContent).toContain('HelloWorld')
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('HelloWorldAgain'))
|
||
})
|
||
await flushReact()
|
||
|
||
deltas = Array.from(host.querySelectorAll('.text-node-stream-delta'))
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas.map(delta => delta.textContent)).toEqual(['Again'])
|
||
expect(host.textContent).toContain('HelloWorldAgain')
|
||
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
})
|
||
|
||
it('settles a finished strong-node delta when following sibling text keeps streaming', async () => {
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const root = createRoot(host)
|
||
const renderMarkdown = (content: string) =>
|
||
React.createElement(StrictMode, null, React.createElement(NodeRenderer as any, {
|
||
content,
|
||
batchRendering: false,
|
||
deferNodesUntilVisible: false,
|
||
viewportPriority: false,
|
||
maxLiveNodes: 0,
|
||
}))
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('1. **记忆化递归(动态规划'))
|
||
})
|
||
await flushReact()
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('1. **记忆化递归(动态规划)*'))
|
||
})
|
||
await flushReact()
|
||
|
||
let strongDelta = host.querySelector('.strong-node .text-node-stream-delta')
|
||
expect(strongDelta?.textContent).toBe(')')
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('1. **记忆化递归(动态规划)**:'))
|
||
})
|
||
await flushReact()
|
||
|
||
strongDelta = host.querySelector('.strong-node .text-node-stream-delta')
|
||
expect(strongDelta).toBeNull()
|
||
expect(host.querySelector('.strong-node')?.textContent).toBe('记忆化递归(动态规划)')
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('1. **记忆化递归(动态规划)**:使'))
|
||
})
|
||
await flushReact()
|
||
|
||
expect(host.querySelector('.strong-node .text-node-stream-delta')).toBeNull()
|
||
expect(host.textContent).toContain('记忆化递归(动态规划):使')
|
||
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
})
|
||
|
||
it('replays appended-text fade inside heading nodes without fading the whole heading', async () => {
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const root = createRoot(host)
|
||
const renderMarkdown = (content: string) =>
|
||
React.createElement(StrictMode, null, React.createElement(NodeRenderer as any, {
|
||
content,
|
||
deferNodesUntilVisible: false,
|
||
viewportPriority: false,
|
||
}))
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('# Hello'))
|
||
})
|
||
await flushReact()
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('# HelloWorld'))
|
||
})
|
||
await flushReact()
|
||
|
||
const heading = host.querySelector('h1')
|
||
const deltas = Array.from(host.querySelectorAll('.text-node-stream-delta'))
|
||
|
||
expect(heading).toBeTruthy()
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas[0]?.textContent).toBe('World')
|
||
expect(heading?.textContent).toBe('HelloWorld')
|
||
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
})
|
||
|
||
it('replays appended-text fade inside inline code nodes while preserving the code chip shell', async () => {
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const root = createRoot(host)
|
||
const renderMarkdown = (content: string) =>
|
||
React.createElement(StrictMode, null, React.createElement(NodeRenderer as any, {
|
||
content,
|
||
deferNodesUntilVisible: false,
|
||
viewportPriority: false,
|
||
}))
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('Use `foo` now'))
|
||
})
|
||
await flushReact()
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('Use `foobar` now'))
|
||
})
|
||
await flushReact()
|
||
|
||
const inlineCode = host.querySelector('code.inline-code')
|
||
const deltas = Array.from(host.querySelectorAll('code.inline-code .text-node-stream-delta'))
|
||
|
||
expect(inlineCode).toBeTruthy()
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas[0]?.textContent).toBe('bar')
|
||
expect(inlineCode?.textContent).toBe('foobar')
|
||
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
})
|
||
|
||
it('replays appended-text fade while a loading link label grows', async () => {
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const root = createRoot(host)
|
||
const textStreamState = new Map<string, string>()
|
||
const renderLink = (text: string) =>
|
||
React.createElement(StrictMode, null, React.createElement(LinkNode as any, {
|
||
node: {
|
||
type: 'link',
|
||
href: 'https://example.com',
|
||
title: null,
|
||
text,
|
||
loading: true,
|
||
},
|
||
ctx: {
|
||
typewriter: true,
|
||
textStreamState,
|
||
},
|
||
indexKey: 'link-0',
|
||
}))
|
||
|
||
await act(async () => {
|
||
root.render(renderLink('Exam'))
|
||
})
|
||
await flushReact()
|
||
|
||
await act(async () => {
|
||
root.render(renderLink('Example'))
|
||
})
|
||
await flushReact()
|
||
|
||
const loading = host.querySelector('.link-loading')
|
||
const deltas = Array.from(host.querySelectorAll('.link-loading .text-node-stream-delta'))
|
||
|
||
expect(loading).toBeTruthy()
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas[0]?.textContent).toBe('ple')
|
||
expect(loading?.textContent).toContain('Example')
|
||
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
})
|
||
|
||
it('replays appended-text fade inside list items', async () => {
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const root = createRoot(host)
|
||
const renderMarkdown = (content: string) =>
|
||
React.createElement(StrictMode, null, React.createElement(NodeRenderer as any, {
|
||
content,
|
||
deferNodesUntilVisible: false,
|
||
viewportPriority: false,
|
||
}))
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('- Hello'))
|
||
})
|
||
await flushReact()
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown('- HelloWorld'))
|
||
})
|
||
await flushReact()
|
||
|
||
const listItem = host.querySelector('li.list-item')
|
||
const deltas = Array.from(host.querySelectorAll('li.list-item .text-node-stream-delta'))
|
||
|
||
expect(listItem).toBeTruthy()
|
||
expect(deltas).toHaveLength(1)
|
||
expect(deltas[0]?.textContent).toBe('World')
|
||
expect(listItem?.textContent).toContain('HelloWorld')
|
||
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
})
|
||
|
||
it('keeps explanatory list text visible while an inline code span is still streaming', async () => {
|
||
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
||
|
||
const host = document.createElement('div')
|
||
document.body.appendChild(host)
|
||
const root = createRoot(host)
|
||
const initial = `- **计算工具验证**
|
||
通过数学计算工具确认结果:`
|
||
const mid = `${initial}
|
||
\`363 ÷ 15,135 × 100 = 2.39841427...`
|
||
const final = `${mid}\``
|
||
const renderMarkdown = (content: string) =>
|
||
React.createElement(StrictMode, null, React.createElement(NodeRenderer as any, {
|
||
content,
|
||
batchRendering: false,
|
||
deferNodesUntilVisible: false,
|
||
viewportPriority: false,
|
||
maxLiveNodes: 0,
|
||
}))
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown(initial))
|
||
})
|
||
await flushReact()
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown(mid))
|
||
})
|
||
await flushReact()
|
||
|
||
const midText = String(host.textContent ?? '').replace(/\s*\n\s*/g, '')
|
||
expect(midText).toContain('计算工具验证通过数学计算工具确认结果:363 ÷ 15,135 × 100 = 2.39841427')
|
||
expect(host.querySelector('.strong-node')?.textContent).toBe('计算工具验证')
|
||
expect(host.querySelector('code.inline-code')?.textContent).toContain('363 ÷ 15,135 × 100 = 2.39841427')
|
||
|
||
await act(async () => {
|
||
root.render(renderMarkdown(final))
|
||
})
|
||
await flushReact()
|
||
|
||
const finalText = String(host.textContent ?? '').replace(/\s*\n\s*/g, '')
|
||
expect(finalText).toContain('计算工具验证通过数学计算工具确认结果:363 ÷ 15,135 × 100 = 2.39841427...')
|
||
expect(host.querySelector('.strong-node')?.textContent).toBe('计算工具验证')
|
||
expect(host.querySelector('code.inline-code')?.textContent).toBe('363 ÷ 15,135 × 100 = 2.39841427...')
|
||
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
})
|
||
})
|