test: add comprehensive tests for CustomizeModal component (#29709)

This commit is contained in:
yyh
2025-12-16 14:21:05 +08:00
committed by GitHub
parent a915b8a584
commit 240e1d155a
5 changed files with 436 additions and 2 deletions

View File

@@ -2,3 +2,4 @@
- Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests. - Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests.
- When proposing or saving tests, re-read that document and follow every requirement. - When proposing or saving tests, re-read that document and follow every requirement.
- All frontend tests MUST also comply with the `frontend-testing` skill. Treat the skill as a mandatory constraint, not optional guidance.

1
web/CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -401,7 +401,6 @@ function AppCard({
/> />
<CustomizeModal <CustomizeModal
isShow={showCustomizeModal} isShow={showCustomizeModal}
linkUrl=""
onClose={() => setShowCustomizeModal(false)} onClose={() => setShowCustomizeModal(false)}
appId={appInfo.id} appId={appInfo.id}
api_base_url={appInfo.api_base_url} api_base_url={appInfo.api_base_url}

View File

@@ -0,0 +1,434 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CustomizeModal from './index'
import { AppModeEnum } from '@/types/app'
// Mock useDocLink from context
const mockDocLink = jest.fn((path?: string) => `https://docs.dify.ai/en-US${path || ''}`)
jest.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
// Mock window.open
const mockWindowOpen = jest.fn()
Object.defineProperty(window, 'open', {
value: mockWindowOpen,
writable: true,
})
describe('CustomizeModal', () => {
const defaultProps = {
isShow: true,
onClose: jest.fn(),
api_base_url: 'https://api.example.com',
appId: 'test-app-id-123',
mode: AppModeEnum.CHAT,
}
beforeEach(() => {
jest.clearAllMocks()
})
// Rendering tests - verify component renders correctly with various configurations
describe('Rendering', () => {
it('should render without crashing when isShow is true', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
})
})
it('should not render content when isShow is false', async () => {
// Arrange
const props = { ...defaultProps, isShow: false }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
expect(screen.queryByText('appOverview.overview.appInfo.customize.title')).not.toBeInTheDocument()
})
})
it('should render modal description', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('appOverview.overview.appInfo.customize.explanation')).toBeInTheDocument()
})
})
it('should render way 1 and way 2 tags', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('appOverview.overview.appInfo.customize.way 1')).toBeInTheDocument()
expect(screen.getByText('appOverview.overview.appInfo.customize.way 2')).toBeInTheDocument()
})
})
it('should render all step numbers (1, 2, 3)', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
})
it('should render step instructions', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step1')).toBeInTheDocument()
expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step2')).toBeInTheDocument()
expect(screen.getByText('appOverview.overview.appInfo.customize.way1.step3')).toBeInTheDocument()
})
})
it('should render environment variables with appId and api_base_url', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
expect(preElement).toBeInTheDocument()
expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'test-app-id-123\'')
expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'https://api.example.com\'')
})
})
it('should render GitHub icon in step 1 button', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert - find the GitHub link and verify it contains an SVG icon
await waitFor(() => {
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
expect(githubLink).toBeInTheDocument()
expect(githubLink.querySelector('svg')).toBeInTheDocument()
})
})
})
// Props tests - verify props are correctly applied
describe('Props', () => {
it('should display correct appId in environment variables', async () => {
// Arrange
const customAppId = 'custom-app-id-456'
const props = { ...defaultProps, appId: customAppId }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
expect(preElement?.textContent).toContain(`NEXT_PUBLIC_APP_ID='${customAppId}'`)
})
})
it('should display correct api_base_url in environment variables', async () => {
// Arrange
const customApiUrl = 'https://custom-api.example.com'
const props = { ...defaultProps, api_base_url: customApiUrl }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
expect(preElement?.textContent).toContain(`NEXT_PUBLIC_API_URL='${customApiUrl}'`)
})
})
})
// Mode-based conditional rendering tests - verify GitHub link changes based on app mode
describe('Mode-based GitHub link', () => {
it('should link to webapp-conversation repo for CHAT mode', async () => {
// Arrange
const props = { ...defaultProps, mode: AppModeEnum.CHAT }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation')
})
})
it('should link to webapp-conversation repo for ADVANCED_CHAT mode', async () => {
// Arrange
const props = { ...defaultProps, mode: AppModeEnum.ADVANCED_CHAT }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation')
})
})
it('should link to webapp-text-generator repo for COMPLETION mode', async () => {
// Arrange
const props = { ...defaultProps, mode: AppModeEnum.COMPLETION }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
})
})
it('should link to webapp-text-generator repo for WORKFLOW mode', async () => {
// Arrange
const props = { ...defaultProps, mode: AppModeEnum.WORKFLOW }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
})
})
it('should link to webapp-text-generator repo for AGENT_CHAT mode', async () => {
// Arrange
const props = { ...defaultProps, mode: AppModeEnum.AGENT_CHAT }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
expect(githubLink).toHaveAttribute('href', 'https://github.com/langgenius/webapp-text-generator')
})
})
})
// External links tests - verify external links have correct security attributes
describe('External links', () => {
it('should have GitHub repo link that opens in new tab', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
expect(githubLink).toHaveAttribute('target', '_blank')
expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
it('should have Vercel docs link that opens in new tab', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const vercelLink = screen.getByRole('link', { name: /step2Operation/i })
expect(vercelLink).toHaveAttribute('href', 'https://vercel.com/docs/concepts/deployments/git/vercel-for-github')
expect(vercelLink).toHaveAttribute('target', '_blank')
expect(vercelLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
})
// User interactions tests - verify user actions trigger expected behaviors
describe('User Interactions', () => {
it('should call window.open with doc link when way 2 button is clicked', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
await waitFor(() => {
expect(screen.getByText('appOverview.overview.appInfo.customize.way2.operation')).toBeInTheDocument()
})
const way2Button = screen.getByText('appOverview.overview.appInfo.customize.way2.operation').closest('button')
expect(way2Button).toBeInTheDocument()
fireEvent.click(way2Button!)
// Assert
expect(mockWindowOpen).toHaveBeenCalledTimes(1)
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining('/guides/application-publishing/developing-with-apis'),
'_blank',
)
})
it('should call onClose when modal close button is clicked', async () => {
// Arrange
const onClose = jest.fn()
const props = { ...defaultProps, onClose }
// Act
render(<CustomizeModal {...props} />)
// Wait for modal to be fully rendered
await waitFor(() => {
expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
})
// Find the close button by navigating from the heading to the close icon
// The close icon is an SVG inside a sibling div of the title
const heading = screen.getByRole('heading', { name: /customize\.title/i })
const closeIcon = heading.parentElement!.querySelector('svg')
// Assert - closeIcon must exist for the test to be valid
expect(closeIcon).toBeInTheDocument()
fireEvent.click(closeIcon!)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Edge cases tests - verify component handles boundary conditions
describe('Edge Cases', () => {
it('should handle empty appId', async () => {
// Arrange
const props = { ...defaultProps, appId: '' }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
expect(preElement?.textContent).toContain('NEXT_PUBLIC_APP_ID=\'\'')
})
})
it('should handle empty api_base_url', async () => {
// Arrange
const props = { ...defaultProps, api_base_url: '' }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
expect(preElement?.textContent).toContain('NEXT_PUBLIC_API_URL=\'\'')
})
})
it('should handle special characters in appId', async () => {
// Arrange
const specialAppId = 'app-id-with-special-chars_123'
const props = { ...defaultProps, appId: specialAppId }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const preElement = screen.getByText(/NEXT_PUBLIC_APP_ID/i).closest('pre')
expect(preElement?.textContent).toContain(`NEXT_PUBLIC_APP_ID='${specialAppId}'`)
})
})
it('should handle URL with special characters in api_base_url', async () => {
// Arrange
const specialApiUrl = 'https://api.example.com:8080/v1'
const props = { ...defaultProps, api_base_url: specialApiUrl }
// Act
render(<CustomizeModal {...props} />)
// Assert
await waitFor(() => {
const preElement = screen.getByText(/NEXT_PUBLIC_API_URL/i).closest('pre')
expect(preElement?.textContent).toContain(`NEXT_PUBLIC_API_URL='${specialApiUrl}'`)
})
})
})
// StepNum component tests - verify step number styling
describe('StepNum component', () => {
it('should render step numbers with correct styling class', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert - The StepNum component is the direct container of the text
await waitFor(() => {
const stepNumber1 = screen.getByText('1')
expect(stepNumber1).toHaveClass('rounded-2xl')
})
})
})
// GithubIcon component tests - verify GitHub icon renders correctly
describe('GithubIcon component', () => {
it('should render GitHub icon SVG within GitHub link button', async () => {
// Arrange
const props = { ...defaultProps }
// Act
render(<CustomizeModal {...props} />)
// Assert - Find GitHub link and verify it contains an SVG icon with expected class
await waitFor(() => {
const githubLink = screen.getByRole('link', { name: /step1Operation/i })
const githubIcon = githubLink.querySelector('svg')
expect(githubIcon).toBeInTheDocument()
expect(githubIcon).toHaveClass('text-text-secondary')
})
})
})
})

View File

@@ -12,7 +12,6 @@ import Tag from '@/app/components/base/tag'
type IShareLinkProps = { type IShareLinkProps = {
isShow: boolean isShow: boolean
onClose: () => void onClose: () => void
linkUrl: string
api_base_url: string api_base_url: string
appId: string appId: string
mode: AppModeEnum mode: AppModeEnum