test: add test cases for some base components (#32314)

This commit is contained in:
Saumya Talwani
2026-02-23 10:47:46 +05:30
committed by GitHub
parent ce8354a42a
commit ab64c4adf9
21 changed files with 1779 additions and 84 deletions

View File

@@ -0,0 +1,299 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import mermaid from 'mermaid'
import Flowchart from './index'
vi.mock('mermaid', () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg</svg>', diagramType: 'flowchart' }),
mermaidAPI: {
render: vi.fn().mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg-api</svg>', diagramType: 'flowchart' }),
},
},
}))
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
svgToBase64: vi.fn().mockResolvedValue('data:image/svg+xml;base64,dGVzdC1zdmc='),
waitForDOMElement: vi.fn((cb: () => Promise<unknown>) => cb()),
}
})
describe('Mermaid Flowchart Component', () => {
const mockCode = 'graph TD\n A-->B'
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(mermaid.initialize).mockImplementation(() => { })
})
describe('Rendering', () => {
it('should initialize mermaid on mount', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} />)
})
expect(mermaid.initialize).toHaveBeenCalled()
})
it('should render mermaid chart after debounce', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should render gantt charts with specific formatting', async () => {
const ganttCode = 'gantt\ntitle T\nTask :after task1, after task2'
await act(async () => {
render(<Flowchart PrimitiveCode={ganttCode} />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should render mindmap and sequenceDiagram charts', async () => {
const mindmapCode = 'mindmap\n root\n topic1'
const { unmount } = await act(async () => {
return render(<Flowchart PrimitiveCode={mindmapCode} />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
unmount()
const sequenceCode = 'sequenceDiagram\n A->>B: Hello'
await act(async () => {
render(<Flowchart PrimitiveCode={sequenceCode} />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should handle dark theme configuration', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} theme="dark" />)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
})
describe('Interactions', () => {
it('should switch between classic and handDrawn looks', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} />)
})
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
const handDrawnBtn = screen.getByText(/handDrawn/i)
await act(async () => {
fireEvent.click(handDrawnBtn)
})
await waitFor(() => {
expect(screen.getByText('test-svg-api')).toBeInTheDocument()
}, { timeout: 3000 })
const classicBtn = screen.getByText(/classic/i)
await act(async () => {
fireEvent.click(classicBtn)
})
await waitFor(() => {
expect(screen.getByText('test-svg')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should toggle theme manually', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} theme="light" />)
})
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
const toggleBtn = screen.getByRole('button')
await act(async () => {
fireEvent.click(toggleBtn)
})
await waitFor(() => {
expect(mermaid.initialize).toHaveBeenCalled()
}, { timeout: 3000 })
})
it('should open image preview when clicking the chart', async () => {
await act(async () => {
render(<Flowchart PrimitiveCode={mockCode} />)
})
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
const chartDiv = screen.getByText('test-svg').closest('.mermaid')
await act(async () => {
fireEvent.click(chartDiv!)
})
await waitFor(() => {
expect(document.body.querySelector('.image-preview-container')).toBeInTheDocument()
}, { timeout: 3000 })
})
})
describe('Edge Cases', () => {
it('should not render when code is too short', async () => {
const shortCode = 'graph'
vi.useFakeTimers()
render(<Flowchart PrimitiveCode={shortCode} />)
await vi.advanceTimersByTimeAsync(1000)
expect(mermaid.render).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('should handle rendering errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const errorMsg = 'Syntax error'
vi.mocked(mermaid.render).mockRejectedValue(new Error(errorMsg))
// Use unique code to avoid hitting the module-level diagramCache from previous tests
const uniqueCode = 'graph TD\n X-->Y\n Y-->Z'
const { container } = render(<Flowchart PrimitiveCode={uniqueCode} />)
await waitFor(() => {
const errorSpan = container.querySelector('.text-red-500 span.ml-2')
expect(errorSpan).toBeInTheDocument()
expect(errorSpan?.textContent).toContain('Rendering failed')
}, { timeout: 5000 })
consoleSpy.mockRestore()
// Restore default mock to prevent leaking into subsequent tests
vi.mocked(mermaid.render).mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg</svg>', diagramType: 'flowchart' })
}, 10000)
it('should use cached diagram if available', async () => {
const { rerender } = render(<Flowchart PrimitiveCode={mockCode} />)
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
vi.mocked(mermaid.render).mockClear()
await act(async () => {
rerender(<Flowchart PrimitiveCode={mockCode} />)
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 500))
})
expect(mermaid.render).not.toHaveBeenCalled()
})
it('should handle invalid mermaid code completion', async () => {
const invalidCode = 'graph TD\nA -->' // Incomplete
await act(async () => {
render(<Flowchart PrimitiveCode={invalidCode} />)
})
await waitFor(() => {
expect(screen.getByText('Diagram code is not complete or invalid.')).toBeInTheDocument()
}, { timeout: 3000 })
})
it('should handle unmount cleanup', async () => {
const { unmount } = render(<Flowchart PrimitiveCode={mockCode} />)
await act(async () => {
unmount()
})
})
})
})
describe('Mermaid Flowchart Component Module Isolation', () => {
const mockCode = 'graph TD\n A-->B'
let mermaidFresh: typeof mermaid
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
const mod = await import('mermaid') as unknown as { default: typeof mermaid } | typeof mermaid
mermaidFresh = 'default' in mod ? mod.default : mod
vi.mocked(mermaidFresh.initialize).mockImplementation(() => { })
})
describe('Error Handling', () => {
it('should handle initialization failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const { default: FlowchartFresh } = await import('./index')
vi.mocked(mermaidFresh.initialize).mockImplementationOnce(() => {
throw new Error('Init fail')
})
await act(async () => {
render(<FlowchartFresh PrimitiveCode={mockCode} />)
})
expect(consoleSpy).toHaveBeenCalledWith('Mermaid initialization error:', expect.any(Error))
consoleSpy.mockRestore()
})
it('should handle mermaidAPI missing fallback', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const originalMermaidAPI = mermaidFresh.mermaidAPI
// @ts-expect-error need to set undefined for testing
mermaidFresh.mermaidAPI = undefined
const { default: FlowchartFresh } = await import('./index')
const { container } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
// Wait for initial render to complete
await waitFor(() => {
expect(screen.getByText(/handDrawn/)).toBeInTheDocument()
}, { timeout: 3000 })
const handDrawnBtn = screen.getByText(/handDrawn/)
await act(async () => {
fireEvent.click(handDrawnBtn)
})
// When mermaidAPI is undefined, handDrawn style falls back to mermaid.render.
// The module captures mermaidAPI at import time, so setting it to undefined on
// the mocked object may not affect the module's internal reference.
// Verify that the rendering completes (either with svg or error)
await waitFor(() => {
const hasSvg = container.querySelector('.mermaid div')
const hasError = container.querySelector('.text-red-500')
expect(hasSvg || hasError).toBeTruthy()
}, { timeout: 5000 })
mermaidFresh.mermaidAPI = originalMermaidAPI
consoleSpy.mockRestore()
}, 10000)
it('should handle configuration failure', async () => {
vi.mocked(mermaidFresh.initialize).mockImplementation(() => {
throw new Error('Config fail')
})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const { default: FlowchartFresh } = await import('./index')
await act(async () => {
render(<FlowchartFresh PrimitiveCode={mockCode} />)
})
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Mermaid initialization error:', expect.any(Error))
})
consoleSpy.mockRestore()
})
})
})

