mirror of https://github.com/langgenius/dify.git
fix(web): Zustand testing best practices and state read optimization (#31163)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
8893913b3a
commit
e8397ae7a8
|
|
@ -83,6 +83,9 @@ vi.mock('next/navigation', () => ({
|
|||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
// ✅ Zustand stores: Use real stores (auto-mocked globally)
|
||||
// Set test state with: useAppStore.setState({ ... })
|
||||
|
||||
// Shared state for mocks (if needed)
|
||||
let mockSharedState = false
|
||||
|
||||
|
|
@ -296,7 +299,7 @@ For each test file generated, aim for:
|
|||
For more detailed information, refer to:
|
||||
|
||||
- `references/workflow.md` - **Incremental testing workflow** (MUST READ for multi-file testing)
|
||||
- `references/mocking.md` - Mock patterns and best practices
|
||||
- `references/mocking.md` - Mock patterns, Zustand store testing, and best practices
|
||||
- `references/async-testing.md` - Async operations and API calls
|
||||
- `references/domain-components.md` - Workflow, Dataset, Configuration testing
|
||||
- `references/common-patterns.md` - Frequently used testing patterns
|
||||
|
|
|
|||
|
|
@ -37,16 +37,36 @@ Only mock these categories:
|
|||
1. **Third-party libraries with side effects** - `next/navigation`, external SDKs
|
||||
1. **i18n** - Always mock to return keys
|
||||
|
||||
### Zustand Stores - DO NOT Mock Manually
|
||||
|
||||
**Zustand is globally mocked** in `web/vitest.setup.ts`. Use real stores with `setState()`:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Use real store, set test state
|
||||
import { useAppStore } from '@/app/components/app/store'
|
||||
|
||||
useAppStore.setState({ appDetail: { id: 'test', name: 'Test' } })
|
||||
render(<MyComponent />)
|
||||
|
||||
// ❌ WRONG: Don't mock the store module
|
||||
vi.mock('@/app/components/app/store', () => ({ ... }))
|
||||
```
|
||||
|
||||
See [Zustand Store Testing](#zustand-store-testing) section for full details.
|
||||
|
||||
## Mock Placement
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| `web/vitest.setup.ts` | Global mocks shared by all tests (for example `react-i18next`, `next/image`) |
|
||||
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `next/image`, `zustand`) |
|
||||
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
|
||||
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
|
||||
| Test file | Test-specific mocks, inline with `vi.mock()` |
|
||||
|
||||
Modules are not mocked automatically. Use `vi.mock` in test files, or add global mocks in `web/vitest.setup.ts`.
|
||||
|
||||
**Note**: Zustand is special - it's globally mocked but you should NOT mock store modules manually. See [Zustand Store Testing](#zustand-store-testing).
|
||||
|
||||
## Essential Mocks
|
||||
|
||||
### 1. i18n (Auto-loaded via Global Mock)
|
||||
|
|
@ -276,6 +296,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||
|
||||
1. **Use real base components** - Import from `@/app/components/base/` directly
|
||||
1. **Use real project components** - Prefer importing over mocking
|
||||
1. **Use real Zustand stores** - Set test state via `store.setState()`
|
||||
1. **Reset mocks in `beforeEach`**, not `afterEach`
|
||||
1. **Match actual component behavior** in mocks (when mocking is necessary)
|
||||
1. **Use factory functions** for complex mock data
|
||||
|
|
@ -285,6 +306,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
|||
### ❌ DON'T
|
||||
|
||||
1. **Don't mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
||||
1. **Don't mock Zustand store modules** - Use real stores with `setState()`
|
||||
1. Don't mock components you can import directly
|
||||
1. Don't create overly simplified mocks that miss conditional logic
|
||||
1. Don't forget to clean up nock after each test
|
||||
|
|
@ -308,10 +330,151 @@ Need to use a component in test?
|
|||
├─ Is it a third-party lib with side effects?
|
||||
│ └─ YES → Mock it (next/navigation, external SDKs)
|
||||
│
|
||||
├─ Is it a Zustand store?
|
||||
│ └─ YES → DO NOT mock the module!
|
||||
│ Use real store + setState() to set test state
|
||||
│ (Global mock handles auto-reset)
|
||||
│
|
||||
└─ Is it i18n?
|
||||
└─ YES → Uses shared mock (auto-loaded). Override only for custom translations
|
||||
```
|
||||
|
||||
## Zustand Store Testing
|
||||
|
||||
### Global Zustand Mock (Auto-loaded)
|
||||
|
||||
Zustand is globally mocked in `web/vitest.setup.ts` following the [official Zustand testing guide](https://zustand.docs.pmnd.rs/guides/testing). The mock in `web/__mocks__/zustand.ts` provides:
|
||||
|
||||
- Real store behavior with `getState()`, `setState()`, `subscribe()` methods
|
||||
- Automatic store reset after each test via `afterEach`
|
||||
- Proper test isolation between tests
|
||||
|
||||
### ✅ Recommended: Use Real Stores (Official Best Practice)
|
||||
|
||||
**DO NOT mock store modules manually.** Import and use the real store, then use `setState()` to set test state:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Use real store with setState
|
||||
import { useAppStore } from '@/app/components/app/store'
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('should render app details', () => {
|
||||
// Arrange: Set test state via setState
|
||||
useAppStore.setState({
|
||||
appDetail: {
|
||||
id: 'test-app',
|
||||
name: 'Test App',
|
||||
mode: 'chat',
|
||||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<MyComponent />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test App')).toBeInTheDocument()
|
||||
// Can also verify store state directly
|
||||
expect(useAppStore.getState().appDetail?.name).toBe('Test App')
|
||||
})
|
||||
|
||||
// No cleanup needed - global mock auto-resets after each test
|
||||
})
|
||||
```
|
||||
|
||||
### ❌ Avoid: Manual Store Module Mocking
|
||||
|
||||
Manual mocking conflicts with the global Zustand mock and loses store functionality:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Don't mock the store module
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector) => mockSelector(selector), // Missing getState, setState!
|
||||
}))
|
||||
|
||||
// ❌ WRONG: This conflicts with global zustand mock
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: vi.fn(() => mockState),
|
||||
}))
|
||||
```
|
||||
|
||||
**Problems with manual mocking:**
|
||||
|
||||
1. Loses `getState()`, `setState()`, `subscribe()` methods
|
||||
1. Conflicts with global Zustand mock behavior
|
||||
1. Requires manual maintenance of store API
|
||||
1. Tests don't reflect actual store behavior
|
||||
|
||||
### When Manual Store Mocking is Necessary
|
||||
|
||||
In rare cases where the store has complex initialization or side effects, you can mock it, but ensure you provide the full store API:
|
||||
|
||||
```typescript
|
||||
// If you MUST mock (rare), include full store API
|
||||
const mockStore = {
|
||||
appDetail: { id: 'test', name: 'Test' },
|
||||
setAppDetail: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: Object.assign(
|
||||
(selector: (state: typeof mockStore) => unknown) => selector(mockStore),
|
||||
{
|
||||
getState: () => mockStore,
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
},
|
||||
),
|
||||
}))
|
||||
```
|
||||
|
||||
### Store Testing Decision Tree
|
||||
|
||||
```
|
||||
Need to test a component using Zustand store?
|
||||
│
|
||||
├─ Can you use the real store?
|
||||
│ └─ YES → Use real store + setState (RECOMMENDED)
|
||||
│ useAppStore.setState({ ... })
|
||||
│
|
||||
├─ Does the store have complex initialization/side effects?
|
||||
│ └─ YES → Consider mocking, but include full API
|
||||
│ (getState, setState, subscribe)
|
||||
│
|
||||
└─ Are you testing the store itself (not a component)?
|
||||
└─ YES → Test store directly with getState/setState
|
||||
const store = useMyStore
|
||||
store.setState({ count: 0 })
|
||||
store.getState().increment()
|
||||
expect(store.getState().count).toBe(1)
|
||||
```
|
||||
|
||||
### Example: Testing Store Actions
|
||||
|
||||
```typescript
|
||||
import { useCounterStore } from '@/stores/counter'
|
||||
|
||||
describe('Counter Store', () => {
|
||||
it('should increment count', () => {
|
||||
// Initial state (auto-reset by global mock)
|
||||
expect(useCounterStore.getState().count).toBe(0)
|
||||
|
||||
// Call action
|
||||
useCounterStore.getState().increment()
|
||||
|
||||
// Verify state change
|
||||
expect(useCounterStore.getState().count).toBe(1)
|
||||
})
|
||||
|
||||
it('should reset to initial state', () => {
|
||||
// Set some state
|
||||
useCounterStore.setState({ count: 100 })
|
||||
expect(useCounterStore.getState().count).toBe(100)
|
||||
|
||||
// After this test, global mock will reset to initial state
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Factory Function Pattern
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
|||
import type { Inputs, ModelConfig } from '@/models/debug'
|
||||
import type { PromptVariable } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app'
|
||||
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
|
||||
|
|
@ -21,9 +22,7 @@ type PromptVariableWithMeta = Omit<PromptVariable, 'type' | 'required'> & {
|
|||
const mockUseDebugConfigurationContext = vi.fn()
|
||||
const mockUseFeaturesSelector = vi.fn()
|
||||
const mockUseEventEmitterContext = vi.fn()
|
||||
const mockUseAppStoreSelector = vi.fn()
|
||||
const mockEventEmitter = { emit: vi.fn() }
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
let capturedChatInputProps: MockChatInputAreaProps | null = null
|
||||
let modelIdCounter = 0
|
||||
let featureState: FeatureStoreState
|
||||
|
|
@ -63,10 +62,6 @@ vi.mock('@/context/event-emitter', () => ({
|
|||
useEventEmitterContextContext: () => mockUseEventEmitterContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => mockUseAppStoreSelector(selector),
|
||||
}))
|
||||
|
||||
vi.mock('./debug-item', () => ({
|
||||
default: ({
|
||||
modelAndParameter,
|
||||
|
|
@ -191,7 +186,6 @@ describe('DebugWithMultipleModel', () => {
|
|||
featureState = createFeatureState()
|
||||
mockUseFeaturesSelector.mockImplementation(selector => selector(featureState))
|
||||
mockUseEventEmitterContext.mockReturnValue({ eventEmitter: mockEventEmitter })
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal }))
|
||||
mockUseDebugConfigurationContext.mockReturnValue(createDebugConfiguration())
|
||||
})
|
||||
|
||||
|
|
@ -438,7 +432,7 @@ describe('DebugWithMultipleModel', () => {
|
|||
expect(capturedChatInputProps?.showFileUpload).toBe(false)
|
||||
expect(capturedChatInputProps?.speechToTextConfig).toEqual(featureState.features.speech2text)
|
||||
expect(capturedChatInputProps?.visionConfig).toEqual(featureState.features.file)
|
||||
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
|
||||
expect(useAppStore.getState().showAppConfigureFeaturesModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should render chat input in agent chat mode', () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { ProviderContextState } from '@/context/provider-context'
|
|||
import type { DatasetConfigs, ModelConfig } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
|
|
@ -376,15 +377,7 @@ vi.mock('../hooks', () => ({
|
|||
useFormattingChangedSubscription: mockUseFormattingChangedSubscription,
|
||||
}))
|
||||
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn((selector?: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => {
|
||||
if (typeof selector === 'function')
|
||||
return selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal })
|
||||
return mockSetShowAppConfigureFeaturesModal
|
||||
}),
|
||||
}))
|
||||
// Use real store - global zustand mock will auto-reset between tests
|
||||
|
||||
// Mock event emitter context
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
|
|
@ -659,7 +652,7 @@ describe('DebugWithSingleModel', () => {
|
|||
|
||||
fireEvent.click(screen.getByTestId('feature-bar-button'))
|
||||
|
||||
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
|
||||
expect(useAppStore.getState().showAppConfigureFeaturesModal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,11 @@ import type { IPromptValuePanelProps } from './index'
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum, ModelModeType, Resolution } from '@/types/app'
|
||||
import PromptValuePanel from './index'
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
// Use real store - global zustand mock will auto-reset between tests
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
|
||||
default: ({ onFeatureBarClick }: { onFeatureBarClick: () => void }) => (
|
||||
<button type="button" onClick={onFeatureBarClick}>
|
||||
|
|
@ -18,8 +15,6 @@ vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
const mockSetShowAppConfigureFeaturesModal = vi.fn()
|
||||
const mockUseStore = vi.mocked(useStore)
|
||||
const mockSetInputs = vi.fn()
|
||||
const mockOnSend = vi.fn()
|
||||
|
||||
|
|
@ -69,20 +64,9 @@ const renderPanel = (options: {
|
|||
|
||||
describe('PromptValuePanel', () => {
|
||||
beforeEach(() => {
|
||||
mockUseStore.mockImplementation(selector => selector({
|
||||
setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal,
|
||||
appSidebarExpand: '',
|
||||
currentLogModalActiveTab: 'prompt',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
setShowPromptLogModal: vi.fn(),
|
||||
setShowAgentLogModal: vi.fn(),
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false,
|
||||
} as any))
|
||||
vi.clearAllMocks()
|
||||
mockSetInputs.mockClear()
|
||||
mockOnSend.mockClear()
|
||||
mockSetShowAppConfigureFeaturesModal.mockClear()
|
||||
})
|
||||
|
||||
it('updates inputs, clears values, and triggers run when ready', async () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { App } from '@/types/app'
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
|
|
@ -17,10 +18,7 @@ vi.mock('next/navigation', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
const mockSetAppDetail = vi.fn()
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: any) => unknown) => selector({ setAppDetail: mockSetAppDetail }),
|
||||
}))
|
||||
// Use real store - global zustand mock will auto-reset between tests
|
||||
|
||||
const mockSwitchApp = vi.fn()
|
||||
const mockDeleteApp = vi.fn()
|
||||
|
|
@ -137,9 +135,17 @@ const renderComponent = (overrides: Partial<React.ComponentProps<typeof SwitchAp
|
|||
}
|
||||
}
|
||||
|
||||
const setAppDetailSpy = vi.fn()
|
||||
|
||||
describe('SwitchAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Spy on setAppDetail
|
||||
const originalSetAppDetail = useAppStore.getState().setAppDetail
|
||||
setAppDetailSpy.mockImplementation((...args: Parameters<typeof originalSetAppDetail>) => {
|
||||
originalSetAppDetail(...args)
|
||||
})
|
||||
useAppStore.setState({ setAppDetail: setAppDetailSpy as typeof originalSetAppDetail })
|
||||
mockIsEditor = true
|
||||
mockEnableBilling = false
|
||||
mockPlan = {
|
||||
|
|
@ -275,7 +281,7 @@ describe('SwitchAppModal', () => {
|
|||
})
|
||||
expect(mockReplace).toHaveBeenCalledWith('/app/new-app-002/workflow')
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(mockSetAppDetail).toHaveBeenCalledTimes(1)
|
||||
expect(setAppDetailSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify error when switch app fails', async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
// Import after mocks
|
||||
|
|
@ -123,18 +124,7 @@ vi.mock('@/service/use-apps', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
// Mock tag store
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: (selector: (state: { tagList: any[], setTagList: any, showTagManagementModal: boolean, setShowTagManagementModal: any }) => any) => {
|
||||
const state = {
|
||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app' }],
|
||||
setTagList: vi.fn(),
|
||||
showTagManagementModal: false,
|
||||
setShowTagManagementModal: vi.fn(),
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
// Use real tag store - global zustand mock will auto-reset between tests
|
||||
|
||||
// Mock tag service to avoid API calls in TagFilter
|
||||
vi.mock('@/service/tag', () => ({
|
||||
|
|
@ -247,6 +237,11 @@ beforeAll(() => {
|
|||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Set up tag store state
|
||||
useTagStore.setState({
|
||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
|
||||
showTagManagementModal: false,
|
||||
})
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockDragging = false
|
||||
|
|
|
|||
|
|
@ -92,6 +92,16 @@ vi.mock('@/app/components/workflow/store', () => {
|
|||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
pipelineId: 'test-pipeline-id',
|
||||
knowledgeName: 'Test Knowledge',
|
||||
knowledgeIcon: {
|
||||
icon_type: 'emoji' as const,
|
||||
icon: '📚',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: '',
|
||||
},
|
||||
setShowInputFieldPanel: mockSetShowInputFieldPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setShowImportDSLModal: mockSetShowImportDSLModal,
|
||||
setIsPreparingDataSource: mockSetIsPreparingDataSource,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setPublishedAt: mockSetPublishedAt,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ vi.mock('@/app/components/workflow/store', () => ({
|
|||
}
|
||||
return selector(state)
|
||||
},
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setRagPipelineVariables: mockSetRagPipelineVariables,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useNodesSyncDraft hook
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import Button from '@/app/components/base/button'
|
|||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
type PublishAsKnowledgePipelineModalProps = {
|
||||
confirmDisabled?: boolean
|
||||
|
|
@ -28,10 +28,9 @@ const PublishAsKnowledgePipelineModal = ({
|
|||
onConfirm,
|
||||
}: PublishAsKnowledgePipelineModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const knowledgeName = useStore(s => s.knowledgeName)
|
||||
const knowledgeIcon = useStore(s => s.knowledgeIcon)
|
||||
const [pipelineName, setPipelineName] = useState(knowledgeName!)
|
||||
const [pipelineIcon, setPipelineIcon] = useState(knowledgeIcon!)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const [pipelineName, setPipelineName] = useState(() => workflowStore.getState().knowledgeName!)
|
||||
const [pipelineIcon, setPipelineIcon] = useState(() => workflowStore.getState().knowledgeIcon!)
|
||||
const [description, setDescription] = useState('')
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ vi.mock('@/app/components/workflow/store', () => ({
|
|||
useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState),
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setShowInputFieldPanel: mockSetShowInputFieldPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setIsPreparingDataSource: mockSetIsPreparingDataSource,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setPublishedAt: mockSetPublishedAt,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Header from '@/app/components/workflow/header'
|
||||
import {
|
||||
useStore,
|
||||
|
|
@ -13,9 +12,7 @@ import Publisher from './publisher'
|
|||
import RunMode from './run-mode'
|
||||
|
||||
const RagPipelineHeader = () => {
|
||||
const { t } = useTranslation()
|
||||
const pipelineId = useStore(s => s.pipelineId)
|
||||
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
|
||||
|
||||
const viewHistoryProps = useMemo(() => {
|
||||
return {
|
||||
|
|
@ -42,7 +39,7 @@ const RagPipelineHeader = () => {
|
|||
viewHistoryProps,
|
||||
},
|
||||
}
|
||||
}, [viewHistoryProps, showDebugAndPreviewPanel, t])
|
||||
}, [viewHistoryProps])
|
||||
|
||||
return (
|
||||
<Header {...headerProps} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { ReactElement } from 'react'
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import type { App } from '@/types/app'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
|
|
@ -17,7 +19,6 @@ const mockUseFeatures = vi.fn()
|
|||
const mockUseProviderContext = vi.fn()
|
||||
const mockUseNodes = vi.fn()
|
||||
const mockUseEdges = vi.fn()
|
||||
const mockUseAppStoreSelector = vi.fn()
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockHandleCheckBeforePublish = vi.fn()
|
||||
|
|
@ -27,7 +28,6 @@ const mockUpdatePublishedWorkflow = vi.fn()
|
|||
const mockResetWorkflowVersionHistory = vi.fn()
|
||||
const mockInvalidateAppTriggers = vi.fn()
|
||||
const mockFetchAppDetail = vi.fn()
|
||||
const mockSetAppDetail = vi.fn()
|
||||
const mockSetPublishedAt = vi.fn()
|
||||
const mockSetLastPublishedHasUserInput = vi.fn()
|
||||
|
||||
|
|
@ -134,9 +134,7 @@ vi.mock('@/hooks/use-theme', () => ({
|
|||
default: () => mockUseTheme(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail?: { id: string }, setAppDetail: typeof mockSetAppDetail }) => unknown) => mockUseAppStoreSelector(selector),
|
||||
}))
|
||||
// Use real app store - global zustand mock will auto-reset between tests
|
||||
|
||||
const createProviderContext = ({
|
||||
type = Plan.sandbox,
|
||||
|
|
@ -178,7 +176,8 @@ describe('FeaturesTrigger', () => {
|
|||
mockUseProviderContext.mockReturnValue(createProviderContext({}))
|
||||
mockUseNodes.mockReturnValue([])
|
||||
mockUseEdges.mockReturnValue([])
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({ appDetail: { id: 'app-id' }, setAppDetail: mockSetAppDetail }))
|
||||
// Set up app store state
|
||||
useAppStore.setState({ appDetail: { id: 'app-id' } as unknown as App })
|
||||
mockFetchAppDetail.mockResolvedValue({ id: 'app-id' })
|
||||
mockPublishWorkflow.mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
|
||||
})
|
||||
|
|
@ -424,7 +423,7 @@ describe('FeaturesTrigger', () => {
|
|||
expect(mockResetWorkflowVersionHistory).toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
|
||||
expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-id' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalled()
|
||||
expect(useAppStore.getState().appDetail).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import WorkflowHeader from './index'
|
||||
|
||||
const mockUseAppStoreSelector = vi.fn()
|
||||
const mockSetCurrentLogItem = vi.fn()
|
||||
const mockSetShowMessageLogModal = vi.fn()
|
||||
const mockResetWorkflowVersionHistory = vi.fn()
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
|
|
@ -39,20 +38,14 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
|
|||
...overrides,
|
||||
})
|
||||
|
||||
let appDetail: App
|
||||
|
||||
const mockAppStore = (overrides: Partial<App> = {}) => {
|
||||
appDetail = createMockApp(overrides)
|
||||
mockUseAppStoreSelector.mockImplementation(selector => selector({
|
||||
appDetail,
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
}))
|
||||
// Helper to set up app store state
|
||||
const setupAppStore = (overrides: Partial<App> = {}) => {
|
||||
const appDetail = createMockApp(overrides)
|
||||
useAppStore.setState({ appDetail })
|
||||
return appDetail
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail?: App, setCurrentLogItem: typeof mockSetCurrentLogItem, setShowMessageLogModal: typeof mockSetShowMessageLogModal }) => unknown) => mockUseAppStoreSelector(selector),
|
||||
}))
|
||||
// Use real store - global zustand mock will auto-reset between tests
|
||||
|
||||
vi.mock('@/app/components/workflow/header', () => ({
|
||||
default: (props: HeaderProps) => {
|
||||
|
|
@ -87,7 +80,12 @@ vi.mock('@/service/use-workflow', () => ({
|
|||
describe('WorkflowHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppStore()
|
||||
setupAppStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup before zustand mock resets store to avoid re-render with undefined appDetail
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Verifies the wrapper renders the workflow header shell.
|
||||
|
|
@ -105,7 +103,7 @@ describe('WorkflowHeader', () => {
|
|||
describe('Props', () => {
|
||||
it('should configure preview mode when app is in advanced chat mode', () => {
|
||||
// Arrange
|
||||
mockAppStore({ mode: AppModeEnum.ADVANCED_CHAT })
|
||||
setupAppStore({ mode: AppModeEnum.ADVANCED_CHAT })
|
||||
|
||||
// Act
|
||||
render(<WorkflowHeader />)
|
||||
|
|
@ -119,7 +117,7 @@ describe('WorkflowHeader', () => {
|
|||
|
||||
it('should configure run mode when app is not in advanced chat mode', () => {
|
||||
// Arrange
|
||||
mockAppStore({ mode: AppModeEnum.COMPLETION })
|
||||
setupAppStore({ mode: AppModeEnum.COMPLETION })
|
||||
|
||||
// Act
|
||||
render(<WorkflowHeader />)
|
||||
|
|
@ -136,14 +134,18 @@ describe('WorkflowHeader', () => {
|
|||
describe('User Interactions', () => {
|
||||
it('should clear log and close message modal when clearing history modal state', () => {
|
||||
// Arrange
|
||||
useAppStore.setState({
|
||||
currentLogItem: { id: 'log-item' } as unknown as IChatItem,
|
||||
showMessageLogModal: true,
|
||||
})
|
||||
render(<WorkflowHeader />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: /clear-history/i }))
|
||||
|
||||
// Assert
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
|
||||
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false)
|
||||
// Assert - verify store state was updated
|
||||
expect(useAppStore.getState().currentLogItem).toBeUndefined()
|
||||
expect(useAppStore.getState().showMessageLogModal).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
arrayStringPlaceholder,
|
||||
objectPlaceholder,
|
||||
} from '@/app/components/workflow/panel/chat-variable-panel/utils'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import ArrayBoolList from './array-bool-list'
|
||||
|
|
@ -58,7 +58,7 @@ const ChatVariableModal = ({
|
|||
}: ModalPropsType) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const varList = useStore(s => s.conversationVariables)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const [name, setName] = React.useState('')
|
||||
const [type, setType] = React.useState<ChatVarType>(ChatVarType.String)
|
||||
const [value, setValue] = React.useState<any>()
|
||||
|
|
@ -234,6 +234,7 @@ const ChatVariableModal = ({
|
|||
const handleSave = () => {
|
||||
if (!checkVariableName(name))
|
||||
return
|
||||
const varList = workflowStore.getState().conversationVariables
|
||||
if (!chatVar && varList.some(chatVar => chatVar.name === name))
|
||||
return notify({ type: 'error', message: 'name is existed' })
|
||||
// if (type !== ChatVarType.Object && !value)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import Button from '@/app/components/base/button'
|
|||
import Input from '@/app/components/base/input'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
|
||||
|
|
@ -25,8 +25,7 @@ const VariableModal = ({
|
|||
}: ModalPropsType) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const envList = useStore(s => s.environmentVariables)
|
||||
const envSecrets = useStore(s => s.envSecrets)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const [type, setType] = React.useState<'string' | 'number' | 'secret'>('string')
|
||||
const [name, setName] = React.useState('')
|
||||
const [value, setValue] = React.useState<any>()
|
||||
|
|
@ -58,6 +57,7 @@ const VariableModal = ({
|
|||
return notify({ type: 'error', message: 'value can not be empty' })
|
||||
|
||||
// Add check for duplicate name when editing
|
||||
const envList = workflowStore.getState().environmentVariables
|
||||
if (env && env.name !== name && envList.some(e => e.name === name))
|
||||
return notify({ type: 'error', message: 'name is existed' })
|
||||
// Original check for create new variable
|
||||
|
|
@ -78,10 +78,11 @@ const VariableModal = ({
|
|||
if (env) {
|
||||
setType(env.value_type)
|
||||
setName(env.name)
|
||||
const envSecrets = workflowStore.getState().envSecrets
|
||||
setValue(env.value_type === 'secret' ? envSecrets[env.id] : env.value)
|
||||
setDescription(env.description)
|
||||
}
|
||||
}, [env, envSecrets])
|
||||
}, [env, workflowStore])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ vi.mock('../../store', () => ({
|
|||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
deleteAllInspectVars: vi.fn(),
|
||||
setShowWorkflowVersionHistoryPanel: vi.fn(),
|
||||
setCurrentVersion: mockSetCurrentVersion,
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -518,7 +518,7 @@
|
|||
},
|
||||
"app/components/app/configuration/prompt-value-panel/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/app/configuration/prompt-value-panel/utils.ts": {
|
||||
|
|
@ -619,11 +619,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app/switch-app-modal/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app/switch-app-modal/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
|
|
@ -675,7 +670,7 @@
|
|||
},
|
||||
"app/components/apps/list.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 9
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"app/components/apps/list.tsx": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue