Merge main HEAD (segment 5) into sandboxed-agent-rebase

Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files.
Preserve sandbox/agent/collaboration features while adopting main's
UI refactorings (Dialog/AlertDialog/Popover), model provider updates,
and enterprise features.

Made-with: Cursor
This commit is contained in:
Novice
2026-03-23 14:20:06 +08:00
1671 changed files with 124822 additions and 22302 deletions

View File

@ -0,0 +1,115 @@
import type { PanelProps } from '../index'
import { screen } from '@testing-library/react'
import { createNode } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import Panel from '../index'
const mockVersionHistoryPanel = vi.hoisted(() => vi.fn())
class MockResizeObserver implements ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(_callback: ResizeObserverCallback) {}
}
vi.mock('@/next/dynamic', () => ({
default: () => (props: { latestVersionId?: string }) => {
mockVersionHistoryPanel(props)
return <div data-testid="version-history-panel">{props.latestVersionId}</div>
},
}))
vi.mock('reactflow', async () => {
const mod = await import('../../__tests__/reactflow-mock-state')
const base = mod.createReactFlowModuleMock()
return {
...base,
useStore: vi.fn(selector => selector({
getNodes: () => mod.rfState.nodes,
})),
}
})
vi.mock('../env-panel', () => ({
default: () => <div data-testid="env-panel" />,
}))
vi.mock('../../nodes', () => ({
Panel: ({ id }: { id: string }) => <div data-testid="node-panel">{id}</div>,
}))
const versionHistoryPanelProps = {
latestVersionId: 'version-1',
restoreVersionUrl: (versionId: string) => `/workflows/${versionId}/restore`,
} satisfies NonNullable<PanelProps['versionHistoryPanelProps']>
describe('Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
resetReactFlowMockState()
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Version History Panel', () => {
it('should render the version history panel when the panel is open and props are provided', () => {
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
expect(screen.getByTestId('version-history-panel')).toHaveTextContent('version-1')
expect(mockVersionHistoryPanel).toHaveBeenCalledWith(expect.objectContaining({
latestVersionId: 'version-1',
}))
})
it('should not render the version history panel when the panel is open but props are missing', () => {
renderWorkflowComponent(
<Panel />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: true,
},
},
)
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(mockVersionHistoryPanel).not.toHaveBeenCalled()
})
it('should not render the version history panel when the panel is closed', () => {
rfState.nodes = [
createNode({
id: 'selected-node',
data: {
selected: true,
},
}),
] as typeof rfState.nodes
renderWorkflowComponent(
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
{
initialStoreState: {
showWorkflowVersionHistoryPanel: false,
},
},
)
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('node-panel')).toHaveTextContent('selected-node')
})
})
})

View File