View File

@@ -1,59 +1,265 @@
import { cleanUpSvgCode, prepareMermaidCode, sanitizeMermaidCode } from './utils'
import { cleanUpSvgCode, isMermaidCodeComplete, prepareMermaidCode, processSvgForTheme, sanitizeMermaidCode, svgToBase64, waitForDOMElement } from './utils'
describe('cleanUpSvgCode', () => {
it('replaces old-style <br> tags with the new style', () => {
it('should replace old-style <br> tags with self-closing <br/>', () => {
const result = cleanUpSvgCode('<br>test<br>')
expect(result).toEqual('<br/>test<br/>')
})
})
describe('sanitizeMermaidCode', () => {
it('removes click directives to prevent link/callback injection', () => {
const unsafeProtocol = ['java', 'script:'].join('')
const input = [
'gantt',
'title Demo',
'section S1',
'Task 1 :a1, 2020-01-01, 1d',
`click A href "${unsafeProtocol}alert(location.href)"`,
'click B call callback()',
].join('\n')
const result = sanitizeMermaidCode(input)
expect(result).toContain('gantt')
expect(result).toContain('Task 1')
expect(result).not.toContain('click A')
expect(result).not.toContain('click B')
expect(result).not.toContain(unsafeProtocol)
describe('Edge Cases', () => {
it('should handle null/non-string input', () => {
// @ts-expect-error need to test null input
expect(sanitizeMermaidCode(null)).toBe('')
// @ts-expect-error need to test undefined input
expect(sanitizeMermaidCode(undefined)).toBe('')
// @ts-expect-error need to test non-string input
expect(sanitizeMermaidCode(123)).toBe('')
})
})
it('removes Mermaid init directives to prevent config overrides', () => {
const input = [
'%%{init: {"securityLevel":"loose"}}%%',
'graph TD',
'A-->B',
].join('\n')
describe('Security', () => {
it('should remove click directives to prevent link/callback injection', () => {
const unsafeProtocol = ['java', 'script:'].join('')
const input = [
'gantt',
'title Demo',
'section S1',
'Task 1 :a1, 2020-01-01, 1d',
`click A href "${unsafeProtocol}alert(location.href)"`,
'click B call callback()',
].join('\n')
const result = sanitizeMermaidCode(input)
const result = sanitizeMermaidCode(input)
expect(result).toEqual(['graph TD', 'A-->B'].join('\n'))
expect(result).toContain('gantt')
expect(result).toContain('Task 1')
expect(result).not.toContain('click A')
expect(result).not.toContain('click B')
expect(result).not.toContain(unsafeProtocol)
})
it('should remove Mermaid init directives to prevent config overrides', () => {
const input = [
'%%{init: {"securityLevel":"loose"}}%%',
'graph TD',
'A-->B',
].join('\n')
const result = sanitizeMermaidCode(input)
expect(result).toEqual(['graph TD', 'A-->B'].join('\n'))
})
})
})
describe('prepareMermaidCode', () => {
it('sanitizes click directives in flowcharts', () => {
const unsafeProtocol = ['java', 'script:'].join('')
const input = [
'graph TD',
'A[Click]-->B',
`click A href "${unsafeProtocol}alert(1)"`,
].join('\n')
describe('Edge Cases', () => {
it('should handle null/non-string input', () => {
// @ts-expect-error need to test null input
expect(prepareMermaidCode(null, 'classic')).toBe('')
})
})
const result = prepareMermaidCode(input, 'classic')
describe('Sanitization', () => {
it('should sanitize click directives in flowcharts', () => {
const unsafeProtocol = ['java', 'script:'].join('')
const input = [
'graph TD',
'A[Click]-->B',
`click A href "${unsafeProtocol}alert(1)"`,
].join('\n')
expect(result).toContain('graph TD')
expect(result).not.toContain('click ')
expect(result).not.toContain(unsafeProtocol)
const result = prepareMermaidCode(input, 'classic')
expect(result).toContain('graph TD')
expect(result).not.toContain('click ')
expect(result).not.toContain(unsafeProtocol)
})
it('should replace <br> with newline', () => {
const input = 'graph TD\nA[Node<br>Line]-->B'
const result = prepareMermaidCode(input, 'classic')
expect(result).toContain('Node\nLine')
})
})
describe('HandDrawn Style', () => {
it('should handle handDrawn style specifically', () => {
const input = 'flowchart TD\nstyle A fill:#fff\nlinkStyle 0 stroke:#000\nA-->B'
const result = prepareMermaidCode(input, 'handDrawn')
expect(result).toContain('graph TD')
expect(result).not.toContain('style ')
expect(result).not.toContain('linkStyle ')
expect(result).toContain('A-->B')
})
it('should add TD fallback for handDrawn if missing', () => {
const input = 'A-->B'
const result = prepareMermaidCode(input, 'handDrawn')
expect(result).toBe('graph TD\nA-->B')
})
})
})
describe('svgToBase64', () => {
describe('Rendering', () => {
it('should return empty string for empty input', async () => {
expect(await svgToBase64('')).toBe('')
})
it('should convert svg to base64', async () => {
const svg = '<svg>test</svg>'
const result = await svgToBase64(svg)
expect(result).toContain('base64,')
expect(result).toContain('image/svg+xml')
})
it('should convert svg with xml declaration to base64', async () => {
const svg = '<?xml version="1.0" encoding="UTF-8"?><svg>test</svg>'
const result = await svgToBase64(svg)
expect(result).toContain('base64,')
expect(result).toContain('image/svg+xml')
})
})
describe('Edge Cases', () => {
it('should handle errors gracefully', async () => {
const encoderSpy = vi.spyOn(globalThis, 'TextEncoder').mockImplementation(() => ({
encoding: 'utf-8',
encode: () => { throw new Error('Encoder fail') },
encodeInto: () => ({ read: 0, written: 0 }),
} as unknown as TextEncoder))
const result = await svgToBase64('<svg>fail</svg>')
expect(result).toBe('')
encoderSpy.mockRestore()
})
})
})
describe('processSvgForTheme', () => {
const themes = {
light: {
nodeColors: [{ bg: '#fefefe' }, { bg: '#eeeeee' }],
connectionColor: '#cccccc',
},
dark: {
nodeColors: [{ bg: '#121212' }, { bg: '#222222' }],
connectionColor: '#333333',
},
}
describe('Light Theme', () => {
it('should process light theme node colors', () => {
const svg = '<rect fill="#ffffff" class="node-1"/>'
const result = processSvgForTheme(svg, false, false, themes)
expect(result).toContain('fill="#fefefe"')
})
it('should process handDrawn style for light theme', () => {
const svg = '<path fill="#ffffff" stroke="#ffffff"/>'
const result = processSvgForTheme(svg, false, true, themes)
expect(result).toContain('fill="#fefefe"')
expect(result).toContain('stroke="#cccccc"')
})
})
describe('Dark Theme', () => {
it('should process dark theme node colors and general elements', () => {
const svg = '<rect fill="#ffffff" class="node-1"/><path stroke="#ffffff"/><rect fill="#ffffff" style="fill: #000000; stroke: #000000"/>'
const result = processSvgForTheme(svg, true, false, themes)
expect(result).toContain('fill="#121212"')
expect(result).toContain('fill="#1e293b"') // Generic rect replacement
expect(result).toContain('stroke="#333333"')
})
it('should handle multiple node colors in cyclic manner', () => {
const svg = '<rect fill="#ffffff" class="node-1"/><rect fill="#ffffff" class="node-2"/><rect fill="#ffffff" class="node-3"/>'
const result = processSvgForTheme(svg, true, false, themes)
const fillMatches = result.match(/fill="#[a-fA-F0-9]{6}"/g)
expect(fillMatches).toContain('fill="#121212"')
expect(fillMatches).toContain('fill="#222222"')
expect(fillMatches?.filter(f => f === 'fill="#121212"').length).toBe(2)
})
it('should process handDrawn style for dark theme', () => {
const svg = '<path fill="#ffffff" stroke="#ffffff"/>'
const result = processSvgForTheme(svg, true, true, themes)
expect(result).toContain('fill="#121212"')
expect(result).toContain('stroke="#333333"')
})
})
})
describe('isMermaidCodeComplete', () => {
describe('Edge Cases', () => {
it('should return false for empty input', () => {
expect(isMermaidCodeComplete('')).toBe(false)
expect(isMermaidCodeComplete(' ')).toBe(false)
})
it('should detect common syntax errors', () => {
expect(isMermaidCodeComplete('graph TD\nA--> undefined')).toBe(false)
expect(isMermaidCodeComplete('graph TD\nA--> [object Object]')).toBe(false)
expect(isMermaidCodeComplete('graph TD\nA-->')).toBe(false)
})
it('should handle validation error gracefully', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
const startsWithSpy = vi.spyOn(String.prototype, 'startsWith').mockImplementation(() => {
throw new Error('Start fail')
})
expect(isMermaidCodeComplete('graph TD')).toBe(false)
expect(consoleSpy).toHaveBeenCalledWith('Mermaid code validation error:', expect.any(Error))
startsWithSpy.mockRestore()
consoleSpy.mockRestore()
})
})
describe('Chart Types', () => {
it('should validate gantt charts', () => {
expect(isMermaidCodeComplete('gantt\ntitle T\nsection S\nTask')).toBe(true)
expect(isMermaidCodeComplete('gantt\ntitle T')).toBe(false)
})
it('should validate mindmaps', () => {
expect(isMermaidCodeComplete('mindmap\nroot')).toBe(true)
expect(isMermaidCodeComplete('mindmap')).toBe(false)
})
it('should validate other chart types', () => {
expect(isMermaidCodeComplete('graph TD\nA-->B')).toBe(true)
expect(isMermaidCodeComplete('pie title P\n"A": 10')).toBe(true)
expect(isMermaidCodeComplete('invalid chart')).toBe(false)
})
})
})
describe('waitForDOMElement', () => {
it('should resolve when callback resolves', async () => {
const cb = vi.fn().mockResolvedValue('success')
const result = await waitForDOMElement(cb)
expect(result).toBe('success')
expect(cb).toHaveBeenCalledTimes(1)
})
it('should retry on failure', async () => {
const cb = vi.fn()
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValue('success')
const result = await waitForDOMElement(cb, 3, 10)
expect(result).toBe('success')
expect(cb).toHaveBeenCalledTimes(2)
})
it('should reject after max attempts', async () => {
const cb = vi.fn().mockRejectedValue(new Error('fail'))
await expect(waitForDOMElement(cb, 2, 10)).rejects.toThrow('fail')
expect(cb).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,104 @@
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { fireEvent, render, screen } from '@testing-library/react'
import { useStore } from '@/app/components/app/store'
import MessageLogModal from './index'
let clickAwayHandler: (() => void) | null = null
vi.mock('ahooks', () => ({
useClickAway: (fn: () => void) => {
clickAwayHandler = fn
},
}))
vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(),
}))
vi.mock('@/app/components/workflow/run', () => ({
default: ({ activeTab, runDetailUrl, tracingListUrl }: { activeTab: string, runDetailUrl: string, tracingListUrl: string }) => (
<div
data-testid="workflow-run"
data-active-tab={activeTab}
data-run-detail-url={runDetailUrl}
data-tracing-list-url={tracingListUrl}
/>
),
}))
const mockLog = {
id: 'msg-1',
content: 'mock log message',
workflow_run_id: 'run-1',
isAnswer: true,
}
describe('MessageLogModal', () => {
const onCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
clickAwayHandler = null
// eslint-disable-next-line ts/no-explicit-any
vi.mocked(useStore).mockImplementation((selector: any) => selector({
appDetail: { id: 'app-1' },
}))
})
describe('Render', () => {
it('renders nothing if currentLogItem is missing', () => {
const { container } = render(<MessageLogModal width={800} onCancel={onCancel} />)
expect(container.firstChild).toBeNull()
})
it('renders nothing if currentLogItem.workflow_run_id is missing', () => {
const { container } = render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={{ id: '1' } as IChatItem} />)
expect(container.firstChild).toBeNull()
})
it('renders modal with correct title and Run component', () => {
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
expect(screen.getByText(/title/i)).toBeInTheDocument()
expect(screen.getByTestId('workflow-run')).toBeInTheDocument()
})
})
describe('Props', () => {
it('passes correct props to Run component', () => {
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} defaultTab="TRACING" />)
const runComponent = screen.getByTestId('workflow-run')
expect(runComponent.getAttribute('data-active-tab')).toBe('TRACING')
expect(runComponent.getAttribute('data-run-detail-url')).toBe('/apps/app-1/workflow-runs/run-1')
expect(runComponent.getAttribute('data-tracing-list-url')).toBe('/apps/app-1/workflow-runs/run-1/node-executions')
})
it('sets fixed style when fixedWidth is false (floating)', () => {
const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={false} />)
const modal = container.firstChild as HTMLElement
expect(modal.style.position).toBe('fixed')
expect(modal.style.width).toBe('480px')
})
it('sets fixed width when fixedWidth is true', () => {
const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={true} />)
const modal = container.firstChild as HTMLElement
expect(modal.style.width).toBe('1000px')
})
})
describe('Interaction', () => {
it('calls onCancel when close icon is clicked', () => {
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
const closeButton = screen.getByTestId('close-button')
expect(closeButton).toBeInTheDocument()
fireEvent.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('calls onCancel when clicked away', () => {
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
expect(clickAwayHandler).toBeTruthy()
clickAwayHandler!()
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -57,8 +57,8 @@ const MessageLogModal: FC<MessageLogModalProps> = ({
}}
ref={ref}
>
<h1 className="system-xl-semibold shrink-0 px-4 py-1 text-text-primary">{t('runDetail.title', { ns: 'appLog' })}</h1>
<span className="absolute right-3 top-4 z-20 cursor-pointer p-1" onClick={onCancel}>
<h1 className="shrink-0 px-4 py-1 text-text-primary system-xl-semibold">{t('runDetail.title', { ns: 'appLog' })}</h1>
<span className="absolute right-3 top-4 z-20 cursor-pointer p-1" onClick={onCancel} data-testid="close-button">
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</span>
<Run

View File

@@ -0,0 +1,84 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import ModalLikeWrap from '.'
describe('ModalLikeWrap', () => {
const defaultProps = {
title: 'Test Title',
onClose: vi.fn(),
onConfirm: vi.fn(),
children: <div>Test Content</div>,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Render', () => {
it('renders title and content correctly', () => {
render(<ModalLikeWrap {...defaultProps} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('renders beforeHeader if provided', () => {
const beforeHeader = <div data-testid="before-header">Before Header</div>
render(<ModalLikeWrap {...defaultProps} beforeHeader={beforeHeader} />)
expect(screen.getByTestId('before-header')).toBeInTheDocument()
expect(screen.getByText('Before Header')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls onClose when close icon is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const closeBtn = screen.getByTestId('modal-close-btn')
expect(closeBtn).toBeInTheDocument()
await act(async () => {
fireEvent.click(closeBtn)
})
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('calls onClose when Cancel button is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const cancelBtn = screen.getByText('common.operation.cancel')
await act(async () => {
fireEvent.click(cancelBtn)
})
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('calls onConfirm when Save button is clicked', async () => {
render(<ModalLikeWrap {...defaultProps} />)
const saveBtn = screen.getByText('common.operation.save')
await act(async () => {
fireEvent.click(saveBtn)
})
expect(defaultProps.onConfirm).toHaveBeenCalled()
})
})
describe('Props', () => {
it('hides close icon when hideCloseBtn is true', () => {
render(<ModalLikeWrap {...defaultProps} hideCloseBtn={true} />)
const closeBtn = document.querySelector('.remixicon')
expect(closeBtn).not.toBeInTheDocument()
})
it('applies custom className', () => {
const { container } = render(<ModalLikeWrap {...defaultProps} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
})
})

View File

@@ -1,6 +1,5 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
@@ -31,13 +30,13 @@ const ModalLikeWrap: FC<Props> = ({
<div className={cn('w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-3 pb-4 pt-3.5 shadow-xl', className)}>
{beforeHeader || null}
<div className="mb-1 flex h-6 items-center justify-between">
<div className="system-xl-semibold text-text-primary">{title}</div>
<div className="text-text-primary system-xl-semibold">{title}</div>
{!hideCloseBtn && (
<div
className="cursor-pointer p-1.5 text-text-tertiary"
onClick={onClose}
>
<RiCloseLine className="size-4" />
<span className="i-ri-close-line size-4" data-testid="modal-close-btn" />
</div>
)}
</div>

View File

@@ -0,0 +1,185 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Modal from '.'
describe('Modal', () => {
describe('Render', () => {
it('should not render content when isShow is false', () => {
render(
<Modal isShow={false} title="Test Modal">
<div>Modal Content</div>
</Modal>,
)
expect(screen.queryByText('Test Modal')).not.toBeInTheDocument()
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument()
})
it('should render content when isShow is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal">
<div>Modal Content</div>
</Modal>,
)
})
expect(screen.getByText('Test Modal')).toBeInTheDocument()
expect(screen.getByText('Modal Content')).toBeInTheDocument()
})
it('should render description when provided', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" description="Test Description">
<div>Content</div>
</Modal>,
)
})
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
})
describe('Interaction', () => {
it('should call onClose when close button is clicked', async () => {
const handleClose = vi.fn()
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" closable={true} onClose={handleClose}>
<div>Content</div>
</Modal>,
)
})
const closeButton = screen.getByTestId('modal-close-button')
expect(closeButton).toBeInTheDocument()
await act(async () => {
fireEvent.click(closeButton!)
})
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should prevent propagation when clicking the scrollable container', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal">
<div>Content</div>
</Modal>,
)
})
const wrapper = document.querySelector('.overflow-y-auto')
expect(wrapper).toBeInTheDocument()
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
await act(async () => {
wrapper!.dispatchEvent(event)
})
expect(stopPropagationSpy).toHaveBeenCalled()
expect(preventDefaultSpy).toHaveBeenCalled()
})
it('should handle clickOutsideNotClose prop', async () => {
const handleClose = vi.fn()
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" clickOutsideNotClose={true} onClose={handleClose}>
<div>Content</div>
</Modal>,
)
})
await act(async () => {
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' })
})
expect(handleClose).not.toHaveBeenCalled()
})
})
describe('Props', () => {
it('should apply custom className to the panel', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" className="custom-panel-class">
<div>Content</div>
</Modal>,
)
})
const panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('custom-panel-class')
})
it('should apply wrapperClassName and containerClassName', async () => {
await act(async () => {
render(
<Modal
isShow={true}
title="Test Modal"
wrapperClassName="custom-wrapper"
containerClassName="custom-container"
>
<div>Content</div>
</Modal>,
)
})
const dialog = document.querySelector('.custom-wrapper')
expect(dialog).toBeInTheDocument()
const container = document.querySelector('.custom-container')
expect(container).toBeInTheDocument()
})
it('should apply highPriority z-index when highPriority is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" highPriority={true}>
<div>Content</div>
</Modal>,
)
})
const dialog = document.querySelector('.z-\\[1100\\]')
expect(dialog).toBeInTheDocument()
})
it('should apply overlayOpacity background when overlayOpacity is true', async () => {
await act(async () => {
render(
<Modal isShow={true} title="Test Modal" overlayOpacity={true}>
<div>Content</div>
</Modal>,
)
})
const overlay = document.querySelector('.bg-workflow-canvas-canvas-overlay')
expect(overlay).toBeInTheDocument()
})
it('should toggle overflow-visible class based on overflowVisible prop', async () => {
const { rerender } = render(
<Modal isShow={true} title="Test Modal" overflowVisible={true}>
<div>Content</div>
</Modal>,
)
let panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('overflow-visible')
await act(async () => {
rerender(
<Modal isShow={true} title="Test Modal" overflowVisible={false}>
<div>Content</div>
</Modal>,
)
})
panel = screen.getByText('Test Modal').parentElement
expect(panel).toHaveClass('overflow-hidden')
})
})
})

