test: add unit tests for base components (#32818)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-03-02 11:40:43 +08:00
committed by GitHub
parent 8cc775d9f2
commit 335b500aea
401 changed files with 820 additions and 819 deletions

View File

@ -0,0 +1,73 @@
import type { NamedExoticComponent } from 'react'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
// AudioBlock.integration.spec.tsx
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AudioBlock from '../audio-block'
// Mock the nested AudioPlayer used by AudioGallery (do not mock AudioGallery itself)
const audioPlayerMock = vi.fn()
vi.mock('@/app/components/base/audio-gallery/AudioPlayer', () => ({
default: (props: { srcs: string[] }) => {
audioPlayerMock(props)
return <div data-testid="audio-player" data-srcs={JSON.stringify(props.srcs)} />
},
})) // adjust path if AudioBlock sits elsewhere
describe('AudioBlock (integration - real AudioGallery)', () => {
beforeEach(() => {
audioPlayerMock.mockClear()
})
it('renders AudioGallery with multiple srcs extracted from node.children', () => {
const node = {
children: [
{ properties: { src: 'one.mp3' } },
{ properties: { src: 'two.mp3' } },
{ type: 'text', value: 'plain' },
],
properties: {},
}
const { container } = render(<AudioBlock node={node} />)
const gallery = screen.getByTestId('audio-player')
expect(gallery).toBeInTheDocument()
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['one.mp3', 'two.mp3'] })
expect(container.firstChild).not.toBeNull()
})
it('renders AudioGallery with single src from node.properties when no children with properties', () => {
const node = {
children: [{ type: 'text', value: 'no-src' }],
properties: { src: 'single.mp3' },
}
render(<AudioBlock node={node} />)
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['single.mp3'] })
expect(screen.getByTestId('audio-player')).toBeInTheDocument()
})
it('returns null when there are no audio sources', () => {
const node = {
children: [{ type: 'text', value: 'nothing here' }],
properties: {},
}
const { container } = render(<AudioBlock node={node} />)
expect(container.firstChild).toBeNull()
expect(audioPlayerMock).not.toHaveBeenCalled()
})
it('has displayName set to AudioBlock', () => {
const component = AudioBlock as NamedExoticComponent<{ node: unknown }>
expect(component.displayName).toBe('AudioBlock')
})
})

View File

