test(web): verify canvas updates and debounced variable edits (#44361)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-19 15:16:30 +00:00
1 parent 823f40e71f
commit ac1791279d
4 files changed
+500 -629

No files matched your search

@@ -1,3 +1,6 @@
import type { ReasoningConfigValue } from '../reasoning-config-form'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { Var } from '@/app/components/workflow/types'
import { describe, expect, it } from 'vite-plus/test'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
@@ -17,156 +20,184 @@ import {
updateVariableTypeValue,
} from '../reasoning-config-form.helpers'
const createSchema = (overrides: Partial<ToolFormSchema> = {}): ToolFormSchema => ({
name: 'field',
variable: 'field',
label: { en_US: 'Field', zh_Hans: '字段' },
type: FormTypeEnum.textInput,
_type: 'string',
form: 'llm',
required: false,
default: '',
tooltip: { en_US: 'Tooltip', zh_Hans: '提示' },
show_on: [],
options: [],
placeholder: { en_US: 'Placeholder', zh_Hans: '占位符' },
scope: 'all',
url: '',
input_schema: undefined,
...overrides,
})
const createVar = (type: Var['type']): Var => ({ variable: 'field', type })
describe('reasoning-config-form helpers', () => {
it('maps schema types to variable-kind types and target variable types', () => {
expect(getVarKindType(FormTypeEnum.files)).toBe(VarKindType.variable)
expect(getVarKindType(FormTypeEnum.textNumber)).toBe(VarKindType.constant)
expect(getVarKindType(FormTypeEnum.textInput)).toBe(VarKindType.mixed)
expect(getVarKindType(FormTypeEnum.date)).toBe(VarKindType.constant)
expect(getVarKindType(FormTypeEnum.dateRange)).toBe(VarKindType.constant)
expect(getVarKindType(FormTypeEnum.dynamicSelect)).toBeUndefined()
expect(resolveTargetVarType(FormTypeEnum.textInput)).toBe(VarType.string)
expect(resolveTargetVarType(FormTypeEnum.textNumber)).toBe(VarType.number)
expect(resolveTargetVarType(FormTypeEnum.date)).toBe(VarType.string)
expect(resolveTargetVarType(FormTypeEnum.dateRange)).toBe(VarType.string)
expect(resolveTargetVarType(FormTypeEnum.files)).toBe(VarType.arrayFile)
expect(resolveTargetVarType(FormTypeEnum.file)).toBe(VarType.file)
expect(resolveTargetVarType(FormTypeEnum.checkbox)).toBe(VarType.boolean)
expect(resolveTargetVarType(FormTypeEnum.object)).toBe(VarType.object)
expect(resolveTargetVarType(FormTypeEnum.array)).toBe(VarType.arrayObject)
it.each([
{
type: FormTypeEnum.files,
kind: VarKindType.variable,
target: VarType.arrayFile,
},
{
type: FormTypeEnum.textNumber,
kind: VarKindType.constant,
target: VarType.number,
},
{
type: FormTypeEnum.textInput,
kind: VarKindType.mixed,
target: VarType.string,
},
{
type: FormTypeEnum.date,
kind: VarKindType.constant,
target: VarType.string,
},
{
type: FormTypeEnum.dateRange,
kind: VarKindType.constant,
target: VarType.string,
},
{
type: FormTypeEnum.dynamicSelect,
kind: undefined,
target: VarType.string,
},
])('maps $type fields to their input and target variable contracts', ({ type, kind, target }) => {
expect(getVarKindType(type)).toBe(kind)
expect(resolveTargetVarType(type)).toBe(target)
})
it('creates variable filters for supported field types', () => {
const numberFilter = createFilterVar(FormTypeEnum.textNumber)
const stringFilter = createFilterVar(FormTypeEnum.textInput)
const fileFilter = createFilterVar(FormTypeEnum.files)
expect(numberFilter?.({ type: VarType.number } as never)).toBe(true)
expect(numberFilter?.({ type: VarType.string } as never)).toBe(false)
expect(stringFilter?.({ type: VarType.secret } as never)).toBe(true)
expect(fileFilter?.({ type: VarType.arrayFile } as never)).toBe(true)
it.each([
[FormTypeEnum.file, VarType.file],
[FormTypeEnum.checkbox, VarType.boolean],
[FormTypeEnum.object, VarType.object],
[FormTypeEnum.array, VarType.arrayObject],
])('resolves %s fields to %s variables', (type, target) => {
expect(resolveTargetVarType(type)).toBe(target)
})
it('creates variable filters for date fields', () => {
it('filters variables according to the field type', () => {
const numberFilter = createFilterVar(FormTypeEnum.textNumber)!
const stringFilter = createFilterVar(FormTypeEnum.textInput)!
const fileFilter = createFilterVar(FormTypeEnum.files)!
expect(numberFilter(createVar(VarType.number))).toBe(true)
expect(numberFilter(createVar(VarType.string))).toBe(false)
expect(stringFilter(createVar(VarType.secret))).toBe(true)
expect(fileFilter(createVar(VarType.arrayFile))).toBe(true)
expect(fileFilter(createVar(VarType.string))).toBe(false)
})
it('applies date-specific picker filtering without filtering date ranges', () => {
const dateFilter = createPickerProps({
type: FormTypeEnum.date,
value: {},
language: 'en_US',
schema: { type: FormTypeEnum.date } as never,
}).filterVar
expect(dateFilter?.({ type: VarType.string } as never)).toBe(true)
expect(dateFilter?.({ type: VarType.number } as never)).toBe(true)
expect(dateFilter?.({ type: VarType.file } as never)).toBe(false)
schema: createSchema({ type: FormTypeEnum.date }),
}).filterVar!
expect(dateFilter(createVar(VarType.string))).toBe(true)
expect(dateFilter(createVar(VarType.number))).toBe(true)
expect(dateFilter(createVar(VarType.file))).toBe(false)
expect(
createPickerProps({
type: FormTypeEnum.dateRange,
value: {},
language: 'en_US',
schema: { type: FormTypeEnum.dateRange } as never,
schema: createSchema({ type: FormTypeEnum.dateRange }),
}).filterVar,
).toBeUndefined()
})
it('filters select options based on show_on conditions', () => {
const options = [
it('requires every show_on condition and falls back to the English option label', () => {
const options: NonNullable<ToolFormSchema['options']> = [
{
value: 'one',
label: { en_US: 'One', zh_Hans: 'One' },
value: 'always',
label: { en_US: 'Always', zh_Hans: '始终' },
show_on: [],
},
{
value: 'two',
label: { en_US: 'Two', zh_Hans: 'Two' },
show_on: [{ variable: 'mode', value: 'advanced' }],
value: 'qualified',
label: { en_US: 'Qualified', zh_Hans: '符合条件' },
show_on: [
{ variable: 'mode', value: 'advanced' },
{ variable: 'tier', value: 'pro' },
],
},
]
expect(
getVisibleSelectOptions(
options as never,
{
mode: { value: { value: 'advanced' } },
},
'en_US',
),
).toEqual([
{ value: 'one', name: 'One' },
{ value: 'two', name: 'Two' },
])
expect(
getVisibleSelectOptions(
options as never,
{
mode: { value: { value: 'basic' } },
},
'en_US',
),
).toEqual([{ value: 'one', name: 'One' }])
})
it('updates reasoning values for auto, constant, variable, and merged states', () => {
const value = {
prompt: {
value: {
type: VarKindType.constant,
value: 'hello',
},
auto: 0 as const,
},
const value: ReasoningConfigValue = {
mode: { value: { value: 'advanced' } },
tier: { value: { value: 'pro' } },
}
expect(updateInputAutoState(value, 'prompt', true, FormTypeEnum.textInput)).toEqual({
prompt: {
value: null,
auto: 1,
},
})
expect(updateVariableTypeValue(value, 'prompt', VarKindType.variable, '')).toEqual({
prompt: {
value: {
type: VarKindType.variable,
value: '',
},
auto: 0,
},
})
expect(updateReasoningValue(value, 'prompt', FormTypeEnum.textInput, 'updated')).toEqual({
prompt: {
value: {
type: VarKindType.mixed,
value: 'updated',
},
auto: 0,
},
})
expect(mergeReasoningValue(value, 'prompt', { extra: true })).toEqual({
prompt: {
value: {
type: VarKindType.constant,
value: 'hello',
extra: true,
},
auto: 0,
},
})
expect(updateVariableSelectorValue(value, 'prompt', ['node', 'field'])).toEqual({
prompt: {
value: {
type: VarKindType.variable,
value: ['node', 'field'],
},
auto: 0,
},
})
expect(getVisibleSelectOptions(options, value, 'de_DE')).toEqual([
{ value: 'always', name: 'Always' },
{ value: 'qualified', name: 'Qualified' },
])
expect(
getVisibleSelectOptions(options, { ...value, tier: { value: { value: 'basic' } } }, 'de_DE'),
).toEqual([{ value: 'always', name: 'Always' }])
})
it('derives field flags and picker props from schema types', () => {
it('transitions a field while preserving unrelated values and the original input', () => {
const original: ReasoningConfigValue = {
prompt: {
value: { type: VarKindType.constant, value: 'hello' },
auto: 0,
},
untouched: {
value: { type: VarKindType.constant, value: 'keep me', metadata: true },
auto: 0,
},
}
const snapshot = structuredClone(original)
const automatic = updateInputAutoState(original, 'prompt', true, FormTypeEnum.textInput)
expect(automatic.prompt).toEqual({ value: null, auto: 1 })
expect(automatic.untouched).toEqual(original.untouched)
const variable = updateVariableTypeValue(
updateInputAutoState(automatic, 'prompt', false, FormTypeEnum.textInput),
'prompt',
VarKindType.variable,
'ignored default',
)
expect(variable.prompt).toEqual({
value: { type: VarKindType.variable, value: '' },
auto: 0,
})
const edited = updateReasoningValue(variable, 'prompt', FormTypeEnum.textInput, 'updated')
expect(edited.prompt!.value).toEqual({ type: VarKindType.mixed, value: 'updated' })
const merged = mergeReasoningValue(edited, 'prompt', { format: 'markdown' })
expect(merged.prompt!.value).toEqual({
type: VarKindType.mixed,
value: 'updated',
format: 'markdown',
})
const selected = updateVariableSelectorValue(merged, 'prompt', ['node', 'field'])
expect(selected.prompt!.value).toEqual({
type: VarKindType.variable,
value: ['node', 'field'],
})
expect(selected.untouched).toEqual(original.untouched)
expect(original).toEqual(snapshot)
})
it('derives editor flags and select picker props from typed schemas', () => {
expect(getFieldFlags(FormTypeEnum.object, { type: VarKindType.constant })).toEqual(
expect.objectContaining({
isObject: true,
@@ -175,7 +206,6 @@ describe('reasoning-config-form helpers', () => {
isConstant: true,
}),
)
expect(getFieldFlags(FormTypeEnum.date)).toEqual(
expect.objectContaining({
isDate: true,
@@ -193,22 +223,22 @@ describe('reasoning-config-form helpers', () => {
}),
)
expect(
createPickerProps({
const pickerProps = createPickerProps({
type: FormTypeEnum.select,
value: {},
language: 'en_US',
schema: createSchema({
type: FormTypeEnum.select,
value: {},
language: 'en_US',
schema: {
options: [
{
value: 'one',
label: { en_US: 'One', zh_Hans: 'One' },
show_on: [],
},
],
} as never,
options: [
{
value: 'one',
label: { en_US: 'One', zh_Hans: '一' },
show_on: [],
},
],
}),
).toEqual(
})
expect(pickerProps).toEqual(
expect.objectContaining({
targetVarType: VarType.string,
selectItems: [{ value: 'one', name: 'One' }],
@@ -216,7 +246,7 @@ describe('reasoning-config-form helpers', () => {
)
})
it('uses the selected language and falls back to English for missing translations', () => {
it('uses the requested field-title language and falls back to English', () => {
const labels = { en_US: 'Prompt', zh_Hans: '提示词' }
expect(getFieldTitle(labels, 'zh_Hans')).toBe('提示词')
expect(getFieldTitle(labels, 'de_DE')).toBe('Prompt')
@@ -1,89 +1,27 @@
import type { SnippetWorkflowResponse } from '@dify/contracts/api/console/snippets/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import type { Edge, Node } from '../../../types'
import { act } from '@testing-library/react'
import { useStoreApi } from 'reactflow'
import { consoleQuery } from '@/service/console'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { createTestQueryClient } from '@/test/query-client'
import { createEdge, createNode } from '../../../__tests__/fixtures'
import { renderWorkflowCanvasHook } from '../../../__tests__/workflow-test-env'
import { NESTED_ELEMENT_Z_INDEX } from '../../../constants'
import { useInsertSnippet } from '../use-insert-snippet'
type TestNode = {
id: string
position: { x: number; y: number }
selected?: boolean
parentId?: string
zIndex?: number
data: {
type?: string
selected?: boolean
_children?: { nodeId: string; nodeType: string }[]
_connectedSourceHandleIds?: string[]
_connectedTargetHandleIds?: string[]
variables?: { variable: string; value_selector: string[] }[]
prompt_template?: { role: string; text: string }[]
items?: {
variable_selector: string[]
input_type: 'variable' | 'constant'
operation: string
value: unknown
}[]
assigned_variable_selector?: string[]
input_variable_selector?: string[]
iteration_id?: string
loop_id?: string
start_node_id?: string
iterator_selector?: string[]
output_selector?: string[]
}
}
type TestEdge = {
id: string
source: string
sourceHandle?: string
target: string
targetHandle?: string
zIndex?: number
data?: {
isInIteration?: boolean
iteration_id?: string
isInLoop?: boolean
loop_id?: string
}
}
const mockRequest = vi.hoisted(() => vi.fn())
const mockHandleSyncWorkflowDraft = vi.fn()
const mockSaveStateToHistory = vi.fn()
const mockToastError = vi.fn()
const mockGetNodes = vi.fn()
const mockSetNodes = vi.fn()
const mockSetEdges = vi.fn()
const mockIncrementSnippetUseCount = vi.fn()
let mockEdges: unknown[] = [{ id: 'existing-edge', source: 'old', target: 'old-2' }]
vi.mock('@/service/base', () => ({
request: (...args: unknown[]) => mockRequest(...args),
sseGeneratorPost: vi.fn(),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
edges: mockEdges,
setEdges: mockSetEdges,
}),
}),
}))
vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
}))
// History owns a debounced temporal store and is covered separately; insertion only emits its event.
vi.mock('../../../hooks/use-workflow-history', () => ({
useWorkflowHistory: () => ({
saveStateToHistory: mockSaveStateToHistory,
@@ -119,22 +57,31 @@ const seedPublishedWorkflow = (queryClient: QueryClient, workflow: PublishedWork
describe('useInsertSnippet', () => {
let queryClient: QueryClient
const renderUseInsertSnippet = () =>
renderHook(() => useInsertSnippet(), {
wrapper: createQueryClientWrapper(queryClient),
const renderUseInsertSnippet = ({
nodes = [createNode({ id: 'existing-node', selected: true, data: { selected: true } })],
edges = [],
}: { nodes?: Node[]; edges?: Edge[] } = {}) =>
renderWorkflowCanvasHook(() => ({ ...useInsertSnippet(), reactFlowStore: useStoreApi() }), {
queryClient,
nodes,
edges,
hooksStoreProps: { doSyncWorkflowDraft: mockHandleSyncWorkflowDraft },
initialStoreState: {
debouncedSyncWorkflowDraft: (syncWorkflowDraft) => syncWorkflowDraft(),
},
})
const getCanvas = (reactFlowStore: ReturnType<typeof useStoreApi>) => {
const state = reactFlowStore.getState()
return {
nodes: state.getNodes(),
edges: state.edges,
}
}
beforeEach(() => {
vi.resetAllMocks()
queryClient = createTestQueryClient()
mockEdges = [{ id: 'existing-edge', source: 'old', target: 'old-2' }]
mockGetNodes.mockReturnValue([
{
id: 'existing-node',
position: { x: 0, y: 0 },
data: { selected: true },
},
])
})
afterEach(() => {
@@ -172,7 +119,7 @@ describe('useInsertSnippet', () => {
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
})
const nextNodes = mockSetNodes.mock.calls[insertion]![0] as TestNode[]
const { nodes: nextNodes, edges } = getCanvas(result.current.reactFlowStore)
const insertedNodes = nextNodes.slice(-2)
const extractor = insertedNodes[0]!
const llm = insertedNodes[1]!
@@ -182,12 +129,11 @@ describe('useInsertSnippet', () => {
{ role: 'system', text: `{{#${extractor.id}.text#}}` },
])
expect(new Set(nextNodes.map((node) => node.id)).size).toBe(nextNodes.length)
const edges = mockSetEdges.mock.calls[insertion]![0] as TestEdge[]
expect(edges.at(-1)).toEqual(
expect.objectContaining({ source: extractor.id, target: llm.id }),
)
mockGetNodes.mockReturnValue(nextNodes)
mockEdges = edges
expect(nextNodes).toHaveLength(3 + insertion * 2)
expect(edges).toHaveLength(insertion + 1)
}
} finally {
timestamp.mockRestore()
@@ -243,10 +189,7 @@ describe('useInsertSnippet', () => {
await result.current.handleInsertSnippet('snippet-1')
})
expect(mockSetNodes).toHaveBeenCalledTimes(1)
expect(mockSetEdges).toHaveBeenCalledTimes(1)
const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[]
const { nodes: nextNodes, edges: nextEdges } = getCanvas(result.current.reactFlowStore)
expect(nextNodes[0]!.selected).toBe(false)
expect(nextNodes[0]!.data.selected).toBe(false)
expect(nextNodes).toHaveLength(3)
@@ -260,11 +203,10 @@ describe('useInsertSnippet', () => {
expect(nextNodes[1]!.data.output_selector).toEqual([nextNodes[2]!.id, 'result'])
expect(nextNodes[2]!.data.iteration_id).toBe(nextNodes[1]!.id)
const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[]
expect(nextEdges).toHaveLength(2)
expect(nextEdges[1]!.source).toBe(nextNodes[1]!.id)
expect(nextEdges[1]!.target).toBe(nextNodes[2]!.id)
expect(nextEdges[1]!.zIndex).toBe(NESTED_ELEMENT_Z_INDEX)
expect(nextEdges).toHaveLength(1)
expect(nextEdges[0]!.source).toBe(nextNodes[1]!.id)
expect(nextEdges[0]!.target).toBe(nextNodes[2]!.id)
expect(nextEdges[0]!.zIndex).toBe(NESTED_ELEMENT_Z_INDEX)
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodePaste', {
nodeId: nextNodes[1]!.id,
@@ -321,9 +263,11 @@ describe('useInsertSnippet', () => {
await result.current.handleInsertSnippet('snippet-1')
})
const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[]
const { nodes: nextNodes } = getCanvas(result.current.reactFlowStore)
const insertedLLMNode = nextNodes.find((node) => node.data.type === 'llm')!
const insertedCodeNode = nextNodes.find((node) => node.data.type === 'code')!
const insertedCodeNode = nextNodes.find(
(node) => node.id !== 'existing-node' && node.data.type === 'code',
)!
expect(insertedLLMNode.id).not.toBe('snippet-llm')
expect(insertedCodeNode.data.variables).toEqual([
@@ -422,9 +366,11 @@ describe('useInsertSnippet', () => {
await result.current.handleInsertSnippet('snippet-1')
})
const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[]
const { nodes: nextNodes } = getCanvas(result.current.reactFlowStore)
const insertedLoop = nextNodes.find((node) => node.data.type === 'loop')!
const insertedCode = nextNodes.find((node) => node.data.type === 'code')!
const insertedCode = nextNodes.find(
(node) => node.id !== 'existing-node' && node.data.type === 'code',
)!
const loopStart = nextNodes.find((node) => node.data.type === 'loop-start')!
expect(insertedLoop.data.start_node_id).toBe(loopStart.id)
expect(loopStart.data.loop_id).toBe(insertedLoop.id)
@@ -517,24 +463,20 @@ describe('useInsertSnippet', () => {
await result.current.handleInsertSnippet('snippet-1')
})
const firstInsertion = mockSetNodes.mock.calls[0]![0] as TestNode[]
const firstSource = firstInsertion.find((node) => node.data.type === 'code')!
const firstAssigner = firstInsertion.find((node) => node.data.type === 'assigner')!
mockGetNodes.mockReturnValue(firstInsertion)
const { nodes: firstInsertion } = getCanvas(result.current.reactFlowStore)
const firstSource = firstInsertion.at(-2)!
const firstAssigner = firstInsertion.at(-1)!
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
})
const secondInsertion = mockSetNodes.mock.calls[1]![0] as TestNode[]
const insertedSources = secondInsertion.filter((node) => node.data.type === 'code')
const secondSource = insertedSources.find((node) => node.id !== firstSource.id)!
const { nodes: secondInsertion } = getCanvas(result.current.reactFlowStore)
const secondSource = secondInsertion.at(-2)!
const secondAssigner = secondInsertion.at(-1)!
const firstAssignerAfterSecondInsert = secondInsertion.find(
(node) => node.id === firstAssigner.id,
)!
const secondAssigner = secondInsertion.find(
(node) => node.data.type === 'assigner' && node.id !== firstAssigner.id,
)!
expect(firstAssignerAfterSecondInsert.data.items![0]).toEqual(
expect.objectContaining({
@@ -578,18 +520,18 @@ describe('useInsertSnippet', () => {
{ enabled: true, description: 'leaves routed outputs for the user to connect' },
{ enabled: false, description: 'automatically connects the ordinary Agent output' },
])('$description when inserting an Agent snippet into an edge', async ({ enabled }) => {
mockGetNodes.mockReturnValue([
{ id: 'start', position: { x: 0, y: 0 }, data: { type: 'start' } },
{ id: 'end', position: { x: 600, y: 0 }, data: { type: 'end' } },
])
mockEdges = [
{
const nodes = [
createNode({ id: 'start', position: { x: 0, y: 0 }, data: { type: 'start' } }),
createNode({ id: 'end', position: { x: 600, y: 0 }, data: { type: 'end' } }),
]
const edges = [
createEdge({
id: 'start-source-end-target',
source: 'start',
sourceHandle: 'source',
target: 'end',
targetHandle: 'target',
},
}),
]
seedPublishedWorkflow(queryClient, {
graph: {
@@ -615,7 +557,7 @@ describe('useInsertSnippet', () => {
},
})
const { result } = renderUseInsertSnippet()
const { result } = renderUseInsertSnippet({ nodes, edges })
await act(async () => {
await result.current.handleInsertSnippet('snippet-1', {
@@ -626,9 +568,8 @@ describe('useInsertSnippet', () => {
})
})
const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[]
const { nodes: nextNodes, edges: nextEdges } = getCanvas(result.current.reactFlowStore)
const insertedAgent = nextNodes.find((node) => node.data.type === 'agent')!
const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[]
const expectedEdges = [
expect.objectContaining({
source: 'start',
@@ -653,8 +594,8 @@ describe('useInsertSnippet', () => {
it.each(['iteration', 'loop'] as const)(
'should connect inserted snippet nodes inside the %s container',
async (containerType) => {
mockGetNodes.mockReturnValue([
{
const nodes = [
createNode({
id: 'container-node',
position: { x: 0, y: 0 },
data: {
@@ -665,23 +606,23 @@ describe('useInsertSnippet', () => {
{ nodeId: 'next-node', nodeType: 'code' },
],
},
},
{
}),
createNode({
id: 'prev-node',
parentId: 'container-node',
position: { x: 0, y: 0 },
width: 240,
data: { type: 'code', selected: true, _connectedSourceHandleIds: ['source'] },
},
{
}),
createNode({
id: 'next-node',
parentId: 'container-node',
position: { x: 300, y: 0 },
data: { type: 'code', selected: false, _connectedTargetHandleIds: ['target'] },
},
])
mockEdges = [
{
}),
]
const edges = [
createEdge({
id: 'prev-node-source-next-node-target',
source: 'prev-node',
sourceHandle: 'source',
@@ -691,7 +632,7 @@ describe('useInsertSnippet', () => {
sourceType: 'code',
targetType: 'code',
},
},
}),
]
seedPublishedWorkflow(queryClient, {
graph: {
@@ -728,7 +669,7 @@ describe('useInsertSnippet', () => {
},
})
const { result } = renderUseInsertSnippet()
const { result } = renderUseInsertSnippet({ nodes, edges })
await act(async () => {
await result.current.handleInsertSnippet('snippet-1', {
@@ -739,7 +680,7 @@ describe('useInsertSnippet', () => {
})
})
const nextNodes = mockSetNodes.mock.calls[0]![0] as TestNode[]
const { nodes: nextNodes, edges: nextEdges } = getCanvas(result.current.reactFlowStore)
const insertedEntry = nextNodes.find(
(node) => node.id !== 'prev-node' && node.id !== 'next-node' && node.data.type === 'llm',
)!
@@ -758,7 +699,6 @@ describe('useInsertSnippet', () => {
expect(insertedExit.data._connectedSourceHandleIds).toEqual(['source'])
expect(shiftedNextNode.data._connectedTargetHandleIds).toEqual(['target'])
const nextEdges = mockSetEdges.mock.calls[0]![0] as TestEdge[]
expect(nextEdges).toHaveLength(3)
expect(nextEdges.some((edge) => edge.id === 'prev-node-source-next-node-target')).toBe(
false,
@@ -811,16 +751,23 @@ describe('useInsertSnippet', () => {
it('should show error toast when fetching snippet workflow fails', async () => {
mockRequest.mockRejectedValue(new Error('insert failed'))
const initialNodes = [
createNode({ id: 'existing-node', data: { selected: true } }),
createNode({ id: 'other-node' }),
]
const initialEdges = [
createEdge({ id: 'existing-edge', source: 'existing-node', target: 'other-node' }),
]
const { result } = renderUseInsertSnippet()
const { result } = renderUseInsertSnippet({ nodes: initialNodes, edges: initialEdges })
const before = getCanvas(result.current.reactFlowStore)
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
})
expect(mockToastError).toHaveBeenCalledWith('insert failed')
expect(mockSetNodes).not.toHaveBeenCalled()
expect(mockSetEdges).not.toHaveBeenCalled()
expect(getCanvas(result.current.reactFlowStore)).toEqual(before)
expect(mockSaveStateToHistory).not.toHaveBeenCalled()
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
expect(mockIncrementSnippetUseCount).not.toHaveBeenCalled()
@@ -1,11 +1,13 @@
import type { VarInInspect } from '@/types/workflow'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { VarType } from '@/app/components/workflow/types'
import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config'
import { VarInInspectType } from '@/types/workflow'
import ValueContent from '../value-content'
// Rich JSON editing is independently tested; this shim keeps ValueContent's value contract visible.
vi.mock(
'@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor',
() => ({
@@ -19,7 +21,7 @@ vi.mock(
readonly?: boolean
}) => (
<textarea
data-testid="json-editor"
aria-label="JSON value"
readOnly={readonly}
value={schema}
onChange={(event) => onUpdate(event.target.value)}
@@ -28,6 +30,7 @@ vi.mock(
}),
)
// Upload mechanics are independently tested; this boundary supplies completed and pending file states.
vi.mock('@/app/components/base/file-uploader', () => ({
FileUploaderInAttachmentWrapper: ({
onChange,
@@ -57,17 +60,42 @@ vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: '' }),
}))
describe('ValueContent', () => {
const createVar = (overrides: Partial<VarInInspect>): VarInInspect =>
({
id: 'var-default',
name: 'query',
type: VarInInspectType.node,
value_type: VarType.string,
value: '',
...overrides,
}) as VarInInspect
const createVar = (overrides: Partial<VarInInspect> = {}): VarInInspect => ({
id: 'var-default',
name: 'query',
type: VarInInspectType.node,
value_type: VarType.string,
value: '',
description: '',
selector: ['node-1', 'query'],
edited: false,
visible: true,
is_truncated: false,
full_content: null,
...overrides,
})
const renderValueContent = (
currentVar: VarInInspect,
handleValueChange = vi.fn(),
isTruncated = false,
) =>
renderWorkflowComponent(
<ValueContent
currentVar={currentVar}
handleValueChange={handleValueChange}
isTruncated={isTruncated}
/>,
)
const setupDebounceTest = () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
// React Testing Library detects this Jest-shaped bridge before draining its async wrapper.
vi.stubGlobal('jest', { advanceTimersByTime: vi.advanceTimersByTime })
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
}
describe('ValueContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@@ -75,271 +103,139 @@ describe('ValueContent', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('should debounce text changes for string variables', async () => {
it('coalesces intermediate string edits into the latest debounced value', async () => {
const user = setupDebounceTest()
const handleValueChange = vi.fn()
renderValueContent(createVar({ id: 'var-1', value: 'hello' }), handleValueChange)
const input = screen.getByRole('textbox')
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-1',
value_type: VarType.string,
value: 'hello',
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
await user.clear(input)
await user.type(input, 'draft')
await user.clear(input)
await user.type(input, 'updated')
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'updated' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-1', 'updated')
})
})
it('should surface parse errors from invalid json input', async () => {
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-2',
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
})}
handleValueChange={vi.fn()}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByTestId('json-editor'), { target: { value: '{' } })
await waitFor(() => {
expect(screen.getByText(/json/i)).toBeInTheDocument()
})
})
it('should debounce numeric changes', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-3',
name: 'count',
value_type: VarType.number,
value: 1,
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '24.5' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-3', 24.5)
})
expect(handleValueChange).toHaveBeenCalledTimes(1)
})
it('keeps invalid numeric drafts local and cancels a pending numeric change', async () => {
vi.useFakeTimers()
const handleValueChange = vi.fn()
const { unmount } = renderWorkflowComponent(
<ValueContent
currentVar={createVar({ value_type: VarType.number, value: 1 })}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '12' } })
fireEvent.change(screen.getByRole('textbox'), { target: { value: '-' } })
act(() => vi.advanceTimersByTime(500))
expect(screen.getByRole('textbox')).toHaveValue('-')
await vi.advanceTimersByTimeAsync(499)
expect(handleValueChange).not.toHaveBeenCalled()
fireEvent.change(screen.getByRole('textbox'), { target: { value: '-2.5' } })
act(() => vi.advanceTimersByTime(500))
expect(handleValueChange).toHaveBeenCalledExactlyOnceWith('var-default', -2.5)
unmount()
vi.useRealTimers()
await vi.advanceTimersByTimeAsync(1)
expect(handleValueChange).toHaveBeenCalledExactlyOnceWith('var-1', 'updated')
})
it('should update boolean values', async () => {
it('does not save invalid JSON and recovers after a valid edit', async () => {
const user = setupDebounceTest()
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-4',
name: 'enabled',
value_type: VarType.boolean,
value: false,
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
renderValueContent(
createVar({
id: 'var-2',
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
}),
handleValueChange,
)
const editor = screen.getByRole('textbox', { name: 'JSON value' })
fireEvent.click(screen.getByText('True'))
await user.clear(editor)
await user.paste('[')
expect(screen.getByText(/json/i)).toBeInTheDocument()
await vi.advanceTimersByTimeAsync(500)
expect(handleValueChange).not.toHaveBeenCalled()
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-4', true)
})
await user.clear(editor)
await user.paste('{"foo":2}')
expect(screen.queryByText(/json/i)).not.toBeInTheDocument()
await vi.advanceTimersByTimeAsync(500)
expect(handleValueChange).toHaveBeenCalledExactlyOnceWith('var-2', { foo: 2 })
})
it('should not emit changes when the content is truncated', async () => {
it('cancels a pending numeric value while the draft is invalid, then saves recovery', async () => {
const user = setupDebounceTest()
const handleValueChange = vi.fn()
renderValueContent(
createVar({ name: 'count', value_type: VarType.number, value: 1 }),
handleValueChange,
)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '12')
await user.clear(input)
await user.type(input, '-')
expect(input).toHaveValue('-')
await vi.advanceTimersByTimeAsync(500)
expect(handleValueChange).not.toHaveBeenCalled()
await user.type(input, '2.5')
await vi.advanceTimersByTimeAsync(500)
expect(handleValueChange).toHaveBeenCalledExactlyOnceWith('var-default', -2.5)
})
it('updates boolean values', async () => {
const user = userEvent.setup()
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-5',
value_type: VarType.string,
value: 'hello',
})}
handleValueChange={handleValueChange}
isTruncated
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
renderValueContent(
createVar({ id: 'var-4', name: 'enabled', value_type: VarType.boolean, value: false }),
handleValueChange,
)
await user.click(screen.getByRole('button', { name: 'True' }))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledExactlyOnceWith('var-4', true)
})
})
it('does not emit changes when the content is truncated', async () => {
const user = userEvent.setup()
const handleValueChange = vi.fn()
renderValueContent(createVar({ id: 'var-5', value: 'hello' }), handleValueChange, true)
expect(screen.getByRole('textbox')).toBeDisabled()
await user.type(screen.getByRole('textbox'), 'updated')
expect(handleValueChange).not.toHaveBeenCalled()
})
it('should update boolean array values', async () => {
it('updates the selected boolean array value', async () => {
const user = userEvent.setup()
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-6',
name: 'flags',
value_type: VarType.arrayBoolean,
value: [true, false],
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
renderValueContent(
createVar({
id: 'var-6',
name: 'flags',
value_type: VarType.arrayBoolean,
value: [true, false],
}),
handleValueChange,
)
await user.click(screen.getAllByRole('button', { name: 'True' })[1]!)
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-6', [true, true])
expect(handleValueChange).toHaveBeenCalledExactlyOnceWith('var-6', [true, true])
})
})
it('should parse valid json values', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-7',
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByTestId('json-editor'), { target: { value: '{"foo":2}' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-7', { foo: 2 })
})
})
it('should render LLM environment variables as read-only JSON', async () => {
it('renders LLM environment variables as read-only JSON', () => {
const value = { provider: 'openai', name: 'gpt-4o', mode: 'chat' }
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-llm',
name: 'for_summarize',
type: VarInInspectType.environment,
value_type: 'llm' as VarType,
value,
})}
handleValueChange={vi.fn()}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
renderValueContent(
createVar({
id: 'var-llm',
name: 'for_summarize',
type: VarInInspectType.environment,
value_type: 'llm',
value,
}),
)
await waitFor(() => {
expect(screen.getByTestId('json-editor')).toHaveValue(JSON.stringify(value, null, 2))
})
expect(screen.getByTestId('json-editor')).toHaveAttribute('readonly')
expect(screen.getByRole('textbox', { name: 'JSON value' })).toHaveValue(
JSON.stringify(value, null, 2),
)
expect(screen.getByRole('textbox', { name: 'JSON value' })).toHaveAttribute('readonly')
})
it('should update uploaded single file values and ignore pending uploads', async () => {
it('ignores pending single-file uploads and saves completed files', async () => {
const user = setupDebounceTest()
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
@@ -351,69 +247,25 @@ describe('ValueContent', () => {
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
{ initialStoreState: { fileUploadConfig: createFileUploadConfigFixture() } },
)
fireEvent.click(screen.getByText('file-pending'))
await user.click(screen.getByRole('button', { name: 'file-pending' }))
await vi.advanceTimersByTimeAsync(500)
expect(handleValueChange).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('file-uploaded'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith(
'var-8',
expect.objectContaining({ upload_file_id: 'file-1' }),
)
})
await user.click(screen.getByRole('button', { name: 'file-uploaded' }))
await vi.advanceTimersByTimeAsync(500)
expect(handleValueChange).toHaveBeenCalledExactlyOnceWith(
'var-8',
expect.objectContaining({ upload_file_id: 'file-1' }),
)
})
it('should update uploaded file arrays and react to resize observer changes', async () => {
it('converts every completed file in an uploaded array', async () => {
const user = userEvent.setup()
const handleValueChange = vi.fn()
const observe = vi.fn()
const disconnect = vi.fn()
const originalResizeObserver = globalThis.ResizeObserver
const originalClientHeight = Object.getOwnPropertyDescriptor(
HTMLDivElement.prototype,
'clientHeight',
)
Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', {
configurable: true,
get: () => 120,
})
class MockResizeObserver {
callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
}
observe = (target: Element) => {
observe(target)
this.callback(
[
{
borderBoxSize: [{ inlineSize: 300, blockSize: 20 }],
} as unknown as ResizeObserverEntry,
],
this as unknown as ResizeObserver,
)
}
disconnect = disconnect
}
vi.stubGlobal('ResizeObserver', MockResizeObserver as unknown as typeof ResizeObserver)
const { unmount } = renderWorkflowComponent(
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-9',
@@ -425,38 +277,15 @@ describe('ValueContent', () => {
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
{ initialStoreState: { fileUploadConfig: createFileUploadConfigFixture() } },
)
fireEvent.click(screen.getByText('file-array-uploaded'))
await user.click(screen.getByRole('button', { name: 'file-array-uploaded' }))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith(
'var-9',
expect.arrayContaining([
expect.objectContaining({ upload_file_id: 'file-1' }),
expect.objectContaining({ upload_file_id: 'file-2' }),
]),
)
expect(handleValueChange).toHaveBeenCalledExactlyOnceWith('var-9', [
expect.objectContaining({ upload_file_id: 'file-1' }),
expect.objectContaining({ upload_file_id: 'file-2' }),
])
})
expect(observe).toHaveBeenCalled()
expect(document.querySelector('[style="height: 100px;"]')).toBeInTheDocument()
if (originalClientHeight)
Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', originalClientHeight)
else delete (HTMLDivElement.prototype as { clientHeight?: number }).clientHeight
if (originalResizeObserver) vi.stubGlobal('ResizeObserver', originalResizeObserver)
else vi.unstubAllGlobals()
unmount()
expect(disconnect).toHaveBeenCalledOnce()
})
})
@@ -1,33 +1,33 @@
import type { Node } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import { act } from '@testing-library/react'
import { useStoreApi } from 'reactflow'
import { createLoopNode, createNode } from '@/app/components/workflow/__tests__/fixtures'
import { renderWorkflowCanvasHook } from '@/app/components/workflow/__tests__/workflow-test-env'
import { LOOP_PADDING } from '@/app/components/workflow/constants'
import { useNodeLoopInteractions } from '../hooks'
const mockGetNodes = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const renderInteractions = (nodes: Node[]) =>
renderWorkflowCanvasHook(
() => ({ interactions: useNodeLoopInteractions(), reactFlowStore: useStoreApi() }),
{ nodes },
)
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
}),
}),
Position: {
Left: 'left',
Right: 'right',
},
}))
const getNode = (reactFlowStore: ReturnType<typeof useStoreApi>, nodeId: string) =>
reactFlowStore
.getState()
.getNodes()
.find((node) => node.id === nodeId)!
describe('workflow preview loop interactions', () => {
beforeEach(() => {
mockGetNodes.mockReset()
mockSetNodes.mockReset()
})
it('expands the loop node when children overflow the current bounds', () => {
mockGetNodes.mockReturnValue([
it('uses the independently rightmost and bottommost children and leaves unrelated nodes unchanged', () => {
const unrelatedNode = createNode({
id: 'unrelated',
position: { x: 1_000, y: 1_000 },
width: 500,
height: 500,
data: { title: 'Outside the loop' },
})
const { result } = renderInteractions([
createLoopNode({
id: 'loop-node',
width: 120,
@@ -35,22 +35,87 @@ describe('workflow preview loop interactions', () => {
data: { width: 120, height: 80 },
}),
createNode({
id: 'child-node',
id: 'rightmost-child',
parentId: 'loop-node',
position: { x: 100, y: 90 },
width: 60,
height: 40,
position: { x: 200, y: 10 },
width: 30,
height: 20,
}),
createNode({
id: 'bottommost-child',
parentId: 'loop-node',
position: { x: 10, y: 160 },
width: 20,
height: 50,
}),
unrelatedNode,
])
const { result } = renderHook(() => useNodeLoopInteractions())
result.current.handleNodeLoopRerender('loop-node')
act(() => result.current.interactions.handleNodeLoopRerender('loop-node'))
expect(mockSetNodes).toHaveBeenCalledTimes(1)
const updatedLoopNode = mockSetNodes.mock.calls[0]![0].find(
(node: Node) => node.id === 'loop-node',
)
expect(updatedLoopNode.width).toBe(100 + 60 + LOOP_PADDING.right)
expect(updatedLoopNode.height).toBe(90 + 40 + LOOP_PADDING.bottom)
const loopNode = getNode(result.current.reactFlowStore, 'loop-node')
const expectedWidth = 200 + 30 + LOOP_PADDING.right
const expectedHeight = 160 + 50 + LOOP_PADDING.bottom
expect(loopNode).toMatchObject({ width: expectedWidth, height: expectedHeight })
expect(loopNode.data).toMatchObject({ width: expectedWidth, height: expectedHeight })
expect(getNode(result.current.reactFlowStore, 'unrelated')).toMatchObject(unrelatedNode)
})
it.each([
{
dimension: 'width only',
loopSize: { width: 100, height: 300 },
child: { position: { x: 100, y: 10 }, width: 50, height: 20 },
expected: { width: 150 + LOOP_PADDING.right, height: 300 },
},
{
dimension: 'height only',
loopSize: { width: 300, height: 100 },
child: { position: { x: 10, y: 100 }, width: 20, height: 50 },
expected: { width: 300, height: 150 + LOOP_PADDING.bottom },
},
])('expands $dimension without changing the other dimension', ({ loopSize, child, expected }) => {
const { result } = renderInteractions([
createLoopNode({ id: 'loop-node', ...loopSize, data: loopSize }),
createNode({ id: 'child-node', parentId: 'loop-node', ...child }),
])
act(() => result.current.interactions.handleNodeLoopRerender('loop-node'))
const loopNode = getNode(result.current.reactFlowStore, 'loop-node')
expect(loopNode).toMatchObject(expected)
expect(loopNode.data).toMatchObject(expected)
})
it.each([
{
scenario: 'children fit',
children: [
createNode({
id: 'child-node',
parentId: 'loop-node',
position: { x: 20, y: 20 },
width: 40,
height: 40,
}),
],
},
{ scenario: 'there are no children', children: [] },
])('does not shrink the loop when $scenario', ({ children }) => {
const loopNode = createLoopNode({
id: 'loop-node',
width: 300,
height: 240,
data: { width: 300, height: 240 },
})
const { result } = renderInteractions([loopNode, ...children])
act(() => result.current.interactions.handleNodeLoopRerender('loop-node'))
expect(getNode(result.current.reactFlowStore, 'loop-node')).toMatchObject({
width: 300,
height: 240,
data: { width: 300, height: 240 },
})
})
})