From ab64c4adf933e6a20aeb0b71f6d8775d19c84c31 Mon Sep 17 00:00:00 2001 From: Saumya Talwani <68903741+saumyatalwani@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:47:46 +0530 Subject: [PATCH] test: add test cases for some base components (#32314) --- .../components/base/mermaid/index.spec.tsx | 299 ++++++++++++++++++ web/app/components/base/mermaid/utils.spec.ts | 284 ++++++++++++++--- .../base/message-log-modal/index.spec.tsx | 104 ++++++ .../base/message-log-modal/index.tsx | 4 +- .../base/modal-like-wrap/index.spec.tsx | 84 +++++ .../components/base/modal-like-wrap/index.tsx | 5 +- web/app/components/base/modal/index.spec.tsx | 185 +++++++++++ web/app/components/base/modal/index.tsx | 10 +- web/app/components/base/modal/modal.spec.tsx | 114 +++++++ web/app/components/base/modal/modal.tsx | 7 +- .../components/base/popover/index.spec.tsx | 238 ++++++++++++++ .../progress-bar/progress-circle.spec.tsx | 89 ++++++ .../base/prompt-log-modal/card.spec.tsx | 25 ++ .../base/prompt-log-modal/index.spec.tsx | 60 ++++ .../base/prompt-log-modal/index.tsx | 4 +- web/app/components/base/qrcode/index.spec.tsx | 94 ++++++ web/app/components/base/qrcode/index.tsx | 9 +- .../base/simple-pie-chart/index.spec.tsx | 44 +++ .../base/svg-gallery/index.spec.tsx | 137 ++++++++ web/app/components/base/svg/index.spec.tsx | 44 +++ web/eslint-suppressions.json | 23 -- 21 files changed, 1779 insertions(+), 84 deletions(-) create mode 100644 web/app/components/base/mermaid/index.spec.tsx create mode 100644 web/app/components/base/message-log-modal/index.spec.tsx create mode 100644 web/app/components/base/modal-like-wrap/index.spec.tsx create mode 100644 web/app/components/base/modal/index.spec.tsx create mode 100644 web/app/components/base/modal/modal.spec.tsx create mode 100644 web/app/components/base/popover/index.spec.tsx create mode 100644 web/app/components/base/progress-bar/progress-circle.spec.tsx create mode 100644 web/app/components/base/prompt-log-modal/card.spec.tsx create mode 100644 web/app/components/base/prompt-log-modal/index.spec.tsx create mode 100644 web/app/components/base/qrcode/index.spec.tsx create mode 100644 web/app/components/base/simple-pie-chart/index.spec.tsx create mode 100644 web/app/components/base/svg-gallery/index.spec.tsx create mode 100644 web/app/components/base/svg/index.spec.tsx diff --git a/web/app/components/base/mermaid/index.spec.tsx b/web/app/components/base/mermaid/index.spec.tsx new file mode 100644 index 0000000000..198f4de003 --- /dev/null +++ b/web/app/components/base/mermaid/index.spec.tsx @@ -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: 'test-svg', diagramType: 'flowchart' }), + mermaidAPI: { + render: vi.fn().mockResolvedValue({ svg: 'test-svg-api', diagramType: 'flowchart' }), + }, + }, +})) + +vi.mock('./utils', async (importOriginal) => { + const actual = await importOriginal() as Record + return { + ...actual, + svgToBase64: vi.fn().mockResolvedValue('data:image/svg+xml;base64,dGVzdC1zdmc='), + waitForDOMElement: vi.fn((cb: () => Promise) => 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() + }) + expect(mermaid.initialize).toHaveBeenCalled() + }) + + it('should render mermaid chart after debounce', async () => { + await act(async () => { + render() + }) + + 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() + }) + + 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() + }) + await waitFor(() => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, { timeout: 3000 }) + + unmount() + + const sequenceCode = 'sequenceDiagram\n A->>B: Hello' + await act(async () => { + render() + }) + await waitFor(() => { + expect(screen.getByText('test-svg')).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('should handle dark theme configuration', async () => { + await act(async () => { + render() + }) + 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() + }) + + 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() + }) + + 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() + }) + + 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() + 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() + + 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: 'test-svg', diagramType: 'flowchart' }) + }, 10000) + + it('should use cached diagram if available', async () => { + const { rerender } = render() + + await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 }) + + vi.mocked(mermaid.render).mockClear() + + await act(async () => { + rerender() + }) + + 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() + }) + + await waitFor(() => { + expect(screen.getByText('Diagram code is not complete or invalid.')).toBeInTheDocument() + }, { timeout: 3000 }) + }) + + it('should handle unmount cleanup', async () => { + const { unmount } = render() + 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() + }) + + 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() + + // 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() + }) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Mermaid initialization error:', expect.any(Error)) + }) + consoleSpy.mockRestore() + }) + }) +}) diff --git a/web/app/components/base/mermaid/utils.spec.ts b/web/app/components/base/mermaid/utils.spec.ts index 7a73aa1fc9..d698e1234c 100644 --- a/web/app/components/base/mermaid/utils.spec.ts +++ b/web/app/components/base/mermaid/utils.spec.ts @@ -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
tags with the new style', () => { + it('should replace old-style
tags with self-closing
', () => { const result = cleanUpSvgCode('
test
') expect(result).toEqual('
test
') }) }) 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
with newline', () => { + const input = 'graph TD\nA[Node
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 = 'test' + 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 = 'test' + 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('fail') + 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 = '' + const result = processSvgForTheme(svg, false, false, themes) + expect(result).toContain('fill="#fefefe"') + }) + + it('should process handDrawn style for light theme', () => { + const svg = '' + 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 = '' + 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 = '' + 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 = '' + 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) }) }) diff --git a/web/app/components/base/message-log-modal/index.spec.tsx b/web/app/components/base/message-log-modal/index.spec.tsx new file mode 100644 index 0000000000..10793c2ba0 --- /dev/null +++ b/web/app/components/base/message-log-modal/index.spec.tsx @@ -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 }) => ( +
+ ), +})) + +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() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing if currentLogItem.workflow_run_id is missing', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders modal with correct title and Run component', () => { + render() + expect(screen.getByText(/title/i)).toBeInTheDocument() + expect(screen.getByTestId('workflow-run')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('passes correct props to Run component', () => { + render() + 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() + 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() + const modal = container.firstChild as HTMLElement + expect(modal.style.width).toBe('1000px') + }) + }) + + describe('Interaction', () => { + it('calls onCancel when close icon is clicked', () => { + render() + const closeButton = screen.getByTestId('close-button') + expect(closeButton).toBeInTheDocument() + fireEvent.click(closeButton) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel when clicked away', () => { + render() + expect(clickAwayHandler).toBeTruthy() + clickAwayHandler!() + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/base/message-log-modal/index.tsx b/web/app/components/base/message-log-modal/index.tsx index a4c1531d46..a1bc791f39 100644 --- a/web/app/components/base/message-log-modal/index.tsx +++ b/web/app/components/base/message-log-modal/index.tsx @@ -57,8 +57,8 @@ const MessageLogModal: FC = ({ }} ref={ref} > -

{t('runDetail.title', { ns: 'appLog' })}

- +

{t('runDetail.title', { ns: 'appLog' })}

+ { + const defaultProps = { + title: 'Test Title', + onClose: vi.fn(), + onConfirm: vi.fn(), + children:
Test Content
, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Render', () => { + it('renders title and content correctly', () => { + render() + + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test Content')).toBeInTheDocument() + }) + + it('renders beforeHeader if provided', () => { + const beforeHeader =
Before Header
+ render() + + expect(screen.getByTestId('before-header')).toBeInTheDocument() + expect(screen.getByText('Before Header')).toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('calls onClose when close icon is clicked', async () => { + render() + + 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() + + 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() + + 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() + + const closeBtn = document.querySelector('.remixicon') + expect(closeBtn).not.toBeInTheDocument() + }) + + it('applies custom className', () => { + const { container } = render() + + expect(container.firstChild).toHaveClass('custom-class') + }) + }) +}) diff --git a/web/app/components/base/modal-like-wrap/index.tsx b/web/app/components/base/modal-like-wrap/index.tsx index 0970185c54..0b51ae1829 100644 --- a/web/app/components/base/modal-like-wrap/index.tsx +++ b/web/app/components/base/modal-like-wrap/index.tsx @@ -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 = ({
{beforeHeader || null}
-
{title}
+
{title}
{!hideCloseBtn && (
- +
)}
diff --git a/web/app/components/base/modal/index.spec.tsx b/web/app/components/base/modal/index.spec.tsx new file mode 100644 index 0000000000..cab95c7cb1 --- /dev/null +++ b/web/app/components/base/modal/index.spec.tsx @@ -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 Content
+
, + ) + + 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 Content
+
, + ) + }) + + expect(screen.getByText('Test Modal')).toBeInTheDocument() + expect(screen.getByText('Modal Content')).toBeInTheDocument() + }) + + it('should render description when provided', async () => { + await act(async () => { + render( + +
Content
+
, + ) + }) + + 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( + +
Content
+
, + ) + }) + + 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( + +
Content
+
, + ) + }) + + 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( + +
Content
+
, + ) + }) + + 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( + +
Content
+
, + ) + }) + + const panel = screen.getByText('Test Modal').parentElement + expect(panel).toHaveClass('custom-panel-class') + }) + + it('should apply wrapperClassName and containerClassName', async () => { + await act(async () => { + render( + +
Content
+
, + ) + }) + + 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( + +
Content
+
, + ) + }) + + const dialog = document.querySelector('.z-\\[1100\\]') + expect(dialog).toBeInTheDocument() + }) + + it('should apply overlayOpacity background when overlayOpacity is true', async () => { + await act(async () => { + render( + +
Content
+
, + ) + }) + + 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( + +
Content
+
, + ) + + let panel = screen.getByText('Test Modal').parentElement + expect(panel).toHaveClass('overflow-visible') + + await act(async () => { + rerender( + +
Content
+
, + ) + }) + panel = screen.getByText('Test Modal').parentElement + expect(panel).toHaveClass('overflow-hidden') + }) + }) +}) diff --git a/web/app/components/base/modal/index.tsx b/web/app/components/base/modal/index.tsx index 192fb7b70a..023934b674 100644 --- a/web/app/components/base/modal/index.tsx +++ b/web/app/components/base/modal/index.tsx @@ -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 && ( {title} )} {!!description && ( -
+
{description}
)} {closable && (
- { e.stopPropagation() onClose() } } + data-testid="modal-close-button" />
)} diff --git a/web/app/components/base/modal/modal.spec.tsx b/web/app/components/base/modal/modal.spec.tsx new file mode 100644 index 0000000000..df2c3bd15d --- /dev/null +++ b/web/app/components/base/modal/modal.spec.tsx @@ -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( + +
Child Content
+
, + ) + + 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() + expect(screen.getByText('Test Subtitle')).toBeInTheDocument() + }) + + it('renders and handles extra button', () => { + const onExtraClick = vi.fn() + render( + , + ) + + const extraBtn = screen.getByText('Extra Action') + expect(extraBtn).toBeInTheDocument() + fireEvent.click(extraBtn) + expect(onExtraClick).toHaveBeenCalledTimes(1) + }) + + it('renders footerSlot and bottomSlot', () => { + render( + Footer
} + bottomSlot={
Bottom
} + />, + ) + + expect(screen.getByTestId('footer-slot')).toBeInTheDocument() + expect(screen.getByTestId('bottom-slot')).toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('calls onClose when close icon is clicked', () => { + render() + const closeIcon = screen.getByTestId('close-icon').parentElement + fireEvent.click(closeIcon!) + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onConfirm when confirm button is clicked', () => { + render() + fireEvent.click(screen.getByText(/confirm/i)) + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel when cancel button is clicked', () => { + render() + fireEvent.click(screen.getByText('Cancel Me')) + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1) + }) + + it('handles clickOutsideNotClose logic', () => { + const onClose = vi.fn() + const { rerender } = render() + + fireEvent.click(screen.getByRole('tooltip')) + expect(onClose).toHaveBeenCalledTimes(1) + + onClose.mockClear() + rerender() + fireEvent.click(screen.getByRole('tooltip')) + expect(onClose).not.toHaveBeenCalled() + }) + + it('prevents propagation on internal container click', () => { + const onClose = vi.fn() + render() + fireEvent.click(screen.getByText('Test Modal')) + expect(onClose).not.toHaveBeenCalled() + }) + }) + + describe('Props', () => { + it('disables buttons when disabled prop is true', () => { + render() + expect(screen.getByText(/cancel/i).closest('button')).toBeDisabled() + expect(screen.getByText(/save/i).closest('button')).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/base/modal/modal.tsx b/web/app/components/base/modal/modal.tsx index 0061cdf7a0..3ad08e2493 100644 --- a/web/app/components/base/modal/modal.tsx +++ b/web/app/components/base/modal/modal.tsx @@ -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()} > -
+
{title} { subTitle && ( -
+
{subTitle}
) @@ -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} > - +
{ diff --git a/web/app/components/base/popover/index.spec.tsx b/web/app/components/base/popover/index.spec.tsx new file mode 100644 index 0000000000..f90a024bcf --- /dev/null +++ b/web/app/components/base/popover/index.spec.tsx @@ -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 }) => ( + +) + +describe('CustomPopover', () => { + const defaultProps = { + btnElement: Trigger, + htmlContent:
Popover Content
, + } + + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + if (vi.isFakeTimers?.()) + vi.clearAllTimers() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + describe('Rendering', () => { + it('should render the trigger element', () => { + render() + expect(screen.getByTestId('trigger')).toBeInTheDocument() + }) + + it('should render string as htmlContent', async () => { + render() + 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() + 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() + + 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() + + 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() + + 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() + + 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() + + 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( + } + 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() + 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( + , + ) + + 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( + open ? 'btn-open' : 'btn-closed'} + />, + ) + + const button = screen.getByTestId('trigger').parentElement + expect(button).toHaveClass('btn-closed') + }) + }) +}) diff --git a/web/app/components/base/progress-bar/progress-circle.spec.tsx b/web/app/components/base/progress-bar/progress-circle.spec.tsx new file mode 100644 index 0000000000..9acc525d90 --- /dev/null +++ b/web/app/components/base/progress-bar/progress-circle.spec.tsx @@ -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() + + 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( + , + ) + + 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( + , + ) + 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( + , + ) + 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() + 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() + 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() + const path = container.querySelector('path')! + const d = path.getAttribute('d') || '' + expect(d).toContain('A') + expect(extractLargeArcFlag(d)).toBe('0') + }) + }) +}) diff --git a/web/app/components/base/prompt-log-modal/card.spec.tsx b/web/app/components/base/prompt-log-modal/card.spec.tsx new file mode 100644 index 0000000000..500e9db941 --- /dev/null +++ b/web/app/components/base/prompt-log-modal/card.spec.tsx @@ -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() + + 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() + + expect(screen.getByText('USER')).toBeInTheDocument() + expect(screen.getByText('ASSISTANT')).toBeInTheDocument() + expect(screen.getByText('Message 1')).toBeInTheDocument() + expect(screen.getByText('Message 2')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/prompt-log-modal/index.spec.tsx b/web/app/components/base/prompt-log-modal/index.spec.tsx new file mode 100644 index 0000000000..c04e668026 --- /dev/null +++ b/web/app/components/base/prompt-log-modal/index.spec.tsx @@ -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[0]['currentLogItem'], + } + + describe('Render', () => { + it('renders correctly when currentLogItem is provided', () => { + render() + expect(screen.getByText('PROMPT LOG')).toBeInTheDocument() + expect(screen.getByText('Hello')).toBeInTheDocument() + }) + + it('returns null when currentLogItem is missing', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders copy feedback when log length is 1', () => { + render() + expect(screen.getByTestId('close-btn-container')).toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('calls onCancel when close button is clicked', () => { + render() + 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( +
+
Outside
+ +
, + ) + + await waitFor(() => { + expect(screen.getByTestId('close-btn')).toBeInTheDocument() + }) + + await user.click(screen.getByTestId('outside')) + }) + }) +}) diff --git a/web/app/components/base/prompt-log-modal/index.tsx b/web/app/components/base/prompt-log-modal/index.tsx index aab75d4989..bd29782bd0 100644 --- a/web/app/components/base/prompt-log-modal/index.tsx +++ b/web/app/components/base/prompt-log-modal/index.tsx @@ -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 = ({
- +
diff --git a/web/app/components/base/qrcode/index.spec.tsx b/web/app/components/base/qrcode/index.spec.tsx new file mode 100644 index 0000000000..3e1d5ff6d9 --- /dev/null +++ b/web/app/components/base/qrcode/index.spec.tsx @@ -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() + expect(screen.getByRole('button').firstElementChild).toBeInTheDocument() + }) + }) + + describe('Interaction', () => { + it('toggles QR code panel when clicking the icon', async () => { + const user = userEvent.setup() + render() + + 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( +
+
Outside
+ +
, + ) + + 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() + + 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() + + 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 + } + }) + }) +}) diff --git a/web/app/components/base/qrcode/index.tsx b/web/app/components/base/qrcode/index.tsx index dd0eebdac6..4ff84d7a77 100644 --- a/web/app/components/base/qrcode/index.tsx +++ b/web/app/components/base/qrcode/index.tsx @@ -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) => { -
+
- + {isShow && (
{ onClick={handlePanelClick} > -
+
{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}
ยท
{t('overview.appInfo.qrcode.download', { ns: 'appOverview' })}
diff --git a/web/app/components/base/simple-pie-chart/index.spec.tsx b/web/app/components/base/simple-pie-chart/index.spec.tsx new file mode 100644 index 0000000000..f1403ffe20 --- /dev/null +++ b/web/app/components/base/simple-pie-chart/index.spec.tsx @@ -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() + const chart = container.querySelector('.echarts-for-react') + expect(chart).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + const chart = container.querySelector('.echarts-for-react') + expect(chart).toHaveClass('custom-chart') + }) + + it('should apply custom size via style', () => { + const { container } = render() + 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() + 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() + 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() + const chart = container.querySelector('.echarts-for-react') as HTMLElement + expect(chart.style.getPropertyValue('--simple-pie-chart-color')).toBe('#fdb022') + }) + }) +}) diff --git a/web/app/components/base/svg-gallery/index.spec.tsx b/web/app/components/base/svg-gallery/index.spec.tsx new file mode 100644 index 0000000000..01994f0a16 --- /dev/null +++ b/web/app/components/base/svg-gallery/index.spec.tsx @@ -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 = '' + let parseFromStringSpy: ReturnType + 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() + + 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() + + await waitFor(() => { + expect(screen.getByText(/Error rendering SVG/)).toBeInTheDocument() + }) + }) + + it('re-renders on window resize', async () => { + render() + 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() + + await waitFor(() => { + expect(mockViewbox).toHaveBeenCalledWith(0, 0, 400, 600) + }) + }) + }) + + describe('Image Preview Interactions', () => { + it('opens image preview on click', async () => { + render() + + 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() + + 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() + }) + }) +}) diff --git a/web/app/components/base/svg/index.spec.tsx b/web/app/components/base/svg/index.spec.tsx new file mode 100644 index 0000000000..fd05af0e70 --- /dev/null +++ b/web/app/components/base/svg/index.spec.tsx @@ -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() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('calls setIsSVG with a toggle function when clicked', () => { + const setIsSVG = vi.fn() + render() + + 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() + 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() + const icon = screen.getByRole('button').firstChild as HTMLElement + expect(icon?.className).toMatch(/_svgIconed_\w+/) + }) + }) +}) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 8f08836a70..535d9889dd 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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