@ -0,0 +1,121 @@
import type { NamedExoticComponent } from 'react'
import type { ChatContextValue } from '@/app/components/base/chat/chat/context'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// markdown-button.spec.tsx
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChatContextProvider } from '@/app/components/base/chat/chat/context'
import MarkdownButton from '../button'
// Only mock the URL utility so behavior is deterministic
const isValidUrlSpy = vi.fn()
vi.mock('../utils', () => ({
isValidUrl: (u: string) => isValidUrlSpy(u),
})) // test subject
type TestNode = {
properties?: {
dataVariant?: string
dataMessage?: string
dataLink?: string
dataSize?: string
}
children?: Array<{ value?: string }>
}
describe('MarkdownButton (integration)', () => {
const onSendSpy = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
function renderWithCtx(node: TestNode) {
// Provide minimal ChatContext; cast to ChatContextValue to satisfy the provider signature
const ctx = {
onSend: (msg: unknown) => onSendSpy(msg),
// other props are optional at runtime; assert type to satisfy TS
} as unknown as ChatContextValue
return render(
<ChatContextProvider {...ctx}>
<MarkdownButton node={node as unknown as Record<string, unknown>} />
</ChatContextProvider>,
)
}
it('renders button text from node children', () => {
const node: TestNode = { children: [{ value: 'Click me' }], properties: {} }
renderWithCtx(node)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('opens new tab when link is valid and does not call onSend', async () => {
isValidUrlSpy.mockReturnValue(true)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const user = userEvent.setup()
const node: TestNode = {
properties: { dataLink: 'https://example.com' },
children: [{ value: 'Go' }],
}
renderWithCtx(node)
await user.click(screen.getByRole('button'))
expect(isValidUrlSpy).toHaveBeenCalledWith('https://example.com')
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
expect(onSendSpy).not.toHaveBeenCalled()
openSpy.mockRestore()
})
it('calls onSend when link is invalid but message exists', async () => {
isValidUrlSpy.mockReturnValue(false)
const user = userEvent.setup()
const node: TestNode = {
properties: { dataLink: 'not-a-url', dataMessage: 'hello!' },
children: [{ value: 'Send' }],
}
renderWithCtx(node)
await user.click(screen.getByRole('button'))
expect(isValidUrlSpy).toHaveBeenCalledWith('not-a-url')
expect(onSendSpy).toHaveBeenCalledTimes(1)
expect(onSendSpy).toHaveBeenCalledWith('hello!')
})
it('does nothing when no link and no message', async () => {
isValidUrlSpy.mockReturnValue(false)
const user = userEvent.setup()
const node: TestNode = { properties: {}, children: [{ value: 'Empty' }] }
renderWithCtx(node)
await user.click(screen.getByRole('button'))
expect(isValidUrlSpy).not.toHaveBeenCalled()
expect(onSendSpy).not.toHaveBeenCalled()
})
it('calls onSend when message present and no link', async () => {
const user = userEvent.setup()
const node: TestNode = {
properties: { dataMessage: 'msg-only' },
children: [{ value: 'Msg' }],
}
renderWithCtx(node)
await user.click(screen.getByRole('button'))
expect(onSendSpy).toHaveBeenCalledWith('msg-only')
})
it('has displayName set to MarkdownButton', () => {
const comp = MarkdownButton as NamedExoticComponent<{ node: unknown }>
expect(comp.displayName).toBe('MarkdownButton')
})
})

View File

@ -0,0 +1,356 @@
import { createRequire } from 'node:module'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Theme } from '@/types/app'
import CodeBlock from '../code-block'
type UseThemeReturn = {
theme: Theme
}
const mockUseTheme = vi.fn<() => UseThemeReturn>(() => ({ theme: Theme.light }))
const require = createRequire(import.meta.url)
const echartsCjs = require('echarts') as {
getInstanceByDom: (dom: HTMLDivElement | null) => {
resize: (opts?: { width?: string, height?: string }) => void
} | null
}
let clientWidthSpy: { mockRestore: () => void } | null = null
let clientHeightSpy: { mockRestore: () => void } | null = null
let offsetWidthSpy: { mockRestore: () => void } | null = null
let offsetHeightSpy: { mockRestore: () => void } | null = null
type AudioContextCtor = new () => unknown
type WindowWithLegacyAudio = Window & {
AudioContext?: AudioContextCtor
webkitAudioContext?: AudioContextCtor
abcjsAudioContext?: unknown
}
let originalAudioContext: AudioContextCtor | undefined
let originalWebkitAudioContext: AudioContextCtor | undefined
class MockAudioContext {
state = 'running'
currentTime = 0
destination = {}
resume = vi.fn(async () => undefined)
decodeAudioData = vi.fn(async (_data: ArrayBuffer, success?: (audioBuffer: unknown) => void) => {
const mockAudioBuffer = {}
success?.(mockAudioBuffer)
return mockAudioBuffer
})
createBufferSource = vi.fn(() => ({
buffer: null as unknown,
connect: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
onended: undefined as undefined | (() => void),
}))
}
vi.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => mockUseTheme(),
}))
const findEchartsHost = async () => {
await waitFor(() => {
expect(document.querySelector('.echarts-for-react')).toBeInTheDocument()
})
return document.querySelector('.echarts-for-react') as HTMLDivElement
}
const findEchartsInstance = async () => {
const host = await findEchartsHost()
await waitFor(() => {
expect(echartsCjs.getInstanceByDom(host)).toBeTruthy()
})
return echartsCjs.getInstanceByDom(host)!
}
describe('CodeBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light })
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
clientHeightSpy = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(400)
offsetWidthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(900)
offsetHeightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(400)
const windowWithLegacyAudio = window as WindowWithLegacyAudio
originalAudioContext = windowWithLegacyAudio.AudioContext
originalWebkitAudioContext = windowWithLegacyAudio.webkitAudioContext
windowWithLegacyAudio.AudioContext = MockAudioContext as unknown as AudioContextCtor
windowWithLegacyAudio.webkitAudioContext = MockAudioContext as unknown as AudioContextCtor
delete windowWithLegacyAudio.abcjsAudioContext
})
afterEach(() => {
vi.useRealTimers()
clientWidthSpy?.mockRestore()
clientHeightSpy?.mockRestore()
offsetWidthSpy?.mockRestore()
offsetHeightSpy?.mockRestore()
clientWidthSpy = null
clientHeightSpy = null
offsetWidthSpy = null
offsetHeightSpy = null
const windowWithLegacyAudio = window as WindowWithLegacyAudio
if (originalAudioContext)
windowWithLegacyAudio.AudioContext = originalAudioContext
else
delete windowWithLegacyAudio.AudioContext
if (originalWebkitAudioContext)
windowWithLegacyAudio.webkitAudioContext = originalWebkitAudioContext
else
delete windowWithLegacyAudio.webkitAudioContext
delete windowWithLegacyAudio.abcjsAudioContext
originalAudioContext = undefined
originalWebkitAudioContext = undefined
})
// Base rendering behaviors for inline and language labels.
describe('Rendering', () => {
it('should render inline code element when inline prop is true', () => {
const { container } = render(<CodeBlock inline className="language-javascript">const a=1;</CodeBlock>)
const code = container.querySelector('code')
expect(code).toBeTruthy()
expect(code?.textContent).toBe('const a=1;')
})
it('should render code element when className does not include language prefix', () => {
const { container } = render(<CodeBlock className="plain">abc</CodeBlock>)
expect(container.querySelector('code')?.textContent).toBe('abc')
})
it('should render code element when className is not provided', () => {
const { container } = render(<CodeBlock>plain text</CodeBlock>)
expect(container.querySelector('code')?.textContent).toBe('plain text')
})
it('should render syntax-highlighted output when language is standard', () => {
render(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
})
it('should format unknown language labels with capitalized fallback when language is not in map', () => {
render(<CodeBlock className="language-ruby">puts "ok"</CodeBlock>)
expect(screen.getByText('Ruby')).toBeInTheDocument()
})
it('should render mermaid controls when language is mermaid', async () => {
render(<CodeBlock className="language-mermaid">graph TB; A--&gt;B;</CodeBlock>)
expect(await screen.findByText('app.mermaid.classic')).toBeInTheDocument()
expect(screen.getByText('Mermaid')).toBeInTheDocument()
})
it('should render abc section header when language is abc', () => {
render(<CodeBlock className="language-abc">X:1\nT:test</CodeBlock>)
expect(screen.getByText('ABC')).toBeInTheDocument()
})
it('should hide svg renderer when toggle is clicked for svg language', async () => {
const user = userEvent.setup()
render(<CodeBlock className="language-svg">{'<svg/>'}</CodeBlock>)
expect(await screen.findByText(/Error rendering SVG/i)).toBeInTheDocument()
const svgToggleButton = screen.getAllByRole('button')[0]
await user.click(svgToggleButton)
expect(screen.queryByText(/Error rendering SVG/i)).not.toBeInTheDocument()
})
it('should render syntax-highlighted output when language is standard and app theme is dark', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<CodeBlock className="language-javascript">const y = 2;</CodeBlock>)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
})
})
// ECharts behaviors for loading, parsing, and chart lifecycle updates.
describe('ECharts', () => {
it('should show loading indicator when echarts content is empty', () => {
render(<CodeBlock className="language-echarts"></CodeBlock>)
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should keep loading when echarts content is whitespace only', () => {
render(<CodeBlock className="language-echarts">{' '}</CodeBlock>)
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should render echarts with parsed option when JSON is valid', async () => {
const option = { title: [{ text: 'Hello' }] }
render(<CodeBlock className="language-echarts">{JSON.stringify(option)}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
})
it('should use error option when echarts content is invalid but structurally complete', async () => {
render(<CodeBlock className="language-echarts">{'{a:1}'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
})
it('should use error option when echarts content is invalid non-structured text', async () => {
render(<CodeBlock className="language-echarts">{'not a json {'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
})
it('should keep loading when option is valid JSON but not an object', async () => {
render(<CodeBlock className="language-echarts">"text-value"</CodeBlock>)
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should keep loading when echarts content matches incomplete quote-pattern guard', async () => {
render(<CodeBlock className="language-echarts">{'x{"a":1'}</CodeBlock>)
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should keep loading when echarts content has unmatched opening array bracket', async () => {
render(<CodeBlock className="language-echarts">[[1,2]</CodeBlock>)
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
})
it('should keep chart instance stable when window resize is triggered', async () => {
render(<CodeBlock className="language-echarts">{'{}'}</CodeBlock>)
await findEchartsHost()
act(() => {
window.dispatchEvent(new Event('resize'))
})
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should keep rendering when echarts content updates repeatedly', async () => {
const { rerender } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
await findEchartsHost()
rerender(<CodeBlock className="language-echarts">{'{"a":2}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":3}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":4}'}</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":5}'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should stop processing extra finished events when chart finished callback fires repeatedly', async () => {
render(<CodeBlock className="language-echarts">{'{"series":[]}'}</CodeBlock>)
const chart = await findEchartsInstance()
const chartWithTrigger = chart as unknown as { trigger?: (eventName: string, event?: unknown) => void }
act(() => {
for (let i = 0; i < 8; i++) {
chartWithTrigger.trigger?.('finished', {})
chart.resize()
}
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 500))
})
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should switch from loading to chart when streaming content becomes valid JSON', async () => {
const { rerender } = render(<CodeBlock className="language-echarts">{'{ "a":'}</CodeBlock>)
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
rerender(<CodeBlock className="language-echarts">{'{ "a": 1 }'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should parse array JSON after previously incomplete streaming content', async () => {
const parseSpy = vi.spyOn(JSON, 'parse')
parseSpy.mockImplementationOnce(() => ({ series: [] }) as unknown as object)
const { rerender } = render(<CodeBlock className="language-echarts">[1, 2</CodeBlock>)
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
rerender(<CodeBlock className="language-echarts">[1, 2]</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
parseSpy.mockRestore()
})
it('should parse non-structured streaming content when JSON.parse fallback succeeds', async () => {
const parseSpy = vi.spyOn(JSON, 'parse')
parseSpy.mockImplementationOnce(() => ({ recovered: true }) as unknown as object)
render(<CodeBlock className="language-echarts">abcde</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
parseSpy.mockRestore()
})
it('should render dark themed echarts path when app theme is dark', async () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<CodeBlock className="language-echarts">{'{"series":[]}'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should render dark mode error option when app theme is dark and echarts content is invalid', async () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<CodeBlock className="language-echarts">{'{a:1}'}</CodeBlock>)
expect(await findEchartsHost()).toBeInTheDocument()
})
it('should wire resize listener when echarts view re-enters with a ready chart instance', async () => {
const { rerender, unmount } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
await findEchartsHost()
rerender(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
rerender(<CodeBlock className="language-echarts">{'{"a":2}'}</CodeBlock>)
act(() => {
window.dispatchEvent(new Event('resize'))
})
expect(await findEchartsHost()).toBeInTheDocument()
unmount()
})
it('should cleanup echarts resize listener without pending timer on unmount', async () => {
const { unmount } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
await findEchartsHost()
unmount()
})
})
})

View File

@ -0,0 +1,320 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from '@/app/components/base/date-and-time-picker/utils/dayjs'
import MarkdownForm from '../form'
type TextNode = {
type: 'text'
value: string
}
type ElementNode = {
type: 'element'
tagName: string
properties: Record<string, unknown>
children: Array<ElementNode | TextNode>
}
type RootNode = {
properties: Record<string, unknown>
children: Array<ElementNode | TextNode>
}
const { mockOnSend, mockFormatDateForOutput } = vi.hoisted(() => ({
mockOnSend: vi.fn(),
mockFormatDateForOutput: vi.fn((_date: unknown, includeTime?: boolean) => {
return includeTime ? 'formatted-datetime' : 'formatted-date'
}),
}))
vi.mock('@/app/components/base/chat/chat/context', () => ({
useChatContext: () => ({
onSend: mockOnSend,
}),
}))
vi.mock('@/app/components/base/date-and-time-picker/utils/dayjs', async () => {
const actual = await vi.importActual<typeof import('@/app/components/base/date-and-time-picker/utils/dayjs')>(
'@/app/components/base/date-and-time-picker/utils/dayjs',
)
return {
...actual,
formatDateForOutput: mockFormatDateForOutput,
}
})
const createTextNode = (value: string): TextNode => ({
type: 'text',
value,
})
const createElementNode = (
tagName: string,
properties: Record<string, unknown> = {},
children: Array<ElementNode | TextNode> = [],
): ElementNode => ({
type: 'element',
tagName,
properties,
children,
})
const createRootNode = (
children: Array<ElementNode | TextNode>,
properties: Record<string, unknown> = {},
): RootNode => ({
properties,
children,
})
describe('MarkdownForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Render supported tags and fallback output for unsupported tags.
describe('Rendering', () => {
it('should render label, inputs, textarea, button, and unsupported tag fallback', () => {
const node = createRootNode([
createElementNode('label', { for: 'name' }, [createTextNode('Name')]),
createElementNode('input', { type: 'text', name: 'name', placeholder: 'Enter name' }),
createElementNode('textarea', { name: 'bio', placeholder: 'Enter bio' }),
createElementNode('button', {}, [createTextNode('Submit')]),
createElementNode('article', {}, [createTextNode('Unsupported child')]),
])
render(<MarkdownForm node={node} />)
expect(screen.getByText('Name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter bio')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
expect(screen.getByText(/Unsupported tag:\s*article/)).toBeInTheDocument()
})
})
// Convert current form values to plain text output by default.
describe('Text format submission', () => {
it('should call onSend with text output when dataFormat is not provided', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', { type: 'text', name: 'name', value: 'Alice' }),
createElementNode('textarea', { name: 'bio', value: 'Hello' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('name: Alice\nbio: Hello')
})
})
it('should submit updated text input and textarea values after user typing', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', { type: 'text', name: 'name', value: '', placeholder: 'Name input' }),
createElementNode('textarea', { name: 'bio', value: '', placeholder: 'Bio input' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
const nameInput = screen.getByPlaceholderText('Name input')
const bioInput = screen.getByPlaceholderText('Bio input')
await user.type(nameInput, 'Bob')
await user.type(bioInput, 'Hi there')
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('name: Bob\nbio: Hi there')
})
})
})
// Emit serialized JSON when data-format requests JSON output.
describe('JSON format submission', () => {
it('should call onSend with JSON output when dataFormat is json', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', { type: 'hidden', name: 'token', value: 'secret-token' }),
createElementNode('input', { type: 'select', name: 'color', value: 'red', dataOptions: ['red', 'blue'] }),
createElementNode('button', {}, [createTextNode('Send JSON')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Send JSON' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('{"token":"secret-token","color":"red"}')
})
})
it('should fallback hidden value to empty string when value is missing', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', { type: 'hidden', name: 'token' }),
createElementNode('button', {}, [createTextNode('Send JSON')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Send JSON' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('{"token":""}')
})
})
})
// Select options parser should handle both valid and invalid string payloads.
describe('Select options parsing', () => {
it('should parse options from data-options string and submit selected value', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', {
'type': 'select',
'name': 'city',
'value': 'Paris',
'data-options': '["Paris","Tokyo"]',
}),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('city: Paris')
})
})
it('should handle invalid data-options string without crashing', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = createRootNode([
createElementNode('input', {
'type': 'select',
'name': 'city',
'value': 'Paris',
'data-options': 'not-json',
}),
createElementNode('button', {}, [createTextNode('Submit')]),
])
try {
render(<MarkdownForm node={node} />)
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
expect(consoleErrorSpy).toHaveBeenCalled()
}
finally {
consoleErrorSpy.mockRestore()
}
})
it('should update selected value via onSelect and submit the new option', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', {
type: 'select',
name: 'city',
value: 'Paris',
dataOptions: ['Paris', 'Tokyo'],
}),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
const triggerText = await screen.findByTitle('Paris')
await user.click(triggerText)
await user.click(await screen.findByText('Tokyo'))
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('city: Tokyo')
})
})
})
// Date and datetime values should be formatted through shared utility before submission.
describe('Date formatting', () => {
it('should format date and datetime values before sending', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('input', { type: 'date', name: 'startDate', value: dayjs('2026-01-10') }),
createElementNode('input', { type: 'datetime', name: 'runAt', value: dayjs('2026-01-10T08:30:00') }),
createElementNode('button', {}, [createTextNode('Submit')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockFormatDateForOutput).toHaveBeenCalledTimes(2)
expect(mockFormatDateForOutput).toHaveBeenNthCalledWith(1, expect.anything(), false)
expect(mockFormatDateForOutput).toHaveBeenNthCalledWith(2, expect.anything(), true)
expect(mockOnSend).toHaveBeenCalledWith('{"startDate":"formatted-date","runAt":"formatted-datetime"}')
})
})
})
// Checkbox interactions should update form state and be reflected in submission output.
describe('Checkbox interaction', () => {
it('should toggle checkbox value and submit updated value', async () => {
const user = userEvent.setup()
const node = createRootNode([
createElementNode('input', { type: 'checkbox', name: 'acceptTerms', value: false, dataTip: 'Accept terms' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
render(<MarkdownForm node={node} />)
await user.click(screen.getByTestId('checkbox-acceptTerms'))
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith('acceptTerms: true')
})
})
})
// Native submit event is intentionally blocked at form level.
describe('Form submit behavior', () => {
it('should prevent native submit propagation from form onSubmit', () => {
const parentOnSubmit = vi.fn()
const node = createRootNode([
createElementNode('input', { type: 'text', name: 'name', value: 'Alice' }),
createElementNode('button', {}, [createTextNode('Submit')]),
])
const { container } = render(
<div onSubmit={parentOnSubmit}>
<MarkdownForm node={node} />
</div>,
)
const form = container.querySelector('form')
expect(form).not.toBeNull()
if (!form)
throw new Error('Form element not found')
fireEvent.submit(form)
expect(parentOnSubmit).not.toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,164 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Link from '../link'
// ---- mocks ----
const mockOnSend = vi.fn()
vi.mock('@/app/components/base/chat/chat/context', () => ({
useChatContext: () => ({
onSend: mockOnSend,
}),
}))
const mockIsValidUrl = vi.fn()
vi.mock('../utils', () => ({
isValidUrl: (url: string) => mockIsValidUrl(url),
}))
describe('Link component', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------
// ABBR LINK
// --------------------------
it('renders abbr link and calls onSend when clicked', () => {
const node = {
properties: {
href: 'abbr:hello%20world',
},
children: [{ value: 'Tooltip text' }],
}
render(<Link node={node} />)
const abbr = screen.getByText('Tooltip text')
expect(abbr.tagName).toBe('ABBR')
fireEvent.click(abbr)
expect(mockOnSend).toHaveBeenCalledWith('hello world')
})
// --------------------------
// HASH SCROLL LINK
// --------------------------
it('scrolls to target element when hash link clicked', () => {
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
const node = {
properties: {
href: '#section1',
},
}
const container = document.createElement('div')
container.className = 'chat-answer-container'
const target = document.createElement('div')
target.id = 'section1'
container.appendChild(target)
document.body.appendChild(container)
render(
<div className="chat-answer-container">
<div id="section1" />
<Link node={node}>Go</Link>
</div>,
)
const link = screen.getByText('Go')
fireEvent.click(link)
expect(scrollIntoView).toHaveBeenCalled()
})
// --------------------------
// INVALID URL
// --------------------------
it('renders span when url is invalid', () => {
mockIsValidUrl.mockReturnValue(false)
const node = {
properties: {
href: 'not-a-url',
},
}
render(<Link node={node}>Invalid</Link>)
const span = screen.getByText('Invalid')
expect(span.tagName).toBe('SPAN')
})
// --------------------------
// VALID EXTERNAL URL
// --------------------------
it('renders external link with target blank when url is valid', () => {
mockIsValidUrl.mockReturnValue(true)
const node = {
properties: {
href: 'https://example.com',
},
}
render(<Link node={node}>Visit</Link>)
const link = screen.getByText('Visit')
expect(link.tagName).toBe('A')
expect(link).toHaveAttribute('href', 'https://example.com')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
// --------------------------
// NO HREF
// --------------------------
it('renders span when no href provided', () => {
const node = {
properties: {},
}
render(<Link node={node}>NoHref</Link>)
const span = screen.getByText('NoHref')
expect(span.tagName).toBe('SPAN')
})
// --------------------------
// DEFAULT TEXT FALLBACK
// --------------------------
it('renders default text for external link if children not provided', () => {
mockIsValidUrl.mockReturnValue(true)
const node = {
properties: {
href: 'https://example.com',
},
}
render(<Link node={node} />)
expect(screen.getByText('Download')).toBeInTheDocument()
})
it('renders default text for hash link if children not provided', () => {
const node = {
properties: {
href: '#section1',
},
}
render(<Link node={node} />)
expect(screen.getByText('ScrollView')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ErrorBoundary from '@/app/components/base/markdown/error-boundary'
import MarkdownMusic from '../music'
describe('MarkdownMusic', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Base rendering behavior for the component shell.
describe('Rendering', () => {
it('should render wrapper and two internal container nodes', () => {
const { container } = render(<MarkdownMusic><span>child</span></MarkdownMusic>)
const topLevel = container.firstElementChild as HTMLElement | null
expect(topLevel).toBeTruthy()
expect(topLevel?.children.length).toBe(2)
expect(topLevel?.style.minWidth).toBe('100%')
expect(topLevel?.style.overflow).toBe('auto')
})
})
// String input triggers abcjs execution in jsdom; verify error is safely catchable.
describe('String Input', () => {
it('should render fallback when abcjs audio initialization fails in test environment', async () => {
render(
<ErrorBoundary>
<MarkdownMusic>{'X:1\nT:Test\nK:C\nC D E F|'}</MarkdownMusic>
</ErrorBoundary>,
)
expect(await screen.findByText(/Oops! An error occurred./i)).toBeInTheDocument()
})
it('should not render fallback when children is not a string', () => {
render(
<ErrorBoundary>
<MarkdownMusic><span>not a string</span></MarkdownMusic>
</ErrorBoundary>,
)
expect(screen.queryByText(/Oops! An error occurred./i)).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Paragraph from '../paragraph'
vi.mock('@/app/components/base/image-gallery', () => ({
default: ({ srcs }: { srcs: string[] }) => (
<div data-testid="image-gallery">{srcs.join(',')}</div>
),
}))
type MockNode = {
children?: Array<{
tagName?: string
properties?: {
src?: string
}
}>
}
type ParagraphProps = {
node: MockNode
children?: React.ReactNode
}
const renderParagraph = (props: ParagraphProps) => {
return render(<Paragraph {...props} />)
}
describe('Paragraph', () => {
it('should render normal paragraph when no image child exists', () => {
renderParagraph({
node: { children: [] },
children: 'Hello world',
})
expect(screen.getByText('Hello world').tagName).toBe('P')
})
it('should render image gallery when first child is img', () => {
renderParagraph({
node: {
children: [
{
tagName: 'img',
properties: { src: 'test.png' },
},
],
},
children: ['Image only'],
})
expect(screen.getByTestId('image-gallery')).toBeInTheDocument()
expect(screen.getByTestId('image-gallery')).toHaveTextContent('test.png')
})
it('should render additional content after image when children length > 1', () => {
renderParagraph({
node: {
children: [
{
tagName: 'img',
properties: { src: 'test.png' },
},
],
},
children: ['Image', <span key="1">Caption</span>],
})
expect(screen.getByTestId('image-gallery')).toBeInTheDocument()
expect(screen.getByText('Caption')).toBeInTheDocument()
})
it('should render paragraph when first child exists but is not img', () => {
renderParagraph({
node: {
children: [
{
tagName: 'div',
},
],
},
children: 'Not image',
})
expect(screen.getByText('Not image').tagName).toBe('P')
})
it('should render paragraph when children_node is undefined', () => {
renderParagraph({
node: {},
children: 'Fallback',
})
expect(screen.getByText('Fallback').tagName).toBe('P')
})
})

View File

@ -0,0 +1,96 @@
import { cleanup, render, screen } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginImg } from '../plugin-img'
/* -------------------- Mocks -------------------- */
vi.mock('@/app/components/base/image-gallery', () => ({
__esModule: true,
default: ({ srcs }: { srcs: string[] }) => (
<div data-testid="image-gallery">{srcs[0]}</div>
),
}))
const mockUsePluginReadmeAsset = vi.fn()
vi.mock('@/service/use-plugins', () => ({
usePluginReadmeAsset: (args: unknown) => mockUsePluginReadmeAsset(args),
}))
const mockGetMarkdownImageURL = vi.fn()
vi.mock('../utils', () => ({
getMarkdownImageURL: (src: string, pluginId?: string) =>
mockGetMarkdownImageURL(src, pluginId),
}))
/* -------------------- Tests -------------------- */
describe('PluginImg', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
it('uses blob URL when assetData exists', () => {
const fakeBlob = new Blob(['test'])
const fakeObjectUrl = 'blob:test-url'
mockUsePluginReadmeAsset.mockReturnValue({ data: fakeBlob })
mockGetMarkdownImageURL.mockReturnValue('fallback-url')
const createSpy = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue(fakeObjectUrl)
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL')
const { unmount } = render(
<PluginImg
src="file.png"
pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }}
/>,
)
const gallery = screen.getByTestId('image-gallery')
expect(gallery.textContent).toBe(fakeObjectUrl)
expect(createSpy).toHaveBeenCalledWith(fakeBlob)
unmount()
expect(revokeSpy).toHaveBeenCalledWith(fakeObjectUrl)
})
it('falls back to getMarkdownImageURL when no assetData', () => {
mockUsePluginReadmeAsset.mockReturnValue({ data: undefined })
mockGetMarkdownImageURL.mockReturnValue('computed-url')
render(
<PluginImg
src="file.png"
pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }}
/>,
)
const gallery = screen.getByTestId('image-gallery')
expect(gallery.textContent).toBe('computed-url')
expect(mockGetMarkdownImageURL).toHaveBeenCalledWith('file.png', '123')
})
it('works without pluginInfo', () => {
mockUsePluginReadmeAsset.mockReturnValue({ data: undefined })
mockGetMarkdownImageURL.mockReturnValue('default-url')
render(<PluginImg src="file.png" />)
const gallery = screen.getByTestId('image-gallery')
expect(gallery.textContent).toBe('default-url')
expect(mockGetMarkdownImageURL).toHaveBeenCalledWith('file.png', undefined)
})
})

View File

@ -0,0 +1,181 @@
/* eslint-disable next/no-img-element */
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { usePluginReadmeAsset } from '@/service/use-plugins'
import { PluginParagraph } from '../plugin-paragraph'
import { getMarkdownImageURL } from '../utils'
// Mock dependencies
vi.mock('@/service/use-plugins', () => ({
usePluginReadmeAsset: vi.fn(),
}))
vi.mock('../utils', () => ({
getMarkdownImageURL: vi.fn(),
}))
vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
default: ({ url, onCancel }: { url: string, onCancel: () => void }) => (
<div data-testid="image-preview-modal">
<span>{url}</span>
<button onClick={onCancel} type="button">Close</button>
</div>
),
}))
/**
* Interfaces to avoid 'any' and satisfy strict linting
*/
type MockNode = {
children?: Array<{
tagName?: string
properties?: { src?: string }
}>
}
type HookReturn = {
data?: Blob
isLoading?: boolean
error?: Error | null
}
describe('PluginParagraph', () => {
const mockPluginInfo = {
pluginUniqueIdentifier: 'test-plugin-id',
pluginId: 'plugin-123',
}
beforeEach(() => {
vi.clearAllMocks()
// Ensure URL globals exist in the test environment using globalThis
if (!globalThis.URL.createObjectURL) {
globalThis.URL.createObjectURL = vi.fn()
globalThis.URL.revokeObjectURL = vi.fn()
}
// Default mock return to prevent destructuring errors
vi.mocked(usePluginReadmeAsset).mockReturnValue({
data: undefined,
isLoading: false,
error: null,
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
})
it('should render a standard paragraph when not an image', () => {
const node: MockNode = { children: [{ tagName: 'span' }] }
render(
<PluginParagraph node={node}>
Hello World
</PluginParagraph>,
)
expect(screen.getByTestId('standard-paragraph')).toHaveTextContent('Hello World')
})
it('should render an ImageGallery when the first child is an image', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
vi.mocked(getMarkdownImageURL).mockReturnValue('https://cdn.com/test-img.png')
const { container } = render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
</PluginParagraph>,
)
expect(screen.getByTestId('image-paragraph-wrapper')).toBeInTheDocument()
// Query by selector since alt="" removes the 'img' role from the accessibility tree
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'https://cdn.com/test-img.png')
})
it('should use a blob URL when asset data is successfully fetched', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
const mockBlob = new Blob([''], { type: 'image/png' })
vi.mocked(usePluginReadmeAsset).mockReturnValue({
data: mockBlob,
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
vi.spyOn(globalThis.URL, 'createObjectURL').mockReturnValue('blob:actual-blob-url')
const { container } = render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
</PluginParagraph>,
)
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'blob:actual-blob-url')
})
it('should render remaining children below the image gallery', () => {
const node: MockNode = {
children: [
{ tagName: 'img', properties: { src: 'test-img.png' } },
{ tagName: 'text' },
],
}
render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
<span>Caption Text</span>
</PluginParagraph>,
)
expect(screen.getByTestId('remaining-children')).toHaveTextContent('Caption Text')
})
it('should revoke the blob URL on unmount to prevent memory leaks', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
const mockBlob = new Blob([''], { type: 'image/png' })
vi.mocked(usePluginReadmeAsset).mockReturnValue({
data: mockBlob,
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
const revokeSpy = vi.spyOn(globalThis.URL, 'revokeObjectURL')
vi.spyOn(globalThis.URL, 'createObjectURL').mockReturnValue('blob:cleanup-test')
const { unmount } = render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
</PluginParagraph>,
)
unmount()
expect(revokeSpy).toHaveBeenCalledWith('blob:cleanup-test')
})
it('should open the image preview modal when an image in the gallery is clicked', async () => {
const user = userEvent.setup()
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
vi.mocked(getMarkdownImageURL).mockReturnValue('https://cdn.com/gallery.png')
const { container } = render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
</PluginParagraph>,
)
const img = container.querySelector('img')
if (img)
await user.click(img)
// ImageGallery is not mocked, so it should trigger the preview
expect(screen.getByTestId('image-preview-modal')).toBeInTheDocument()
expect(screen.getByText('https://cdn.com/gallery.png')).toBeInTheDocument()
const closeBtn = screen.getByText('Close')
await user.click(closeBtn)
expect(screen.queryByTestId('image-preview-modal')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it } from 'vitest'
import PreCode from '../pre-code'
describe('PreCode Component', () => {
it('renders children correctly inside the pre tag', () => {
const { container } = render(
<PreCode>
<code data-testid="test-code">console.log("hello world")</code>
</PreCode>,
)
const preElement = container.querySelector('pre')
const codeElement = screen.getByTestId('test-code')
expect(preElement).toBeInTheDocument()
expect(codeElement).toBeInTheDocument()
// Verify code is a descendant of pre
expect(preElement).toContainElement(codeElement)
expect(codeElement.textContent).toBe('console.log("hello world")')
})
it('contains the copy button span for CSS targeting', () => {
const { container } = render(
<PreCode>
<code>test content</code>
</PreCode>,
)
const copySpan = container.querySelector('.copy-code-button')
expect(copySpan).toBeInTheDocument()
expect(copySpan?.tagName).toBe('SPAN')
})
it('renders as a <pre> element', () => {
const { container } = render(<PreCode>Content</PreCode>)
expect(container.querySelector('pre')).toBeInTheDocument()
})
it('handles multiple children correctly', () => {
render(
<PreCode>
<span>Line 1</span>
<span>Line 2</span>
</PreCode>,
)
expect(screen.getByText('Line 1')).toBeInTheDocument()
expect(screen.getByText('Line 2')).toBeInTheDocument()
})
it('correctly instantiates the pre element node', () => {
const { container } = render(<PreCode>Ref check</PreCode>)
const pre = container.querySelector('pre')
// Verifies the node is an actual HTMLPreElement,
// confirming the ref-linked element rendered correctly.
expect(pre).toBeInstanceOf(HTMLPreElement)
})
})

View File

@ -0,0 +1,69 @@
import { cleanup, render } from '@testing-library/react'
import * as React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import ScriptBlock from '../script-block'
afterEach(() => {
cleanup()
})
type ScriptNode = {
children: Array<{ value?: string }>
}
describe('ScriptBlock', () => {
it('renders script tag string when child has value', () => {
const node: ScriptNode = {
children: [{ value: 'alert("hi")' }],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script>alert("hi")</script>')
})
it('renders empty script tag when child value is undefined', () => {
const node: ScriptNode = {
children: [{}],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script></script>')
})
it('renders empty script tag when children array is empty', () => {
const node: ScriptNode = {
children: [],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe('<script></script>')
})
it('preserves multiline script content', () => {
const multi = `console.log("line1");
console.log("line2");`
const node: ScriptNode = {
children: [{ value: multi }],
}
const { container } = render(
<ScriptBlock node={node} />,
)
expect(container.textContent).toBe(`<script>${multi}</script>`)
})
it('has displayName set correctly', () => {
expect(ScriptBlock.displayName).toBe('ScriptBlock')
})
})

View File

@ -0,0 +1,248 @@
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ChatContextProvider } from '@/app/components/base/chat/chat/context'
import ThinkBlock from '../think-block'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'chat.thinking': 'Thinking...',
'chat.thought': 'Thought',
}
return translations[key] || key
},
}),
}))
// Helper to wrap component with ChatContextProvider
const renderWithContext = (
children: React.ReactNode,
isResponding: boolean = true,
) => {
return render(
<ChatContextProvider
config={undefined}
isResponding={isResponding}
chatList={[]}
showPromptLog={false}
questionIcon={undefined}
answerIcon={undefined}
onSend={undefined}
onRegenerate={undefined}
onAnnotationEdited={undefined}
onAnnotationAdded={undefined}
onAnnotationRemoved={undefined}
onFeedback={undefined}
>
{children}
</ChatContextProvider>,
)
}
describe('ThinkBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render regular details element when data-think is false', () => {
render(
<ThinkBlock data-think={false}>
<p>Regular content</p>
</ThinkBlock>,
)
expect(screen.getByText('Regular content')).toBeInTheDocument()
})
it('should render think block with thinking state when data-think is true', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<p>Thinking content</p>
</ThinkBlock>,
true,
)
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
expect(screen.getByText('Thinking content')).toBeInTheDocument()
})
it('should render thought state when content has ENDTHINKFLAG', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<p>Completed thinking[ENDTHINKFLAG]</p>
</ThinkBlock>,
true,
)
expect(screen.getByText(/Thought/)).toBeInTheDocument()
})
})
describe('Timer behavior', () => {
it('should update elapsed time while thinking', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<p>Thinking...</p>
</ThinkBlock>,
true,
)
// Initial state should show 0.0s
expect(screen.getByText(/\(0\.0s\)/)).toBeInTheDocument()
// Advance timer by 500ms and run pending timers
act(() => {
vi.advanceTimersByTime(500)
})
// Should show approximately 0.5s
expect(screen.getByText(/\(0\.5s\)/)).toBeInTheDocument()
})
it('should stop timer when isResponding becomes false', () => {
const { rerender } = render(
<ChatContextProvider
config={undefined}
isResponding={true}
chatList={[]}
showPromptLog={false}
questionIcon={undefined}
answerIcon={undefined}
onSend={undefined}
onRegenerate={undefined}
onAnnotationEdited={undefined}
onAnnotationAdded={undefined}
onAnnotationRemoved={undefined}
onFeedback={undefined}
>
<ThinkBlock data-think={true}>
<p>Thinking content</p>
</ThinkBlock>
</ChatContextProvider>,
)
// Verify initial thinking state
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
// Advance timer
act(() => {
vi.advanceTimersByTime(1000)
})
// Simulate user clicking stop (isResponding becomes false)
rerender(
<ChatContextProvider
config={undefined}
isResponding={false}
chatList={[]}
showPromptLog={false}
questionIcon={undefined}
answerIcon={undefined}
onSend={undefined}
onRegenerate={undefined}
onAnnotationEdited={undefined}
onAnnotationAdded={undefined}
onAnnotationRemoved={undefined}
onFeedback={undefined}
>
<ThinkBlock data-think={true}>
<p>Thinking content</p>
</ThinkBlock>
</ChatContextProvider>,
)
// Should now show "Thought" instead of "Thinking..."
expect(screen.getByText(/Thought/)).toBeInTheDocument()
})
it('should NOT stop timer when isResponding is undefined (outside ChatContextProvider)', () => {
// Render without ChatContextProvider
render(
<ThinkBlock data-think={true}>
<p>Content without ENDTHINKFLAG</p>
</ThinkBlock>,
)
// Initial state should show "Thinking..."
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
// Advance timer
act(() => {
vi.advanceTimersByTime(2000)
})
// Timer should still be running (showing "Thinking..." not "Thought")
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
expect(screen.getByText(/\(2\.0s\)/)).toBeInTheDocument()
})
})
describe('ENDTHINKFLAG handling', () => {
it('should remove ENDTHINKFLAG from displayed content', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<p>Content[ENDTHINKFLAG]</p>
</ThinkBlock>,
true,
)
expect(screen.getByText('Content')).toBeInTheDocument()
expect(screen.queryByText('[ENDTHINKFLAG]')).not.toBeInTheDocument()
})
it('should detect ENDTHINKFLAG in nested children', () => {
renderWithContext(
<ThinkBlock data-think={true}>
<div>
<span>Nested content[ENDTHINKFLAG]</span>
</div>
</ThinkBlock>,
true,
)
// Should show "Thought" since ENDTHINKFLAG is present
expect(screen.getByText(/Thought/)).toBeInTheDocument()
})
it('should detect ENDTHINKFLAG in array children', () => {
renderWithContext(
<ThinkBlock data-think={true}>
{['Part 1', 'Part 2[ENDTHINKFLAG]']}
</ThinkBlock>,
true,
)
expect(screen.getByText(/Thought/)).toBeInTheDocument()
})
})
describe('Edge cases', () => {
it('should handle empty children', () => {
renderWithContext(
<ThinkBlock data-think={true}></ThinkBlock>,
true,
)
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
})
it('should handle null children gracefully', () => {
renderWithContext(
<ThinkBlock data-think={true}>
{null}
</ThinkBlock>,
true,
)
expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,84 @@
import { render } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it } from 'vitest'
import VideoGallery from '../../video-gallery'
import VideoBlock from '../video-block'
type ChildNode = {
properties?: {
src?: string
}
}
type BlockNode = {
children: ChildNode[]
properties?: {
src?: string
}
}
describe('VideoBlock', () => {
it('renders multiple video sources from node.children', () => {
const node: BlockNode = {
children: [
{ properties: { src: 'a.mp4' } },
{ properties: { src: 'b.mp4' } },
],
}
render(<VideoBlock node={node} />)
const video = document.querySelector('video')
expect(video).toBeTruthy()
const sources = document.querySelectorAll('source')
expect(sources).toHaveLength(2)
expect(sources[0]).toHaveAttribute('src', 'a.mp4')
expect(sources[1]).toHaveAttribute('src', 'b.mp4')
})
it('renders single video from node.properties.src when no children srcs', () => {
const node: BlockNode = {
children: [],
properties: { src: 'single.mp4' },
}
render(<VideoBlock node={node} />)
const sources = document.querySelectorAll('source')
expect(sources).toHaveLength(1)
expect(sources[0]).toHaveAttribute('src', 'single.mp4')
})
it('returns null when no sources exist', () => {
const node: BlockNode = {
children: [],
properties: {},
}
const { container } = render(<VideoBlock node={node} />)
expect(container.innerHTML).toBe('')
})
it('has displayName set', () => {
expect(VideoBlock.displayName).toBe('VideoBlock')
})
})
describe('VideoGallery', () => {
it('returns null when srcs are empty or invalid', () => {
const { container } = render(<VideoGallery srcs={['', '']} />)
expect(container.innerHTML).toBe('')
})
it('renders video when valid srcs provided', () => {
render(<VideoGallery srcs={['ok.mp4', 'also.mp4']} />)
const sources = document.querySelectorAll('source')
expect(sources).toHaveLength(2)
expect(sources[0]).toHaveAttribute('src', 'ok.mp4')
expect(sources[1]).toHaveAttribute('src', 'also.mp4')
})
})