@ -0,0 +1,301 @@
import type { Shape as HooksStoreShape } from '../../hooks-store/store'
import type { RunFile } from '../../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransferMethod } from '@/types/app'
import { FlowType } from '@/types/common'
import { createStartNode } from '../../__tests__/fixtures'
import { renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env'
import { InputVarType, WorkflowRunningStatus } from '../../types'
import InputsPanel from '../inputs-panel'
const mockCheckInputsForm = vi.fn()
const mockNotify = vi.fn()
vi.mock('next/navigation', () => ({
useParams: () => ({}),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
close: vi.fn(),
}),
}))
vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({
useCheckInputsForms: () => ({
checkInputsForm: mockCheckInputsForm,
}),
}))
const fileSettingsWithImage = {
enabled: true,
image: {
enabled: true,
},
allowed_file_upload_methods: [TransferMethod.remote_url],
number_limits: 3,
image_file_size_limit: 10,
} satisfies FileUpload & { image_file_size_limit: number }
const uploadedRunFile = {
transfer_method: TransferMethod.remote_url,
upload_file_id: 'file-2',
} as unknown as RunFile
const uploadingRunFile = {
transfer_method: TransferMethod.local_file,
} as unknown as RunFile
const createHooksStoreProps = (
overrides: Partial<HooksStoreShape> = {},
): Partial<HooksStoreShape> => ({
handleRun: vi.fn(),
configsMap: {
flowId: 'flow-1',
flowType: FlowType.appFlow,
fileSettings: fileSettingsWithImage,
},
...overrides,
})
const renderInputsPanel = (
startNode: ReturnType<typeof createStartNode>,
options?: Omit<Parameters<typeof renderWorkflowFlowComponent>[1], 'nodes' | 'edges'>,
onRun = vi.fn(),
) =>
renderWorkflowFlowComponent(
<InputsPanel onRun={onRun} />,
{
nodes: [startNode],
edges: [],
...options,
},
)
describe('InputsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckInputsForm.mockReturnValue(true)
})
describe('Rendering', () => {
it('should render current inputs, defaults, and the image uploader from the start node', () => {
renderInputsPanel(
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
default: 'default question',
},
{
type: InputVarType.number,
variable: 'count',
label: 'Count',
required: false,
default: '2',
},
],
},
}),
{
initialStoreState: {
inputs: {
question: 'overridden question',
},
},
hooksStoreProps: createHooksStoreProps(),
},
)
expect(screen.getByDisplayValue('overridden question')).toHaveFocus()
expect(screen.getByRole('spinbutton')).toHaveValue(2)
expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should update workflow inputs and image files when users edit the form', async () => {
const user = userEvent.setup()
const { store } = renderInputsPanel(
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
},
],
},
}),
{
hooksStoreProps: createHooksStoreProps(),
},
)
await user.type(screen.getByPlaceholderText('Question'), 'changed question')
expect(store.getState().inputs).toEqual({ question: 'changed question' })
await user.click(screen.getByText('common.imageUploader.pasteImageLink'))
await user.type(
await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'),
'https://example.com/image.png',
)
await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
await waitFor(() => {
expect(store.getState().files).toEqual([{
type: 'image',
transfer_method: TransferMethod.remote_url,
url: 'https://example.com/image.png',
upload_file_id: '',
}])
})
})
it('should not start a run when input validation fails', async () => {
const user = userEvent.setup()
mockCheckInputsForm.mockReturnValue(false)
const onRun = vi.fn()
const handleRun = vi.fn()
renderInputsPanel(
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
default: 'default question',
},
],
},
}),
{
hooksStoreProps: createHooksStoreProps({ handleRun }),
},
onRun,
)
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
expect(mockCheckInputsForm).toHaveBeenCalledWith(
{ question: 'default question' },
expect.arrayContaining([
expect.objectContaining({ variable: 'question' }),
expect.objectContaining({ variable: '__image' }),
]),
)
expect(onRun).not.toHaveBeenCalled()
expect(handleRun).not.toHaveBeenCalled()
})
it('should start a run with processed inputs when validation succeeds', async () => {
const user = userEvent.setup()
const onRun = vi.fn()
const handleRun = vi.fn()
renderInputsPanel(
createStartNode({
data: {
variables: [
{
type: InputVarType.textInput,
variable: 'question',
label: 'Question',
required: true,
},
{
type: InputVarType.checkbox,
variable: 'confirmed',
label: 'Confirmed',
required: false,
},
],
},
}),
{
initialStoreState: {
inputs: {
question: 'run this',
confirmed: 'truthy',
},
files: [uploadedRunFile],
},
hooksStoreProps: createHooksStoreProps({
handleRun,
configsMap: {
flowId: 'flow-1',
flowType: FlowType.appFlow,
fileSettings: {
enabled: false,
},
},
}),
},
onRun,
)
await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
expect(onRun).toHaveBeenCalledTimes(1)
expect(handleRun).toHaveBeenCalledWith({
inputs: {
question: 'run this',
confirmed: true,
},
files: [uploadedRunFile],
})
})
})
describe('Disabled States', () => {
it('should disable the run button while a local file is still uploading', () => {
renderInputsPanel(createStartNode(), {
initialStoreState: {
files: [uploadingRunFile],
},
hooksStoreProps: createHooksStoreProps({
configsMap: {
flowId: 'flow-1',
flowType: FlowType.appFlow,
fileSettings: {
enabled: false,
},
},
}),
})
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
})
it('should disable the run button while the workflow is already running', () => {
renderInputsPanel(createStartNode(), {
initialStoreState: {
workflowRunningData: {
result: {
status: WorkflowRunningStatus.Running,
inputs_truncated: false,
process_data_truncated: false,
outputs_truncated: false,
},
tracing: [],
},
},
hooksStoreProps: createHooksStoreProps(),
})
expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
})
})
})

View File

