mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
test: add unit tests for base components-part-4 (#32452)
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
This commit is contained in:
356
web/app/components/base/markdown-blocks/code-block.spec.tsx
Normal file
356
web/app/components/base/markdown-blocks/code-block.spec.tsx
Normal 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-->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()
|
||||
})
|
||||
})
|
||||
})
|
||||
164
web/app/components/base/markdown-blocks/link.spec.tsx
Normal file
164
web/app/components/base/markdown-blocks/link.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
46
web/app/components/base/markdown-blocks/music.spec.tsx
Normal file
46
web/app/components/base/markdown-blocks/music.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
96
web/app/components/base/markdown-blocks/plugin-img.spec.tsx
Normal file
96
web/app/components/base/markdown-blocks/plugin-img.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
84
web/app/components/base/markdown-blocks/video-block.spec.tsx
Normal file
84
web/app/components/base/markdown-blocks/video-block.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user