frontend auto testing rules (#28679)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: 姜涵煦 <hanxujiang@jianghanxudeMacBook-Pro.local>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Coding On Star
2025-11-26 15:18:07 +08:00
committed by GitHub
parent 591414307a
commit dbecba710b
16 changed files with 2293 additions and 80 deletions

6
.cursorrules Normal file
View File

@@ -0,0 +1,6 @@
# Cursor Rules for Dify Project
## Automated Test Generation
- 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.

12
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,12 @@
# Copilot Instructions
GitHub Copilot must follow the unified frontend testing requirements documented in `web/testing/testing.md`.
Key reminders:
- Generate tests using the mandated tech stack, naming, and code style (AAA pattern, `fireEvent`, descriptive test names, cleans up mocks).
- Cover rendering, prop combinations, and edge cases by default; extend coverage for hooks, routing, async flows, and domain-specific components when applicable.
- Target >95% line and branch coverage and 100% function/statement coverage.
- Apply the project's mocking conventions for i18n, toast notifications, and Next.js utilities.
Any suggestions from Copilot that conflict with `web/testing/testing.md` should be revised before acceptance.

View File

@@ -0,0 +1,5 @@
# Windsurf Testing Rules
- Use `web/testing/testing.md` as the single source of truth for frontend automated testing.
- Honor every requirement in that document when generating or accepting tests.
- When proposing or saving tests, re-read that document and follow every requirement.

View File

@@ -77,6 +77,8 @@ How we prioritize:
For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
**Testing**: All React components must have comprehensive test coverage. See [web/testing/testing.md](https://github.com/langgenius/dify/blob/main/web/testing/testing.md) for the canonical frontend testing guidelines and follow every requirement described there.
#### Backend
For setting up the backend service, kindly refer to our detailed [instructions](https://github.com/langgenius/dify/blob/main/api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.

View File

@@ -99,9 +99,9 @@ If your IDE is VSCode, rename `web/.vscode/settings.example.json` to `web/.vscod
## Test
We start to use [Jest](https://jestjs.io/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing.
We use [Jest](https://jestjs.io/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing.
You can create a test file with a suffix of `.spec` beside the file that to be tested. For example, if you want to test a file named `util.ts`. The test file name should be `util.spec.ts`.
**📖 Complete Testing Guide**: See [web/testing/testing.md](./testing/testing.md) for detailed testing specifications, best practices, and examples.
Run test:
@@ -109,10 +109,22 @@ Run test:
pnpm run test
```
If you are not familiar with writing tests, here is some code to refer to:
### Example Code
- [classnames.spec.ts](./utils/classnames.spec.ts)
- [index.spec.tsx](./app/components/base/button/index.spec.tsx)
If you are not familiar with writing tests, refer to:
- [classnames.spec.ts](./utils/classnames.spec.ts) - Utility function test example
- [index.spec.tsx](./app/components/base/button/index.spec.tsx) - Component test example
### Analyze Component Complexity
Before writing tests, use the script to analyze component complexity:
```bash
pnpm analyze-component app/components/your-component/index.tsx
```
This will help you determine the testing strategy. See [web/testing/testing.md](./testing/testing.md) for details.
## Documentation

View File

@@ -1,6 +1,24 @@
import { BlockEnum } from '@/app/components/workflow/types'
import { useWorkflowStore } from '@/app/components/workflow/store'
// Type for mocked store
type MockWorkflowStore = {
showOnboarding: boolean
setShowOnboarding: jest.Mock
hasShownOnboarding: boolean
setHasShownOnboarding: jest.Mock
hasSelectedStartNode: boolean
setHasSelectedStartNode: jest.Mock
setShouldAutoOpenStartNodeSelector: jest.Mock
notInitialWorkflow: boolean
}
// Type for mocked node
type MockNode = {
id: string
data: { type?: BlockEnum }
}
// Mock zustand store
jest.mock('@/app/components/workflow/store')
@@ -39,7 +57,7 @@ describe('Workflow Onboarding Integration Logic', () => {
describe('Onboarding State Management', () => {
it('should initialize onboarding state correctly', () => {
const store = useWorkflowStore()
const store = useWorkflowStore() as unknown as MockWorkflowStore
expect(store.showOnboarding).toBe(false)
expect(store.hasSelectedStartNode).toBe(false)
@@ -47,7 +65,7 @@ describe('Workflow Onboarding Integration Logic', () => {
})
it('should update onboarding visibility', () => {
const store = useWorkflowStore()
const store = useWorkflowStore() as unknown as MockWorkflowStore
store.setShowOnboarding(true)
expect(mockSetShowOnboarding).toHaveBeenCalledWith(true)
@@ -57,14 +75,14 @@ describe('Workflow Onboarding Integration Logic', () => {
})
it('should track node selection state', () => {
const store = useWorkflowStore()
const store = useWorkflowStore() as unknown as MockWorkflowStore
store.setHasSelectedStartNode(true)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true)
})
it('should track onboarding show state', () => {
const store = useWorkflowStore()
const store = useWorkflowStore() as unknown as MockWorkflowStore
store.setHasShownOnboarding(true)
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
@@ -205,60 +223,44 @@ describe('Workflow Onboarding Integration Logic', () => {
it('should auto-expand for TriggerSchedule in new workflow', () => {
const shouldAutoOpenStartNodeSelector = true
const nodeType = BlockEnum.TriggerSchedule
const nodeType: BlockEnum = BlockEnum.TriggerSchedule
const isChatMode = false
const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin]
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && (
nodeType === BlockEnum.Start
|| nodeType === BlockEnum.TriggerSchedule
|| nodeType === BlockEnum.TriggerWebhook
|| nodeType === BlockEnum.TriggerPlugin
) && !isChatMode
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
expect(shouldAutoExpand).toBe(true)
})
it('should auto-expand for TriggerWebhook in new workflow', () => {
const shouldAutoOpenStartNodeSelector = true
const nodeType = BlockEnum.TriggerWebhook
const nodeType: BlockEnum = BlockEnum.TriggerWebhook
const isChatMode = false
const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin]
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && (
nodeType === BlockEnum.Start
|| nodeType === BlockEnum.TriggerSchedule
|| nodeType === BlockEnum.TriggerWebhook
|| nodeType === BlockEnum.TriggerPlugin
) && !isChatMode
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
expect(shouldAutoExpand).toBe(true)
})
it('should auto-expand for TriggerPlugin in new workflow', () => {
const shouldAutoOpenStartNodeSelector = true
const nodeType = BlockEnum.TriggerPlugin
const nodeType: BlockEnum = BlockEnum.TriggerPlugin
const isChatMode = false
const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin]
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && (
nodeType === BlockEnum.Start
|| nodeType === BlockEnum.TriggerSchedule
|| nodeType === BlockEnum.TriggerWebhook
|| nodeType === BlockEnum.TriggerPlugin
) && !isChatMode
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
expect(shouldAutoExpand).toBe(true)
})
it('should not auto-expand for non-trigger nodes', () => {
const shouldAutoOpenStartNodeSelector = true
const nodeType = BlockEnum.LLM
const nodeType: BlockEnum = BlockEnum.LLM
const isChatMode = false
const validStartTypes = [BlockEnum.Start, BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin]
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && (
nodeType === BlockEnum.Start
|| nodeType === BlockEnum.TriggerSchedule
|| nodeType === BlockEnum.TriggerWebhook
|| nodeType === BlockEnum.TriggerPlugin
) && !isChatMode
const shouldAutoExpand = shouldAutoOpenStartNodeSelector && validStartTypes.includes(nodeType) && !isChatMode
expect(shouldAutoExpand).toBe(false)
})
@@ -321,7 +323,7 @@ describe('Workflow Onboarding Integration Logic', () => {
const nodeData = { type: BlockEnum.Start, title: 'Start' }
// Simulate node creation logic from workflow-children.tsx
const createdNodeData = {
const createdNodeData: Record<string, unknown> = {
...nodeData,
// Note: 'selected: true' should NOT be added
}
@@ -334,7 +336,7 @@ describe('Workflow Onboarding Integration Logic', () => {
const nodeData = { type: BlockEnum.TriggerWebhook, title: 'Webhook Trigger' }
const toolConfig = { webhook_url: 'https://example.com/webhook' }
const createdNodeData = {
const createdNodeData: Record<string, unknown> = {
...nodeData,
...toolConfig,
// Note: 'selected: true' should NOT be added
@@ -352,7 +354,7 @@ describe('Workflow Onboarding Integration Logic', () => {
config: { interval: '1h' },
}
const createdNodeData = {
const createdNodeData: Record<string, unknown> = {
...nodeData,
}
@@ -495,7 +497,7 @@ describe('Workflow Onboarding Integration Logic', () => {
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]
const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data?.type))
const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data?.type as BlockEnum))
const isEmpty = nodes.length === 0 || !hasStartNode
expect(isEmpty).toBe(true)
@@ -516,7 +518,7 @@ describe('Workflow Onboarding Integration Logic', () => {
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]
const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type))
const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data.type as BlockEnum))
const isEmpty = nodes.length === 0 || !hasStartNode
expect(isEmpty).toBe(true)
@@ -536,7 +538,7 @@ describe('Workflow Onboarding Integration Logic', () => {
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]
const hasStartNode = nodes.some(node => startNodeTypes.includes(node.data.type))
const hasStartNode = nodes.some((node: MockNode) => startNodeTypes.includes(node.data.type as BlockEnum))
const isEmpty = nodes.length === 0 || !hasStartNode
expect(isEmpty).toBe(false)
@@ -571,7 +573,7 @@ describe('Workflow Onboarding Integration Logic', () => {
})
// Simulate the check logic with hasShownOnboarding = true
const store = useWorkflowStore()
const store = useWorkflowStore() as unknown as MockWorkflowStore
const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow
expect(shouldTrigger).toBe(false)
@@ -605,7 +607,7 @@ describe('Workflow Onboarding Integration Logic', () => {
})
// Simulate the check logic with notInitialWorkflow = true
const store = useWorkflowStore()
const store = useWorkflowStore() as unknown as MockWorkflowStore
const shouldTrigger = !store.hasShownOnboarding && !store.showOnboarding && !store.notInitialWorkflow
expect(shouldTrigger).toBe(false)

View File

@@ -1,4 +1,5 @@
import { getWorkflowEntryNode } from '@/app/components/workflow/utils/workflow-entry'
import type { Node } from '@/app/components/workflow/types'
// Mock the getWorkflowEntryNode function
jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({
@@ -7,6 +8,9 @@ jest.mock('@/app/components/workflow/utils/workflow-entry', () => ({
const mockGetWorkflowEntryNode = getWorkflowEntryNode as jest.MockedFunction<typeof getWorkflowEntryNode>
// Mock entry node for testing (truthy value)
const mockEntryNode = { id: 'start-node', data: { type: 'start' } } as Node
describe('App Card Toggle Logic', () => {
beforeEach(() => {
jest.clearAllMocks()
@@ -39,7 +43,7 @@ describe('App Card Toggle Logic', () => {
describe('Entry Node Detection Logic', () => {
it('should disable toggle when workflow missing entry node', () => {
mockGetWorkflowEntryNode.mockReturnValue(false)
mockGetWorkflowEntryNode.mockReturnValue(undefined)
const result = calculateToggleState(
'workflow',
@@ -55,7 +59,7 @@ describe('App Card Toggle Logic', () => {
})
it('should enable toggle when workflow has entry node', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
const result = calculateToggleState(
'workflow',
@@ -101,7 +105,7 @@ describe('App Card Toggle Logic', () => {
})
it('should consider published state when workflow has graph', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
const result = calculateToggleState(
'workflow',
@@ -117,7 +121,7 @@ describe('App Card Toggle Logic', () => {
describe('Permissions Logic', () => {
it('should disable webapp toggle when user lacks editor permissions', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
const result = calculateToggleState(
'workflow',
@@ -132,7 +136,7 @@ describe('App Card Toggle Logic', () => {
})
it('should disable api toggle when user lacks manager permissions', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
const result = calculateToggleState(
'workflow',
@@ -147,7 +151,7 @@ describe('App Card Toggle Logic', () => {
})
it('should enable toggle when user has proper permissions', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
const webappResult = calculateToggleState(
'workflow',
@@ -172,7 +176,7 @@ describe('App Card Toggle Logic', () => {
describe('Combined Conditions Logic', () => {
it('should handle multiple disable conditions correctly', () => {
mockGetWorkflowEntryNode.mockReturnValue(false)
mockGetWorkflowEntryNode.mockReturnValue(undefined)
const result = calculateToggleState(
'workflow',
@@ -191,7 +195,7 @@ describe('App Card Toggle Logic', () => {
})
it('should enable when all conditions are satisfied', () => {
mockGetWorkflowEntryNode.mockReturnValue(true)
mockGetWorkflowEntryNode.mockReturnValue(mockEntryNode)
const result = calculateToggleState(
'workflow',

View File

@@ -0,0 +1,675 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import Drawer from './index'
import type { IDrawerProps } from './index'
// Capture dialog onClose for testing
let capturedDialogOnClose: (() => void) | null = null
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock @headlessui/react
jest.mock('@headlessui/react', () => ({
Dialog: ({ children, open, onClose, className, unmount }: {
children: React.ReactNode
open: boolean
onClose: () => void
className: string
unmount: boolean
}) => {
capturedDialogOnClose = onClose
if (!open)
return null
return (
<div
data-testid="dialog"
data-open={open}
data-unmount={unmount}
className={className}
role="dialog"
>
{children}
</div>
)
},
DialogBackdrop: ({ children, className, onClick }: {
children?: React.ReactNode
className: string
onClick: () => void
}) => (
<div
data-testid="dialog-backdrop"
className={className}
onClick={onClick}
>
{children}
</div>
),
DialogTitle: ({ children, as: _as, className, ...props }: {
children: React.ReactNode
as?: string
className?: string
}) => (
<div data-testid="dialog-title" className={className} {...props}>
{children}
</div>
),
}))
// Mock XMarkIcon
jest.mock('@heroicons/react/24/outline', () => ({
XMarkIcon: ({ className, onClick }: { className: string; onClick?: () => void }) => (
<svg data-testid="close-icon" className={className} onClick={onClick} />
),
}))
// Helper function to render Drawer with default props
const defaultProps: IDrawerProps = {
isOpen: true,
onClose: jest.fn(),
children: <div data-testid="drawer-content">Content</div>,
}
const renderDrawer = (props: Partial<IDrawerProps> = {}) => {
const mergedProps = { ...defaultProps, ...props }
return render(<Drawer {...mergedProps} />)
}
describe('Drawer', () => {
beforeEach(() => {
jest.clearAllMocks()
capturedDialogOnClose = null
})
// Basic rendering tests
describe('Rendering', () => {
it('should render when isOpen is true', () => {
// Arrange & Act
renderDrawer({ isOpen: true })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByTestId('drawer-content')).toBeInTheDocument()
})
it('should not render when isOpen is false', () => {
// Arrange & Act
renderDrawer({ isOpen: false })
// Assert
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render children content', () => {
// Arrange
const childContent = <p data-testid="custom-child">Custom Content</p>
// Act
renderDrawer({ children: childContent })
// Assert
expect(screen.getByTestId('custom-child')).toBeInTheDocument()
expect(screen.getByText('Custom Content')).toBeInTheDocument()
})
})
// Title and description tests
describe('Title and Description', () => {
it('should render title when provided', () => {
// Arrange & Act
renderDrawer({ title: 'Test Title' })
// Assert
expect(screen.getByText('Test Title')).toBeInTheDocument()
})
it('should not render title when not provided', () => {
// Arrange & Act
renderDrawer({ title: '' })
// Assert
const titles = screen.queryAllByTestId('dialog-title')
const titleWithText = titles.find(el => el.textContent !== '')
expect(titleWithText).toBeUndefined()
})
it('should render description when provided', () => {
// Arrange & Act
renderDrawer({ description: 'Test Description' })
// Assert
expect(screen.getByText('Test Description')).toBeInTheDocument()
})
it('should not render description when not provided', () => {
// Arrange & Act
renderDrawer({ description: '' })
// Assert
expect(screen.queryByText('Test Description')).not.toBeInTheDocument()
})
it('should render both title and description together', () => {
// Arrange & Act
renderDrawer({
title: 'My Title',
description: 'My Description',
})
// Assert
expect(screen.getByText('My Title')).toBeInTheDocument()
expect(screen.getByText('My Description')).toBeInTheDocument()
})
})
// Close button tests
describe('Close Button', () => {
it('should render close icon when showClose is true', () => {
// Arrange & Act
renderDrawer({ showClose: true })
// Assert
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
})
it('should not render close icon when showClose is false', () => {
// Arrange & Act
renderDrawer({ showClose: false })
// Assert
expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument()
})
it('should not render close icon by default', () => {
// Arrange & Act
renderDrawer({})
// Assert
expect(screen.queryByTestId('close-icon')).not.toBeInTheDocument()
})
it('should call onClose when close icon is clicked', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ showClose: true, onClose })
// Act
fireEvent.click(screen.getByTestId('close-icon'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Backdrop/Mask tests
describe('Backdrop and Mask', () => {
it('should render backdrop when noOverlay is false', () => {
// Arrange & Act
renderDrawer({ noOverlay: false })
// Assert
expect(screen.getByTestId('dialog-backdrop')).toBeInTheDocument()
})
it('should not render backdrop when noOverlay is true', () => {
// Arrange & Act
renderDrawer({ noOverlay: true })
// Assert
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument()
})
it('should apply mask background when mask is true', () => {
// Arrange & Act
renderDrawer({ mask: true })
// Assert
const backdrop = screen.getByTestId('dialog-backdrop')
expect(backdrop.className).toContain('bg-black/30')
})
it('should not apply mask background when mask is false', () => {
// Arrange & Act
renderDrawer({ mask: false })
// Assert
const backdrop = screen.getByTestId('dialog-backdrop')
expect(backdrop.className).not.toContain('bg-black/30')
})
it('should call onClose when backdrop is clicked and clickOutsideNotOpen is false', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose, clickOutsideNotOpen: false })
// Act
fireEvent.click(screen.getByTestId('dialog-backdrop'))
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should not call onClose when backdrop is clicked and clickOutsideNotOpen is true', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose, clickOutsideNotOpen: true })
// Act
fireEvent.click(screen.getByTestId('dialog-backdrop'))
// Assert
expect(onClose).not.toHaveBeenCalled()
})
})
// Footer tests
describe('Footer', () => {
it('should render default footer with cancel and save buttons when footer is undefined', () => {
// Arrange & Act
renderDrawer({ footer: undefined })
// Assert
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
})
it('should not render footer when footer is null', () => {
// Arrange & Act
renderDrawer({ footer: null })
// Assert
expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.save')).not.toBeInTheDocument()
})
it('should render custom footer when provided', () => {
// Arrange
const customFooter = <div data-testid="custom-footer">Custom Footer</div>
// Act
renderDrawer({ footer: customFooter })
// Assert
expect(screen.getByTestId('custom-footer')).toBeInTheDocument()
expect(screen.queryByText('common.operation.cancel')).not.toBeInTheDocument()
})
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const onCancel = jest.fn()
renderDrawer({ onCancel })
// Act
const cancelButton = screen.getByText('common.operation.cancel')
fireEvent.click(cancelButton)
// Assert
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onOk when save button is clicked', () => {
// Arrange
const onOk = jest.fn()
renderDrawer({ onOk })
// Act
const saveButton = screen.getByText('common.operation.save')
fireEvent.click(saveButton)
// Assert
expect(onOk).toHaveBeenCalledTimes(1)
})
it('should not throw when onCancel is not provided and cancel is clicked', () => {
// Arrange
renderDrawer({ onCancel: undefined })
// Act & Assert
expect(() => {
fireEvent.click(screen.getByText('common.operation.cancel'))
}).not.toThrow()
})
it('should not throw when onOk is not provided and save is clicked', () => {
// Arrange
renderDrawer({ onOk: undefined })
// Act & Assert
expect(() => {
fireEvent.click(screen.getByText('common.operation.save'))
}).not.toThrow()
})
})
// Custom className tests
describe('Custom ClassNames', () => {
it('should apply custom dialogClassName', () => {
// Arrange & Act
renderDrawer({ dialogClassName: 'custom-dialog-class' })
// Assert
expect(screen.getByRole('dialog').className).toContain('custom-dialog-class')
})
it('should apply custom dialogBackdropClassName', () => {
// Arrange & Act
renderDrawer({ dialogBackdropClassName: 'custom-backdrop-class' })
// Assert
expect(screen.getByTestId('dialog-backdrop').className).toContain('custom-backdrop-class')
})
it('should apply custom containerClassName', () => {
// Arrange & Act
const { container } = renderDrawer({ containerClassName: 'custom-container-class' })
// Assert
const containerDiv = container.querySelector('.custom-container-class')
expect(containerDiv).toBeInTheDocument()
})
it('should apply custom panelClassName', () => {
// Arrange & Act
const { container } = renderDrawer({ panelClassName: 'custom-panel-class' })
// Assert
const panelDiv = container.querySelector('.custom-panel-class')
expect(panelDiv).toBeInTheDocument()
})
})
// Position tests
describe('Position', () => {
it('should apply center position class when positionCenter is true', () => {
// Arrange & Act
const { container } = renderDrawer({ positionCenter: true })
// Assert
const containerDiv = container.querySelector('.\\!justify-center')
expect(containerDiv).toBeInTheDocument()
})
it('should use end position by default when positionCenter is false', () => {
// Arrange & Act
const { container } = renderDrawer({ positionCenter: false })
// Assert
const containerDiv = container.querySelector('.justify-end')
expect(containerDiv).toBeInTheDocument()
})
})
// Unmount prop tests
describe('Unmount Prop', () => {
it('should pass unmount prop to Dialog component', () => {
// Arrange & Act
renderDrawer({ unmount: true })
// Assert
expect(screen.getByTestId('dialog').getAttribute('data-unmount')).toBe('true')
})
it('should default unmount to false', () => {
// Arrange & Act
renderDrawer({})
// Assert
expect(screen.getByTestId('dialog').getAttribute('data-unmount')).toBe('false')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty string title', () => {
// Arrange & Act
renderDrawer({ title: '' })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle empty string description', () => {
// Arrange & Act
renderDrawer({ description: '' })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle special characters in title', () => {
// Arrange
const specialTitle = '<script>alert("xss")</script>'
// Act
renderDrawer({ title: specialTitle })
// Assert
expect(screen.getByText(specialTitle)).toBeInTheDocument()
})
it('should handle very long title', () => {
// Arrange
const longTitle = 'A'.repeat(500)
// Act
renderDrawer({ title: longTitle })
// Assert
expect(screen.getByText(longTitle)).toBeInTheDocument()
})
it('should handle complex children with multiple elements', () => {
// Arrange
const complexChildren = (
<div data-testid="complex-children">
<h1>Heading</h1>
<p>Paragraph</p>
<input data-testid="input-element" />
<button data-testid="button-element">Button</button>
</div>
)
// Act
renderDrawer({ children: complexChildren })
// Assert
expect(screen.getByTestId('complex-children')).toBeInTheDocument()
expect(screen.getByText('Heading')).toBeInTheDocument()
expect(screen.getByText('Paragraph')).toBeInTheDocument()
expect(screen.getByTestId('input-element')).toBeInTheDocument()
expect(screen.getByTestId('button-element')).toBeInTheDocument()
})
it('should handle null children gracefully', () => {
// Arrange & Act
renderDrawer({ children: null as unknown as React.ReactNode })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle undefined footer without crashing', () => {
// Arrange & Act
renderDrawer({ footer: undefined })
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle rapid open/close toggles', () => {
// Arrange
const onClose = jest.fn()
const { rerender } = render(
<Drawer {...defaultProps} isOpen={true} onClose={onClose}>
<div>Content</div>
</Drawer>,
)
// Act - Toggle multiple times
rerender(
<Drawer {...defaultProps} isOpen={false} onClose={onClose}>
<div>Content</div>
</Drawer>,
)
rerender(
<Drawer {...defaultProps} isOpen={true} onClose={onClose}>
<div>Content</div>
</Drawer>,
)
rerender(
<Drawer {...defaultProps} isOpen={false} onClose={onClose}>
<div>Content</div>
</Drawer>,
)
// Assert
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
// Combined prop scenarios
describe('Combined Prop Scenarios', () => {
it('should render with all optional props', () => {
// Arrange & Act
renderDrawer({
title: 'Full Feature Title',
description: 'Full Feature Description',
dialogClassName: 'custom-dialog',
dialogBackdropClassName: 'custom-backdrop',
containerClassName: 'custom-container',
panelClassName: 'custom-panel',
showClose: true,
mask: true,
positionCenter: true,
unmount: true,
noOverlay: false,
footer: <div data-testid="custom-full-footer">Footer</div>,
})
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Full Feature Title')).toBeInTheDocument()
expect(screen.getByText('Full Feature Description')).toBeInTheDocument()
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
expect(screen.getByTestId('custom-full-footer')).toBeInTheDocument()
})
it('should render minimal drawer with only required props', () => {
// Arrange
const minimalProps: IDrawerProps = {
isOpen: true,
onClose: jest.fn(),
children: <div>Minimal Content</div>,
}
// Act
render(<Drawer {...minimalProps} />)
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Minimal Content')).toBeInTheDocument()
})
it('should handle showClose with title simultaneously', () => {
// Arrange & Act
renderDrawer({
title: 'Title with Close',
showClose: true,
})
// Assert
expect(screen.getByText('Title with Close')).toBeInTheDocument()
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
})
it('should handle noOverlay with clickOutsideNotOpen', () => {
// Arrange
const onClose = jest.fn()
// Act
renderDrawer({
noOverlay: true,
clickOutsideNotOpen: true,
onClose,
})
// Assert - backdrop should not exist
expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument()
})
})
// Dialog onClose callback tests (e.g., Escape key)
describe('Dialog onClose Callback', () => {
it('should call onClose when Dialog triggers close and clickOutsideNotOpen is false', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose, clickOutsideNotOpen: false })
// Act - Simulate Dialog's onClose (e.g., pressing Escape)
capturedDialogOnClose?.()
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should not call onClose when Dialog triggers close and clickOutsideNotOpen is true', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose, clickOutsideNotOpen: true })
// Act - Simulate Dialog's onClose (e.g., pressing Escape)
capturedDialogOnClose?.()
// Assert
expect(onClose).not.toHaveBeenCalled()
})
it('should call onClose by default when Dialog triggers close', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ onClose })
// Act
capturedDialogOnClose?.()
// Assert
expect(onClose).toHaveBeenCalledTimes(1)
})
})
// Event handler interaction tests
describe('Event Handler Interactions', () => {
it('should handle multiple consecutive close icon clicks', () => {
// Arrange
const onClose = jest.fn()
renderDrawer({ showClose: true, onClose })
// Act
const closeIcon = screen.getByTestId('close-icon')
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
fireEvent.click(closeIcon)
// Assert
expect(onClose).toHaveBeenCalledTimes(3)
})
it('should handle onCancel and onOk being the same function', () => {
// Arrange
const handler = jest.fn()
renderDrawer({ onCancel: handler, onOk: handler })
// Act
fireEvent.click(screen.getByText('common.operation.cancel'))
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(handler).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -2,10 +2,12 @@ import React, { useCallback } from 'react'
import { act, render } from '@testing-library/react'
import { useTriggerStatusStore } from '../store/trigger-status'
import { isTriggerNode } from '../types'
import type { BlockEnum } from '../types'
import type { EntryNodeStatus } from '../store/trigger-status'
// Mock the isTriggerNode function
// Mock the isTriggerNode function while preserving BlockEnum
jest.mock('../types', () => ({
...jest.requireActual('../types'),
isTriggerNode: jest.fn(),
}))
@@ -17,7 +19,7 @@ const TestTriggerNode: React.FC<{
nodeType: string
}> = ({ nodeId, nodeType }) => {
const triggerStatus = useTriggerStatusStore(state =>
mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled',
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled',
)
return (
@@ -271,7 +273,7 @@ describe('Trigger Status Synchronization Integration', () => {
nodeType: string
}> = ({ nodeId, nodeType }) => {
const triggerStatusSelector = useCallback((state: any) =>
mockIsTriggerNode(nodeType) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled',
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled',
[nodeId, nodeType],
)
const triggerStatus = useTriggerStatusStore(triggerStatusSelector)
@@ -313,7 +315,7 @@ describe('Trigger Status Synchronization Integration', () => {
const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => {
const triggerStatusSelector = useCallback((state: any) =>
mockIsTriggerNode(nodeType) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled',
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled',
['test-node', nodeType], // Dependencies should match implementation
)
const status = useTriggerStatusStore(triggerStatusSelector)

View File

@@ -1,6 +1,7 @@
import { isValidCronExpression, parseCronExpression } from './cron-parser'
import { getNextExecutionTime, getNextExecutionTimes } from './execution-time-calculator'
import type { ScheduleTriggerNodeType } from '../types'
import { BlockEnum } from '../../../types'
// Comprehensive integration tests for cron-parser and execution-time-calculator compatibility
describe('cron-parser + execution-time-calculator integration', () => {
@@ -14,13 +15,13 @@ describe('cron-parser + execution-time-calculator integration', () => {
})
const createCronData = (overrides: Partial<ScheduleTriggerNodeType> = {}): ScheduleTriggerNodeType => ({
id: 'test-cron',
type: 'schedule-trigger',
type: BlockEnum.TriggerSchedule,
title: 'test-schedule',
mode: 'cron',
frequency: 'daily',
timezone: 'UTC',
...overrides,
})
} as ScheduleTriggerNodeType)
describe('backward compatibility validation', () => {
it('maintains exact behavior for legacy cron expressions', () => {

View File

@@ -43,13 +43,18 @@ jest.mock('@/app/components/billing/trigger-events-limit-modal', () => ({
}))
type DefaultPlanShape = typeof defaultPlan
type ResetShape = {
apiRateLimit: number | null
triggerEvents: number | null
}
type PlanShape = Omit<DefaultPlanShape, 'reset'> & { reset: ResetShape }
type PlanOverrides = Partial<Omit<DefaultPlanShape, 'usage' | 'total' | 'reset'>> & {
usage?: Partial<DefaultPlanShape['usage']>
total?: Partial<DefaultPlanShape['total']>
reset?: Partial<DefaultPlanShape['reset']>
reset?: Partial<ResetShape>
}
const createPlan = (overrides: PlanOverrides = {}): DefaultPlanShape => ({
const createPlan = (overrides: PlanOverrides = {}): PlanShape => ({
...defaultPlan,
...overrides,
usage: {

View File

@@ -37,6 +37,7 @@
"check:i18n-types": "node ./i18n-config/check-i18n-sync.js",
"test": "jest",
"test:watch": "jest --watch",
"analyze-component": "node testing/analyze-component.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"preinstall": "npx only-allow pnpm",

1057
web/testing/analyze-component.js Executable file

File diff suppressed because it is too large Load Diff

432
web/testing/testing.md Normal file
View File

@@ -0,0 +1,432 @@
# Frontend Testing Guide
This document is the complete testing specification for the Dify frontend project.
Goal: Readable, change-friendly, reusable, and debuggable tests.
When I ask you to write/refactor/fix tests, follow these rules by default.
## Tech Stack
- **Framework**: Next.js 15 + React 19 + TypeScript
- **Testing Tools**: Jest 29.7 + React Testing Library 16.0
- **Test Environment**: @happy-dom/jest-environment
- **File Naming**: `ComponentName.spec.tsx` (same directory as component)
## Running Tests
```bash
# Run all tests
pnpm test
# Watch mode
pnpm test -- --watch
# Generate coverage report
pnpm test -- --coverage
# Run specific file
pnpm test -- path/to/file.spec.tsx
```
## Project Test Setup
- **Configuration**: `jest.config.ts` loads the Testing Library presets, sets the `@happy-dom/jest-environment`, and respects our path aliases (`@/...`). Check this file before adding new transformers or module name mappers.
- **Global setup**: `jest.setup.ts` already imports `@testing-library/jest-dom` and runs `cleanup()` after every test. Add any environment-level mocks (for example `ResizeObserver`, `matchMedia`, `IntersectionObserver`, `TextEncoder`, `crypto`) here so they are shared consistently.
- **Manual mocks**: Place reusable mocks inside `web/__mocks__/`. Use `jest.mock('module-name')` to point to these helpers rather than redefining mocks in every spec.
- **Script utilities**: `web/testing/analyze-component.js` analyzes component complexity and generates test prompts for AI assistants. Commands:
- `pnpm analyze-component <path>` - Analyze and generate test prompt
- `pnpm analyze-component <path> --json` - Output analysis as JSON
- `pnpm analyze-component <path> --review` - Generate test review prompt
- `pnpm analyze-component --help` - Show help
- **Integration suites**: Files in `web/__tests__/` exercise cross-component flows. Prefer adding new end-to-end style specs there rather than mixing them into component directories.
## Test Authoring Principles
- **Single behavior per test**: Each test verifies one user-observable behavior.
- **Black-box first**: Assert external behavior and observable outputs, avoid internal implementation details.
- **Semantic naming**: Use `should <behavior> when <condition>` and group related cases with `describe(<subject or scenario>)`.
- **AAA / GivenWhenThen**: Separate Arrange, Act, and Assert clearly with code blocks or comments.
- **Minimal but sufficient assertions**: Keep only the expectations that express the essence of the behavior.
- **Reusable test data**: Prefer test data builders or factories over hard-coded masses of data.
- **De-flake**: Control time, randomness, network, concurrency, and ordering.
- **Fast & stable**: Keep unit tests running in milliseconds; reserve integration tests for cross-module behavior with isolation.
- **Structured describe blocks**: Organize tests with `describe` sections and add a brief comment before each block to explain the scenario it covers so readers can quickly understand the scope.
## Component Complexity Guidelines
Use `pnpm analyze-component <path>` to analyze component complexity and adopt different testing strategies based on the results.
### 🔴 Very Complex Components (Complexity > 50)
- **Refactor first**: Break component into smaller pieces
- **Integration tests**: Test complex workflows end-to-end
- **Data-driven tests**: Use `test.each()` for multiple scenarios
- **Performance benchmarks**: Add performance tests for critical paths
### ⚠️ Complex Components (Complexity 30-50)
- **Multiple describe blocks**: Group related test cases
- **Integration scenarios**: Test feature combinations
- **Organized structure**: Keep tests maintainable
### 📏 Large Components (500+ lines)
- **Consider refactoring**: Split into smaller components if possible
- **Section testing**: Test major sections separately
- **Helper functions**: Reduce test complexity with utilities
## Basic Guidelines
- ✅ AAA pattern: Arrange (setup) → Act (execute) → Assert (verify)
- ✅ Descriptive test names: `"should [behavior] when [condition]"`
- ✅ TypeScript: No `any` types
-**Cleanup**: `jest.clearAllMocks()` should be in `beforeEach()`, not `afterEach()`. This ensures mock call history is reset before each test, preventing test pollution when using assertions like `toHaveBeenCalledWith()` or `toHaveBeenCalledTimes()`.
**⚠️ Mock components must accurately reflect actual component behavior**, especially conditional rendering based on props or state.
**Rules**:
1. **Match actual conditional rendering**: If the real component returns `null` or doesn't render under certain conditions, the mock must do the same. Always check the actual component implementation before creating mocks.
1. **Use shared state variables when needed**: When mocking components that depend on shared context or state (e.g., `PortalToFollowElem` with `PortalToFollowElemContent`), use module-level variables to track state and reset them in `beforeEach`.
1. **Always reset shared mock state in beforeEach**: Module-level variables used in mocks must be reset in `beforeEach` to ensure test isolation, even if you set default values elsewhere.
1. **Use fake timers only when needed**: Only use `jest.useFakeTimers()` if:
- Testing components that use real `setTimeout`/`setInterval` (not mocked)
- Testing time-based behavior (delays, animations)
- If you mock all time-dependent functions, fake timers are unnecessary
1. **Prefer importing over mocking project components**: When tests need other components from the project, import them directly instead of mocking them. Only mock external dependencies, APIs, or complex context providers that are difficult to set up.
**Why this matters**: Mocks that don't match actual behavior can lead to:
- **False positives**: Tests pass but code would fail in production
- **Missed bugs**: Tests don't catch real conditional rendering issues
- **Maintenance burden**: Tests become misleading documentation
- **State leakage**: Tests interfere with each other when shared state isn't reset
## Testing Components with Dedicated Dependencies
When a component has dedicated dependencies (custom hooks, managers, utilities) that are **only used by that component**, use the following strategy to balance integration testing and unit testing.
### Summary Checklist
When testing components with dedicated dependencies:
- **Identify** which dependencies are dedicated vs. reusable
- **Write integration tests** for component + dedicated dependencies together
- **Write unit tests** for complex edge cases in dependencies
- **Avoid mocking** dedicated dependencies in integration tests
- **Use fake timers** if timing logic is involved
- **Test user behavior**, not implementation details
- **Document** the testing strategy in code comments
- **Ensure** integration tests cover 100% of user-facing scenarios
- **Reserve** unit tests for edge cases not practical in integration tests
## Test Scenarios
Apply the following test scenarios based on component features:
### 1. Rendering Tests (REQUIRED - All Components)
**Key Points**:
- Verify component renders properly
- Check key elements exist
- Use semantic queries (getByRole, getByLabelText)
### 2. Props Testing (REQUIRED - All Components)
Exercise the prop combinations that change observable behavior. Show how required props gate functionality, how optional props fall back to their defaults, and how invalid combinations surface through user-facing safeguards. Let TypeScript catch structural issues; keep runtime assertions focused on what the component renders or triggers.
### 3. State Management
Treat component state as part of the public behavior: confirm the initial render in context, execute the interactions or prop updates that move the state machine, and assert the resulting UI or side effects. Use `waitFor()`/async queries whenever transitions resolve asynchronously, and only check cleanup paths when they change what a user sees or experiences (duplicate events, lingering timers, etc.).
#### Context, Providers, and Stores
- ✅ Wrap components with the actual provider from `web/context` or `app/components/.../context` whenever practical.
- ✅ When creating lightweight provider stubs, mirror the real default values and surface helper builders (for example `createMockWorkflowContext`).
- ✅ Reset shared stores (React context, Zustand, TanStack Query cache) between tests to avoid leaking state. Prefer helper factory functions over module-level singletons in specs.
- ✅ For hooks that read from context, use `renderHook` with a custom wrapper that supplies required providers.
### 4. Performance Optimization
Cover memoized callbacks or values only when they influence observable behavior—memoized children, subscription updates, expensive computations. Trigger realistic re-renders and assert the outcomes (avoided rerenders, reused results) instead of inspecting hook internals.
### 5. Event Handlers
Simulate the interactions that matter to users—primary clicks, change events, submits, and relevant keyboard shortcuts—and confirm the resulting behavior. When handlers prevent defaults or rely on bubbling, cover the scenarios where that choice affects the UI or downstream flows.
### 6. API Calls and Async Operations
**Must Test**:
- ✅ Mock all API calls using `jest.mock`
- ✅ Test retry logic (if applicable)
- ✅ Verify error handling and user feedback
- ✅ Use `waitFor()` for async operations
- ✅ For `@tanstack/react-query`, instantiate a fresh `QueryClient` per spec and wrap with `QueryClientProvider`
- ✅ Clear timers, intervals, and pending promises between tests when using fake timers
**Guidelines**:
- Prefer spying on `global.fetch`/`axios`/`ky` and returning deterministic responses over reaching out to the network.
- Use MSW (`msw` is already installed) when you need declarative request handlers across multiple specs.
- Keep async assertions inside `await waitFor(...)` blocks or the async `findBy*` queries to avoid race conditions.
### 7. Next.js Routing
Mock the specific Next.js navigation hooks your component consumes (`useRouter`, `usePathname`, `useSearchParams`) and drive realistic routing flows—query parameters, redirects, guarded routes, URL updates—while asserting the rendered outcome or navigation side effects.
### 8. Edge Cases (REQUIRED - All Components)
**Must Test**:
- ✅ null/undefined/empty values
- ✅ Boundary conditions
- ✅ Error states
- ✅ Loading states
- ✅ Unexpected inputs
### 9. Test Data Builders (Anti-hardcoding)
For complex inputs/entities, use Builders with solid defaults and chainable overrides.
### 10. Accessibility Testing (Optional)
- Test keyboard navigation
- Verify ARIA attributes
- Test focus management
- Ensure screen reader compatibility
### 11. Snapshot Testing (Use Sparingly)
Reserve snapshots for static, deterministic fragments (icons, badges, layout chrome). Keep them tight, prefer explicit assertions for behavior, and review any snapshot updates deliberately instead of accepting them wholesale.
**Note**: Dify is a desktop application. **No need for** responsive/mobile testing.
## Code Style
### Example Structure
```typescript
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import Component from './index'
// Mock dependencies
jest.mock('@/service/api')
// Shared state for mocks (if needed)
let mockSharedState = false
describe('ComponentName', () => {
beforeEach(() => {
jest.clearAllMocks() // ✅ Reset mocks before each test
mockSharedState = false // ✅ Reset shared state if used in mocks
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = { title: 'Test' }
// Act
render(<Component {...props} />)
// Assert
expect(screen.getByText('Test')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should handle click events', () => {
const handleClick = jest.fn()
render(<Component onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle null data', () => {
render(<Component data={null} />)
expect(screen.getByText(/no data/i)).toBeInTheDocument()
})
})
})
```
## Dify-Specific Components
### General
1. **i18n**: Always return key
```typescript
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
```
1. **Forms**: Test validation logic thoroughly
1. **Example - Correct mock with conditional rendering**:
```typescript
// ✅ CORRECT: Matches actual component behavior
let mockPortalOpenState = false
jest.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open, ...props }: any) => {
mockPortalOpenState = open || false // Update shared state
return <div data-open={open}>{children}</div>
},
PortalToFollowElemContent: ({ children }: any) => {
// ✅ Matches actual: returns null when open is false
if (!mockPortalOpenState) return null
return <div>{children}</div>
},
}))
describe('Component', () => {
beforeEach(() => {
jest.clearAllMocks() // ✅ Reset mock call history
mockPortalOpenState = false // ✅ Reset shared state
})
})
```
### Workflow Components (`workflow/`)
**Must Test**:
- ⚙️ **Node configuration**: Test all node configuration options
- ✔️ **Data validation**: Verify input/output validation rules
- 🔄 **Variable passing**: Test data flow between nodes
- 🔗 **Edge connections**: Test graph structure and connections
- ❌ **Error handling**: Verify invalid configuration handling
- 🧪 **Integration**: Test complete workflow execution paths
### Dataset Components (`dataset/`)
**Must Test**:
- 📤 **File upload**: Test file upload and validation
- 📄 **File types**: Verify supported format handling
- 📃 **Pagination**: Test data loading and pagination
- 🔍 **Search & filtering**: Test query functionality
- 📊 **Data format handling**: Test various data formats
- ⚠️ **Error states**: Test upload failures and invalid data
### Configuration Components (`app/configuration`, `config/`)
**Must Test**:
- ✅ **Form validation**: Test all validation rules thoroughly
- 💾 **Save/reset functionality**: Test data persistence
- 🔒 **Required vs optional fields**: Verify field validation
- 📌 **Configuration persistence**: Test state preservation
- 💬 **Error feedback**: Verify user error messages
- 🎯 **Default values**: Test initial configuration state
## Testing Strategy Quick Reference
### Required (All Components)
- ✅ Renders without crashing
- ✅ Props (required, optional, defaults)
- ✅ Edge cases (null, undefined, empty values)
### Conditional (When Present in Component)
- 🔄 **useState** → State initialization, transitions, cleanup
- ⚡ **useEffect** → Execution, dependencies, cleanup
- 🎯 **Event Handlers** → All onClick, onChange, onSubmit, keyboard events
- 🌐 **API Calls** → Loading, success, error states
- 🔀 **Routing** → Navigation, params, query strings
- 🚀 **useCallback/useMemo** → Referential equality, dependencies
- ⚙️ **Workflow** → Node config, data flow, validation
- 📚 **Dataset** → Upload, pagination, search
- 🎛️ **Configuration** → Form validation, persistence
### Complex Components (Complexity 30+)
- Group tests in multiple `describe` blocks
- Test integration scenarios
- Consider splitting component before testing
## Coverage Goals
### ⚠️ MANDATORY: Complete Coverage in Single Generation
Aim for 100% coverage:
- ✅ 100% function coverage (every exported function/method tested)
- ✅ 100% statement coverage (every line executed)
- ✅ >95% branch coverage (every if/else, switch case, ternary tested)
- ✅ >95% line coverage
Generate comprehensive tests covering **all** code paths and scenarios.
## Debugging Tips
### View Rendered DOM
```typescript
import { screen } from '@testing-library/react'
// Print entire DOM
screen.debug()
// Print specific element
screen.debug(screen.getByRole('button'))
```
### Finding Elements
Priority order (recommended top to bottom):
1. `getByRole` - Most recommended, follows accessibility standards
1. `getByLabelText` - Form fields
1. `getByPlaceholderText` - Only when no label
1. `getByText` - Non-interactive elements
1. `getByDisplayValue` - Current form value
1. `getByAltText` - Images
1. `getByTitle` - Last choice
1. `getByTestId` - Only as last resort
### Async Debugging
```typescript
// Wait for element to appear
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument()
})
// Wait for element to disappear
await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument()
})
// Find async element
const element = await screen.findByText('Async Content')
```
## Reference Examples
Test examples in the project:
- [classnames.spec.ts](../utils/classnames.spec.ts) - Utility function tests
- [index.spec.tsx](../app/components/base/button/index.spec.tsx) - Component tests
## Resources
- [Jest Documentation](https://jestjs.io/docs/getting-started)
- [React Testing Library Documentation](https://testing-library.com/docs/react-testing-library/intro/)
- [Testing Library Best Practices](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)
- [Jest Mock Functions](https://jestjs.io/docs/mock-functions)
______________________________________________________________________
**Remember**: Writing tests is not just about coverage, but ensuring code quality and maintainability. Good tests should be clear, concise, and meaningful.

View File

@@ -6,6 +6,7 @@
"dom.iterable",
"esnext"
],
"types": ["jest", "node", "@testing-library/jest-dom"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -40,11 +41,6 @@
"app/components/develop/Prose.jsx"
],
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"__tests__/**"
"node_modules"
]
}

View File

@@ -2,6 +2,7 @@
* Test suite for app redirection utility functions
* Tests navigation path generation based on user permissions and app modes
*/
import { AppModeEnum } from '@/types/app'
import { getRedirection, getRedirectionPath } from './app-redirection'
describe('app-redirection', () => {
@@ -12,44 +13,44 @@ describe('app-redirection', () => {
*/
describe('getRedirectionPath', () => {
test('returns overview path when user is not editor', () => {
const app = { id: 'app-123', mode: 'chat' as const }
const app = { id: 'app-123', mode: AppModeEnum.CHAT }
const result = getRedirectionPath(false, app)
expect(result).toBe('/app/app-123/overview')
})
test('returns workflow path for workflow mode when user is editor', () => {
const app = { id: 'app-123', mode: 'workflow' as const }
const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-123/workflow')
})
test('returns workflow path for advanced-chat mode when user is editor', () => {
const app = { id: 'app-123', mode: 'advanced-chat' as const }
const app = { id: 'app-123', mode: AppModeEnum.ADVANCED_CHAT }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-123/workflow')
})
test('returns configuration path for chat mode when user is editor', () => {
const app = { id: 'app-123', mode: 'chat' as const }
const app = { id: 'app-123', mode: AppModeEnum.CHAT }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-123/configuration')
})
test('returns configuration path for completion mode when user is editor', () => {
const app = { id: 'app-123', mode: 'completion' as const }
const app = { id: 'app-123', mode: AppModeEnum.COMPLETION }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-123/configuration')
})
test('returns configuration path for agent-chat mode when user is editor', () => {
const app = { id: 'app-456', mode: 'agent-chat' as const }
const app = { id: 'app-456', mode: AppModeEnum.AGENT_CHAT }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-456/configuration')
})
test('handles different app IDs', () => {
const app1 = { id: 'abc-123', mode: 'chat' as const }
const app2 = { id: 'xyz-789', mode: 'workflow' as const }
const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT }
const app2 = { id: 'xyz-789', mode: AppModeEnum.WORKFLOW }
expect(getRedirectionPath(false, app1)).toBe('/app/abc-123/overview')
expect(getRedirectionPath(true, app2)).toBe('/app/xyz-789/workflow')
@@ -64,7 +65,7 @@ describe('app-redirection', () => {
* Tests that the redirection function is called with the correct path
*/
test('calls redirection function with correct path for non-editor', () => {
const app = { id: 'app-123', mode: 'chat' as const }
const app = { id: 'app-123', mode: AppModeEnum.CHAT }
const mockRedirect = jest.fn()
getRedirection(false, app, mockRedirect)
@@ -74,7 +75,7 @@ describe('app-redirection', () => {
})
test('calls redirection function with workflow path for editor', () => {
const app = { id: 'app-123', mode: 'workflow' as const }
const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW }
const mockRedirect = jest.fn()
getRedirection(true, app, mockRedirect)
@@ -84,7 +85,7 @@ describe('app-redirection', () => {
})
test('calls redirection function with configuration path for chat mode editor', () => {
const app = { id: 'app-123', mode: 'chat' as const }
const app = { id: 'app-123', mode: AppModeEnum.CHAT }
const mockRedirect = jest.fn()
getRedirection(true, app, mockRedirect)
@@ -94,7 +95,7 @@ describe('app-redirection', () => {
})
test('works with different redirection functions', () => {
const app = { id: 'app-123', mode: 'workflow' as const }
const app = { id: 'app-123', mode: AppModeEnum.WORKFLOW }
const paths: string[] = []
const customRedirect = (path: string) => paths.push(path)