@ -0,0 +1,163 @@
import type { WorkflowRunDetailResponse } from '@/models/log'
import { act, screen } from '@testing-library/react'
import { createEdge, createNode } from '../../__tests__/fixtures'
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
import Record from '../record'
const mockHandleUpdateWorkflowCanvas = vi.fn()
const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => finishedAt ? ' (Finished)' : ' (Running)')
let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowUpdate: () => ({
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
}),
}))
vi.mock('@/app/components/workflow/run', () => ({
default: ({
runDetailUrl,
tracingListUrl,
getResultCallback,
}: {
runDetailUrl: string
tracingListUrl: string
getResultCallback: (res: WorkflowRunDetailResponse) => void
}) => {
latestGetResultCallback = getResultCallback
return (
<div
data-run-detail-url={runDetailUrl}
data-testid="run"
data-tracing-list-url={tracingListUrl}
/>
)
},
}))
vi.mock('@/app/components/workflow/utils', () => ({
formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
}))
const createRunDetail = (overrides: Partial<WorkflowRunDetailResponse> = {}): WorkflowRunDetailResponse => ({
id: 'run-1',
version: '1',
graph: {
nodes: [],
edges: [],
},
inputs: '{}',
inputs_truncated: false,
status: 'succeeded',
outputs: '{}',
outputs_truncated: false,
total_steps: 1,
created_by_role: 'account',
created_at: 1,
finished_at: 2,
...overrides,
})
describe('Record', () => {
beforeEach(() => {
vi.clearAllMocks()
latestGetResultCallback = undefined
})
it('renders the run title and passes run and trace URLs to the run panel', () => {
const getWorkflowRunAndTraceUrl = vi.fn((runId?: string) => ({
runUrl: `/runs/${runId}`,
traceUrl: `/traces/${runId}`,
}))
renderWorkflowComponent(<Record />, {
initialStoreState: {
historyWorkflowData: {
id: 'run-1',
status: 'succeeded',
finished_at: 1700000000000,
},
},
hooksStoreProps: {
getWorkflowRunAndTraceUrl,
},
})
expect(screen.getByText('Test Run (Finished)')).toBeInTheDocument()
expect(screen.getByTestId('run')).toHaveAttribute('data-run-detail-url', '/runs/run-1')
expect(screen.getByTestId('run')).toHaveAttribute('data-tracing-list-url', '/traces/run-1')
expect(getWorkflowRunAndTraceUrl).toHaveBeenCalledTimes(2)
expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(1, 'run-1')
expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(2, 'run-1')
expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1700000000000)
})
it('updates the workflow canvas with a fallback viewport when the response omits one', () => {
const nodes = [createNode({ id: 'node-1' })]
const edges = [createEdge({ id: 'edge-1' })]
renderWorkflowComponent(<Record />, {
initialStoreState: {
historyWorkflowData: {
id: 'run-1',
status: 'succeeded',
},
},
hooksStoreProps: {
getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
},
})
expect(latestGetResultCallback).toBeDefined()
act(() => {
latestGetResultCallback?.(createRunDetail({
graph: {
nodes,
edges,
},
}))
})
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes,
edges,
viewport: { x: 0, y: 0, zoom: 1 },
})
})
it('uses the response viewport when one is available', () => {
const nodes = [createNode({ id: 'node-1' })]
const edges = [createEdge({ id: 'edge-1' })]
const viewport = { x: 12, y: 24, zoom: 0.75 }
renderWorkflowComponent(<Record />, {
initialStoreState: {
historyWorkflowData: {
id: 'run-1',
status: 'succeeded',
},
},
hooksStoreProps: {
getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
},
})
act(() => {
latestGetResultCallback?.(createRunDetail({
graph: {
nodes,
edges,
viewport,
},
}))
})
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
nodes,
edges,
viewport,
})
})
})

View File

