mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): verify canvas updates and debounced variable edits (#44361)
This commit is contained in:
1 parent
823f40e71f
commit
ac1791279d
4 files changed
+500
-629
No files matched your search
+168
-138
@@ -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')
|
||||
|
||||
+80
-133
@@ -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()
|
||||
})
|
||||
})
|
||||
+99
-34
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user