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:
CodingOnStar
2026-02-06 18:03:41 +08:00
parent a985eb8725
commit 48cba768b1
2 changed files with 928 additions and 5 deletions

View File

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

View File

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