mirror of
https://github.com/langgenius/dify.git
synced 2026-02-09 15:10:13 -05:00
test: enhance text generation and workflow callbacks tests
- Added comprehensive tests for the useTextGeneration hook, covering handleSend and handleStop functionalities, including file processing and error handling. - Improved workflow callbacks tests to handle edge cases and ensure graceful handling of missing nodes and undefined tracing. - Introduced new test cases for validating prompt variables and processing files in the text generation flow.
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import type { UseTextGenerationProps } from './use-text-generation'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
AppSourceType,
|
||||
sendCompletionMessage,
|
||||
sendWorkflowMessage,
|
||||
stopChatMessageResponding,
|
||||
stopWorkflowMessage,
|
||||
} from '@/service/share'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { sleep } from '@/utils'
|
||||
import { useTextGeneration } from './use-text-generation'
|
||||
|
||||
// Mock external services
|
||||
@@ -20,10 +29,6 @@ vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('i18next', () => ({
|
||||
t: (key: string) => key,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
sleep: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
@@ -37,6 +42,12 @@ vi.mock('@/utils/model-config', () => ({
|
||||
formatBooleanInputs: vi.fn((_vars: unknown, inputs: unknown) => inputs),
|
||||
}))
|
||||
|
||||
// Extracted parameter types for typed mock implementations
|
||||
type CompletionBody = Parameters<typeof sendCompletionMessage>[0]
|
||||
type CompletionCbs = Parameters<typeof sendCompletionMessage>[1]
|
||||
type WorkflowBody = Parameters<typeof sendWorkflowMessage>[0]
|
||||
type WorkflowCbs = Parameters<typeof sendWorkflowMessage>[1]
|
||||
|
||||
// Factory for default hook props
|
||||
function createProps(overrides: Partial<UseTextGenerationProps> = {}): UseTextGenerationProps {
|
||||
return {
|
||||
@@ -280,4 +291,614 @@ describe('useTextGeneration', () => {
|
||||
expect(onRunControlChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
// handleStop with active task
|
||||
describe('handleStop - with active task', () => {
|
||||
it('should call stopWorkflowMessage for workflow', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendWorkflowMessage).mockImplementationOnce(
|
||||
(async (_data: WorkflowBody, callbacks: WorkflowCbs) => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
|
||||
}) as unknown as typeof sendWorkflowMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(stopWorkflowMessage).toHaveBeenCalledWith('app-1', 'task-1', AppSourceType.webApp, 'app-1')
|
||||
})
|
||||
|
||||
it('should call stopChatMessageResponding for completion', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, { onData, getAbortController }: CompletionCbs) => {
|
||||
getAbortController?.(new AbortController())
|
||||
onData('chunk', true, { messageId: 'msg-1', taskId: 'task-1' })
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(stopChatMessageResponding).toHaveBeenCalledWith('app-1', 'task-1', AppSourceType.webApp, 'app-1')
|
||||
})
|
||||
|
||||
it('should handle stop API errors gracefully', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendWorkflowMessage).mockImplementationOnce(
|
||||
(async (_data: WorkflowBody, callbacks: WorkflowCbs) => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
|
||||
}) as unknown as typeof sendWorkflowMessage,
|
||||
)
|
||||
vi.mocked(stopWorkflowMessage).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', message: 'Network error' }),
|
||||
)
|
||||
expect(result.current.isStopping).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// File processing in handleSend
|
||||
describe('handleSend - file processing', () => {
|
||||
it('should process file-type and file-list prompt variables', async () => {
|
||||
const fileValue = { name: 'doc.pdf', size: 100 }
|
||||
const fileListValue = [{ name: 'a.pdf' }, { name: 'b.pdf' }]
|
||||
const props = createProps({
|
||||
promptConfig: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [
|
||||
{ key: 'doc', name: 'Document', type: 'file', required: false },
|
||||
{ key: 'docs', name: 'Documents', type: 'file-list', required: false },
|
||||
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
|
||||
},
|
||||
inputs: { doc: fileValue, docs: fileListValue } as unknown as Record<string, UseTextGenerationProps['inputs'][string]>,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(sendCompletionMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ inputs: expect.objectContaining({ doc: expect.anything(), docs: expect.anything() }) }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should include vision files when vision is enabled', async () => {
|
||||
const props = createProps({
|
||||
visionConfig: { enabled: true, number_limits: 2, detail: 'low', transfer_methods: [] } as UseTextGenerationProps['visionConfig'],
|
||||
completionFiles: [
|
||||
{ transfer_method: TransferMethod.local_file, url: 'http://local', upload_file_id: 'f1' },
|
||||
{ transfer_method: TransferMethod.remote_url, url: 'http://remote' },
|
||||
] as UseTextGenerationProps['completionFiles'],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(sendCompletionMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({ transfer_method: TransferMethod.local_file, url: '' }),
|
||||
expect.objectContaining({ transfer_method: TransferMethod.remote_url, url: 'http://remote' }),
|
||||
]),
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Validation edge cases
|
||||
describe('handleSend - validation edge cases', () => {
|
||||
it('should block when files are uploading and no prompt variables', async () => {
|
||||
const props = createProps({
|
||||
promptConfig: { prompt_template: '', prompt_variables: [] },
|
||||
completionFiles: [
|
||||
{ transfer_method: TransferMethod.local_file, url: '' },
|
||||
] as UseTextGenerationProps['completionFiles'],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'info' }),
|
||||
)
|
||||
expect(sendCompletionMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip boolean/checkbox vars in required check', async () => {
|
||||
const props = createProps({
|
||||
promptConfig: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [
|
||||
{ key: 'flag', name: 'Flag', type: 'boolean', required: true },
|
||||
{ key: 'check', name: 'Check', type: 'checkbox', required: true },
|
||||
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
|
||||
},
|
||||
inputs: {},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
// Should pass validation - boolean/checkbox are skipped
|
||||
expect(sendCompletionMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop checking after first empty required var', async () => {
|
||||
const props = createProps({
|
||||
promptConfig: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [
|
||||
{ key: 'first', name: 'First', type: 'string', required: true },
|
||||
{ key: 'second', name: 'Second', type: 'string', required: true },
|
||||
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
|
||||
},
|
||||
inputs: { second: 'value' },
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
// Error should mention 'First', not 'Second'
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should block when files uploading after vars pass', async () => {
|
||||
const props = createProps({
|
||||
promptConfig: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [
|
||||
{ key: 'name', name: 'Name', type: 'string', required: true },
|
||||
] as UseTextGenerationProps['promptConfig'] extends infer T ? T extends { prompt_variables: infer V } ? V : never : never,
|
||||
},
|
||||
inputs: { name: 'Alice' },
|
||||
completionFiles: [
|
||||
{ transfer_method: TransferMethod.local_file, url: '' },
|
||||
] as UseTextGenerationProps['completionFiles'],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'info' }),
|
||||
)
|
||||
expect(sendCompletionMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// sendCompletionMessage callbacks
|
||||
describe('sendCompletionMessage callbacks', () => {
|
||||
it('should accumulate text and track task/message via onData', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, { onData }: CompletionCbs) => {
|
||||
onData('Hello ', true, { messageId: 'msg-1', taskId: 'task-1' })
|
||||
onData('World', false, { messageId: 'msg-1', taskId: 'task-1' })
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(result.current.completionRes).toBe('Hello World')
|
||||
expect(result.current.currentTaskId).toBe('task-1')
|
||||
})
|
||||
|
||||
it('should finalize state via onCompleted', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
const onCompleted = vi.fn()
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, callbacks: CompletionCbs) => {
|
||||
callbacks.onData('result', true, { messageId: 'msg-1', taskId: 'task-1' })
|
||||
callbacks.onCompleted()
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps({ onCompleted })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
expect(result.current.messageId).toBe('msg-1')
|
||||
expect(onCompleted).toHaveBeenCalledWith('result', undefined, true)
|
||||
})
|
||||
|
||||
it('should replace text via onMessageReplace', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, { onData, onMessageReplace }: CompletionCbs) => {
|
||||
onData('old text', true, { messageId: 'msg-1', taskId: 'task-1' })
|
||||
onMessageReplace!({ answer: 'replaced text' } as never)
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(result.current.completionRes).toBe('replaced text')
|
||||
})
|
||||
|
||||
it('should handle error via onError', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
const onCompleted = vi.fn()
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, { onError }: CompletionCbs) => {
|
||||
onError('test error')
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps({ onCompleted })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
expect(onCompleted).toHaveBeenCalledWith('', undefined, false)
|
||||
})
|
||||
|
||||
it('should store abort controller via getAbortController', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
const abortController = new AbortController()
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, { getAbortController }: CompletionCbs) => {
|
||||
getAbortController?.(abortController)
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
// Verify abort controller is stored by triggering stop
|
||||
expect(result.current.isResponding).toBe(true)
|
||||
})
|
||||
|
||||
it('should show timeout warning when onCompleted fires after timeout', async () => {
|
||||
// Default sleep mock resolves immediately, so timeout fires
|
||||
let capturedCallbacks: CompletionCbs | null = null
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, callbacks: CompletionCbs) => {
|
||||
capturedCallbacks = callbacks
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
// Timeout has fired (sleep resolved immediately, isEndRef still false)
|
||||
await act(async () => {
|
||||
capturedCallbacks!.onCompleted()
|
||||
})
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'warning' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show timeout warning when onError fires after timeout', async () => {
|
||||
let capturedCallbacks: CompletionCbs | null = null
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, callbacks: CompletionCbs) => {
|
||||
capturedCallbacks = callbacks
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
capturedCallbacks!.onError('test error')
|
||||
})
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'warning' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// sendWorkflowMessage error handling
|
||||
describe('sendWorkflowMessage error', () => {
|
||||
it('should handle workflow API rejection', async () => {
|
||||
vi.mocked(sendWorkflowMessage).mockRejectedValueOnce(new Error('API error'))
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
// Wait for the catch handler to process
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', message: 'API error' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// controlStopResponding effect
|
||||
describe('effects - controlStopResponding', () => {
|
||||
it('should abort and reset state when controlStopResponding changes', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, { onData, getAbortController }: CompletionCbs) => {
|
||||
getAbortController?.(new AbortController())
|
||||
onData('chunk', true, { messageId: 'msg-1', taskId: 'task-1' })
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: UseTextGenerationProps) => useTextGeneration(props),
|
||||
{ initialProps: createProps({ controlStopResponding: 0 }) },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
expect(result.current.isResponding).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
rerender(createProps({ controlStopResponding: Date.now() }))
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// onRunControlChange with active task
|
||||
describe('effects - onRunControlChange with active task', () => {
|
||||
it('should provide control object when responding with active task', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendWorkflowMessage).mockImplementationOnce(
|
||||
(async (_data: WorkflowBody, callbacks: WorkflowCbs) => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
|
||||
}) as unknown as typeof sendWorkflowMessage,
|
||||
)
|
||||
|
||||
const onRunControlChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useTextGeneration(createProps({ isWorkflow: true, onRunControlChange })),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(onRunControlChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ onStop: expect.any(Function), isStopping: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: handleStop when already stopping
|
||||
describe('handleStop - branch coverage', () => {
|
||||
it('should do nothing when already stopping', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendWorkflowMessage).mockImplementationOnce(
|
||||
(async (_data: WorkflowBody, callbacks: WorkflowCbs) => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'run-1', task_id: 'task-1' } as never)
|
||||
}) as unknown as typeof sendWorkflowMessage,
|
||||
)
|
||||
// Make stopWorkflowMessage hang to keep isStopping=true
|
||||
vi.mocked(stopWorkflowMessage).mockReturnValueOnce(new Promise(() => {}))
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
// First stop sets isStopping=true
|
||||
act(() => {
|
||||
result.current.handleStop()
|
||||
})
|
||||
expect(result.current.isStopping).toBe(true)
|
||||
|
||||
// Second stop should be a no-op
|
||||
await act(async () => {
|
||||
await result.current.handleStop()
|
||||
})
|
||||
|
||||
expect(stopWorkflowMessage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: onData with falsy/empty taskId
|
||||
describe('sendCompletionMessage callbacks - branch coverage', () => {
|
||||
it('should not set taskId when taskId is empty', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, { onData }: CompletionCbs) => {
|
||||
onData('chunk', true, { messageId: 'msg-1', taskId: '' })
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(result.current.currentTaskId).toBeNull()
|
||||
})
|
||||
|
||||
it('should not override taskId when already set', async () => {
|
||||
vi.mocked(sleep).mockReturnValueOnce(new Promise(() => {}))
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, { onData }: CompletionCbs) => {
|
||||
onData('a', true, { messageId: 'msg-1', taskId: 'first-task' })
|
||||
onData('b', false, { messageId: 'msg-1', taskId: 'second-task' })
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps()))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
// Should keep 'first-task', not override with 'second-task'
|
||||
expect(result.current.currentTaskId).toBe('first-task')
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: promptConfig null
|
||||
describe('handleSend - promptConfig null', () => {
|
||||
it('should handle null promptConfig gracefully', async () => {
|
||||
const props = createProps({ promptConfig: null })
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(sendCompletionMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: onCompleted before timeout (isEndRef=true skips timeout)
|
||||
describe('sendCompletionMessage - timeout skip branch', () => {
|
||||
it('should skip timeout when onCompleted fires before timeout resolves', async () => {
|
||||
// Use default sleep mock (resolves immediately) - NOT overriding to never-resolve
|
||||
const onCompleted = vi.fn()
|
||||
vi.mocked(sendCompletionMessage).mockImplementationOnce(
|
||||
(async (_data: CompletionBody, callbacks: CompletionCbs) => {
|
||||
callbacks.onData('res', true, { messageId: 'msg-1', taskId: 'task-1' })
|
||||
callbacks.onCompleted()
|
||||
// isEndRef.current = true now, so timeout IIFE will skip
|
||||
}) as unknown as typeof sendCompletionMessage,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps({ onCompleted })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
// onCompleted should be called once (from callback), not twice (timeout skipped)
|
||||
expect(onCompleted).toHaveBeenCalledTimes(1)
|
||||
expect(onCompleted).toHaveBeenCalledWith('res', undefined, true)
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: workflow error with non-Error object
|
||||
describe('sendWorkflowMessage - non-Error rejection', () => {
|
||||
it('should handle non-Error rejection via String()', async () => {
|
||||
vi.mocked(sendWorkflowMessage).mockRejectedValueOnce('string error')
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(createProps({ isWorkflow: true })))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error', message: 'string error' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: hasUploadingFiles false branch
|
||||
describe('handleSend - file upload branch', () => {
|
||||
it('should proceed when files have upload_file_id (not uploading)', async () => {
|
||||
const props = createProps({
|
||||
completionFiles: [
|
||||
{ transfer_method: TransferMethod.local_file, url: 'http://file', upload_file_id: 'f1' },
|
||||
] as UseTextGenerationProps['completionFiles'],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(sendCompletionMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should proceed when files use remote_url transfer method', async () => {
|
||||
const props = createProps({
|
||||
completionFiles: [
|
||||
{ transfer_method: TransferMethod.remote_url, url: 'http://remote' },
|
||||
] as UseTextGenerationProps['completionFiles'],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTextGeneration(props))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSend()
|
||||
})
|
||||
|
||||
expect(sendCompletionMessage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -292,4 +292,306 @@ describe('createWorkflowCallbacks', () => {
|
||||
expect(produced.resultText).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
// handleGroupNext with valid node_id (covers findTrace)
|
||||
describe('handleGroupNext', () => {
|
||||
it('should push empty details to matching group when node_id exists', () => {
|
||||
const existingTrace = createTrace({
|
||||
node_id: 'group-node',
|
||||
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
|
||||
details: [[]],
|
||||
} as Partial<NodeTracing>)
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [existingTrace] })),
|
||||
requestData: { inputs: {}, node_id: 'group-node', execution_metadata: { parallel_id: 'p1' } },
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onIterationNext()
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0].details).toHaveLength(2)
|
||||
expect(produced.expand).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle no matching group gracefully', () => {
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [] })),
|
||||
requestData: { inputs: {}, node_id: 'nonexistent' },
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
// Should not throw even when no matching trace is found
|
||||
cb.onLoopNext()
|
||||
|
||||
expect(deps.setProcessData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// markNodesStopped edge cases
|
||||
describe('markNodesStopped', () => {
|
||||
it('should handle undefined tracing gracefully', () => {
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => ({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
expand: false,
|
||||
resultText: '',
|
||||
} as unknown as WorkflowProcess)),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onWorkflowFinished({
|
||||
data: { status: WorkflowRunningStatus.Stopped },
|
||||
} as never)
|
||||
|
||||
expect(deps.setProcessData).toHaveBeenCalled()
|
||||
expect(deps.onCompleted).toHaveBeenCalledWith('', undefined, false)
|
||||
})
|
||||
|
||||
it('should recursively mark running/waiting nodes and nested structures as stopped', () => {
|
||||
const nestedTrace = createTrace({ node_id: 'nested', status: NodeRunningStatus.Running })
|
||||
const retryTrace = createTrace({ node_id: 'retry', status: NodeRunningStatus.Waiting })
|
||||
const parallelChild = createTrace({ node_id: 'p-child', status: NodeRunningStatus.Running })
|
||||
const parentTrace = createTrace({
|
||||
node_id: 'parent',
|
||||
status: NodeRunningStatus.Running,
|
||||
details: [[nestedTrace]],
|
||||
retryDetail: [retryTrace],
|
||||
parallelDetail: { children: [parallelChild] },
|
||||
} as Partial<NodeTracing>)
|
||||
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [parentTrace] })),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onWorkflowFinished({
|
||||
data: { status: WorkflowRunningStatus.Stopped },
|
||||
} as never)
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Stopped)
|
||||
expect(produced.tracing[0].details![0][0].status).toBe(NodeRunningStatus.Stopped)
|
||||
expect(produced.tracing[0].retryDetail![0].status).toBe(NodeRunningStatus.Stopped)
|
||||
const parallel = produced.tracing[0].parallelDetail as { children: NodeTracing[] }
|
||||
expect(parallel.children[0].status).toBe(NodeRunningStatus.Stopped)
|
||||
})
|
||||
|
||||
it('should not change status of already succeeded nodes', () => {
|
||||
const succeededTrace = createTrace({
|
||||
node_id: 'done',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
})
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [succeededTrace] })),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onWorkflowFinished({
|
||||
data: { status: WorkflowRunningStatus.Stopped },
|
||||
} as never)
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
|
||||
it('should handle trace with no nested details/retryDetail/parallelDetail', () => {
|
||||
const simpleTrace = createTrace({ node_id: 'simple', status: NodeRunningStatus.Running })
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [simpleTrace] })),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onWorkflowFinished({
|
||||
data: { status: WorkflowRunningStatus.Stopped },
|
||||
} as never)
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Stopped)
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: handleGroupNext early return
|
||||
describe('handleGroupNext - early return', () => {
|
||||
it('should return early when requestData has no node_id', () => {
|
||||
const deps = createMockDeps({
|
||||
requestData: { inputs: {} }, // no node_id
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onIterationNext()
|
||||
|
||||
expect(deps.setProcessData).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: onNodeFinished edge cases
|
||||
describe('onNodeFinished - branch coverage', () => {
|
||||
it('should preserve existing extras when updating trace', () => {
|
||||
const trace = createTrace({
|
||||
node_id: 'n1',
|
||||
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
|
||||
extras: { key: 'val' },
|
||||
} as Partial<NodeTracing>)
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onNodeFinished({
|
||||
data: createTrace({
|
||||
node_id: 'n1',
|
||||
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
|
||||
status: NodeRunningStatus.Succeeded as NodeTracing['status'],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0].extras).toEqual({ key: 'val' })
|
||||
})
|
||||
|
||||
it('should not add extras when existing trace has no extras', () => {
|
||||
const trace = createTrace({
|
||||
node_id: 'n1',
|
||||
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
|
||||
})
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onNodeFinished({
|
||||
data: createTrace({
|
||||
node_id: 'n1',
|
||||
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0]).not.toHaveProperty('extras')
|
||||
})
|
||||
|
||||
it('should do nothing when trace is not found (idx === -1)', () => {
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [] })),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onNodeFinished({
|
||||
data: createTrace({ node_id: 'nonexistent' }),
|
||||
} as never)
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: handleGroupFinish without error
|
||||
describe('handleGroupFinish - branch coverage', () => {
|
||||
it('should set expand=false when no error', () => {
|
||||
const existing = createTrace({
|
||||
node_id: 'n1',
|
||||
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
|
||||
})
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [existing] })),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onLoopFinish({
|
||||
data: createTrace({
|
||||
node_id: 'n1',
|
||||
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0].expand).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: handleWorkflowEnd without error
|
||||
describe('handleWorkflowEnd - branch coverage', () => {
|
||||
it('should not notify when no error message', () => {
|
||||
const deps = createMockDeps()
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onWorkflowFinished({
|
||||
data: { status: WorkflowRunningStatus.Stopped },
|
||||
} as never)
|
||||
|
||||
expect(deps.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: findTraceIndex matching via parallel_id vs execution_metadata
|
||||
describe('findTrace matching', () => {
|
||||
it('should match trace via parallel_id field', () => {
|
||||
const trace = createTrace({
|
||||
node_id: 'n1',
|
||||
parallel_id: 'p1',
|
||||
} as Partial<NodeTracing>)
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onNodeFinished({
|
||||
data: createTrace({
|
||||
node_id: 'n1',
|
||||
execution_metadata: { parallel_id: 'p1' } as NodeTracing['execution_metadata'],
|
||||
status: NodeRunningStatus.Succeeded as NodeTracing['status'],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0].status).toBe(NodeRunningStatus.Succeeded)
|
||||
})
|
||||
|
||||
it('should not match when both parallel_id fields differ', () => {
|
||||
const trace = createTrace({
|
||||
node_id: 'group-node',
|
||||
execution_metadata: { parallel_id: 'other' } as NodeTracing['execution_metadata'],
|
||||
parallel_id: 'also-other',
|
||||
details: [[]],
|
||||
} as Partial<NodeTracing>)
|
||||
const deps = createMockDeps({
|
||||
getProcessData: vi.fn(() => createProcess({ tracing: [trace] })),
|
||||
requestData: { inputs: {}, node_id: 'group-node', execution_metadata: { parallel_id: 'target' } },
|
||||
})
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onIterationNext()
|
||||
|
||||
// group not found, details unchanged
|
||||
const produced = (deps.setProcessData as ReturnType<typeof vi.fn>).mock.calls[0][0] as WorkflowProcess
|
||||
expect(produced.tracing[0].details).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Branch coverage: onWorkflowFinished success with multiple output keys
|
||||
describe('onWorkflowFinished - output branches', () => {
|
||||
it('should not set resultText when outputs have multiple keys', () => {
|
||||
const deps = createMockDeps()
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onWorkflowFinished({
|
||||
data: { status: 'succeeded', outputs: { key1: 'val1', key2: 'val2' } },
|
||||
} as never)
|
||||
|
||||
// setProcessData called once (for succeeded status), not twice (no resultText)
|
||||
expect(deps.setProcessData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not set resultText when single key is not a string', () => {
|
||||
const deps = createMockDeps()
|
||||
const cb = createWorkflowCallbacks(deps)
|
||||
|
||||
cb.onWorkflowFinished({
|
||||
data: { status: 'succeeded', outputs: { data: { nested: true } } },
|
||||
} as never)
|
||||
|
||||
expect(deps.setProcessData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user