Files
simstudioai--sim/apps/sim/serializer/tests/serializer.extended.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1562 lines
50 KiB
TypeScript

/**
* @vitest-environment node
*
* Extended Serializer Tests
*
* These tests cover edge cases, complex scenarios, and gaps in coverage
*/
import {
createBlock,
createLinearWorkflow,
createLoopWorkflow,
createParallelWorkflow,
createStarterBlock,
WorkflowBuilder,
} from '@sim/testing'
import { toolsUtilsMock } from '@sim/testing/mocks'
import { describe, expect, it, vi } from 'vitest'
import { Serializer, WorkflowValidationError } from '@/serializer/index'
import type { SerializedWorkflow } from '@/serializer/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Type helper to convert testing package workflow to app workflow types.
* Needed because @sim/testing has simplified types for test ergonomics.
*/
function asAppBlocks<T>(blocks: T): Record<string, BlockState> {
return blocks as unknown as Record<string, BlockState>
}
/**
* Hoisted mock setup - vi.mock is hoisted, so we need to hoist the config too.
*/
const { mockBlockConfigs, createMockGetBlock, slackWithCanonicalParam } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockBlockConfigs: Record<string, any> = {
starter: {
name: 'Starter',
description: 'Start of the workflow',
category: 'flow',
bgColor: '#4CAF50',
tools: {
access: ['starter'],
config: { tool: () => 'starter' },
},
subBlocks: [
{ id: 'description', type: 'long-input', label: 'Description' },
{ id: 'inputFormat', type: 'table', label: 'Input Format' },
],
inputs: {},
},
agent: {
name: 'Agent',
description: 'AI Agent',
category: 'ai',
bgColor: '#2196F3',
tools: {
access: ['anthropic_chat', 'openai_chat'],
config: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tool: (params: Record<string, any>) => {
const model = params.model || 'gpt-4o'
if (model.includes('claude')) return 'anthropic'
if (model.includes('gpt') || model.includes('o1')) return 'openai'
if (model.includes('gemini')) return 'google'
return 'openai'
},
},
},
subBlocks: [
{ id: 'provider', type: 'dropdown', label: 'Provider' },
{ id: 'model', type: 'dropdown', label: 'Model' },
{ id: 'prompt', type: 'long-input', label: 'Prompt' },
{ id: 'system', type: 'long-input', label: 'System Message' },
{ id: 'tools', type: 'tool-input', label: 'Tools' },
{ id: 'responseFormat', type: 'code', label: 'Response Format' },
{ id: 'messages', type: 'messages-input', label: 'Messages' },
],
inputs: {
input: { type: 'string' },
tools: { type: 'array' },
},
},
function: {
name: 'Function',
description: 'Execute custom code',
category: 'code',
bgColor: '#9C27B0',
tools: {
access: ['function'],
config: { tool: () => 'function' },
},
subBlocks: [
{ id: 'code', type: 'code', label: 'Code' },
{ id: 'language', type: 'dropdown', label: 'Language' },
],
inputs: { input: { type: 'any' } },
},
condition: {
name: 'Condition',
description: 'Branch based on condition',
category: 'flow',
bgColor: '#FF9800',
tools: {
access: ['condition'],
config: { tool: () => 'condition' },
},
subBlocks: [{ id: 'condition', type: 'long-input', label: 'Condition' }],
inputs: { input: { type: 'any' } },
},
api: {
name: 'API',
description: 'Make API request',
category: 'data',
bgColor: '#E91E63',
tools: {
access: ['api'],
config: { tool: () => 'api' },
},
subBlocks: [
{ id: 'url', type: 'short-input', label: 'URL' },
{ id: 'method', type: 'dropdown', label: 'Method' },
{ id: 'headers', type: 'table', label: 'Headers' },
{ id: 'body', type: 'long-input', label: 'Body' },
],
inputs: {},
},
webhook: {
name: 'Webhook',
description: 'Webhook trigger',
category: 'triggers',
bgColor: '#4CAF50',
tools: {
access: ['webhook'],
config: { tool: () => 'webhook' },
},
subBlocks: [{ id: 'path', type: 'short-input', label: 'Path' }],
inputs: {},
},
slack: {
name: 'Slack',
description: 'Send messages to Slack',
category: 'tools',
bgColor: '#611f69',
tools: {
access: ['slack_send_message'],
config: { tool: () => 'slack_send_message' },
},
subBlocks: [
{
id: 'channel',
type: 'dropdown',
label: 'Channel',
mode: 'basic',
canonicalParamId: 'channel',
},
{
id: 'manualChannel',
type: 'short-input',
label: 'Channel ID',
mode: 'advanced',
canonicalParamId: 'channel',
},
{ id: 'text', type: 'long-input', label: 'Message' },
{ id: 'username', type: 'short-input', label: 'Username', mode: 'both' },
],
inputs: { text: { type: 'string' } },
},
conditional_block: {
name: 'Conditional Block',
description: 'Block with conditional fields',
category: 'tools',
bgColor: '#FF5700',
tools: {
access: ['conditional_tool'],
config: { tool: () => 'conditional_tool' },
},
subBlocks: [
{ id: 'mode', type: 'dropdown', label: 'Mode' },
{
id: 'optionA',
type: 'short-input',
label: 'Option A',
condition: { field: 'mode', value: 'a' },
},
{
id: 'optionB',
type: 'short-input',
label: 'Option B',
condition: { field: 'mode', value: 'b' },
},
{
id: 'notModeC',
type: 'short-input',
label: 'Not Mode C',
condition: { field: 'mode', value: 'c', not: true },
},
{
id: 'complexCondition',
type: 'short-input',
label: 'Complex',
condition: { field: 'mode', value: 'a', and: { field: 'optionA', value: 'special' } },
},
{
id: 'arrayCondition',
type: 'short-input',
label: 'Array Condition',
condition: { field: 'mode', value: ['a', 'b'] },
},
],
inputs: {},
},
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createMockGetBlock = (extraConfigs: Record<string, any> = {}) => {
const configs = { ...mockBlockConfigs, ...extraConfigs }
return (type: string) => configs[type] || null
}
const slackWithCanonicalParam = mockBlockConfigs.slack
return { mockBlockConfigs, createMockGetBlock, slackWithCanonicalParam }
})
vi.mock('@/blocks', () => ({
getBlock: createMockGetBlock(),
getAllBlocks: () => Object.values(mockBlockConfigs),
}))
vi.mock('@/tools/utils', () => toolsUtilsMock)
describe('Serializer Extended Tests', () => {
describe('WorkflowValidationError', () => {
it('should create error with block context', () => {
const error = new WorkflowValidationError(
'Test error message',
'block-123',
'agent',
'My Agent'
)
expect(error.message).toBe('Test error message')
expect(error.blockId).toBe('block-123')
expect(error.blockType).toBe('agent')
expect(error.blockName).toBe('My Agent')
expect(error.name).toBe('WorkflowValidationError')
})
it('should work without optional parameters', () => {
const error = new WorkflowValidationError('Simple error')
expect(error.message).toBe('Simple error')
expect(error.blockId).toBeUndefined()
expect(error.blockType).toBeUndefined()
expect(error.blockName).toBeUndefined()
})
})
describe('subflow block serialization', () => {
it('should serialize loop blocks correctly', () => {
const serializer = new Serializer()
const loopBlock: BlockState = {
id: 'loop-1',
type: 'loop',
name: 'My Loop',
position: { x: 100, y: 100 },
subBlocks: {},
outputs: { result: { type: 'string' } },
enabled: true,
data: { loopType: 'forEach', count: 5 },
} as BlockState
const serialized = serializer.serializeWorkflow({ 'loop-1': loopBlock }, [], {})
const serializedLoop = serialized.blocks.find((b) => b.id === 'loop-1')
expect(serializedLoop).toBeDefined()
expect(serializedLoop?.config.tool).toBe('')
expect(serializedLoop?.config.params).toEqual({ loopType: 'forEach', count: 5 })
expect(serializedLoop?.metadata?.id).toBe('loop')
expect(serializedLoop?.metadata?.name).toBe('My Loop')
expect(serializedLoop?.metadata?.category).toBe('subflow')
})
it('should serialize parallel blocks correctly', () => {
const serializer = new Serializer()
const parallelBlock: BlockState = {
id: 'parallel-1',
type: 'parallel',
name: 'My Parallel',
position: { x: 200, y: 200 },
subBlocks: {},
outputs: {},
enabled: false,
data: { parallelType: 'collection', count: 3 },
}
const serialized = serializer.serializeWorkflow({ 'parallel-1': parallelBlock }, [], {})
const serializedParallel = serialized.blocks.find((b) => b.id === 'parallel-1')
expect(serializedParallel).toBeDefined()
expect(serializedParallel?.config.tool).toBe('')
expect(serializedParallel?.config.params).toEqual({ parallelType: 'collection', count: 3 })
expect(serializedParallel?.metadata?.id).toBe('parallel')
expect(serializedParallel?.enabled).toBe(false)
})
it('should deserialize loop blocks correctly', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'loop-1',
position: { x: 100, y: 100 },
config: {
tool: '',
params: { loopType: 'for', count: 10 },
},
inputs: {},
outputs: { result: { type: 'string' } },
metadata: {
id: 'loop',
name: 'My Loop',
description: 'Loop container',
category: 'subflow',
},
enabled: true,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
const loopBlock = deserialized.blocks['loop-1']
expect(loopBlock).toBeDefined()
expect(loopBlock.type).toBe('loop')
expect(loopBlock.name).toBe('My Loop')
expect(loopBlock.data).toEqual({ loopType: 'for', count: 10 })
expect(loopBlock.subBlocks).toEqual({})
})
it('should deserialize parallel blocks correctly', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'parallel-1',
position: { x: 200, y: 200 },
config: {
tool: '',
params: { parallelType: 'count', count: 5 },
},
inputs: {},
outputs: {},
metadata: {
id: 'parallel',
name: 'My Parallel',
description: 'Parallel container',
category: 'subflow',
},
enabled: true,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
const parallelBlock = deserialized.blocks['parallel-1']
expect(parallelBlock).toBeDefined()
expect(parallelBlock.type).toBe('parallel')
expect(parallelBlock.name).toBe('My Parallel')
expect(parallelBlock.data).toEqual({ parallelType: 'count', count: 5 })
})
})
describe('evaluateCondition edge cases', () => {
it('should include field when condition matches simple value', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'cond-block',
type: 'conditional_block',
name: 'Conditional',
position: { x: 0, y: 0 },
subBlocks: {
mode: { id: 'mode', type: 'dropdown', value: 'a' },
optionA: { id: 'optionA', type: 'short-input', value: 'valueA' },
optionB: { id: 'optionB', type: 'short-input', value: 'valueB' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'cond-block': block }, [], {})
const serializedBlock = serialized.blocks.find((b) => b.id === 'cond-block')
expect(serializedBlock?.config.params.mode).toBe('a')
expect(serializedBlock?.config.params.optionA).toBe('valueA')
expect(serializedBlock?.config.params.optionB).toBeUndefined()
})
it('should handle NOT condition correctly', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'cond-block',
type: 'conditional_block',
name: 'Conditional',
position: { x: 0, y: 0 },
subBlocks: {
mode: { id: 'mode', type: 'dropdown', value: 'a' },
notModeC: { id: 'notModeC', type: 'short-input', value: 'shown' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'cond-block': block }, [], {})
const serializedBlock = serialized.blocks.find((b) => b.id === 'cond-block')
expect(serializedBlock?.config.params.notModeC).toBe('shown')
})
it('should exclude field when NOT condition fails', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'cond-block',
type: 'conditional_block',
name: 'Conditional',
position: { x: 0, y: 0 },
subBlocks: {
mode: { id: 'mode', type: 'dropdown', value: 'c' },
notModeC: { id: 'notModeC', type: 'short-input', value: 'hidden' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'cond-block': block }, [], {})
const serializedBlock = serialized.blocks.find((b) => b.id === 'cond-block')
expect(serializedBlock?.config.params.notModeC).toBeUndefined()
})
it('should handle AND condition correctly', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'cond-block',
type: 'conditional_block',
name: 'Conditional',
position: { x: 0, y: 0 },
subBlocks: {
mode: { id: 'mode', type: 'dropdown', value: 'a' },
optionA: { id: 'optionA', type: 'short-input', value: 'special' },
complexCondition: { id: 'complexCondition', type: 'short-input', value: 'shown' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'cond-block': block }, [], {})
const serializedBlock = serialized.blocks.find((b) => b.id === 'cond-block')
expect(serializedBlock?.config.params.complexCondition).toBe('shown')
})
it('should exclude field when AND condition partially fails', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'cond-block',
type: 'conditional_block',
name: 'Conditional',
position: { x: 0, y: 0 },
subBlocks: {
mode: { id: 'mode', type: 'dropdown', value: 'a' },
optionA: { id: 'optionA', type: 'short-input', value: 'not-special' },
complexCondition: { id: 'complexCondition', type: 'short-input', value: 'hidden' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'cond-block': block }, [], {})
const serializedBlock = serialized.blocks.find((b) => b.id === 'cond-block')
expect(serializedBlock?.config.params.complexCondition).toBeUndefined()
})
it('should handle array condition values', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'cond-block',
type: 'conditional_block',
name: 'Conditional',
position: { x: 0, y: 0 },
subBlocks: {
mode: { id: 'mode', type: 'dropdown', value: 'b' },
arrayCondition: { id: 'arrayCondition', type: 'short-input', value: 'included' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'cond-block': block }, [], {})
const serializedBlock = serialized.blocks.find((b) => b.id === 'cond-block')
expect(serializedBlock?.config.params.arrayCondition).toBe('included')
})
})
describe('canonical parameter handling', () => {
it('should use advanced value when canonicalModes specifies advanced', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'slack-1',
type: 'slack',
name: 'Slack',
position: { x: 0, y: 0 },
data: {
canonicalModes: { channel: 'advanced' },
},
subBlocks: {
channel: { id: 'channel', type: 'channel-selector', value: 'general' },
manualChannel: { id: 'manualChannel', type: 'short-input', value: 'C12345' },
text: { id: 'text', type: 'long-input', value: 'Hello' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock?.config.params.channel).toBe('C12345')
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
})
it('should use basic value when canonicalModes specifies basic', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'slack-1',
type: 'slack',
name: 'Slack',
position: { x: 0, y: 0 },
data: {
canonicalModes: { channel: 'basic' },
},
subBlocks: {
channel: { id: 'channel', type: 'channel-selector', value: 'general' },
manualChannel: { id: 'manualChannel', type: 'short-input', value: 'C12345' },
text: { id: 'text', type: 'long-input', value: 'Hello' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock?.config.params.channel).toBe('general')
})
it('should handle missing canonical param values', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'slack-1',
type: 'slack',
name: 'Slack',
position: { x: 0, y: 0 },
subBlocks: {
channel: { id: 'channel', type: 'channel-selector', value: null },
manualChannel: { id: 'manualChannel', type: 'short-input', value: null },
text: { id: 'text', type: 'long-input', value: 'Hello' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock?.config.params.channel).toBeNull()
})
})
describe('trigger mode serialization', () => {
it('should set triggerMode for trigger category blocks', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'webhook-1',
type: 'webhook',
name: 'Webhook',
position: { x: 0, y: 0 },
subBlocks: {
path: { id: 'path', type: 'short-input', value: '/api/webhook' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'webhook-1': block }, [], {})
const webhookBlock = serialized.blocks.find((b) => b.id === 'webhook-1')
expect(webhookBlock?.config.params.triggerMode).toBe(true)
})
it('should set triggerMode when block has triggerMode flag', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'agent-1',
type: 'agent',
name: 'Agent',
position: { x: 0, y: 0 },
triggerMode: true,
subBlocks: {
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
const agentBlock = serialized.blocks.find((b) => b.id === 'agent-1')
expect(agentBlock?.config.params.triggerMode).toBe(true)
})
it('should deserialize triggerMode correctly', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'agent-1',
position: { x: 0, y: 0 },
config: {
tool: 'openai',
params: { model: 'gpt-4o', triggerMode: true },
},
inputs: {},
outputs: {},
metadata: { id: 'agent', name: 'Agent', category: 'ai' },
enabled: true,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
expect(deserialized.blocks['agent-1'].triggerMode).toBe(true)
})
})
describe('advancedMode serialization', () => {
it('should set advancedMode in params when block has advancedMode flag', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'slack-1',
type: 'slack',
name: 'Slack',
position: { x: 0, y: 0 },
advancedMode: true,
subBlocks: {
text: { id: 'text', type: 'long-input', value: 'Hello' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
expect(slackBlock?.config.params.advancedMode).toBe(true)
})
it('should deserialize advancedMode correctly', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'slack-1',
position: { x: 0, y: 0 },
config: {
tool: 'slack_send_message',
params: { text: 'Hello', advancedMode: true },
},
inputs: {},
outputs: {},
metadata: { id: 'slack', name: 'Slack', category: 'tools' },
enabled: true,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
expect(deserialized.blocks['slack-1'].advancedMode).toBe(true)
})
})
describe('migrateAgentParamsToMessages', () => {
it('should migrate systemPrompt and userPrompt to messages array during deserialization', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'agent-1',
position: { x: 0, y: 0 },
config: {
tool: 'openai',
params: {
model: 'gpt-4o',
systemPrompt: 'You are helpful',
userPrompt: 'Hello there',
},
},
inputs: {},
outputs: {},
metadata: { id: 'agent', name: 'Agent' },
enabled: true,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
const agentBlock = deserialized.blocks['agent-1']
expect(agentBlock.subBlocks.messages).toBeDefined()
expect(agentBlock.subBlocks.messages.value).toEqual([
{ role: 'system', content: 'You are helpful' },
{ role: 'user', content: 'Hello there' },
])
})
it('should handle object userPrompt format', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'agent-1',
position: { x: 0, y: 0 },
config: {
tool: 'openai',
params: {
model: 'gpt-4o',
userPrompt: { input: 'From input object' },
},
},
inputs: {},
outputs: {},
metadata: { id: 'agent', name: 'Agent' },
enabled: true,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
const agentBlock = deserialized.blocks['agent-1']
expect(agentBlock.subBlocks.messages.value).toEqual([
{ role: 'user', content: 'From input object' },
])
})
it('should not migrate if messages already exists', () => {
const serializer = new Serializer()
const existingMessages = [{ role: 'user', content: 'Existing' }]
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'agent-1',
position: { x: 0, y: 0 },
config: {
tool: 'openai',
params: {
model: 'gpt-4o',
systemPrompt: 'Should not use',
userPrompt: 'Should not use',
messages: existingMessages,
},
},
inputs: {},
outputs: {},
metadata: { id: 'agent', name: 'Agent' },
enabled: true,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
const agentBlock = deserialized.blocks['agent-1']
expect(agentBlock.subBlocks.messages.value).toEqual(existingMessages)
})
it('should handle object without input property', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'agent-1',
position: { x: 0, y: 0 },
config: {
tool: 'openai',
params: {
model: 'gpt-4o',
userPrompt: { someKey: 'someValue' },
},
},
inputs: {},
outputs: {},
metadata: { id: 'agent', name: 'Agent' },
enabled: true,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
const agentBlock = deserialized.blocks['agent-1']
expect(agentBlock.subBlocks.messages.value).toEqual([
{ role: 'user', content: '{"someKey":"someValue"}' },
])
})
})
describe('using WorkflowBuilder from @sim/testing', () => {
it('should serialize a linear workflow built with WorkflowBuilder', () => {
const serializer = new Serializer()
const workflow = WorkflowBuilder.linear(3).build()
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
expect(serialized.blocks).toHaveLength(3)
expect(serialized.connections).toHaveLength(2)
})
it('should serialize a branching workflow built with WorkflowBuilder', () => {
const serializer = new Serializer()
const workflow = WorkflowBuilder.branching().build()
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
expect(serialized.blocks.length).toBeGreaterThanOrEqual(4)
const conditionEdges = serialized.connections.filter(
(c) => c.sourceHandle === 'condition-if' || c.sourceHandle === 'condition-else'
)
expect(conditionEdges).toHaveLength(2)
})
it('should serialize a workflow with loop built with WorkflowBuilder', () => {
const serializer = new Serializer()
const workflow = WorkflowBuilder.withLoop(5).build()
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
expect(serialized.loops).toBeDefined()
expect(Object.keys(serialized.loops).length).toBeGreaterThan(0)
})
it('should serialize a workflow with parallel built with WorkflowBuilder', () => {
const serializer = new Serializer()
const workflow = WorkflowBuilder.withParallel(3).build()
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops,
workflow.parallels
)
expect(serialized.parallels).toBeDefined()
expect(Object.keys(serialized.parallels!).length).toBeGreaterThan(0)
})
})
describe('using factory functions from @sim/testing', () => {
it('should serialize workflow created with createLinearWorkflow', () => {
const serializer = new Serializer()
const workflow = createLinearWorkflow(4)
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
expect(serialized.blocks).toHaveLength(4)
expect(serialized.connections).toHaveLength(3)
})
it('should serialize workflow created with createLoopWorkflow', () => {
const serializer = new Serializer()
const workflow = createLoopWorkflow(10)
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
expect(serialized.blocks.length).toBeGreaterThanOrEqual(3)
expect(serialized.loops.loop).toBeDefined()
expect(serialized.loops.loop.iterations).toBe(10)
})
it('should serialize workflow created with createParallelWorkflow', () => {
const serializer = new Serializer()
const workflow = createParallelWorkflow(4)
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops,
workflow.parallels
)
expect(serialized.parallels!.parallel).toBeDefined()
expect(serialized.parallels!.parallel.count).toBe(4)
})
it('should serialize blocks created with createBlock factory', () => {
const serializer = new Serializer()
const starterBlock = createStarterBlock({ id: 'starter' })
const functionBlock = createBlock({ id: 'func-1', type: 'function', name: 'My Function' })
const blocks = { starter: starterBlock, 'func-1': functionBlock }
const edges = [{ id: 'e1', source: 'starter', target: 'func-1' }]
const serialized = serializer.serializeWorkflow(asAppBlocks(blocks), edges, {})
expect(serialized.blocks).toHaveLength(2)
expect(serialized.blocks.find((b) => b.id === 'func-1')?.metadata?.name).toBe('My Function')
})
})
describe('error handling', () => {
it('should throw error for invalid block type during serialization', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'invalid-1',
type: 'nonexistent_type',
name: 'Invalid',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
}
expect(() => serializer.serializeWorkflow({ 'invalid-1': block }, [], {})).toThrow(
'Invalid block type: nonexistent_type'
)
})
it('should throw error for invalid block type during deserialization', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'invalid-1',
position: { x: 0, y: 0 },
config: { tool: 'test', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'nonexistent_type' },
enabled: true,
},
],
connections: [],
loops: {},
}
expect(() => serializer.deserializeWorkflow(serializedWorkflow)).toThrow(
'Invalid block type: nonexistent_type'
)
})
it('should throw error when metadata is missing during deserialization', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'no-metadata',
position: { x: 0, y: 0 },
config: { tool: 'test', params: {} },
inputs: {},
outputs: {},
metadata: undefined as any,
enabled: true,
},
],
connections: [],
loops: {},
}
expect(() => serializer.deserializeWorkflow(serializedWorkflow)).toThrow()
})
it('should handle tool selection failure gracefully', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'agent-1',
type: 'agent',
name: 'Agent',
position: { x: 0, y: 0 },
subBlocks: {
model: { id: 'model', type: 'dropdown', value: undefined as any },
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
// When model is undefined, the tool selector uses 'gpt-4o' as default, returning 'openai'
expect(serialized.blocks[0].config.tool).toBe('openai')
})
})
describe('disabled blocks handling', () => {
it('should serialize disabled blocks correctly', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'disabled-1',
type: 'function',
name: 'Disabled Block',
position: { x: 0, y: 0 },
subBlocks: {
code: { id: 'code', type: 'code', value: 'return 1' },
},
outputs: {},
enabled: false,
}
const serialized = serializer.serializeWorkflow({ 'disabled-1': block }, [], {})
expect(serialized.blocks[0].enabled).toBe(false)
})
it('should deserialize enabled status correctly', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'test-1',
position: { x: 0, y: 0 },
config: { tool: 'function', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function', name: 'Function' },
enabled: false,
},
],
connections: [],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
expect(deserialized.blocks['test-1'].enabled).toBe(true)
})
})
describe('connections serialization', () => {
it('should serialize connections with handles', () => {
const serializer = new Serializer()
const blocks = {
start: createStarterBlock({ id: 'start' }) as any,
cond: { ...createBlock({ id: 'cond', type: 'condition' }) } as BlockState,
end: { ...createBlock({ id: 'end', type: 'function' }) } as BlockState,
}
const edges = [
{ id: 'e1', source: 'start', target: 'cond' },
{ id: 'e2', source: 'cond', target: 'end', sourceHandle: 'condition-true' },
]
const serialized = serializer.serializeWorkflow(blocks, edges, {})
expect(serialized.connections).toHaveLength(2)
expect(serialized.connections[1].sourceHandle).toBe('condition-true')
})
it('should deserialize connections back to edges', () => {
const serializer = new Serializer()
const serializedWorkflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'start',
position: { x: 0, y: 0 },
config: { tool: 'starter', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'starter' },
enabled: true,
},
{
id: 'end',
position: { x: 200, y: 0 },
config: { tool: 'function', params: {} },
inputs: {},
outputs: {},
metadata: { id: 'function' },
enabled: true,
},
],
connections: [
{
source: 'start',
target: 'end',
sourceHandle: 'output',
targetHandle: 'input',
},
],
loops: {},
}
const deserialized = serializer.deserializeWorkflow(serializedWorkflow)
expect(deserialized.edges).toHaveLength(1)
expect(deserialized.edges[0].source).toBe('start')
expect(deserialized.edges[0].target).toBe('end')
expect(deserialized.edges[0].sourceHandle).toBe('output')
expect(deserialized.edges[0].targetHandle).toBe('input')
})
})
describe('starter block inputFormat handling', () => {
it('should include inputFormat when it has values', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'starter',
type: 'starter',
name: 'Start',
position: { x: 0, y: 0 },
subBlocks: {
description: { id: 'description', type: 'long-input', value: 'Test' },
inputFormat: {
id: 'inputFormat',
type: 'table',
value: [
['name', 'string'],
['age', 'number'],
],
},
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ starter: block }, [], {})
const starterBlock = serialized.blocks.find((b) => b.id === 'starter')
expect(starterBlock?.config.params.inputFormat).toEqual([
['name', 'string'],
['age', 'number'],
])
})
it('should include empty inputFormat array when present', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'starter',
type: 'starter',
name: 'Start',
position: { x: 0, y: 0 },
subBlocks: {
description: { id: 'description', type: 'long-input', value: 'Test' },
inputFormat: { id: 'inputFormat', type: 'table', value: [] },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ starter: block }, [], {})
const starterBlock = serialized.blocks.find((b) => b.id === 'starter')
// Empty arrays are still serialized (the check is for length > 0 for special handling, but still includes field)
expect(starterBlock?.config.params.inputFormat).toEqual([])
})
})
describe('agent tools handling', () => {
it('should serialize agent with empty tools array string', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'agent-1',
type: 'agent',
name: 'Agent',
position: { x: 0, y: 0 },
subBlocks: {
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
tools: { id: 'tools', type: 'tool-input', value: '[]' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
// With empty tools array, there are no non-custom tools, so toolId stays empty
// But then the fallback to blockConfig.tools.access[0] may happen
expect(serialized.blocks[0].config.tool).toBeDefined()
})
it('should serialize agent with array of tools', () => {
const serializer = new Serializer()
const tools = [
{ type: 'function', name: 'test' },
{ type: 'custom-tool', name: 'custom' },
]
const block: BlockState = {
id: 'agent-1',
type: 'agent',
name: 'Agent',
position: { x: 0, y: 0 },
subBlocks: {
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
tools: { id: 'tools', type: 'tool-input', value: tools as any },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
expect(serialized.blocks[0].config.tool).toBe('openai')
})
it('should handle invalid tools JSON gracefully', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'agent-1',
type: 'agent',
name: 'Agent',
position: { x: 0, y: 0 },
subBlocks: {
model: { id: 'model', type: 'dropdown', value: 'gpt-4o' },
prompt: { id: 'prompt', type: 'long-input', value: 'Test' },
tools: { id: 'tools', type: 'tool-input', value: 'invalid json' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'agent-1': block }, [], {})
expect(serialized.blocks[0].config.tool).toBe('anthropic_chat')
})
})
describe('round-trip serialization with @sim/testing workflows', () => {
it('should preserve data through round-trip with linear workflow', () => {
const serializer = new Serializer()
const workflow = createLinearWorkflow(3)
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
const deserialized = serializer.deserializeWorkflow(serialized)
expect(Object.keys(deserialized.blocks)).toHaveLength(3)
expect(deserialized.edges).toHaveLength(2)
})
it('should preserve data through round-trip with loop workflow', () => {
const serializer = new Serializer()
const workflow = createLoopWorkflow(5)
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
const deserialized = serializer.deserializeWorkflow(serialized)
expect(Object.keys(deserialized.blocks).length).toBeGreaterThanOrEqual(3)
})
})
describe('workflow with loops and parallels metadata', () => {
it('should serialize workflow with loops correctly', () => {
const serializer = new Serializer()
const workflow = createLoopWorkflow(5)
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
expect(serialized.loops).toBeDefined()
expect(serialized.loops.loop).toBeDefined()
expect(serialized.loops.loop.iterations).toBe(5)
expect(serialized.loops.loop.loopType).toBe('for')
})
it('should serialize workflow with parallels correctly', () => {
const serializer = new Serializer()
const workflow = createParallelWorkflow(3)
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops,
workflow.parallels
)
expect(serialized.parallels).toBeDefined()
expect(serialized.parallels!.parallel).toBeDefined()
expect(serialized.parallels!.parallel.count).toBe(3)
expect(serialized.parallels!.parallel.parallelType).toBe('count')
})
it('should handle empty loops and parallels', () => {
const serializer = new Serializer()
const workflow = createLinearWorkflow(2)
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
{},
{}
)
expect(serialized.loops).toEqual({})
expect(serialized.parallels).toEqual({})
})
})
describe('complex workflow scenarios', () => {
it('should serialize a workflow with multiple branches', () => {
const serializer = new Serializer()
const workflow = new WorkflowBuilder()
.addStarter('start')
.addCondition('cond1', { x: 200, y: 0 })
.addFunction('branch1', { x: 400, y: -100 })
.addFunction('branch2', { x: 400, y: 100 })
.addCondition('cond2', { x: 600, y: 0 })
.addFunction('final', { x: 800, y: 0 })
.connect('start', 'cond1')
.connect('cond1', 'branch1', 'condition-if')
.connect('cond1', 'branch2', 'condition-else')
.connect('branch1', 'cond2')
.connect('branch2', 'cond2')
.connect('cond2', 'final', 'condition-if')
.build()
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
expect(serialized.blocks).toHaveLength(6)
expect(serialized.connections).toHaveLength(6)
const conditionConnections = serialized.connections.filter(
(c) => c.sourceHandle === 'condition-if' || c.sourceHandle === 'condition-else'
)
expect(conditionConnections).toHaveLength(3)
})
it('should serialize a workflow with nested structures using WorkflowBuilder', () => {
const serializer = new Serializer()
const workflow = new WorkflowBuilder()
.addStarter('start')
.addLoop('outer-loop', { x: 200, y: 0 }, { iterations: 3 })
.addLoopChild('outer-loop', 'loop-task', 'function')
.addFunction('after-loop', { x: 500, y: 0 })
.connect('start', 'outer-loop')
.connect('outer-loop', 'after-loop')
.build()
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops,
workflow.parallels
)
expect(serialized.blocks.length).toBeGreaterThanOrEqual(3)
expect(serialized.loops['outer-loop']).toBeDefined()
expect(serialized.loops['outer-loop'].nodes).toContain('loop-task')
})
it('should handle a workflow built with WorkflowBuilder.chain', () => {
const serializer = new Serializer()
const workflow = WorkflowBuilder.chain(
{ id: 'start', type: 'starter' },
{ id: 'step1', type: 'function' },
{ id: 'step2', type: 'function' },
{ id: 'step3', type: 'function' }
).build()
const serialized = serializer.serializeWorkflow(
asAppBlocks(workflow.blocks),
workflow.edges,
workflow.loops
)
expect(serialized.blocks).toHaveLength(4)
expect(serialized.connections).toHaveLength(3)
// Verify chain connections
expect(serialized.connections[0].source).toBe('start')
expect(serialized.connections[0].target).toBe('step1')
expect(serialized.connections[1].source).toBe('step1')
expect(serialized.connections[1].target).toBe('step2')
expect(serialized.connections[2].source).toBe('step2')
expect(serialized.connections[2].target).toBe('step3')
})
})
describe('edge cases with empty and null values', () => {
it('should handle blocks with all null subBlock values', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'func-1',
type: 'function',
name: 'Function',
position: { x: 0, y: 0 },
subBlocks: {
code: { id: 'code', type: 'code', value: null },
language: { id: 'language', type: 'dropdown', value: null },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'func-1': block }, [], {})
const funcBlock = serialized.blocks.find((b) => b.id === 'func-1')
expect(funcBlock).toBeDefined()
expect(funcBlock?.config.params.code).toBeNull()
expect(funcBlock?.config.params.language).toBeNull()
})
it('should handle empty workflow', () => {
const serializer = new Serializer()
const serialized = serializer.serializeWorkflow({}, [], {})
expect(serialized.blocks).toHaveLength(0)
expect(serialized.connections).toHaveLength(0)
expect(serialized.loops).toEqual({})
})
it('should handle workflow with orphan blocks (no connections)', () => {
const serializer = new Serializer()
const blocks = {
block1: createBlock({ id: 'block1', type: 'function' }) as BlockState,
block2: createBlock({ id: 'block2', type: 'function' }) as BlockState,
block3: createBlock({ id: 'block3', type: 'function' }) as BlockState,
}
const serialized = serializer.serializeWorkflow(blocks, [], {})
expect(serialized.blocks).toHaveLength(3)
expect(serialized.connections).toHaveLength(0)
})
})
describe('block outputs serialization', () => {
it('should preserve block outputs during serialization', () => {
const serializer = new Serializer()
const block = {
id: 'func-1',
type: 'function',
name: 'Function',
position: { x: 0, y: 0 },
subBlocks: {
code: { id: 'code', type: 'code', value: 'return { result: 42 }' },
},
outputs: {
result: { type: 'number' },
error: { type: 'string' },
},
enabled: true,
} as BlockState
const serialized = serializer.serializeWorkflow({ 'func-1': block }, [], {})
const funcBlock = serialized.blocks.find((b) => b.id === 'func-1')
expect(funcBlock?.outputs.result).toEqual({ type: 'number' })
expect(funcBlock?.outputs.error).toEqual({ type: 'string' })
})
})
describe('position serialization', () => {
it('should preserve block positions during serialization', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'func-1',
type: 'function',
name: 'Function',
position: { x: 123, y: 456 },
subBlocks: {},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'func-1': block }, [], {})
expect(serialized.blocks[0].position).toEqual({ x: 123, y: 456 })
})
it('should preserve positions through round-trip', () => {
const serializer = new Serializer()
const block: BlockState = {
id: 'func-1',
type: 'function',
name: 'Function',
position: { x: 999, y: 888 },
subBlocks: {
code: { id: 'code', type: 'code', value: 'test' },
},
outputs: {},
enabled: true,
}
const serialized = serializer.serializeWorkflow({ 'func-1': block }, [], {})
const deserialized = serializer.deserializeWorkflow(serialized)
expect(deserialized.blocks['func-1'].position).toEqual({ x: 999, y: 888 })
})
})
})