mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
+41![gemini-code-assist[bot]](/avatar/56998622369826d13ab151bc815833de5bcfacd06c957772b394602b82b65bda?size=40)

![autofix-ci[bot]](/avatar/90c1f68f2dec38933926f82cc3639064fb96506a5738419500ebeb5085083409?size=40)







Saumya Talwani
Poojan
sahil-infocusp
非法操作
Pandaaaa906
Asuka Minato
heyszt
autofix-ci[bot]
Ijas
gemini-code-assist[bot]
木之本澪
KinomotoMio
不做了睡大觉
User
Copilot
edvatar
-LAN-
Leilei
HaKu
dependabot[bot]
wangxiaolei
Varun Chawla
Stephen Zhou
yyh
yyh
tda
root
Sisyphus
Niels Kaspers
hj24
Tyson Cung
Stephen Zhou
FFXN
slegarraga
99
Br1an
L1nSn0w
Yunlu Wen
akkoaya
盐粒 Yanli
lif
weiguang li
Copilot
crazywoola
HanWenbo
Coding On Star
CodingOnStar
Stable Genius
Stable Genius
ふるい
Xiyuan Chen
f50e44b24a
Signed-off-by: edvatar <88481784+toroleapinc@users.noreply.github.com> Signed-off-by: -LAN- <laipz8200@outlook.com> Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: majiayu000 <1835304752@qq.com> Co-authored-by: Poojan <poojan@infocusp.com> Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com> Co-authored-by: 非法操作 <hjlarry@163.com> Co-authored-by: Pandaaaa906 <ye.pandaaaa906@gmail.com> Co-authored-by: Asuka Minato <i@asukaminato.eu.org> Co-authored-by: heyszt <270985384@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Ijas <ijas.ahmd.ap@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: 木之本澪 <kinomotomiovo@gmail.com> Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Co-authored-by: 不做了睡大觉 <64798754+stakeswky@users.noreply.github.com> Co-authored-by: User <user@example.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: edvatar <88481784+toroleapinc@users.noreply.github.com> Co-authored-by: -LAN- <laipz8200@outlook.com> Co-authored-by: Leilei <138381132+Inlei@users.noreply.github.com> Co-authored-by: HaKu <104669497+haku-ink@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: wangxiaolei <fatelei@gmail.com> Co-authored-by: Varun Chawla <34209028+veeceey@users.noreply.github.com> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: tda <95275462+tda1017@users.noreply.github.com> Co-authored-by: root <root@DESKTOP-KQLO90N> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Co-authored-by: Niels Kaspers <153818647+nielskaspers@users.noreply.github.com> Co-authored-by: hj24 <mambahj24@gmail.com> Co-authored-by: Tyson Cung <45380903+tysoncung@users.noreply.github.com> Co-authored-by: Stephen Zhou <hi@hyoban.cc> Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com> Co-authored-by: slegarraga <64795732+slegarraga@users.noreply.github.com> Co-authored-by: 99 <wh2099@pm.me> Co-authored-by: Br1an <932039080@qq.com> Co-authored-by: L1nSn0w <l1nsn0w@qq.com> Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai> Co-authored-by: akkoaya <151345394+akkoaya@users.noreply.github.com> Co-authored-by: 盐粒 Yanli <yanli@dify.ai> Co-authored-by: lif <1835304752@qq.com> Co-authored-by: weiguang li <codingpunk@gmail.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: HanWenbo <124024253+hwb96@users.noreply.github.com> Co-authored-by: Coding On Star <447357187@qq.com> Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: Stable Genius <stablegenius043@gmail.com> Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com> Co-authored-by: ふるい <46769295+Echo0ff@users.noreply.github.com> Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
import { createRequire } from 'node:module'
|
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
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.findByTestId('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()
|
|
})
|
|
})
|
|
})
|