View File

@@ -1,5 +1,4 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { Fragment } from 'react'
import { cn } from '@/utils/classnames'
@@ -55,27 +54,28 @@ export default function Modal({
{!!title && (
<DialogTitle
as="h3"
className="title-2xl-semi-bold text-text-primary"
className="text-text-primary title-2xl-semi-bold"
>
{title}
</DialogTitle>
)}
{!!description && (
<div className="body-md-regular mt-2 text-text-secondary">
<div className="mt-2 text-text-secondary body-md-regular">
{description}
</div>
)}
{closable
&& (
<div className="absolute right-6 top-6 z-10 flex h-5 w-5 items-center justify-center rounded-2xl hover:cursor-pointer hover:bg-state-base-hover">
<RiCloseLine
className="h-4 w-4 text-text-tertiary"
<span
className="i-ri-close-line h-4 w-4 text-text-tertiary"
onClick={
(e) => {
e.stopPropagation()
onClose()
}
}
data-testid="modal-close-button"
/>
</div>
)}

View File

@@ -0,0 +1,114 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Modal from './modal'
describe('Modal Component', () => {
const defaultProps = {
title: 'Test Modal',
onClose: vi.fn(),
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Render', () => {
it('renders correctly with title and children', () => {
render(
<Modal {...defaultProps}>
<div data-testid="modal-child">Child Content</div>
</Modal>,
)
expect(screen.getByText('Test Modal')).toBeInTheDocument()
expect(screen.getByTestId('modal-child')).toBeInTheDocument()
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
expect(screen.getByText(/save/i)).toBeInTheDocument()
})
it('renders subTitle when provided', () => {
render(<Modal {...defaultProps} subTitle="Test Subtitle" />)
expect(screen.getByText('Test Subtitle')).toBeInTheDocument()
})
it('renders and handles extra button', () => {
const onExtraClick = vi.fn()
render(
<Modal
{...defaultProps}
showExtraButton={true}
extraButtonText="Extra Action"
onExtraButtonClick={onExtraClick}
/>,
)
const extraBtn = screen.getByText('Extra Action')
expect(extraBtn).toBeInTheDocument()
fireEvent.click(extraBtn)
expect(onExtraClick).toHaveBeenCalledTimes(1)
})
it('renders footerSlot and bottomSlot', () => {
render(
<Modal
{...defaultProps}
footerSlot={<div data-testid="footer-slot">Footer</div>}
bottomSlot={<div data-testid="bottom-slot">Bottom</div>}
/>,
)
expect(screen.getByTestId('footer-slot')).toBeInTheDocument()
expect(screen.getByTestId('bottom-slot')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls onClose when close icon is clicked', () => {
render(<Modal {...defaultProps} />)
const closeIcon = screen.getByTestId('close-icon').parentElement
fireEvent.click(closeIcon!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('calls onConfirm when confirm button is clicked', () => {
render(<Modal {...defaultProps} confirmButtonText="Confirm Me" />)
fireEvent.click(screen.getByText(/confirm/i))
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onCancel when cancel button is clicked', () => {
render(<Modal {...defaultProps} cancelButtonText="Cancel Me" />)
fireEvent.click(screen.getByText('Cancel Me'))
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('handles clickOutsideNotClose logic', () => {
const onClose = vi.fn()
const { rerender } = render(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={false} />)
fireEvent.click(screen.getByRole('tooltip'))
expect(onClose).toHaveBeenCalledTimes(1)
onClose.mockClear()
rerender(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={true} />)
fireEvent.click(screen.getByRole('tooltip'))
expect(onClose).not.toHaveBeenCalled()
})
it('prevents propagation on internal container click', () => {
const onClose = vi.fn()
render(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={false} />)
fireEvent.click(screen.getByText('Test Modal'))
expect(onClose).not.toHaveBeenCalled()
})
})
describe('Props', () => {
it('disables buttons when disabled prop is true', () => {
render(<Modal {...defaultProps} disabled={true} />)
expect(screen.getByText(/cancel/i).closest('button')).toBeDisabled()
expect(screen.getByText(/save/i).closest('button')).toBeDisabled()
})
})
})

View File

@@ -1,5 +1,4 @@
import type { ButtonProps } from '@/app/components/base/button'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -69,11 +68,11 @@ const Modal = ({
)}
onClick={e => e.stopPropagation()}
>
<div className="title-2xl-semi-bold relative shrink-0 p-6 pb-3 pr-14 text-text-primary">
<div className="relative shrink-0 p-6 pb-3 pr-14 text-text-primary title-2xl-semi-bold">
{title}
{
subTitle && (
<div className="system-xs-regular mt-1 text-text-tertiary">
<div className="mt-1 text-text-tertiary system-xs-regular">
{subTitle}
</div>
)
@@ -82,7 +81,7 @@ const Modal = ({
className="absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
onClick={onClose}
>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
<span className="i-ri-close-line h-5 w-5 text-text-tertiary" data-testid="close-icon" />
</div>
</div>
{

View File

@@ -0,0 +1,238 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CustomPopover from '.'
const CloseButtonContent = ({ onClick }: { onClick?: () => void }) => (
<button data-testid="content" onClick={onClick}>Close Me</button>
)
describe('CustomPopover', () => {
const defaultProps = {
btnElement: <span data-testid="trigger">Trigger</span>,
htmlContent: <div data-testid="content">Popover Content</div>,
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
if (vi.isFakeTimers?.())
vi.clearAllTimers()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render the trigger element', () => {
render(<CustomPopover {...defaultProps} />)
expect(screen.getByTestId('trigger')).toBeInTheDocument()
})
it('should render string as htmlContent', async () => {
render(<CustomPopover {...defaultProps} htmlContent="String Content" trigger="click" />)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.getByText('String Content')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should toggle when clicking the button', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} trigger="click" />)
const trigger = screen.getByTestId('trigger')
await user.click(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
await user.click(trigger)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
it('should open on hover when trigger is "hover" (default)', async () => {
render(<CustomPopover {...defaultProps} />)
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseEnter(triggerContainer)
})
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should close after delay on mouse leave when trigger is "hover"', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} />)
const trigger = screen.getByTestId('trigger')
await user.hover(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
await user.unhover(trigger)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
}, { timeout: 2000 })
})
it('should stay open when hovering over the popover content', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<CustomPopover {...defaultProps} />)
const trigger = screen.getByTestId('trigger')
await user.hover(trigger)
expect(screen.getByTestId('content')).toBeInTheDocument()
// Leave trigger but enter content
await user.unhover(trigger)
const content = screen.getByTestId('content')
await user.hover(content)
// Wait for the timeout duration
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 200))
})
// Should still be open because we are hovering the content
expect(screen.getByTestId('content')).toBeInTheDocument()
// Now leave content
await user.unhover(content)
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
}, { timeout: 2000 })
})
it('should cancel close timeout when re-entering during hover delay', async () => {
render(<CustomPopover {...defaultProps} />)
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseEnter(triggerContainer)
})
await act(async () => {
fireEvent.mouseLeave(triggerContainer!)
})
await act(async () => {
vi.advanceTimersByTime(50) // Halfway through timeout
fireEvent.mouseEnter(triggerContainer!)
})
await act(async () => {
vi.advanceTimersByTime(1000) // Much longer than the original timeout
})
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('should not open when disabled', async () => {
render(<CustomPopover {...defaultProps} disabled={true} trigger="click" />)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
it('should pass close function to htmlContent when manualClose is true', async () => {
vi.useRealTimers()
render(
<CustomPopover
{...defaultProps}
htmlContent={<CloseButtonContent />}
trigger="click"
manualClose={true}
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(screen.getByTestId('content')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByTestId('content'))
})
await waitFor(() => {
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
it('should not close when mouse leaves while already closed', async () => {
render(<CustomPopover {...defaultProps} />)
const triggerContainer = screen.getByTestId('trigger').closest('div')
if (!triggerContainer)
throw new Error('Trigger container not found')
await act(async () => {
fireEvent.mouseLeave(triggerContainer)
})
await act(async () => {
vi.runAllTimers()
})
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom class names', async () => {
render(
<CustomPopover
{...defaultProps}
trigger="click"
className="wrapper-class"
popupClassName="popup-inner-class"
btnClassName="btn-class"
/>,
)
await act(async () => {
fireEvent.click(screen.getByTestId('trigger'))
})
expect(document.querySelector('.wrapper-class')).toBeInTheDocument()
expect(document.querySelector('.popup-inner-class')).toBeInTheDocument()
const button = screen.getByTestId('trigger').parentElement
expect(button).toHaveClass('btn-class')
})
it('should handle btnClassName as a function', () => {
render(
<CustomPopover
{...defaultProps}
btnClassName={open => open ? 'btn-open' : 'btn-closed'}
/>,
)
const button = screen.getByTestId('trigger').parentElement
expect(button).toHaveClass('btn-closed')
})
})
})

View File

@@ -0,0 +1,89 @@
import { render } from '@testing-library/react'
import ProgressCircle from './progress-circle'
const extractLargeArcFlag = (pathData: string): string => {
const afterA = pathData.slice(pathData.indexOf('A') + 1)
const tokens = afterA.replace(/,/g, ' ').trim().split(/\s+/)
// Arc syntax: A rx ry x-axis-rotation large-arc-flag sweep-flag x y
return tokens[3]
}
describe('ProgressCircle', () => {
describe('Render', () => {
it('renders an SVG with default props', () => {
const { container } = render(<ProgressCircle />)
const svg = container.querySelector('svg')
const circle = container.querySelector('circle')
const path = container.querySelector('path')
expect(svg).toBeInTheDocument()
expect(circle).toBeInTheDocument()
expect(path).toBeInTheDocument()
})
})
describe('Props', () => {
it('applies correct size and viewBox when size is provided', () => {
const size = 24
const strokeWidth = 2
const { container } = render(
<ProgressCircle size={size} circleStrokeWidth={strokeWidth} />,
)
const svg = container.querySelector('svg') as SVGElement
expect(svg).toHaveAttribute('width', String(size + strokeWidth))
expect(svg).toHaveAttribute('height', String(size + strokeWidth))
expect(svg).toHaveAttribute(
'viewBox',
`0 0 ${size + strokeWidth} ${size + strokeWidth}`,
)
})
it('applies custom stroke and fill classes to the circle', () => {
const { container } = render(
<ProgressCircle
circleStrokeColor="stroke-red-500"
circleFillColor="fill-red-100"
/>,
)
const circle = container.querySelector('circle')!
expect(circle!).toHaveClass('stroke-red-500')
expect(circle!).toHaveClass('fill-red-100')
})
it('applies custom sector fill color to the path', () => {
const { container } = render(
<ProgressCircle sectorFillColor="fill-blue-500" />,
)
const path = container.querySelector('path')!
expect(path!).toHaveClass('fill-blue-500')
})
it('uses large arc flag when percentage is greater than 50', () => {
const { container } = render(<ProgressCircle percentage={75} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('1')
})
it('uses small arc flag when percentage is 50 or less', () => {
const { container } = render(<ProgressCircle percentage={25} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('0')
})
it('uses small arc flag when percentage is exactly 50', () => {
const { container } = render(<ProgressCircle percentage={50} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('0')
})
})
})

View File

@@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import Card from './card'
describe('PromptLogModal Card', () => {
it('renders single log entry correctly', () => {
const log = [{ role: 'user', text: 'Single entry text' }]
render(<Card log={log} />)
expect(screen.getByText('Single entry text')).toBeInTheDocument()
expect(screen.queryByText('USER')).not.toBeInTheDocument()
})
it('renders multiple log entries correctly', () => {
const log = [
{ role: 'user', text: 'Message 1' },
{ role: 'assistant', text: 'Message 2' },
]
render(<Card log={log} />)
expect(screen.getByText('USER')).toBeInTheDocument()
expect(screen.getByText('ASSISTANT')).toBeInTheDocument()
expect(screen.getByText('Message 1')).toBeInTheDocument()
expect(screen.getByText('Message 2')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PromptLogModal from '.'
describe('PromptLogModal', () => {
const defaultProps = {
width: 1000,
onCancel: vi.fn(),
currentLogItem: {
id: '1',
content: 'test',
log: [{ role: 'user', text: 'Hello' }],
} as Parameters<typeof PromptLogModal>[0]['currentLogItem'],
}
describe('Render', () => {
it('renders correctly when currentLogItem is provided', () => {
render(<PromptLogModal {...defaultProps} />)
expect(screen.getByText('PROMPT LOG')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
})
it('returns null when currentLogItem is missing', () => {
const { container } = render(<PromptLogModal {...defaultProps} currentLogItem={undefined} />)
expect(container.firstChild).toBeNull()
})
it('renders copy feedback when log length is 1', () => {
render(<PromptLogModal {...defaultProps} />)
expect(screen.getByTestId('close-btn-container')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls onCancel when close button is clicked', () => {
render(<PromptLogModal {...defaultProps} />)
const closeBtn = screen.getByTestId('close-btn')
expect(closeBtn).toBeInTheDocument()
fireEvent.click(closeBtn)
expect(defaultProps.onCancel).toHaveBeenCalled()
})
it('calls onCancel when clicking outside', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<div>
<div data-testid="outside">Outside</div>
<PromptLogModal {...defaultProps} onCancel={onCancel} />
</div>,
)
await waitFor(() => {
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
})
await user.click(screen.getByTestId('outside'))
})
})
})

View File

@@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { RiCloseLine } from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
@@ -57,8 +56,9 @@ const PromptLogModal: FC<PromptLogModalProps> = ({
<div
onClick={onCancel}
className="flex h-6 w-6 cursor-pointer items-center justify-center"
data-testid="close-btn-container"
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-btn" />
</div>
</div>
</div>

View File

@@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { downloadUrl } from '@/utils/download'
import ShareQRCode from '.'
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
describe('ShareQRCode', () => {
const content = 'https://example.com'
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('renders correctly', () => {
render(<ShareQRCode content={content} />)
expect(screen.getByRole('button').firstElementChild).toBeInTheDocument()
})
})
describe('Interaction', () => {
it('toggles QR code panel when clicking the icon', async () => {
const user = userEvent.setup()
render(<ShareQRCode content={content} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
const trigger = screen.getByTestId('qrcode-container')
await user.click(trigger)
expect(screen.getByRole('img')).toBeInTheDocument()
await user.click(trigger)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('closes panel when clicking outside', async () => {
const user = userEvent.setup()
render(
<div>
<div data-testid="outside">Outside</div>
<ShareQRCode content={content} />
</div>,
)
const trigger = screen.getByTestId('qrcode-container')
await user.click(trigger)
expect(screen.getByRole('img')).toBeInTheDocument()
await user.click(screen.getByTestId('outside'))
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('does not close panel when clicking inside the panel', async () => {
const user = userEvent.setup()
render(<ShareQRCode content={content} />)
const trigger = screen.getByTestId('qrcode-container')
await user.click(trigger)
const canvas = screen.getByRole('img')
const panel = canvas.parentElement
await user.click(panel!)
expect(canvas).toBeInTheDocument()
})
it('calls downloadUrl when clicking download', async () => {
const user = userEvent.setup()
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL
HTMLCanvasElement.prototype.toDataURL = vi.fn(() => 'data:image/png;base64,test')
try {
render(<ShareQRCode content={content} />)
const trigger = screen.getByTestId('qrcode-container')
await user.click(trigger!)
const downloadBtn = screen.getByText('appOverview.overview.appInfo.qrcode.download')
await user.click(downloadBtn)
expect(downloadUrl).toHaveBeenCalledWith({
url: 'data:image/png;base64,test',
fileName: 'qrcode.png',
})
}
finally {
HTMLCanvasElement.prototype.toDataURL = originalToDataURL
}
})
})
})

View File

@@ -1,7 +1,4 @@
'use client'
import {
RiQrCodeLine,
} from '@remixicon/react'
import { QRCodeCanvas as QRCode } from 'qrcode.react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
@@ -55,9 +52,9 @@ const ShareQRCode = ({ content }: Props) => {
<Tooltip
popupContent={t(`${prefixEmbedded}`, { ns: 'appOverview' }) || ''}
>
<div className="relative h-6 w-6" onClick={toggleQRCode}>
<div className="relative h-6 w-6" onClick={toggleQRCode} data-testid="qrcode-container">
<ActionButton>
<RiQrCodeLine className="h-4 w-4" />
<span className="i-ri-qr-code-line h-4 w-4" />
</ActionButton>
{isShow && (
<div
@@ -66,7 +63,7 @@ const ShareQRCode = ({ content }: Props) => {
onClick={handlePanelClick}
>
<QRCode size={160} value={content} className="mb-2" />
<div className="system-xs-regular flex items-center">
<div className="flex items-center system-xs-regular">
<div className="text-text-tertiary">{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}</div>
<div className="text-text-tertiary">·</div>
<div className="cursor-pointer text-text-accent-secondary" onClick={downloadQR}>{t('overview.appInfo.qrcode.download', { ns: 'appOverview' })}</div>

View File

@@ -0,0 +1,44 @@
import { render } from '@testing-library/react'
import SimplePieChart from '.'
describe('SimplePieChart', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<SimplePieChart />)
const chart = container.querySelector('.echarts-for-react')
expect(chart).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<SimplePieChart className="custom-chart" />)
const chart = container.querySelector('.echarts-for-react')
expect(chart).toHaveClass('custom-chart')
})
it('should apply custom size via style', () => {
const { container } = render(<SimplePieChart size={24} />)
const chart = container.querySelector('.echarts-for-react') as HTMLElement
expect(chart).toHaveStyle({ width: '24px', height: '24px' })
})
it('should apply default size of 12', () => {
const { container } = render(<SimplePieChart />)
const chart = container.querySelector('.echarts-for-react') as HTMLElement
expect(chart).toHaveStyle({ width: '12px', height: '12px' })
})
it('should set custom fill color as CSS variable', () => {
const { container } = render(<SimplePieChart fill="red" />)
const chart = container.querySelector('.echarts-for-react') as HTMLElement
expect(chart.style.getPropertyValue('--simple-pie-chart-color')).toBe('red')
})
it('should set default fill color as CSS variable', () => {
const { container } = render(<SimplePieChart />)
const chart = container.querySelector('.echarts-for-react') as HTMLElement
expect(chart.style.getPropertyValue('--simple-pie-chart-color')).toBe('#fdb022')
})
})
})

View File

@@ -0,0 +1,137 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import SVGRenderer from '.'
const mockClick = vi.fn()
const mockSvg = vi.fn().mockReturnValue({
click: mockClick,
})
const mockViewbox = vi.fn()
const mockAddTo = vi.fn()
vi.mock('@svgdotjs/svg.js', () => ({
SVG: vi.fn().mockImplementation(() => ({
addTo: mockAddTo,
})),
}))
vi.mock('dompurify', () => ({
default: {
sanitize: vi.fn(content => content),
},
}))
describe('SVGRenderer', () => {
const validSvg = '<svg width="100" height="100"><circle cx="50" cy="50" r="40" /></svg>'
let parseFromStringSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockAddTo.mockReturnValue({
viewbox: mockViewbox,
svg: mockSvg,
})
mockSvg.mockReturnValue({
click: mockClick,
})
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
mockSvgElement.setAttribute('width', '100')
mockSvgElement.setAttribute('height', '100')
parseFromStringSpy = vi.spyOn(DOMParser.prototype, 'parseFromString').mockReturnValue({
documentElement: mockSvgElement,
} as unknown as Document)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Rendering', () => {
it('renders correctly with content', async () => {
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
expect(mockViewbox).toHaveBeenCalledWith(0, 0, 100, 100)
})
expect(mockSvg).toHaveBeenCalledWith(validSvg)
})
it('shows error message on invalid SVG content', async () => {
parseFromStringSpy.mockReturnValue({
documentElement: document.createElement('div'),
} as unknown as Document)
render(<SVGRenderer content="invalid" />)
await waitFor(() => {
expect(screen.getByText(/Error rendering SVG/)).toBeInTheDocument()
})
})
it('re-renders on window resize', async () => {
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
expect(mockAddTo).toHaveBeenCalledTimes(1)
})
await act(async () => {
window.dispatchEvent(new Event('resize'))
})
await waitFor(() => {
expect(mockAddTo).toHaveBeenCalledTimes(2)
})
})
it('uses default values for width/height if not present', async () => {
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
parseFromStringSpy.mockReturnValue({
documentElement: mockSvgElement,
} as unknown as Document)
render(<SVGRenderer content="<svg></svg>" />)
await waitFor(() => {
expect(mockViewbox).toHaveBeenCalledWith(0, 0, 400, 600)
})
})
})
describe('Image Preview Interactions', () => {
it('opens image preview on click', async () => {
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
expect(mockClick).toHaveBeenCalled()
})
const clickHandler = mockClick.mock.calls[0][0]
await act(async () => {
clickHandler()
})
const img = screen.getByAltText('Preview')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute(
'src',
expect.stringContaining('data:image/svg+xml;base64'),
)
})
it('closes image preview on cancel', async () => {
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
expect(mockClick).toHaveBeenCalled()
})
const clickHandler = mockClick.mock.calls[0][0]
await act(async () => {
clickHandler()
})
expect(screen.getByAltText('Preview')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByAltText('Preview')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,44 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SVGBtn from '.'
describe('SVGBtn', () => {
describe('Rendering', () => {
it('renders correctly', () => {
const setIsSVG = vi.fn()
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('calls setIsSVG with a toggle function when clicked', () => {
const setIsSVG = vi.fn()
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(setIsSVG).toHaveBeenCalledTimes(1)
const toggleFunc = setIsSVG.mock.calls[0][0]
expect(typeof toggleFunc).toBe('function')
expect(toggleFunc(false)).toBe(true)
expect(toggleFunc(true)).toBe(false)
})
})
describe('Props', () => {
it('applies correct class when isSVG is false', () => {
const setIsSVG = vi.fn()
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
const icon = screen.getByRole('button').firstChild as HTMLElement
expect(icon?.className).toMatch(/_svgIcon_\w+/)
})
it('applies correct class when isSVG is true', () => {
const setIsSVG = vi.fn()
render(<SVGBtn isSVG={true} setIsSVG={setIsSVG} />)
const icon = screen.getByRole('button').firstChild as HTMLElement
expect(icon?.className).toMatch(/_svgIconed_\w+/)
})
})
})

View File

@@ -2351,9 +2351,6 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -2363,21 +2360,11 @@
"count": 3
}
},
"app/components/base/modal-like-wrap/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/modal/index.stories.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/components/base/modal/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/modal/modal.stories.tsx": {
"no-console": {
"count": 4
@@ -2386,11 +2373,6 @@
"count": 1
}
},
"app/components/base/modal/modal.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/new-audio-button/index.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2626,11 +2608,6 @@
"count": 1
}
},
"app/components/base/qrcode/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/radio-card/index.stories.tsx": {
"ts/no-explicit-any": {
"count": 1