mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-15 00:44:56 -05:00
* refactor(tool-input): eliminate SyncWrappers, add canonical toggle and dependsOn gating Replace 17+ individual SyncWrapper components with a single centralized ToolSubBlockRenderer that bridges the subblock store with StoredTool.params via synthetic store keys. This reduces ~1000 lines of duplicated wrapper code and ensures tool-input renders subblock components identically to the standalone SubBlock path. - Add ToolSubBlockRenderer with bidirectional store sync - Add basic/advanced mode toggle (ArrowLeftRight) using collaborative functions - Add dependsOn gating via useDependsOnGate (fields disable instead of hiding) - Add paramVisibility field to SubBlockConfig for tool-input visibility control - Pass canonicalModeOverrides through getSubBlocksForToolInput - Show (optional) label for non-user-only fields (LLM can inject at runtime) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): restore optional indicator, fix folder selector and canonical toggle, extract components - Attach resolved paramVisibility to subblocks from getSubBlocksForToolInput - Add labelSuffix prop to SubBlock for "(optional)" badge on user-or-llm params - Fix folder selector missing for tools with canonicalParamId (e.g. Google Drive) - Fix canonical toggle not clickable by letting SubBlock handle dependsOn internally - Extract ParameterWithLabel, ToolSubBlockRenderer, ToolCredentialSelector to components/tools/ - Extract StoredTool interface to types.ts, selection helpers to utils.ts - Remove dead code (mcpError, refreshTools, oldParamIds, initialParams) - Strengthen typing: replace any with proper types on icon components and evaluateParameterCondition * add sibling values to subblock context since subblock store isn't relevant in tool input, and removed unused param * cleanup * fix(tool-input): render uncovered tool params alongside subblocks The SubBlock-first rendering path was hard-returning after rendering subblocks, so tool params without matching subblocks (like inputMapping for workflow tools) were never rendered. Now renders subblocks first, then any remaining displayParams not covered by subblocks via the legacy ParameterWithLabel fallback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): auto-refresh workflow inputs after redeploy After redeploying a child workflow via the stale badge, the workflow state cache was not invalidated, so WorkflowInputMapperInput kept showing stale input fields until page refresh. Now invalidates workflowKeys.state on deploy success. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): correct workflow selector visibility and tighten (optional) spacing - Set workflowId param to user-only in workflow_executor tool config so "Select Workflow" no longer shows "(optional)" indicator - Tighten (optional) label spacing with -ml-[3px] to counteract parent Label's gap-[6px], making it feel inline with the label text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): align (optional) text to baseline instead of center Use items-baseline instead of items-center on Label flex containers so the smaller (optional) text aligns with the label text baseline rather than sitting slightly below it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): increase top padding of expanded tool body Bump the expanded tool body container's top padding from 8px to 12px for more breathing room between the header bar and the first parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): apply extra top padding only to SubBlock-first path Revert container padding to py-[8px] (MCP tools were correct). Wrap SubBlock-first output in a div with pt-[4px] so only registry tools get extra breathing room from the container top. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tool-input): increase gap between SubBlock params for visual clarity SubBlock's internal gap (10px between label and input) matched the between-parameter gap (10px), making them indistinguishable. Increase the between-parameter gap to 14px so consecutive parameters are visually distinct, matching the separation seen in ParameterWithLabel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix spacing and optional tag * update styling + move predeploy checks earlier for first time deploys * update change detection to account for synthetic tool ids * fix remaining blocks who had files visibility set to hidden * cleanup * add catch --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
812 lines
26 KiB
TypeScript
812 lines
26 KiB
TypeScript
/**
|
|
* Tests for workflow normalization utilities
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
|
import {
|
|
filterSubBlockIds,
|
|
normalizedStringify,
|
|
normalizeEdge,
|
|
normalizeLoop,
|
|
normalizeParallel,
|
|
normalizeTriggerConfigValues,
|
|
normalizeValue,
|
|
sanitizeInputFormat,
|
|
sanitizeTools,
|
|
sortEdges,
|
|
} from './normalize'
|
|
|
|
describe('Workflow Normalization Utilities', () => {
|
|
describe('normalizeValue', () => {
|
|
it.concurrent('should return primitives unchanged', () => {
|
|
expect(normalizeValue(42)).toBe(42)
|
|
expect(normalizeValue('hello')).toBe('hello')
|
|
expect(normalizeValue(true)).toBe(true)
|
|
expect(normalizeValue(false)).toBe(false)
|
|
})
|
|
|
|
it.concurrent('should normalize null and undefined to undefined', () => {
|
|
// null and undefined are semantically equivalent in our system
|
|
expect(normalizeValue(null)).toBe(undefined)
|
|
expect(normalizeValue(undefined)).toBe(undefined)
|
|
})
|
|
|
|
it.concurrent('should handle arrays by normalizing each element', () => {
|
|
const input = [
|
|
{ b: 2, a: 1 },
|
|
{ d: 4, c: 3 },
|
|
]
|
|
const result = normalizeValue(input)
|
|
|
|
expect(result).toEqual([
|
|
{ a: 1, b: 2 },
|
|
{ c: 3, d: 4 },
|
|
])
|
|
})
|
|
|
|
it.concurrent('should sort object keys alphabetically', () => {
|
|
const input = { zebra: 1, apple: 2, mango: 3 }
|
|
const result = normalizeValue(input) as Record<string, unknown>
|
|
|
|
expect(Object.keys(result)).toEqual(['apple', 'mango', 'zebra'])
|
|
})
|
|
|
|
it.concurrent('should recursively normalize nested objects', () => {
|
|
const input = {
|
|
outer: {
|
|
z: 1,
|
|
a: {
|
|
y: 2,
|
|
b: 3,
|
|
},
|
|
},
|
|
first: 'value',
|
|
}
|
|
const result = normalizeValue(input) as {
|
|
first: string
|
|
outer: { z: number; a: { y: number; b: number } }
|
|
}
|
|
|
|
expect(Object.keys(result)).toEqual(['first', 'outer'])
|
|
expect(Object.keys(result.outer)).toEqual(['a', 'z'])
|
|
expect(Object.keys(result.outer.a)).toEqual(['b', 'y'])
|
|
})
|
|
|
|
it.concurrent('should handle empty objects', () => {
|
|
expect(normalizeValue({})).toEqual({})
|
|
})
|
|
|
|
it.concurrent('should handle empty arrays', () => {
|
|
expect(normalizeValue([])).toEqual([])
|
|
})
|
|
|
|
it.concurrent('should handle arrays with mixed types', () => {
|
|
const input = [1, 'string', { b: 2, a: 1 }, null, [3, 2, 1]]
|
|
const result = normalizeValue(input) as unknown[]
|
|
|
|
expect(result[0]).toBe(1)
|
|
expect(result[1]).toBe('string')
|
|
expect(Object.keys(result[2] as Record<string, unknown>)).toEqual(['a', 'b'])
|
|
expect(result[3]).toBe(undefined) // null normalized to undefined
|
|
expect(result[4]).toEqual([3, 2, 1]) // Array order preserved
|
|
})
|
|
|
|
it.concurrent('should handle deeply nested structures', () => {
|
|
const input = {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
level4: {
|
|
z: 'deep',
|
|
a: 'value',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
const result = normalizeValue(input) as {
|
|
level1: { level2: { level3: { level4: { z: string; a: string } } } }
|
|
}
|
|
|
|
expect(Object.keys(result.level1.level2.level3.level4)).toEqual(['a', 'z'])
|
|
})
|
|
})
|
|
|
|
describe('normalizedStringify', () => {
|
|
it.concurrent('should produce identical strings for objects with different key orders', () => {
|
|
const obj1 = { b: 2, a: 1, c: 3 }
|
|
const obj2 = { a: 1, c: 3, b: 2 }
|
|
const obj3 = { c: 3, b: 2, a: 1 }
|
|
|
|
const str1 = normalizedStringify(obj1)
|
|
const str2 = normalizedStringify(obj2)
|
|
const str3 = normalizedStringify(obj3)
|
|
|
|
expect(str1).toBe(str2)
|
|
expect(str2).toBe(str3)
|
|
})
|
|
|
|
it.concurrent('should produce valid JSON', () => {
|
|
const obj = { nested: { value: [1, 2, 3] }, name: 'test' }
|
|
const str = normalizedStringify(obj)
|
|
|
|
expect(() => JSON.parse(str)).not.toThrow()
|
|
})
|
|
|
|
it.concurrent('should handle primitive values', () => {
|
|
expect(normalizedStringify(42)).toBe('42')
|
|
expect(normalizedStringify('hello')).toBe('"hello"')
|
|
expect(normalizedStringify(true)).toBe('true')
|
|
})
|
|
|
|
it.concurrent('should treat null and undefined equivalently', () => {
|
|
// Both null and undefined normalize to undefined, which JSON.stringify returns as undefined
|
|
expect(normalizedStringify(null)).toBe(normalizedStringify(undefined))
|
|
})
|
|
|
|
it.concurrent('should produce different strings for different values', () => {
|
|
const obj1 = { a: 1, b: 2 }
|
|
const obj2 = { a: 1, b: 3 }
|
|
|
|
expect(normalizedStringify(obj1)).not.toBe(normalizedStringify(obj2))
|
|
})
|
|
})
|
|
|
|
describe('normalizeLoop', () => {
|
|
it.concurrent('should normalize null/undefined to undefined', () => {
|
|
// null and undefined are semantically equivalent
|
|
expect(normalizeLoop(null)).toBe(undefined)
|
|
expect(normalizeLoop(undefined)).toBe(undefined)
|
|
})
|
|
|
|
it.concurrent('should normalize "for" loop type', () => {
|
|
const loop: Loop & { extraField?: string } = {
|
|
id: 'loop1',
|
|
nodes: ['block1', 'block2'],
|
|
loopType: 'for',
|
|
iterations: 10,
|
|
forEachItems: 'should-be-excluded',
|
|
whileCondition: 'should-be-excluded',
|
|
doWhileCondition: 'should-be-excluded',
|
|
extraField: 'should-be-excluded',
|
|
}
|
|
const result = normalizeLoop(loop)
|
|
|
|
expect(result).toEqual({
|
|
id: 'loop1',
|
|
nodes: ['block1', 'block2'],
|
|
loopType: 'for',
|
|
iterations: 10,
|
|
})
|
|
})
|
|
|
|
it.concurrent('should normalize "forEach" loop type', () => {
|
|
const loop: Loop = {
|
|
id: 'loop2',
|
|
nodes: ['block1'],
|
|
loopType: 'forEach',
|
|
iterations: 5,
|
|
forEachItems: '<block.items>',
|
|
whileCondition: 'should-be-excluded',
|
|
}
|
|
const result = normalizeLoop(loop)
|
|
|
|
expect(result).toEqual({
|
|
id: 'loop2',
|
|
nodes: ['block1'],
|
|
loopType: 'forEach',
|
|
forEachItems: '<block.items>',
|
|
})
|
|
})
|
|
|
|
it.concurrent('should normalize "while" loop type', () => {
|
|
const loop: Loop = {
|
|
id: 'loop3',
|
|
nodes: ['block1', 'block2', 'block3'],
|
|
loopType: 'while',
|
|
iterations: 0,
|
|
whileCondition: '<block.condition> === true',
|
|
doWhileCondition: 'should-be-excluded',
|
|
}
|
|
const result = normalizeLoop(loop)
|
|
|
|
expect(result).toEqual({
|
|
id: 'loop3',
|
|
nodes: ['block1', 'block2', 'block3'],
|
|
loopType: 'while',
|
|
whileCondition: '<block.condition> === true',
|
|
})
|
|
})
|
|
|
|
it.concurrent('should normalize "doWhile" loop type', () => {
|
|
const loop: Loop = {
|
|
id: 'loop4',
|
|
nodes: ['block1'],
|
|
loopType: 'doWhile',
|
|
iterations: 0,
|
|
doWhileCondition: '<counter.value> < 100',
|
|
whileCondition: 'should-be-excluded',
|
|
}
|
|
const result = normalizeLoop(loop)
|
|
|
|
expect(result).toEqual({
|
|
id: 'loop4',
|
|
nodes: ['block1'],
|
|
loopType: 'doWhile',
|
|
doWhileCondition: '<counter.value> < 100',
|
|
})
|
|
})
|
|
|
|
it.concurrent('should extract only relevant fields for for loop type', () => {
|
|
const loop: Loop = {
|
|
id: 'loop5',
|
|
nodes: ['block1'],
|
|
loopType: 'for',
|
|
iterations: 5,
|
|
forEachItems: 'items',
|
|
}
|
|
const result = normalizeLoop(loop)
|
|
|
|
expect(result).toEqual({
|
|
id: 'loop5',
|
|
nodes: ['block1'],
|
|
loopType: 'for',
|
|
iterations: 5,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('normalizeParallel', () => {
|
|
it.concurrent('should normalize null/undefined to undefined', () => {
|
|
// null and undefined are semantically equivalent
|
|
expect(normalizeParallel(null)).toBe(undefined)
|
|
expect(normalizeParallel(undefined)).toBe(undefined)
|
|
})
|
|
|
|
it.concurrent('should normalize "count" parallel type', () => {
|
|
const parallel: Parallel & { extraField?: string } = {
|
|
id: 'parallel1',
|
|
nodes: ['block1', 'block2'],
|
|
parallelType: 'count',
|
|
count: 5,
|
|
distribution: 'should-be-excluded',
|
|
extraField: 'should-be-excluded',
|
|
}
|
|
const result = normalizeParallel(parallel)
|
|
|
|
expect(result).toEqual({
|
|
id: 'parallel1',
|
|
nodes: ['block1', 'block2'],
|
|
parallelType: 'count',
|
|
count: 5,
|
|
})
|
|
})
|
|
|
|
it.concurrent('should normalize "collection" parallel type', () => {
|
|
const parallel: Parallel = {
|
|
id: 'parallel2',
|
|
nodes: ['block1'],
|
|
parallelType: 'collection',
|
|
count: 10,
|
|
distribution: '<block.items>',
|
|
}
|
|
const result = normalizeParallel(parallel)
|
|
|
|
expect(result).toEqual({
|
|
id: 'parallel2',
|
|
nodes: ['block1'],
|
|
parallelType: 'collection',
|
|
distribution: '<block.items>',
|
|
})
|
|
})
|
|
|
|
it.concurrent('should include base fields for undefined parallel type', () => {
|
|
const parallel: Parallel = {
|
|
id: 'parallel3',
|
|
nodes: ['block1'],
|
|
parallelType: undefined,
|
|
count: 5,
|
|
distribution: 'items',
|
|
}
|
|
const result = normalizeParallel(parallel)
|
|
|
|
expect(result).toEqual({
|
|
id: 'parallel3',
|
|
nodes: ['block1'],
|
|
parallelType: undefined,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('sanitizeTools', () => {
|
|
it.concurrent('should return empty array for undefined', () => {
|
|
expect(sanitizeTools(undefined)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('should return empty array for non-array input', () => {
|
|
expect(sanitizeTools(null as any)).toEqual([])
|
|
expect(sanitizeTools('not-an-array' as any)).toEqual([])
|
|
expect(sanitizeTools({} as any)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('should remove isExpanded field from tools', () => {
|
|
const tools = [
|
|
{ id: 'tool1', name: 'Search', isExpanded: true },
|
|
{ id: 'tool2', name: 'Calculator', isExpanded: false },
|
|
{ id: 'tool3', name: 'Weather' },
|
|
]
|
|
const result = sanitizeTools(tools)
|
|
|
|
expect(result).toEqual([
|
|
{ id: 'tool1', name: 'Search' },
|
|
{ id: 'tool2', name: 'Calculator' },
|
|
{ id: 'tool3', name: 'Weather' },
|
|
])
|
|
})
|
|
|
|
it.concurrent('should preserve all other fields', () => {
|
|
const tools = [
|
|
{
|
|
id: 'tool1',
|
|
name: 'Complex Tool',
|
|
isExpanded: true,
|
|
schema: { type: 'function', name: 'search' },
|
|
params: { query: 'test' },
|
|
nested: { deep: { value: 123 } },
|
|
},
|
|
]
|
|
const result = sanitizeTools(tools)
|
|
|
|
expect(result[0]).toEqual({
|
|
id: 'tool1',
|
|
name: 'Complex Tool',
|
|
schema: { type: 'function', name: 'search' },
|
|
params: { query: 'test' },
|
|
nested: { deep: { value: 123 } },
|
|
})
|
|
})
|
|
|
|
it.concurrent('should handle empty array', () => {
|
|
expect(sanitizeTools([])).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('sanitizeInputFormat', () => {
|
|
it.concurrent('should return empty array for undefined', () => {
|
|
expect(sanitizeInputFormat(undefined)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('should return empty array for non-array input', () => {
|
|
expect(sanitizeInputFormat(null as any)).toEqual([])
|
|
expect(sanitizeInputFormat('not-an-array' as any)).toEqual([])
|
|
expect(sanitizeInputFormat({} as any)).toEqual([])
|
|
})
|
|
|
|
it.concurrent('should remove collapsed field but keep value', () => {
|
|
const inputFormat = [
|
|
{ id: 'input1', name: 'Name', value: 'John', collapsed: true },
|
|
{ id: 'input2', name: 'Age', value: 25, collapsed: false },
|
|
{ id: 'input3', name: 'Email' },
|
|
]
|
|
const result = sanitizeInputFormat(inputFormat)
|
|
|
|
expect(result).toEqual([
|
|
{ id: 'input1', name: 'Name', value: 'John' },
|
|
{ id: 'input2', name: 'Age', value: 25 },
|
|
{ id: 'input3', name: 'Email' },
|
|
])
|
|
})
|
|
|
|
it.concurrent('should preserve all other fields including value', () => {
|
|
const inputFormat = [
|
|
{
|
|
id: 'input1',
|
|
name: 'Complex Input',
|
|
value: 'test-value',
|
|
collapsed: true,
|
|
type: 'string',
|
|
required: true,
|
|
validation: { min: 0, max: 100 },
|
|
},
|
|
]
|
|
const result = sanitizeInputFormat(inputFormat)
|
|
|
|
expect(result[0]).toEqual({
|
|
id: 'input1',
|
|
name: 'Complex Input',
|
|
value: 'test-value',
|
|
type: 'string',
|
|
required: true,
|
|
validation: { min: 0, max: 100 },
|
|
})
|
|
})
|
|
|
|
it.concurrent('should handle empty array', () => {
|
|
expect(sanitizeInputFormat([])).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('normalizeEdge', () => {
|
|
it.concurrent('should extract only connection-relevant fields', () => {
|
|
const edge = {
|
|
id: 'edge1',
|
|
source: 'block1',
|
|
sourceHandle: 'output',
|
|
target: 'block2',
|
|
targetHandle: 'input',
|
|
type: 'smoothstep',
|
|
animated: true,
|
|
style: { stroke: 'red' },
|
|
data: { label: 'connection' },
|
|
}
|
|
const result = normalizeEdge(edge)
|
|
|
|
expect(result).toEqual({
|
|
source: 'block1',
|
|
sourceHandle: 'output',
|
|
target: 'block2',
|
|
targetHandle: 'input',
|
|
})
|
|
})
|
|
|
|
it.concurrent('should handle edges without handles', () => {
|
|
const edge = {
|
|
id: 'edge1',
|
|
source: 'block1',
|
|
target: 'block2',
|
|
}
|
|
const result = normalizeEdge(edge)
|
|
|
|
expect(result).toEqual({
|
|
source: 'block1',
|
|
sourceHandle: undefined,
|
|
target: 'block2',
|
|
targetHandle: undefined,
|
|
})
|
|
})
|
|
|
|
it.concurrent('should handle edges with only source handle', () => {
|
|
const edge = {
|
|
id: 'edge1',
|
|
source: 'block1',
|
|
sourceHandle: 'output',
|
|
target: 'block2',
|
|
}
|
|
const result = normalizeEdge(edge)
|
|
|
|
expect(result).toEqual({
|
|
source: 'block1',
|
|
sourceHandle: 'output',
|
|
target: 'block2',
|
|
targetHandle: undefined,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('sortEdges', () => {
|
|
it.concurrent('should sort edges consistently', () => {
|
|
const edges = [
|
|
{ source: 'c', target: 'd' },
|
|
{ source: 'a', target: 'b' },
|
|
{ source: 'b', target: 'c' },
|
|
]
|
|
const result = sortEdges(edges)
|
|
|
|
expect(result[0].source).toBe('a')
|
|
expect(result[1].source).toBe('b')
|
|
expect(result[2].source).toBe('c')
|
|
})
|
|
|
|
it.concurrent(
|
|
'should sort by source, then sourceHandle, then target, then targetHandle',
|
|
() => {
|
|
const edges = [
|
|
{ source: 'a', sourceHandle: 'out2', target: 'b', targetHandle: 'in1' },
|
|
{ source: 'a', sourceHandle: 'out1', target: 'b', targetHandle: 'in1' },
|
|
{ source: 'a', sourceHandle: 'out1', target: 'b', targetHandle: 'in2' },
|
|
{ source: 'a', sourceHandle: 'out1', target: 'c', targetHandle: 'in1' },
|
|
]
|
|
const result = sortEdges(edges)
|
|
|
|
expect(result[0]).toEqual({
|
|
source: 'a',
|
|
sourceHandle: 'out1',
|
|
target: 'b',
|
|
targetHandle: 'in1',
|
|
})
|
|
expect(result[1]).toEqual({
|
|
source: 'a',
|
|
sourceHandle: 'out1',
|
|
target: 'b',
|
|
targetHandle: 'in2',
|
|
})
|
|
expect(result[2]).toEqual({
|
|
source: 'a',
|
|
sourceHandle: 'out1',
|
|
target: 'c',
|
|
targetHandle: 'in1',
|
|
})
|
|
expect(result[3]).toEqual({
|
|
source: 'a',
|
|
sourceHandle: 'out2',
|
|
target: 'b',
|
|
targetHandle: 'in1',
|
|
})
|
|
}
|
|
)
|
|
|
|
it.concurrent('should not mutate the original array', () => {
|
|
const edges = [
|
|
{ source: 'c', target: 'd' },
|
|
{ source: 'a', target: 'b' },
|
|
]
|
|
const originalFirst = edges[0]
|
|
sortEdges(edges)
|
|
|
|
expect(edges[0]).toBe(originalFirst)
|
|
})
|
|
|
|
it.concurrent('should handle empty array', () => {
|
|
expect(sortEdges([])).toEqual([])
|
|
})
|
|
|
|
it.concurrent('should handle edges with undefined handles', () => {
|
|
const edges = [
|
|
{ source: 'b', target: 'c' },
|
|
{ source: 'a', target: 'b', sourceHandle: 'out' },
|
|
]
|
|
const result = sortEdges(edges)
|
|
|
|
expect(result[0].source).toBe('a')
|
|
expect(result[1].source).toBe('b')
|
|
})
|
|
|
|
it.concurrent('should produce identical results regardless of input order', () => {
|
|
const edges1 = [
|
|
{ source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' },
|
|
{ source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' },
|
|
{ source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' },
|
|
]
|
|
const edges2 = [
|
|
{ source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' },
|
|
{ source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' },
|
|
{ source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' },
|
|
]
|
|
const edges3 = [
|
|
{ source: 'b', sourceHandle: 'x', target: 'c', targetHandle: 'y' },
|
|
{ source: 'c', sourceHandle: 'x', target: 'd', targetHandle: 'y' },
|
|
{ source: 'a', sourceHandle: 'x', target: 'b', targetHandle: 'y' },
|
|
]
|
|
|
|
const result1 = normalizedStringify(sortEdges(edges1))
|
|
const result2 = normalizedStringify(sortEdges(edges2))
|
|
const result3 = normalizedStringify(sortEdges(edges3))
|
|
|
|
expect(result1).toBe(result2)
|
|
expect(result2).toBe(result3)
|
|
})
|
|
})
|
|
|
|
describe('filterSubBlockIds', () => {
|
|
it.concurrent('should exclude exact SYSTEM_SUBBLOCK_IDS', () => {
|
|
const ids = ['signingSecret', 'samplePayload', 'triggerInstructions', 'botToken']
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual(['botToken', 'signingSecret'])
|
|
})
|
|
|
|
it.concurrent('should exclude namespaced SYSTEM_SUBBLOCK_IDS (prefix matching)', () => {
|
|
const ids = [
|
|
'signingSecret',
|
|
'samplePayload_slack_webhook',
|
|
'triggerInstructions_slack_webhook',
|
|
'webhookUrlDisplay_slack_webhook',
|
|
'botToken',
|
|
]
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual(['botToken', 'signingSecret'])
|
|
})
|
|
|
|
it.concurrent('should exclude exact TRIGGER_RUNTIME_SUBBLOCK_IDS', () => {
|
|
const ids = ['webhookId', 'triggerPath', 'triggerConfig', 'triggerId', 'signingSecret']
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual(['signingSecret'])
|
|
})
|
|
|
|
it.concurrent('should not exclude IDs that merely contain a system ID substring', () => {
|
|
const ids = ['mySamplePayload', 'notSamplePayload']
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual(['mySamplePayload', 'notSamplePayload'])
|
|
})
|
|
|
|
it.concurrent('should return sorted results', () => {
|
|
const ids = ['zebra', 'alpha', 'middle']
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual(['alpha', 'middle', 'zebra'])
|
|
})
|
|
|
|
it.concurrent('should handle empty array', () => {
|
|
expect(filterSubBlockIds([])).toEqual([])
|
|
})
|
|
|
|
it.concurrent('should handle all IDs being excluded', () => {
|
|
const ids = ['webhookId', 'triggerPath', 'samplePayload', 'triggerConfig']
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it.concurrent('should exclude setupScript and scheduleInfo namespaced variants', () => {
|
|
const ids = ['setupScript_google_sheets_row', 'scheduleInfo_cron_trigger', 'realField']
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual(['realField'])
|
|
})
|
|
|
|
it.concurrent('should exclude triggerCredentials namespaced variants', () => {
|
|
const ids = ['triggerCredentials_slack_webhook', 'signingSecret']
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual(['signingSecret'])
|
|
})
|
|
|
|
it.concurrent('should exclude synthetic tool-input subBlock IDs', () => {
|
|
const ids = [
|
|
'toolConfig',
|
|
'toolConfig-tool-0-query',
|
|
'toolConfig-tool-0-url',
|
|
'toolConfig-tool-1-status',
|
|
'systemPrompt',
|
|
]
|
|
const result = filterSubBlockIds(ids)
|
|
expect(result).toEqual(['systemPrompt', 'toolConfig'])
|
|
})
|
|
})
|
|
|
|
describe('normalizeTriggerConfigValues', () => {
|
|
it.concurrent('should return subBlocks unchanged when no triggerConfig exists', () => {
|
|
const subBlocks = {
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'secret123' },
|
|
botToken: { id: 'botToken', type: 'short-input', value: 'token456' },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect(result).toEqual(subBlocks)
|
|
})
|
|
|
|
it.concurrent('should return subBlocks unchanged when triggerConfig value is null', () => {
|
|
const subBlocks = {
|
|
triggerConfig: { id: 'triggerConfig', type: 'short-input', value: null },
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect(result).toEqual(subBlocks)
|
|
})
|
|
|
|
it.concurrent(
|
|
'should return subBlocks unchanged when triggerConfig value is not an object',
|
|
() => {
|
|
const subBlocks = {
|
|
triggerConfig: { id: 'triggerConfig', type: 'short-input', value: 'string-value' },
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect(result).toEqual(subBlocks)
|
|
}
|
|
)
|
|
|
|
it.concurrent('should populate null individual fields from triggerConfig', () => {
|
|
const subBlocks = {
|
|
triggerConfig: {
|
|
id: 'triggerConfig',
|
|
type: 'short-input',
|
|
value: { signingSecret: 'secret123', botToken: 'token456' },
|
|
},
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
|
|
botToken: { id: 'botToken', type: 'short-input', value: null },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
|
|
expect((result.botToken as Record<string, unknown>).value).toBe('token456')
|
|
})
|
|
|
|
it.concurrent('should populate undefined individual fields from triggerConfig', () => {
|
|
const subBlocks = {
|
|
triggerConfig: {
|
|
id: 'triggerConfig',
|
|
type: 'short-input',
|
|
value: { signingSecret: 'secret123' },
|
|
},
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: undefined },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
|
|
})
|
|
|
|
it.concurrent('should populate empty string individual fields from triggerConfig', () => {
|
|
const subBlocks = {
|
|
triggerConfig: {
|
|
id: 'triggerConfig',
|
|
type: 'short-input',
|
|
value: { signingSecret: 'secret123' },
|
|
},
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: '' },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect((result.signingSecret as Record<string, unknown>).value).toBe('secret123')
|
|
})
|
|
|
|
it.concurrent('should NOT overwrite existing non-empty individual field values', () => {
|
|
const subBlocks = {
|
|
triggerConfig: {
|
|
id: 'triggerConfig',
|
|
type: 'short-input',
|
|
value: { signingSecret: 'old-secret' },
|
|
},
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: 'user-edited-secret' },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect((result.signingSecret as Record<string, unknown>).value).toBe('user-edited-secret')
|
|
})
|
|
|
|
it.concurrent('should skip triggerConfig fields that are null/undefined', () => {
|
|
const subBlocks = {
|
|
triggerConfig: {
|
|
id: 'triggerConfig',
|
|
type: 'short-input',
|
|
value: { signingSecret: null, botToken: undefined },
|
|
},
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
|
|
botToken: { id: 'botToken', type: 'short-input', value: null },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect((result.signingSecret as Record<string, unknown>).value).toBe(null)
|
|
expect((result.botToken as Record<string, unknown>).value).toBe(null)
|
|
})
|
|
|
|
it.concurrent('should skip fields from triggerConfig that have no matching subBlock', () => {
|
|
const subBlocks = {
|
|
triggerConfig: {
|
|
id: 'triggerConfig',
|
|
type: 'short-input',
|
|
value: { nonExistentField: 'value123' },
|
|
},
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
expect(result.nonExistentField).toBeUndefined()
|
|
expect((result.signingSecret as Record<string, unknown>).value).toBe(null)
|
|
})
|
|
|
|
it.concurrent('should not mutate the original subBlocks object', () => {
|
|
const original = {
|
|
triggerConfig: {
|
|
id: 'triggerConfig',
|
|
type: 'short-input',
|
|
value: { signingSecret: 'secret123' },
|
|
},
|
|
signingSecret: { id: 'signingSecret', type: 'short-input', value: null },
|
|
}
|
|
normalizeTriggerConfigValues(original)
|
|
expect((original.signingSecret as Record<string, unknown>).value).toBe(null)
|
|
})
|
|
|
|
it.concurrent('should preserve other subBlock properties when populating value', () => {
|
|
const subBlocks = {
|
|
triggerConfig: {
|
|
id: 'triggerConfig',
|
|
type: 'short-input',
|
|
value: { signingSecret: 'secret123' },
|
|
},
|
|
signingSecret: {
|
|
id: 'signingSecret',
|
|
type: 'short-input',
|
|
value: null,
|
|
placeholder: 'Enter signing secret',
|
|
},
|
|
}
|
|
const result = normalizeTriggerConfigValues(subBlocks)
|
|
const normalized = result.signingSecret as Record<string, unknown>
|
|
expect(normalized.value).toBe('secret123')
|
|
expect(normalized.id).toBe('signingSecret')
|
|
expect(normalized.type).toBe('short-input')
|
|
expect(normalized.placeholder).toBe('Enter signing secret')
|
|
})
|
|
})
|
|
})
|