From 4589157963e0a492e59268535cefb46e9e39ef7e Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:44:51 +0800 Subject: [PATCH] test: Add comprehensive Jest test for AppCard component (#29667) --- .../templates/component-test.template.tsx | 21 +- .../create-app-dialog/app-card/index.spec.tsx | 347 ++++++++++++++++++ .../app/create-app-dialog/app-card/index.tsx | 17 +- .../apikey-info-panel.test-utils.tsx | 12 +- 4 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 web/app/components/app/create-app-dialog/app-card/index.spec.tsx diff --git a/.claude/skills/frontend-testing/templates/component-test.template.tsx b/.claude/skills/frontend-testing/templates/component-test.template.tsx index 9b1542b676..f1ea71a3fd 100644 --- a/.claude/skills/frontend-testing/templates/component-test.template.tsx +++ b/.claude/skills/frontend-testing/templates/component-test.template.tsx @@ -26,13 +26,20 @@ import userEvent from '@testing-library/user-event' // WHY: Mocks must be hoisted to top of file (Jest requirement). // They run BEFORE imports, so keep them before component imports. -// i18n (always required in Dify) -// WHY: Returns key instead of translation so tests don't depend on i18n files -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +// i18n (automatically mocked) +// WHY: Shared mock at web/__mocks__/react-i18next.ts is auto-loaded by Jest +// No explicit mock needed - it returns translation keys as-is +// Override only if custom translations are required: +// jest.mock('react-i18next', () => ({ +// useTranslation: () => ({ +// t: (key: string) => { +// const customTranslations: Record = { +// 'my.custom.key': 'Custom Translation', +// } +// return customTranslations[key] || key +// }, +// }), +// })) // Router (if component uses useRouter, usePathname, useSearchParams) // WHY: Isolates tests from Next.js routing, enables testing navigation behavior diff --git a/web/app/components/app/create-app-dialog/app-card/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/index.spec.tsx new file mode 100644 index 0000000000..3122f06ec3 --- /dev/null +++ b/web/app/components/app/create-app-dialog/app-card/index.spec.tsx @@ -0,0 +1,347 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AppCard from './index' +import type { AppIconType } from '@/types/app' +import { AppModeEnum } from '@/types/app' +import type { App } from '@/models/explore' + +jest.mock('@heroicons/react/20/solid', () => ({ + PlusIcon: ({ className }: any) =>
+
, +})) + +const mockApp: App = { + app: { + id: 'test-app-id', + mode: AppModeEnum.CHAT, + icon_type: 'emoji' as AppIconType, + icon: '🤖', + icon_background: '#FFEAD5', + icon_url: '', + name: 'Test Chat App', + description: 'A test chat application for demonstration purposes', + use_icon_as_answer_icon: false, + }, + app_id: 'test-app-id', + description: 'A comprehensive chat application template', + copyright: 'Test Corp', + privacy_policy: null, + custom_disclaimer: null, + category: 'Assistant', + position: 1, + is_listed: true, + install_count: 100, + installed: false, + editable: true, + is_agent: false, +} + +describe('AppCard', () => { + const defaultProps = { + app: mockApp, + canCreate: true, + onCreate: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + + expect(container.querySelector('em-emoji')).toBeInTheDocument() + expect(screen.getByText('Test Chat App')).toBeInTheDocument() + expect(screen.getByText(mockApp.description)).toBeInTheDocument() + }) + + it('should render app type icon and label', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + expect(screen.getByText('app.typeSelector.chatbot')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + describe('canCreate behavior', () => { + it('should show create button when canCreate is true', () => { + render() + + const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) + expect(button).toBeInTheDocument() + }) + + it('should hide create button when canCreate is false', () => { + render() + + const button = screen.queryByRole('button', { name: /app\.newApp\.useTemplate/ }) + expect(button).not.toBeInTheDocument() + }) + }) + + it('should display app name from appBasicInfo', () => { + const customApp = { + ...mockApp, + app: { + ...mockApp.app, + name: 'Custom App Name', + }, + } + render() + + expect(screen.getByText('Custom App Name')).toBeInTheDocument() + }) + + it('should display app description from app level', () => { + const customApp = { + ...mockApp, + description: 'Custom description for the app', + } + render() + + expect(screen.getByText('Custom description for the app')).toBeInTheDocument() + }) + + it('should truncate long app names', () => { + const longNameApp = { + ...mockApp, + app: { + ...mockApp.app, + name: 'This is a very long app name that should be truncated with line-clamp-1', + }, + } + render() + + const nameElement = screen.getByTitle('This is a very long app name that should be truncated with line-clamp-1') + expect(nameElement).toBeInTheDocument() + }) + }) + + describe('App Modes - Data Driven Tests', () => { + const testCases = [ + { + mode: AppModeEnum.CHAT, + expectedLabel: 'app.typeSelector.chatbot', + description: 'Chat application mode', + }, + { + mode: AppModeEnum.AGENT_CHAT, + expectedLabel: 'app.typeSelector.agent', + description: 'Agent chat mode', + }, + { + mode: AppModeEnum.COMPLETION, + expectedLabel: 'app.typeSelector.completion', + description: 'Completion mode', + }, + { + mode: AppModeEnum.ADVANCED_CHAT, + expectedLabel: 'app.typeSelector.advanced', + description: 'Advanced chat mode', + }, + { + mode: AppModeEnum.WORKFLOW, + expectedLabel: 'app.typeSelector.workflow', + description: 'Workflow mode', + }, + ] + + testCases.forEach(({ mode, expectedLabel, description }) => { + it(`should display correct type label for ${description}`, () => { + const appWithMode = { + ...mockApp, + app: { + ...mockApp.app, + mode, + }, + } + render() + + expect(screen.getByText(expectedLabel)).toBeInTheDocument() + }) + }) + }) + + describe('Icon Type Tests', () => { + it('should render emoji icon without image element', () => { + const appWithIcon = { + ...mockApp, + app: { + ...mockApp.app, + icon_type: 'emoji' as AppIconType, + icon: '🤖', + }, + } + const { container } = render() + + const card = container.firstElementChild as HTMLElement + expect(within(card).queryByRole('img', { name: 'app icon' })).not.toBeInTheDocument() + expect(card.querySelector('em-emoji')).toBeInTheDocument() + }) + + it('should prioritize icon_url when both icon and icon_url are provided', () => { + const appWithImageUrl = { + ...mockApp, + app: { + ...mockApp.app, + icon_type: 'image' as AppIconType, + icon: 'local-icon.png', + icon_url: 'https://example.com/remote-icon.png', + }, + } + render() + + expect(screen.getByRole('img', { name: 'app icon' })).toHaveAttribute('src', 'https://example.com/remote-icon.png') + }) + }) + + describe('User Interactions', () => { + it('should call onCreate when create button is clicked', async () => { + const mockOnCreate = jest.fn() + render() + + const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) + await userEvent.click(button) + expect(mockOnCreate).toHaveBeenCalledTimes(1) + }) + + it('should handle click on card itself', async () => { + const mockOnCreate = jest.fn() + const { container } = render() + + const card = container.firstElementChild as HTMLElement + await userEvent.click(card) + // Note: Card click doesn't trigger onCreate, only the button does + expect(mockOnCreate).not.toHaveBeenCalled() + }) + }) + + describe('Keyboard Accessibility', () => { + it('should allow the create button to be focused', async () => { + const mockOnCreate = jest.fn() + render() + + await userEvent.tab() + const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) as HTMLButtonElement + + // Test that button can be focused + expect(button).toHaveFocus() + + // Test click event works (keyboard events on buttons typically trigger click) + await userEvent.click(button) + expect(mockOnCreate).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle app with null icon_type', () => { + const appWithNullIcon = { + ...mockApp, + app: { + ...mockApp.app, + icon_type: null, + }, + } + const { container } = render() + + const appIcon = container.querySelector('em-emoji') + expect(appIcon).toBeInTheDocument() + // AppIcon component should handle null icon_type gracefully + }) + + it('should handle app with empty description', () => { + const appWithEmptyDesc = { + ...mockApp, + description: '', + } + const { container } = render() + + const descriptionContainer = container.querySelector('.line-clamp-3') + expect(descriptionContainer).toBeInTheDocument() + expect(descriptionContainer).toHaveTextContent('') + }) + + it('should handle app with very long description', () => { + const longDescription = 'This is a very long description that should be truncated with line-clamp-3. '.repeat(5) + const appWithLongDesc = { + ...mockApp, + description: longDescription, + } + render() + + expect(screen.getByText(/This is a very long description/)).toBeInTheDocument() + }) + + it('should handle app with special characters in name', () => { + const appWithSpecialChars = { + ...mockApp, + app: { + ...mockApp.app, + name: 'App & Special "Chars"', + }, + } + render() + + expect(screen.getByText('App & Special "Chars"')).toBeInTheDocument() + }) + + it('should handle onCreate function throwing error', async () => { + const errorOnCreate = jest.fn(() => { + throw new Error('Create failed') + }) + + // Mock console.error to avoid test output noise + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + + render() + + const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) + let capturedError: unknown + try { + await userEvent.click(button) + } + catch (err) { + capturedError = err + } + expect(errorOnCreate).toHaveBeenCalledTimes(1) + expect(consoleSpy).toHaveBeenCalled() + if (capturedError instanceof Error) + expect(capturedError.message).toContain('Create failed') + + consoleSpy.mockRestore() + }) + }) + + describe('Accessibility', () => { + it('should have proper elements for accessibility', () => { + const { container } = render() + + expect(container.querySelector('em-emoji')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should have title attribute for app name when truncated', () => { + render() + + const nameElement = screen.getByText('Test Chat App') + expect(nameElement).toHaveAttribute('title', 'Test Chat App') + }) + + it('should have accessible button with proper label', () => { + render() + + const button = screen.getByRole('button', { name: /app\.newApp\.useTemplate/ }) + expect(button).toBeEnabled() + expect(button).toHaveTextContent('app.newApp.useTemplate') + }) + }) + + describe('User-Visible Behavior Tests', () => { + it('should show plus icon in create button', () => { + render() + + expect(screen.getByTestId('plus-icon')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index 7f7ede0065..a3bf91cb5d 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -15,6 +15,7 @@ export type AppCardProps = { const AppCard = ({ app, + canCreate, onCreate, }: AppCardProps) => { const { t } = useTranslation() @@ -45,14 +46,16 @@ const AppCard = ({ {app.description} - ) } diff --git a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx index 36a1c5a008..1b1e729546 100644 --- a/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/apikey-info-panel.test-utils.tsx @@ -188,15 +188,15 @@ export const interactions = { // Text content keys for assertions export const textKeys = { selfHost: { - titleRow1: 'appOverview.apiKeyInfo.selfHost.title.row1', - titleRow2: 'appOverview.apiKeyInfo.selfHost.title.row2', - setAPIBtn: 'appOverview.apiKeyInfo.setAPIBtn', - tryCloud: 'appOverview.apiKeyInfo.tryCloud', + titleRow1: /appOverview\.apiKeyInfo\.selfHost\.title\.row1/, + titleRow2: /appOverview\.apiKeyInfo\.selfHost\.title\.row2/, + setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/, + tryCloud: /appOverview\.apiKeyInfo\.tryCloud/, }, cloud: { - trialTitle: 'appOverview.apiKeyInfo.cloud.trial.title', + trialTitle: /appOverview\.apiKeyInfo\.cloud\.trial\.title/, trialDescription: /appOverview\.apiKeyInfo\.cloud\.trial\.description/, - setAPIBtn: 'appOverview.apiKeyInfo.setAPIBtn', + setAPIBtn: /appOverview\.apiKeyInfo\.setAPIBtn/, }, }