diff --git a/web/app/components/share/text-generation/result/hooks/use-text-generation.spec.ts b/web/app/components/share/text-generation/result/hooks/use-text-generation.spec.ts index 51a62e9984..b85b2e1d8e 100644 --- a/web/app/components/share/text-generation/result/hooks/use-text-generation.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/use-text-generation.spec.ts @@ -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[0] +type CompletionCbs = Parameters[1] +type WorkflowBody = Parameters[0] +type WorkflowCbs = Parameters[1] + // Factory for default hook props function createProps(overrides: Partial = {}): 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, + }) + + 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() + }) + }) }) diff --git a/web/app/components/share/text-generation/result/hooks/workflow-callbacks.spec.ts b/web/app/components/share/text-generation/result/hooks/workflow-callbacks.spec.ts index a54389a26f..864f8b2fdf 100644 --- a/web/app/components/share/text-generation/result/hooks/workflow-callbacks.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/workflow-callbacks.spec.ts @@ -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) + 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).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) + + 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).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).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).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) + 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).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).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).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).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) + 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).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) + 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).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) + }) + }) })