@ -0,0 +1,135 @@
import type { ChatItemInTree } from '@/app/components/base/chat/types'
import { renderHook } from '@testing-library/react'
import { useChat } from '../hooks'
vi.mock('@/service/base', () => ({
sseGet: vi.fn(),
}))
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => vi.fn(),
}))
vi.mock('@/service/workflow', () => ({
submitHumanInputForm: vi.fn(),
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({ notify: vi.fn() }),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({ getState: () => ({}) }),
}))
vi.mock('../../../hooks', () => ({
useWorkflowRun: () => ({ handleRun: vi.fn() }),
useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: vi.fn() }),
}))
vi.mock('../../../hooks-store', () => ({
useHooksStore: () => null,
}))
vi.mock('../../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setIterTimes: vi.fn(),
setLoopTimes: vi.fn(),
inputs: {},
}),
}),
useStore: () => vi.fn(),
}))
describe('workflow debug useChat opening statement stability', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return empty chatList when config has no opening_statement', () => {
const { result } = renderHook(() => useChat({}))
expect(result.current.chatList).toEqual([])
})
it('should return empty chatList when opening_statement is an empty string', () => {
const { result } = renderHook(() => useChat({ opening_statement: '' }))
expect(result.current.chatList).toEqual([])
})
it('should use stable id "opening-statement" instead of Date.now()', () => {
const config = { opening_statement: 'Welcome!' }
const { result } = renderHook(() => useChat(config))
expect(result.current.chatList[0].id).toBe('opening-statement')
})
it('should preserve reference when inputs change but produce identical content', () => {
const config = {
opening_statement: 'Hello {{name}}',
suggested_questions: ['Ask {{name}}'],
}
const formSettings = { inputs: { name: 'Alice' }, inputsForm: [] }
const { result, rerender } = renderHook(
({ fs }) => useChat(config, fs),
{ initialProps: { fs: formSettings } },
)
const openerBefore = result.current.chatList[0]
expect(openerBefore.content).toBe('Hello Alice')
rerender({ fs: { inputs: { name: 'Alice' }, inputsForm: [] } })
const openerAfter = result.current.chatList[0]
expect(openerAfter).toBe(openerBefore)
})
it('should create new object when content actually changes', () => {
const config = {
opening_statement: 'Hello {{name}}',
suggested_questions: [],
}
const { result, rerender } = renderHook(
({ fs }) => useChat(config, fs),
{ initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } } },
)
const openerBefore = result.current.chatList[0]
expect(openerBefore.content).toBe('Hello Alice')
rerender({ fs: { inputs: { name: 'Bob' }, inputsForm: [] } })
const openerAfter = result.current.chatList[0]
expect(openerAfter.content).toBe('Hello Bob')
expect(openerAfter).not.toBe(openerBefore)
})
it('should preserve reference for existing opening statement in prevChatTree', () => {
const config = {
opening_statement: 'Updated welcome',
suggested_questions: ['S1'],
}
const prevChatTree = [{
id: 'opening-statement',
content: 'old',
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: [],
}]
const { result, rerender } = renderHook(
({ cfg }) => useChat(cfg, undefined, prevChatTree as ChatItemInTree[]),
{ initialProps: { cfg: config } },
)
const openerBefore = result.current.chatList[0]
expect(openerBefore.content).toBe('Updated welcome')
rerender({ cfg: config })
const openerAfter = result.current.chatList[0]
expect(openerAfter).toBe(openerBefore)
})
})

View File

