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:
yyh 2026-01-19 10:31:34 +08:00 committed by GitHub
parent 8893913b3a
commit e8397ae7a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 257 additions and 106 deletions

View File

@ -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

View File

@ -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

View File

@ -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', () => {

View File

@ -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)
})
})

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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

View File

@ -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,

View File

@ -49,6 +49,11 @@ vi.mock('@/app/components/workflow/store', () => ({
}
return selector(state)
},
useWorkflowStore: () => ({
getState: () => ({
setRagPipelineVariables: mockSetRagPipelineVariables,
}),
}),
}))
// Mock useNodesSyncDraft hook

View File

@ -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)

View File

@ -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,

View File

@ -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} />

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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)

View File

@ -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

View File

@ -97,6 +97,8 @@ vi.mock('../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
deleteAllInspectVars: vi.fn(),
setShowWorkflowVersionHistoryPanel: vi.fn(),
setCurrentVersion: mockSetCurrentVersion,
}),
setState: vi.fn(),
}),

View File

@ -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": {