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
500 lines
15 KiB
TypeScript
500 lines
15 KiB
TypeScript
import { setupGlobalFetchMock } from '@sim/testing'
|
|
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
|
import { BlockType } from '@/executor/constants'
|
|
import {
|
|
findMissingRequiredCustomBlockInputs,
|
|
remapCustomBlockInputKeys,
|
|
WorkflowBlockHandler,
|
|
} from '@/executor/handlers/workflow/workflow-handler'
|
|
import type { ExecutionContext } from '@/executor/types'
|
|
import type { SerializedBlock } from '@/serializer/types'
|
|
|
|
const { mockExecutorExecute, mockCreateSnapshot } = vi.hoisted(() => ({
|
|
mockExecutorExecute: vi.fn(),
|
|
mockCreateSnapshot: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/executor', () => ({
|
|
Executor: class {
|
|
execute = mockExecutorExecute
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/lib/logs/execution/snapshot/service', () => ({
|
|
snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot },
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/internal', () => ({
|
|
generateInternalToken: vi.fn().mockResolvedValue('test-token'),
|
|
}))
|
|
|
|
vi.mock('@/executor/utils/http', () => ({
|
|
buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }),
|
|
buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')),
|
|
extractAPIErrorMessage: vi.fn(async (response: Response) => {
|
|
const defaultMessage = `API request failed with status ${response.status}`
|
|
try {
|
|
const errorData = await response.json()
|
|
return errorData.error || defaultMessage
|
|
} catch {
|
|
return defaultMessage
|
|
}
|
|
}),
|
|
}))
|
|
|
|
// Mock fetch globally
|
|
setupGlobalFetchMock()
|
|
|
|
describe('WorkflowBlockHandler', () => {
|
|
let handler: WorkflowBlockHandler
|
|
let mockBlock: SerializedBlock
|
|
let mockContext: ExecutionContext
|
|
let mockFetch: Mock
|
|
|
|
beforeEach(() => {
|
|
// Mock window.location.origin for getBaseUrl()
|
|
;(global as any).window = {
|
|
location: {
|
|
origin: 'http://localhost:3000',
|
|
},
|
|
}
|
|
handler = new WorkflowBlockHandler()
|
|
mockFetch = global.fetch as Mock
|
|
|
|
mockBlock = {
|
|
id: 'workflow-block-1',
|
|
metadata: { id: BlockType.WORKFLOW, name: 'Test Workflow Block' },
|
|
position: { x: 0, y: 0 },
|
|
config: { tool: BlockType.WORKFLOW, params: {} },
|
|
inputs: { workflowId: 'string' },
|
|
outputs: {},
|
|
enabled: true,
|
|
}
|
|
|
|
mockContext = {
|
|
workflowId: 'parent-workflow-id',
|
|
blockStates: new Map(),
|
|
blockLogs: [],
|
|
metadata: { duration: 0 },
|
|
environmentVariables: {},
|
|
decisions: { router: new Map(), condition: new Map() },
|
|
loopExecutions: new Map(),
|
|
executedBlocks: new Set(),
|
|
activeExecutionPath: new Set(),
|
|
completedLoops: new Set(),
|
|
workflow: {
|
|
version: '1.0',
|
|
blocks: [],
|
|
connections: [],
|
|
loops: {},
|
|
},
|
|
}
|
|
|
|
// Reset all mocks
|
|
vi.clearAllMocks()
|
|
|
|
// Setup default fetch mock
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: {
|
|
name: 'Child Workflow',
|
|
state: {
|
|
blocks: [
|
|
{
|
|
id: 'starter',
|
|
metadata: { id: BlockType.STARTER, name: 'Starter' },
|
|
position: { x: 0, y: 0 },
|
|
config: { tool: BlockType.STARTER, params: {} },
|
|
inputs: {},
|
|
outputs: {},
|
|
enabled: true,
|
|
},
|
|
],
|
|
edges: [],
|
|
loops: {},
|
|
parallels: {},
|
|
},
|
|
},
|
|
}),
|
|
})
|
|
})
|
|
|
|
describe('canHandle', () => {
|
|
it('should handle workflow blocks', () => {
|
|
expect(handler.canHandle(mockBlock)).toBe(true)
|
|
})
|
|
|
|
it('should not handle non-workflow blocks', () => {
|
|
const nonWorkflowBlock = { ...mockBlock, metadata: { id: BlockType.FUNCTION } }
|
|
expect(handler.canHandle(nonWorkflowBlock)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('execute', () => {
|
|
it('should throw error when no workflowId is provided', async () => {
|
|
const inputs = {}
|
|
|
|
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
|
'No workflow selected for execution'
|
|
)
|
|
})
|
|
|
|
it('should enforce maximum call chain depth limit', async () => {
|
|
const inputs = { workflowId: 'child-workflow-id' }
|
|
|
|
const deepContext = {
|
|
...mockContext,
|
|
callChain: Array.from({ length: 25 }, (_, i) => `wf-${i}`),
|
|
}
|
|
|
|
await expect(handler.execute(deepContext, mockBlock, inputs)).rejects.toThrow(
|
|
'Maximum workflow call chain depth (25) exceeded'
|
|
)
|
|
})
|
|
|
|
it('should handle child workflow not found', async () => {
|
|
const inputs = { workflowId: 'non-existent-workflow' }
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
|
|
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
|
'"non-existent-workflow" failed: Child workflow non-existent-workflow not found'
|
|
)
|
|
})
|
|
|
|
it('should handle fetch errors gracefully', async () => {
|
|
const inputs = { workflowId: 'child-workflow-id' }
|
|
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
|
'"child-workflow-id" failed: Network error'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('workspace containment', () => {
|
|
const inputs = { workflowId: 'child-workflow-id' }
|
|
|
|
it('should fail a cross-workspace child in the draft loader path', async () => {
|
|
const ctx = { ...mockContext, workspaceId: 'workspace-parent' }
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: {
|
|
name: 'Foreign Workflow',
|
|
workspaceId: 'workspace-other',
|
|
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
|
|
},
|
|
}),
|
|
})
|
|
|
|
await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow(
|
|
'Child workflow child-workflow-id belongs to a different workspace and cannot be executed'
|
|
)
|
|
expect(mockCreateSnapshot).not.toHaveBeenCalled()
|
|
expect(mockExecutorExecute).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should fail a cross-workspace child in the deployed loader path', async () => {
|
|
const ctx = {
|
|
...mockContext,
|
|
workspaceId: 'workspace-parent',
|
|
isDeployedContext: true,
|
|
}
|
|
|
|
mockFetch.mockImplementation(async (url: unknown) => {
|
|
if (String(url).includes('/deployed')) {
|
|
return {
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: {
|
|
deployedState: { blocks: {}, edges: [], loops: {}, parallels: {} },
|
|
},
|
|
}),
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: {
|
|
name: 'Foreign Workflow',
|
|
workspaceId: 'workspace-other',
|
|
variables: {},
|
|
},
|
|
}),
|
|
}
|
|
})
|
|
|
|
await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow(
|
|
'Child workflow child-workflow-id belongs to a different workspace and cannot be executed'
|
|
)
|
|
expect(mockCreateSnapshot).not.toHaveBeenCalled()
|
|
expect(mockExecutorExecute).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should execute a same-workspace child as before', async () => {
|
|
const ctx = { ...mockContext, workspaceId: 'workspace-parent' }
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: {
|
|
name: 'Child Workflow',
|
|
workspaceId: 'workspace-parent',
|
|
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
|
|
},
|
|
}),
|
|
})
|
|
mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } })
|
|
mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } })
|
|
|
|
const result = await handler.execute(ctx, mockBlock, inputs)
|
|
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
childWorkflowId: 'child-workflow-id',
|
|
childWorkflowName: 'Child Workflow',
|
|
childWorkflowSnapshotId: 'snapshot-1',
|
|
result: { data: 'ok' },
|
|
})
|
|
expect(mockExecutorExecute).toHaveBeenCalledWith('child-workflow-id')
|
|
})
|
|
|
|
it('should fail closed when the executing context has no workspace', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: {
|
|
name: 'Child Workflow',
|
|
workspaceId: 'workspace-parent',
|
|
state: { blocks: {}, edges: [], loops: {}, parallels: {} },
|
|
},
|
|
}),
|
|
})
|
|
|
|
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
|
'Cannot execute child workflow child-workflow-id: executing context has no workspace'
|
|
)
|
|
expect(mockExecutorExecute).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('loadChildWorkflow', () => {
|
|
it('should return null for 404 responses', async () => {
|
|
const workflowId = 'non-existent-workflow'
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
statusText: 'Not Found',
|
|
text: () => Promise.resolve(''),
|
|
})
|
|
|
|
const result = await (handler as any).loadChildWorkflow(workflowId)
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('should handle invalid workflow state', async () => {
|
|
const workflowId = 'invalid-workflow'
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: {
|
|
name: 'Invalid Workflow',
|
|
state: null, // Invalid state
|
|
},
|
|
}),
|
|
})
|
|
|
|
await expect((handler as any).loadChildWorkflow(workflowId)).rejects.toThrow(
|
|
'Child workflow invalid-workflow has invalid state'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('mapChildOutputToParent', () => {
|
|
it('should map successful child output correctly', () => {
|
|
const childResult = {
|
|
success: true,
|
|
output: { data: 'test result' },
|
|
}
|
|
|
|
const result = (handler as any).mapChildOutputToParent(
|
|
childResult,
|
|
'child-id',
|
|
'Child Workflow',
|
|
100
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
childWorkflowId: 'child-id',
|
|
childWorkflowName: 'Child Workflow',
|
|
result: { data: 'test result' },
|
|
childTraceSpans: [],
|
|
})
|
|
})
|
|
|
|
it('should throw error for failed child output so BlockExecutor can check error port', () => {
|
|
const childResult = {
|
|
success: false,
|
|
error: 'Child workflow failed',
|
|
}
|
|
|
|
expect(() =>
|
|
(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
|
|
).toThrow('"Child Workflow" failed: Child workflow failed')
|
|
|
|
try {
|
|
;(handler as any).mapChildOutputToParent(childResult, 'child-id', 'Child Workflow', 100)
|
|
} catch (error: any) {
|
|
expect(error.childTraceSpans).toEqual([])
|
|
}
|
|
})
|
|
|
|
it('should handle nested response structures', () => {
|
|
const childResult = {
|
|
output: { nested: 'data' },
|
|
}
|
|
|
|
const result = (handler as any).mapChildOutputToParent(
|
|
childResult,
|
|
'child-id',
|
|
'Child Workflow',
|
|
100
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
childWorkflowId: 'child-id',
|
|
childWorkflowName: 'Child Workflow',
|
|
result: { nested: 'data' },
|
|
childTraceSpans: [],
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('remapCustomBlockInputKeys', () => {
|
|
const childBlocks = {
|
|
start: {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [
|
|
{ id: 'f1', name: 'firstName', type: 'string' },
|
|
{ id: 'f2', name: 'payload', type: 'object' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
it('maps field ids to current names and drops keys with no matching field', () => {
|
|
const out = remapCustomBlockInputKeys(
|
|
{ f1: 'Theodore', removed: 'stale' },
|
|
childBlocks as Record<string, unknown>
|
|
)
|
|
expect(out).toEqual({ firstName: 'Theodore' })
|
|
expect('removed' in out).toBe(false)
|
|
})
|
|
|
|
it('decodes an object/array input from its JSON-string value (no double-encoding)', () => {
|
|
const out = remapCustomBlockInputKeys(
|
|
{ f1: 'Theodore', f2: '"hello"' },
|
|
childBlocks as Record<string, unknown>
|
|
)
|
|
expect(out).toEqual({ firstName: 'Theodore', payload: 'hello' })
|
|
})
|
|
|
|
it('parses a real object value and leaves invalid JSON as a raw string', () => {
|
|
expect(
|
|
remapCustomBlockInputKeys({ f2: '{"a":1}' }, childBlocks as Record<string, unknown>)
|
|
).toEqual({ payload: { a: 1 } })
|
|
expect(
|
|
remapCustomBlockInputKeys({ f2: 'not json' }, childBlocks as Record<string, unknown>)
|
|
).toEqual({ payload: 'not json' })
|
|
})
|
|
})
|
|
|
|
describe('findMissingRequiredCustomBlockInputs', () => {
|
|
const childBlocks = {
|
|
start: {
|
|
type: 'start_trigger',
|
|
subBlocks: {
|
|
inputFormat: {
|
|
value: [
|
|
{ id: 'f1', name: 'firstName', type: 'string' },
|
|
{ id: 'f2', name: 'payload', type: 'object' },
|
|
{ name: 'legacyField', type: 'string' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as Record<string, unknown>
|
|
|
|
it('flags a required field left empty and reports its display name', () => {
|
|
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, {})).toEqual(['firstName'])
|
|
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: '' })).toEqual([
|
|
'firstName',
|
|
])
|
|
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: null })).toEqual([
|
|
'firstName',
|
|
])
|
|
})
|
|
|
|
it('passes when the required field has a value', () => {
|
|
expect(
|
|
findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'Theodore' })
|
|
).toEqual([])
|
|
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 0 })).toEqual([])
|
|
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: false })).toEqual(
|
|
[]
|
|
)
|
|
})
|
|
|
|
it('ignores a stale required override whose field was removed from the Start', () => {
|
|
expect(findMissingRequiredCustomBlockInputs(['removed-field'], childBlocks, {})).toEqual([])
|
|
})
|
|
|
|
it('treats fields without an override as optional', () => {
|
|
expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'x' })).toEqual(
|
|
[]
|
|
)
|
|
expect(findMissingRequiredCustomBlockInputs([], childBlocks, {})).toEqual([])
|
|
})
|
|
|
|
it('keys legacy fields without a stable id by name', () => {
|
|
expect(findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, {})).toEqual([
|
|
'legacyField',
|
|
])
|
|
expect(
|
|
findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, { legacyField: 'v' })
|
|
).toEqual([])
|
|
})
|
|
|
|
it('reports every missing required field at once', () => {
|
|
expect(findMissingRequiredCustomBlockInputs(['f1', 'f2'], childBlocks, {})).toEqual([
|
|
'firstName',
|
|
'payload',
|
|
])
|
|
})
|
|
})
|