@ -91,31 +91,54 @@ export const useChat = (
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
}, [formSettings?.inputs, formSettings?.inputsForm])
const processedOpeningContent = config?.opening_statement
? getIntroduction(config.opening_statement)
: undefined
const processedSuggestionsKey = config?.suggested_questions
? JSON.stringify(config.suggested_questions.map((q: string) => getIntroduction(q)))
: undefined
const openingStatementItem = useMemo<ChatItemInTree | null>(() => {
if (!processedOpeningContent)
return null
return {
id: 'opening-statement',
content: processedOpeningContent,
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: processedSuggestionsKey
? JSON.parse(processedSuggestionsKey) as string[]
: undefined,
}
}, [processedOpeningContent, processedSuggestionsKey])
const threadOpener = useMemo(
() => threadMessages.find(item => item.isOpeningStatement) ?? null,
[threadMessages],
)
const mergedOpeningItem = useMemo<ChatItemInTree | null>(() => {
if (!threadOpener || !openingStatementItem)
return null
return {
...threadOpener,
content: openingStatementItem.content,
suggestedQuestions: openingStatementItem.suggestedQuestions,
}
}, [threadOpener, openingStatementItem])
/** Final chat list that will be rendered */
const chatList = useMemo(() => {
const ret = [...threadMessages]
if (config?.opening_statement) {
if (openingStatementItem) {
const index = threadMessages.findIndex(item => item.isOpeningStatement)
if (index > -1) {
ret[index] = {
...ret[index],
content: getIntroduction(config.opening_statement),
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
}
}
else {
ret.unshift({
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
})
}
if (index > -1 && mergedOpeningItem)
ret[index] = mergedOpeningItem
else if (index === -1)
ret.unshift(openingStatementItem)
}
return ret
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
}, [threadMessages, openingStatementItem, mergedOpeningItem])
useEffect(() => {
setAutoFreeze(false)

View File

@ -1,9 +1,9 @@
import type { FC } from 'react'
import type { VersionHistoryPanelProps } from '@/app/components/workflow/panel/version-history-panel'
import dynamic from 'next/dynamic'
import { memo, useCallback, useEffect, useRef } from 'react'
import { useStore as useReactflow } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import dynamic from '@/next/dynamic'
import { cn } from '@/utils/classnames'
import { Panel as NodePanel } from '../nodes'
import { useStore } from '../store'
@ -145,7 +145,7 @@ const Panel: FC<PanelProps> = ({
components?.right
}
{
showWorkflowVersionHistoryPanel && (
showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
<VersionHistoryPanel {...versionHistoryPanelProps} />
)
}

View File

@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Empty from '../empty'
describe('VersionHistory Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Empty state should show the reset action and forward user clicks.
describe('User Interactions', () => {
it('should call onResetFilter when the reset button is clicked', async () => {
const user = userEvent.setup()
const onResetFilter = vi.fn()
render(<Empty onResetFilter={onResetFilter} />)
expect(screen.getByText('workflow.versionHistory.filter.empty')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.versionHistory.filter.reset' }))
expect(onResetFilter).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,269 @@
import type { Shape } from '../../../store'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useEffect } from 'react'
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockSetCurrentVersion = vi.fn()
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-id',
version: WorkflowVersion.Draft,
graph: { nodes: [], edges: [] },
features: {
opening_statement: '',
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
text_to_speech: { enabled: false },
speech_to_text: { enabled: false },
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
file_upload: { image: { enabled: false } },
},
created_at: Date.now() / 1000,
created_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
hash: 'test-hash',
updated_at: Date.now() / 1000,
updated_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
tool_published: false,
environment_variables: [],
marked_name: '',
marked_comment: '',
...overrides,
})
let mockCurrentVersion: VersionHistory | null = null
type MockVersionStoreState = Pick<Shape, 'currentVersion' | 'setCurrentVersion' | 'setShowWorkflowVersionHistoryPanel'>
type MockRestoreConfirmModalProps = {
isOpen: boolean
versionInfo: VersionHistory
onRestore: (item: VersionHistory) => void
}
type MockVersionHistoryItemProps = {
item: VersionHistory
onClick: (item: VersionHistory) => void
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
}
vi.mock('@/context/app-context', () => ({
useSelector: () => ({ id: 'test-user-id' }),
}))
vi.mock('@/service/use-workflow', () => ({
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
useInvalidAllLastRun: () => vi.fn(),
useResetWorkflowVersionHistory: () => vi.fn(),
useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }),
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
useWorkflowVersionHistory: () => ({
data: {
pages: [
{
items: [
createVersionHistory({
id: 'draft-version-id',
version: WorkflowVersion.Draft,
}),
createVersionHistory({
id: 'published-version-id',
version: '2024-01-01T00:00:00Z',
marked_name: 'v1.0',
marked_comment: 'First release',
}),
],
},
],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
}),
}))
vi.mock('../../../hooks', () => ({
useDSL: () => ({ handleExportDSL: vi.fn() }),
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }),
useWorkflowRun: () => ({
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
}))
vi.mock('../../../hooks-store', () => ({
useHooksStore: () => ({
flowId: 'test-flow-id',
flowType: 'workflow',
}),
}))
vi.mock('../../../store', () => ({
useStore: <T,>(selector: (state: MockVersionStoreState) => T) => {
const state: MockVersionStoreState = {
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
currentVersion: mockCurrentVersion,
setCurrentVersion: mockSetCurrentVersion,
}
return selector(state)
},
useWorkflowStore: () => ({
getState: () => ({
deleteAllInspectVars: vi.fn(),
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
setCurrentVersion: mockSetCurrentVersion,
}),
setState: mockWorkflowStoreSetState,
}),
}))
vi.mock('../delete-confirm-modal', () => ({
default: () => null,
}))
vi.mock('../restore-confirm-modal', () => ({
default: (props: MockRestoreConfirmModalProps) => {
const MockRestoreConfirmModal = () => {
const { isOpen, versionInfo, onRestore } = props
if (!isOpen)
return null
return <button onClick={() => onRestore(versionInfo)}>confirm restore</button>
}
return <MockRestoreConfirmModal />
},
}))
vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
default: () => null,
}))
vi.mock('../version-history-item', () => ({
default: (props: MockVersionHistoryItemProps) => {
const MockVersionHistoryItem = () => {
const { item, onClick, handleClickMenuItem } = props
useEffect(() => {
if (item.version === WorkflowVersion.Draft)
onClick(item)
}, [item, onClick])
return (
<div>
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button>
{item.version !== WorkflowVersion.Draft && (
<button onClick={() => handleClickMenuItem(VersionHistoryContextMenuOptions.restore)}>
{`restore-${item.id}`}
</button>
)}
</div>
)
}
return <MockVersionHistoryItem />
},
}))
describe('VersionHistoryPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentVersion = null
})
describe('Version Click Behavior', () => {
it('should call handleLoadBackupDraft when draft version is selected on mount', async () => {
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
expect(mockHandleLoadBackupDraft).toHaveBeenCalled()
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
})
it('should call handleRestoreFromPublishedWorkflow when clicking published version', async () => {
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('v1.0'))
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled()
expect(mockHandleLoadBackupDraft).not.toHaveBeenCalled()
})
})
it('should set current version before confirming restore from context menu', async () => {
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('restore-published-version-id'))
fireEvent.click(screen.getByText('confirm restore'))
await waitFor(() => {
expect(mockSetCurrentVersion).toHaveBeenCalledWith(expect.objectContaining({
id: 'published-version-id',
}))
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isRestoring: false })
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ backupDraft: undefined })
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled()
})
})
it('should keep restore mode backup state when restore request fails', async () => {
const { VersionHistoryPanel } = await import('../index')
mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed'))
mockCurrentVersion = createVersionHistory({
id: 'draft-version-id',
version: WorkflowVersion.Draft,
})
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
/>,
)
vi.clearAllMocks()
fireEvent.click(screen.getByText('restore-published-version-id'))
fireEvent.click(screen.getByText('confirm restore'))
await waitFor(() => {
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
})
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ isRestoring: false })
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ backupDraft: undefined })
expect(mockSetCurrentVersion).not.toHaveBeenCalled()
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,151 @@
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
import VersionHistoryItem from '../version-history-item'
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { pipelineId?: string }) => unknown) => selector({ pipelineId: undefined }),
}))
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'version-1',
graph: {
nodes: [],
edges: [],
viewport: undefined,
},
features: {},
created_at: 1710000000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1710000000,
updated_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
tool_published: false,
environment_variables: [],
conversation_variables: [],
rag_pipeline_variables: undefined,
version: '2024-01-01T00:00:00Z',
marked_name: 'Release 1',
marked_comment: 'Initial release',
...overrides,
})
describe('VersionHistoryItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Draft items should auto-select on mount and hide published-only metadata.
describe('Draft Behavior', () => {
it('should auto-select the draft version on mount', async () => {
const onClick = vi.fn()
render(
<VersionHistoryItem
item={createVersionHistory({
id: 'draft-version',
version: WorkflowVersion.Draft,
marked_name: '',
marked_comment: '',
})}
currentVersion={null}
latestVersionId="latest-version"
onClick={onClick}
handleClickMenuItem={vi.fn()}
isLast={false}
/>,
)
expect(screen.getByText('workflow.versionHistory.currentDraft')).toBeInTheDocument()
await waitFor(() => {
expect(onClick).toHaveBeenCalledWith(expect.objectContaining({
version: WorkflowVersion.Draft,
}))
})
expect(screen.queryByText('Initial release')).not.toBeInTheDocument()
})
})
// Published items should expose metadata and the hover context menu.
describe('Published Items', () => {
it('should open the context menu for a latest named version and forward restore', async () => {
const user = userEvent.setup()
const handleClickMenuItem = vi.fn()
const onClick = vi.fn()
render(
<VersionHistoryItem
item={createVersionHistory()}
currentVersion={null}
latestVersionId="version-1"
onClick={onClick}
handleClickMenuItem={handleClickMenuItem}
isLast={false}
/>,
)
const title = screen.getByText('Release 1')
const itemContainer = title.closest('.group')
if (!itemContainer)
throw new Error('Expected version history item container')
fireEvent.mouseEnter(itemContainer)
const triggerButton = await screen.findByRole('button')
await user.click(triggerButton)
expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument()
expect(screen.getByText('Initial release')).toBeInTheDocument()
expect(screen.getByText(/Alice$/)).toBeInTheDocument()
expect(screen.getByText('workflow.common.restore')).toBeInTheDocument()
expect(screen.getByText('workflow.versionHistory.editVersionInfo')).toBeInTheDocument()
expect(screen.getByText('app.export')).toBeInTheDocument()
expect(screen.getByText('workflow.versionHistory.copyId')).toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer')
if (!restoreItem)
throw new Error('Expected restore menu item')
fireEvent.click(restoreItem)
expect(handleClickMenuItem).toHaveBeenCalledTimes(1)
expect(handleClickMenuItem).toHaveBeenCalledWith(
VersionHistoryContextMenuOptions.restore,
VersionHistoryContextMenuOptions.restore,
)
})
it('should ignore clicks when the item is already selected', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const item = createVersionHistory()
render(
<VersionHistoryItem
item={item}
currentVersion={item}
latestVersionId="other-version"
onClick={onClick}
handleClickMenuItem={vi.fn()}
isLast
/>,
)
await user.click(screen.getByText('Release 1'))
expect(onClick).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,102 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkflowVersionFilterOptions } from '../../../../types'
import FilterItem from '../filter-item'
import FilterSwitch from '../filter-switch'
import Filter from '../index'
describe('VersionHistory Filter Components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The standalone switch should reflect state and emit checked changes.
describe('FilterSwitch', () => {
it('should render the switch label and emit toggled value', async () => {
const user = userEvent.setup()
const handleSwitch = vi.fn()
render(<FilterSwitch enabled={false} handleSwitch={handleSwitch} />)
expect(screen.getByText('workflow.versionHistory.filter.onlyShowNamedVersions')).toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
await user.click(screen.getByRole('switch'))
expect(handleSwitch).toHaveBeenCalledWith(true)
})
})
// Filter items should show the current selection and forward the option key.
describe('FilterItem', () => {
it('should call onClick with the selected filter key', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
const { container } = render(
<FilterItem
item={{
key: WorkflowVersionFilterOptions.onlyYours,
name: 'Only Yours',
}}
isSelected
onClick={onClick}
/>,
)
expect(screen.getByText('Only Yours')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
await user.click(screen.getByText('Only Yours'))
expect(onClick).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
})
})
// The composed filter popover should open, list options, and delegate actions.
describe('Filter', () => {
it('should open the menu and forward option and switch actions', async () => {
const user = userEvent.setup()
const onClickFilterItem = vi.fn()
const handleSwitch = vi.fn()
const { container } = render(
<Filter
filterValue={WorkflowVersionFilterOptions.all}
isOnlyShowNamedVersions={false}
onClickFilterItem={onClickFilterItem}
handleSwitch={handleSwitch}
/>,
)
const trigger = container.querySelector('.h-6.w-6')
if (!trigger)
throw new Error('Expected filter trigger to exist')
await user.click(trigger)
expect(screen.getByText('workflow.versionHistory.filter.all')).toBeInTheDocument()
expect(screen.getByText('workflow.versionHistory.filter.onlyYours')).toBeInTheDocument()
await user.click(screen.getByText('workflow.versionHistory.filter.onlyYours'))
expect(onClickFilterItem).toHaveBeenCalledWith(WorkflowVersionFilterOptions.onlyYours)
fireEvent.click(screen.getByRole('switch'))
expect(handleSwitch).toHaveBeenCalledWith(true)
})
it('should mark the trigger as active when a filter is applied', () => {
const { container } = render(
<Filter
filterValue={WorkflowVersionFilterOptions.onlyYours}
isOnlyShowNamedVersions={false}
onClickFilterItem={vi.fn()}
handleSwitch={vi.fn()}
/>,
)
expect(container.querySelector('.bg-state-accent-active-alt')).toBeInTheDocument()
expect(container.querySelector('.text-text-accent')).toBeInTheDocument()
})
})
})

View File

@ -8,10 +8,10 @@ import { useTranslation } from 'react-i18next'
import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal'
import Divider from '@/app/components/base/divider'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useLeaderRestore, useWorkflowRun } from '../../hooks'
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
import { useDSL, useLeaderRestore, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks'
import { useHooksStore } from '../../hooks-store'
import { useStore, useWorkflowStore } from '../../store'
import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types'
@ -28,12 +28,14 @@ const INITIAL_PAGE = 1
export type VersionHistoryPanelProps = {
getVersionListUrl?: string
deleteVersionUrl?: (versionId: string) => string
restoreVersionUrl: (versionId: string) => string
updateVersionUrl?: (versionId: string) => string
latestVersionId?: string
}
export const VersionHistoryPanel = ({
getVersionListUrl,
deleteVersionUrl,
restoreVersionUrl,
updateVersionUrl,
latestVersionId,
}: VersionHistoryPanelProps) => {
@ -47,6 +49,7 @@ export const VersionHistoryPanel = ({
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
const { requestRestore } = useLeaderRestore()
const featuresStore = useFeaturesStore()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { handleExportDSL } = useDSL()
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const currentVersion = useStore(s => s.currentVersion)
@ -120,10 +123,7 @@ export const VersionHistoryPanel = ({
break
case VersionHistoryContextMenuOptions.copyId:
copy(item.id)
Toast.notify({
type: 'success',
message: t('versionHistory.action.copyIdSuccess', { ns: 'workflow' }),
})
toast.success(t('versionHistory.action.copyIdSuccess', { ns: 'workflow' }))
break
case VersionHistoryContextMenuOptions.exportDSL:
handleExportDSL?.(false, item.id, item.features?.sandbox?.enabled === true)
@ -146,8 +146,9 @@ export const VersionHistoryPanel = ({
}, [])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
const handleRestore = useCallback((item: VersionHistory) => {
const handleRestore = useCallback(async (item: VersionHistory) => {
setShowWorkflowVersionHistoryPanel(false)
handleRestoreFromPublishedWorkflow(item)
workflowStore.setState({ isRestoring: false })
@ -173,18 +174,12 @@ export const VersionHistoryPanel = ({
conversationVariables,
}, {
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
})
toast.success(t('versionHistory.action.restoreSuccess', { ns: 'workflow' }))
deleteAllInspectVars()
invalidAllLastRun()
},
onError: () => {
Toast.notify({
type: 'error',
message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
})
toast.error(t('versionHistory.action.restoreFailure', { ns: 'workflow' }))
},
onSettled: () => {
resetWorkflowVersionHistory()
@ -198,19 +193,13 @@ export const VersionHistoryPanel = ({
await deleteWorkflow(deleteVersionUrl?.(id) || '', {
onSuccess: () => {
setDeleteConfirmOpen(false)
Toast.notify({
type: 'success',
message: t('versionHistory.action.deleteSuccess', { ns: 'workflow' }),
})
toast.success(t('versionHistory.action.deleteSuccess', { ns: 'workflow' }))
resetWorkflowVersionHistory()
deleteAllInspectVars()
invalidAllLastRun()
},
onError: () => {
Toast.notify({
type: 'error',
message: t('versionHistory.action.deleteFailure', { ns: 'workflow' }),
})
toast.error(t('versionHistory.action.deleteFailure', { ns: 'workflow' }))
},
onSettled: () => {
setDeleteConfirmOpen(false)
@ -228,17 +217,11 @@ export const VersionHistoryPanel = ({
}, {
onSuccess: () => {
setEditModalOpen(false)
Toast.notify({
type: 'success',
message: t('versionHistory.action.updateSuccess', { ns: 'workflow' }),
})
toast.success(t('versionHistory.action.updateSuccess', { ns: 'workflow' }))
resetWorkflowVersionHistory()
},
onError: () => {
Toast.notify({
type: 'error',
message: t('versionHistory.action.updateFailure', { ns: 'workflow' }),
})
toast.error(t('versionHistory.action.updateFailure', { ns: 'workflow' }))
},
onSettled: () => {
setEditModalOpen(false)

View File

@ -0,0 +1,51 @@
import { render } from '@testing-library/react'
import Loading from '../index'
import Item from '../item'
describe('VersionHistory Loading', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Individual skeleton items should hide optional rows based on edge flags.
describe('Item', () => {
it('should hide the release note placeholder for the first row', () => {
const { container } = render(
<Item
titleWidth="w-1/3"
releaseNotesWidth="w-3/4"
isFirst
isLast={false}
/>,
)
expect(container.querySelectorAll('.opacity-20')).toHaveLength(1)
expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument()
})
it('should hide the timeline connector for the last row', () => {
const { container } = render(
<Item
titleWidth="w-2/5"
releaseNotesWidth="w-4/6"
isFirst={false}
isLast
/>,
)
expect(container.querySelectorAll('.opacity-20')).toHaveLength(2)
expect(container.querySelector('.absolute.left-4.top-6')).not.toBeInTheDocument()
})
})
// The loading list should render the configured number of timeline skeleton rows.
describe('Loading List', () => {
it('should render eight loading rows with the overlay mask', () => {
const { container } = render(<Loading />)
expect(container.querySelector('.bg-dataset-chunk-list-mask-bg')).toBeInTheDocument()
expect(container.querySelectorAll('.relative.flex.gap-x-1.p-2')).toHaveLength(8)
expect(container.querySelectorAll('.opacity-20')).toHaveLength(15)
})
})
})

View File

@ -11,9 +11,9 @@ import Button from '@/app/components/base/button'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import { toast } from '@/app/components/base/ui/toast'
import { submitHumanInputForm } from '@/service/workflow'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import {
useWorkflowInteractions,
} from '../hooks'
@ -239,7 +239,7 @@ const WorkflowPreview = () => {
copy(content)
else
copy(JSON.stringify(content))
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
>
<span className="i-ri-clipboard-line h-3.5 w-3.5" />