feat: Step Function + small fixes + more services + color matching the services

This commit is contained in:
Zakaria
2026-09-28 18:38:22 -04:00
parent 0d613ae256
commit 548a15d7ec
23 changed files with 3219 additions and 52 deletions
@@ -0,0 +1,804 @@
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import userEvent from '@testing-library/user-event'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import type {ComponentType, MouseEvent, ReactNode} from 'react'
import {buildFlowGraph, deleteStateInScope, insertRootState, renameStateInScope, spliceLambdaIntoEdge, spliceTaskIntoEdge, WorkflowStudio} from '@/components/WorkflowStudio'
import type {CloudResource} from '@/types/resource'
const {getCloudResource, listCloudResources, updateCloudResource} = vi.hoisted(() => ({getCloudResource: vi.fn(), listCloudResources: vi.fn(), updateCloudResource: vi.fn()}))
vi.mock('@/api/cloudProxyClient', () => ({getCloudResource, listCloudResources, updateCloudResource}))
vi.mock('@xyflow/react', () => ({
Background: () => null,
BaseEdge: () => null,
Controls: () => null,
EdgeLabelRenderer: ({children}: {children: ReactNode}) => children,
getSmoothStepPath: () => ['M 0 0 L 100 100', 50, 50],
Handle: () => null,
MarkerType: {ArrowClosed: 'arrowclosed'},
Position: {Top: 'top', Bottom: 'bottom', Right: 'right'},
ReactFlow: ({nodes, edges, nodeTypes, edgeTypes, onNodeClick}: {nodes: Array<{id: string; type?: string; data: Record<string, unknown>}>; edges: Array<{id: string; type?: string; data?: Record<string, unknown>}>; nodeTypes: Record<string, ComponentType<{data: Record<string, unknown>}>>; edgeTypes: Record<string, ComponentType<Record<string, unknown>>>; onNodeClick: (event: MouseEvent, node: {data: Record<string, unknown>}) => void}) => <div>{nodes.map((node) => {
const NodeType = node.type ? nodeTypes[node.type] : undefined
if (node.type === 'placeholder' && NodeType) return <div key={node.id} onClick={(event) => onNodeClick(event, node)}><NodeType data={node.data}/></div>
return NodeType ? <div key={node.id}><button type="button" onClick={(event) => onNodeClick(event, node)}>{String(node.data.label)}</button><NodeType data={node.data}/></div> : <button key={node.id} type="button" onClick={(event) => onNodeClick(event, node)}>{String(node.data.label)}</button>
})}{edges.map((edge) => {
const EdgeType = edge.type ? edgeTypes[edge.type] : undefined
const edgeProps = {id: edge.id, sourceX: 0, sourceY: 0, targetX: 100, targetY: 100, sourcePosition: 'bottom', targetPosition: 'top', data: edge.data} as Record<string, unknown>
return EdgeType ? <EdgeType key={edge.id} {...edgeProps}/> : null
})}</div>,
}))
const definition = JSON.stringify({
StartAt: 'Work',
States: {
Work: {Type: 'Task', Resource: 'arn:aws:lambda:local', Retry: [{ErrorEquals: ['States.ALL'], UnknownRetryField: {kept: true}}], Catch: [{ErrorEquals: ['States.ALL'], Next: 'Failed', UnknownCatchField: {kept: true}}], Next: 'Decision', UnknownTaskField: {kept: true}},
DirectMissing: {Type: 'Task', Resource: 'arn:aws:lambda:us-east-1:000000000000:function:missing', Next: 'Done', UnknownTaskField: {kept: true}},
IntegrationAttached: {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {FunctionName: 'arn:aws:lambda:local'}, Next: 'Done'},
IntegrationMissing: {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {FunctionName: 'arn:aws:lambda:us-east-1:000000000000:function:missing-integration', Payload: {keep: true}}, Next: 'Done'},
PublishNotice: {Type: 'Task', Resource: 'arn:aws:states:::sns:publish', Parameters: {TopicArn: 'arn:aws:sns:us-east-1:000000000000:old-topic', Message: 'keep'}, Next: 'Done'},
NonLambda: {Type: 'Task', Resource: 'arn:aws:states:::dynamodb:putItem', Next: 'Done'},
NoResource: {Type: 'Task', Next: 'Done'},
Decision: {Type: 'Choice', Choices: [{Variable: '$.approved', BooleanEquals: true, Next: 'Done'}], Default: 'Failed', Custom: ['unchanged']},
PassThrough: {Type: 'Pass', Next: 'WaitHere'},
WaitHere: {Type: 'Wait', Seconds: 3, Next: 'Done'},
Done: {Type: 'Succeed'},
Failed: {Type: 'Fail'},
ParallelWork: {Type: 'Parallel', Branches: [{StartAt: 'BranchPass', States: {BranchPass: {Type: 'Pass', End: true}}}]},
MapWork: {Type: 'Map', Iterator: {StartAt: 'ItemPass', States: {ItemPass: {Type: 'Pass', End: true}}}},
},
})
const resource: CloudResource = {
id: 'arn:aws:states:us-east-1:000000000000:stateMachine:orders',
name: 'orders',
cloud: 'aws',
service: 'workflows',
type: 'state-machine',
region: 'us-east-1',
createdAt: null,
metadata: {machineType: 'STANDARD', definition},
}
function renderStudio(selected = resource) {
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
return {queryClient, ...render(<QueryClientProvider client={queryClient}><WorkflowStudio resource={selected}/></QueryClientProvider>)}
}
describe('WorkflowStudio', () => {
beforeEach(() => {
getCloudResource.mockReset()
listCloudResources.mockReset()
updateCloudResource.mockReset()
})
test('maps supported ASL states, top-to-bottom boundaries, choice labels, retry markers, and directional catch edges', () => {
const graph = buildFlowGraph(JSON.parse(definition) as Record<string, unknown>, new Set(['arn:aws:lambda:local']))
expect(graph.nodes.map((node) => node.data.stateType)).toEqual(expect.arrayContaining(['Task', 'Choice', 'Pass', 'Parallel', 'Map', 'Wait', 'Succeed', 'Fail']))
expect(graph.nodes.find((node) => node.data.label === 'Work')?.data.retry).toBe(true)
expect(graph.edges).toEqual(expect.arrayContaining([
expect.objectContaining({label: '$.approved BooleanEquals true'}),
expect.objectContaining({label: 'Default'}),
expect.objectContaining({label: 'Catch', className: 'workflow-catch-edge'}),
]))
expect(graph.nodes.find((node) => node.data.label === 'BranchPass')?.parentId).toContain('ParallelWork')
expect(graph.nodes.find((node) => node.data.label === 'ItemPass')?.parentId).toContain('MapWork')
expect(graph.nodes.map((node) => node.data.label)).toEqual(expect.arrayContaining(['Start', 'End']))
expect(graph.nodes.find((node) => node.data.label === 'Work')?.position.y).toBeGreaterThan(graph.nodes.find((node) => node.data.label === 'Start')?.position.y ?? 0)
expect(graph.edges.every((edge) => edge.markerEnd)).toBe(true)
expect(graph.nodes.find((node) => node.data.label === 'DirectMissing')?.data.unattached).toBe(true)
expect(graph.nodes.find((node) => node.data.label === 'Work')?.data.unattached).toBe(false)
expect(graph.nodes.find((node) => node.data.label === 'IntegrationAttached')?.data.unattached).toBe(false)
expect(graph.nodes.find((node) => node.data.label === 'IntegrationMissing')?.data.unattached).toBe(true)
expect(graph.nodes.find((node) => node.data.label === 'NonLambda')?.data.unattached).toBe(false)
expect(graph.nodes.find((node) => node.data.label === 'NoResource')?.data.unattached).toBe(false)
expect(graph.nodes.find((node) => node.data.label === 'Decision')?.data.choiceBranches).toEqual(['Rule #1', 'Default'])
expect(graph.edges.find((edge) => edge.label === '$.approved BooleanEquals true')).toMatchObject({sourceHandle: 'choice-0'})
})
test('renders transient start-to-guidance-to-end edges for an empty States object', () => {
const graph = buildFlowGraph({StartAt: 'Initial', States: {}})
expect(graph.nodes.map((node) => node.id)).toEqual(expect.arrayContaining(['workflow-start', 'workflow-empty-guidance', 'workflow-end']))
expect(graph.edges).toEqual(expect.arrayContaining([
expect.objectContaining({source: 'workflow-start', target: 'workflow-empty-guidance'}),
expect.objectContaining({source: 'workflow-empty-guidance', target: 'workflow-end'}),
]))
})
test('connects each terminal state to End', () => {
const graph = buildFlowGraph({StartAt: 'Done', States: {Done: {Type: 'Succeed'}, Failed: {Type: 'Fail'}}})
expect(graph.edges).toEqual(expect.arrayContaining([
expect.objectContaining({source: 'states/Done', target: 'workflow-end'}),
expect.objectContaining({source: 'states/Failed', target: 'workflow-end'}),
]))
})
test('adds focused action skeletons and continues a previous End state', () => {
const initial = {StartAt: 'Done', States: {Done: {Type: 'Pass', End: true}}}
const lambda = insertRootState(initial, 'lambda').definition as {States: Record<string, Record<string, unknown>>}
expect(lambda.States.Done).toEqual({Type: 'Pass', Next: 'LambdaInvoke'})
expect(lambda.States.LambdaInvoke).toEqual({
Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke',
Parameters: {'Payload.$': '$', FunctionName: 'Enter Lambda function name'},
OutputPath: '$.Payload',
Retry: [{ErrorEquals: ['Lambda.ServiceException', 'Lambda.AWSLambdaException', 'Lambda.SdkClientException', 'Lambda.TooManyRequestsException'], IntervalSeconds: 2, MaxAttempts: 6, BackoffRate: 2}],
End: true,
})
const sns = insertRootState(initial, 'sns').definition as {States: Record<string, Record<string, unknown>>}
expect(sns.States.SnsPublish).toEqual({Type: 'Task', Resource: 'arn:aws:states:::sns:publish', Parameters: {TopicArn: ''}, End: true})
const integrations = [
['sqs-send-message', 'SqsSendMessage', 'arn:aws:states:::sqs:sendMessage'],
['dynamodb-put-item', 'DynamoDbPutItem', 'arn:aws:states:::dynamodb:putItem'],
['dynamodb-get-item', 'DynamoDbGetItem', 'arn:aws:states:::dynamodb:getItem'],
['dynamodb-update-item', 'DynamoDbUpdateItem', 'arn:aws:states:::dynamodb:updateItem'],
['dynamodb-delete-item', 'DynamoDbDeleteItem', 'arn:aws:states:::dynamodb:deleteItem'],
['eventbridge-put-events', 'EventBridgePutEvents', 'arn:aws:states:::events:putEvents'],
['ecs-run-task', 'EcsRunTask', 'arn:aws:states:::ecs:runTask'],
['ecs-run-task-sync', 'EcsRunTaskSync', 'arn:aws:states:::ecs:runTask.sync'],
['states-start-execution', 'StartExecution', 'arn:aws:states:::states:startExecution'],
] as const
integrations.forEach(([action, stateName, taskResource]) => {
const workflow = insertRootState(initial, action).definition as {States: Record<string, Record<string, unknown>>}
expect(workflow.States[stateName]).toMatchObject({Type: 'Task', Resource: taskResource, Parameters: expect.any(Object)})
})
})
test('shows only live-audited integrations grouped by service', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockResolvedValue([])
renderStudio()
for (const name of ['Lambda Invoke', 'SNS Publish', 'SQS SendMessage', 'DynamoDB PutItem', 'DynamoDB GetItem', 'DynamoDB UpdateItem', 'DynamoDB DeleteItem', 'EventBridge PutEvents', 'ECS RunTask', 'ECS RunTask (.sync)', 'Step Functions StartExecution']) {
expect(await screen.findByRole('button', {name})).toBeInTheDocument()
}
expect(screen.queryByRole('button', {name: /EC2 RunInstances/})).not.toBeInTheDocument()
expect(screen.queryByRole('button', {name: /API Gateway Invoke/})).not.toBeInTheDocument()
})
test('creates a Choice with two transient branch placeholders and no dangling ASL targets', () => {
const result = insertRootState({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}}, 'choice')
const workflow = result.definition as {States: Record<string, Record<string, unknown>>}
const choice = workflow.States[result.name]
const graph = buildFlowGraph(result.definition)
expect(choice).toEqual({Type: 'Choice', Choices: [{Variable: '$.choice', IsPresent: true}]})
expect(JSON.stringify(result.definition)).not.toMatch(/placeholder|__ui|draft/i)
expect(graph.nodes.filter((node) => node.type === 'placeholder').map((node) => node.data.label)).toEqual(['Rule #1', 'Default'])
expect(graph.edges).toEqual(expect.arrayContaining([
expect.objectContaining({source: `states/${result.name}`, target: `states/${result.name}/placeholder-0`, label: 'Rule #1'}),
expect.objectContaining({source: `states/${result.name}`, target: `states/${result.name}/placeholder-default`, label: 'Default'}),
expect.objectContaining({source: `states/${result.name}/placeholder-0`, target: 'workflow-end'}),
expect.objectContaining({source: `states/${result.name}/placeholder-default`, target: 'workflow-end'}),
]))
})
test('assigns Lambda and SNS to Choice lanes, edits the rule, and saves marker-free valid ASL', async () => {
const simpleDefinition = JSON.stringify({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}})
const simpleResource = {...resource, metadata: {...resource.metadata, definition: simpleDefinition}}
getCloudResource.mockResolvedValue(simpleResource)
listCloudResources.mockResolvedValue([])
updateCloudResource.mockResolvedValue(simpleResource)
const user = userEvent.setup()
renderStudio(simpleResource)
await user.click(await screen.findByRole('button', {name: 'Flow'}))
await user.click(screen.getByRole('button', {name: 'Choice'}))
expect(screen.getByRole('alert')).toHaveTextContent('1 error')
expect(screen.getByRole('button', {name: 'Save definition'})).toBeDisabled()
expect(screen.getByRole('button', {name: 'Rule #1: Drop state here'})).toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Default: Drop state here'})).toBeInTheDocument()
await user.clear(screen.getByRole('textbox', {name: 'Rule 1 variable'}))
await user.type(screen.getByRole('textbox', {name: 'Rule 1 variable'}), '$.total')
await user.selectOptions(screen.getByRole('combobox', {name: 'Rule 1 operator'}), 'NumericEquals')
await user.clear(screen.getByRole('textbox', {name: 'Rule 1 value'}))
await user.type(screen.getByRole('textbox', {name: 'Rule 1 value'}), '42')
await user.click(screen.getByRole('button', {name: 'Rule #1: Drop state here'}))
await user.click(screen.getByRole('button', {name: 'Actions'}))
await user.click(screen.getByRole('button', {name: 'Lambda Invoke'}))
expect(screen.getByRole('button', {name: 'Save definition'})).toBeEnabled()
await user.click(screen.getByRole('button', {name: 'Default: Drop state here'}))
await user.click(screen.getByRole('button', {name: 'SNS Publish'}))
expect(screen.queryByText(/^\d+ errors?$/)).not.toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Save definition'})).toBeEnabled()
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
const savedText = updateCloudResource.mock.calls[0][3].definition as string
const saved = JSON.parse(savedText) as {States: Record<string, Record<string, unknown> & {Choices?: Array<Record<string, unknown>>}>}
expect(saved.States.Choice.Choices?.[0]).toMatchObject({Variable: '$.total', NumericEquals: 42, Next: 'LambdaInvoke'})
expect(saved.States.Choice.Default).toBe('SnsPublish')
expect(saved.States.LambdaInvoke).toMatchObject({Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke'})
expect(saved.States.SnsPublish).toMatchObject({Type: 'Task', Resource: 'arn:aws:states:::sns:publish'})
expect(savedText).not.toMatch(/placeholder|__ui|draft/i)
const graph = buildFlowGraph(saved)
expect(graph.nodes.some((node) => node.type === 'placeholder')).toBe(false)
expect(graph.edges).toEqual(expect.arrayContaining([
expect.objectContaining({source: 'states/Choice', target: 'states/LambdaInvoke'}),
expect.objectContaining({source: 'states/Choice', target: 'states/SnsPublish'}),
expect.objectContaining({source: 'states/LambdaInvoke', target: 'workflow-end'}),
expect.objectContaining({source: 'states/SnsPublish', target: 'workflow-end'}),
]))
})
test('deleting an assigned Choice branch restores its placeholder and blocks saving', async () => {
const assignedDefinition = JSON.stringify({
StartAt: 'Choice',
States: {
Choice: {Type: 'Choice', Choices: [{Variable: '$.ok', BooleanEquals: true, Next: 'LambdaInvoke'}], Default: 'SnsPublish'},
LambdaInvoke: {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {FunctionName: 'worker'}, End: true},
SnsPublish: {Type: 'Task', Resource: 'arn:aws:states:::sns:publish', Parameters: {TopicArn: 'topic'}, End: true},
},
})
const assignedResource = {...resource, metadata: {...resource.metadata, definition: assignedDefinition}}
getCloudResource.mockResolvedValue(assignedResource)
listCloudResources.mockResolvedValue([])
const user = userEvent.setup()
renderStudio(assignedResource)
await user.click(await screen.findByRole('button', {name: 'Delete LambdaInvoke'}))
expect(screen.getByText('1 error')).toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Rule #1: Drop state here'})).toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Save definition'})).toBeDisabled()
expect(screen.getByRole('button', {name: 'SnsPublish'})).toBeInTheDocument()
})
test.each([
['Succeed', {Type: 'Succeed', UnknownTerminalField: {kept: true}}],
['Fail', {Type: 'Fail', Error: 'OriginalFailure', UnknownTerminalField: {kept: true}}],
])('prepends Lambda and SNS from a %s start without detached states', (_type, terminalState) => {
const initial = {StartAt: 'Terminal', States: {Terminal: terminalState}}
const lambdaResult = insertRootState(initial, 'lambda')
const snsResult = insertRootState(lambdaResult.definition, 'sns')
const workflow = snsResult.definition as {StartAt: string; States: Record<string, Record<string, unknown>>}
const graph = buildFlowGraph(snsResult.definition)
expect(workflow.StartAt).toBe('SnsPublish')
expect(workflow.States.SnsPublish).toMatchObject({Type: 'Task', Next: 'LambdaInvoke'})
expect(workflow.States.SnsPublish.End).toBeUndefined()
expect(workflow.States.LambdaInvoke).toMatchObject({Type: 'Task', Next: 'Terminal'})
expect(workflow.States.LambdaInvoke.End).toBeUndefined()
expect(workflow.States.Terminal).toEqual(terminalState)
expect(graph.edges).toEqual(expect.arrayContaining([
expect.objectContaining({source: 'workflow-start', target: 'states/SnsPublish'}),
expect.objectContaining({source: 'states/SnsPublish', target: 'states/LambdaInvoke'}),
expect.objectContaining({source: 'states/LambdaInvoke', target: 'states/Terminal'}),
]))
Object.values(workflow.States).forEach((state) => {
if (state.Type === 'Succeed' || state.Type === 'Fail') {
expect(state.Next).toBeUndefined()
expect(state.End).toBeUndefined()
}
})
})
test.each([
['succeed', 'Fail'],
['fail', 'Succeed'],
] as const)('extends an explicit terminal safely when adding %s', (action, originalType) => {
const initial = {StartAt: 'Terminal', States: {Terminal: {Type: originalType, ...(originalType === 'Fail' ? {Error: 'OriginalFailure'} : {}), UnknownTerminalField: {kept: true}}}}
const result = insertRootState(initial, action)
const workflow = result.definition as {States: Record<string, Record<string, unknown>>}
expect(workflow.States.Terminal).toEqual({Type: 'Pass', Next: result.name, UnknownTerminalField: {kept: true}})
expect(workflow.States[result.name]).toMatchObject({Type: action === 'succeed' ? 'Succeed' : 'Fail'})
expect(workflow.States[result.name].Next).toBeUndefined()
expect(workflow.States[result.name].End).toBeUndefined()
})
test('renames only within the owning States scope and rewrites start, next, choice, and catch references', () => {
const source = {
StartAt: 'Work',
States: {
Work: {Type: 'Task', Next: 'Decision', Catch: [{ErrorEquals: ['States.ALL'], Next: 'Failed'}], Unknown: true},
Decision: {Type: 'Choice', Choices: [{Variable: '$.ok', BooleanEquals: true, Next: 'Work'}], Default: 'Work'},
Failed: {Type: 'Fail'},
Nested: {Type: 'Parallel', Branches: [{StartAt: 'Work', States: {Work: {Type: 'Pass', End: true}}}]},
},
}
const renamed = renameStateInScope(source, ['states', 'Work'], 'Process')!.definition as {StartAt: string; States: Record<string, Record<string, unknown>>}
expect(renamed.StartAt).toBe('Process')
expect(renamed.States.Work).toBeUndefined()
expect(renamed.States.Process).toMatchObject({Next: 'Decision', Catch: [{Next: 'Failed'}], Unknown: true})
expect(renamed.States.Decision).toMatchObject({Choices: [{Next: 'Process'}], Default: 'Process'})
expect((renamed.States.Nested.Branches as Array<{StartAt: string}>)[0].StartAt).toBe('Work')
})
test('adds an unattached Lambda from the palette and supports local undo and redo', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockResolvedValue([])
const user = userEvent.setup()
renderStudio()
await screen.findByRole('button', {name: 'Work'})
await user.click(screen.getByRole('button', {name: 'Lambda Invoke'}))
expect(screen.getByRole('alert')).toHaveTextContent('Task states reference a Lambda that is not in the current inventory')
await user.click(screen.getByRole('button', {name: 'Definition'}))
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"FunctionName": "Enter Lambda function name"')
expect(screen.getByRole('button', {name: 'Undo'})).toBeEnabled()
await user.click(screen.getByRole('button', {name: 'Undo'}))
expect(screen.queryByRole('button', {name: 'LambdaInvoke'})).not.toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Redo'}))
expect(screen.getByRole('button', {name: 'LambdaInvoke'})).toBeInTheDocument()
})
test('shows the complete editable workflow definition rather than only the selected state', async () => {
getCloudResource.mockResolvedValue(resource)
const user = userEvent.setup()
renderStudio()
await user.click(await screen.findByRole('button', {name: 'Decision'}))
await user.click(screen.getByRole('button', {name: 'Definition'}))
const editor = screen.getByRole('textbox', {name: 'Complete ASL definition'})
expect((editor as HTMLTextAreaElement).value).toContain('"Custom": [')
expect((editor as HTMLTextAreaElement).value).toContain('"StartAt": "Work"')
})
test('deletes a state from its compact node button without selecting the node first', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockResolvedValue([])
const user = userEvent.setup()
renderStudio()
await screen.findByRole('button', {name: 'Delete Work'})
await user.click(screen.getByRole('button', {name: 'Delete Work'}))
expect(screen.queryByRole('button', {name: 'Delete Work'})).not.toBeInTheDocument()
expect(screen.queryByRole('button', {name: 'Work'})).not.toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Save definition'})).toBeEnabled()
})
test('uses deterministic deletion for linear, terminal, catch, and converging references', () => {
const linear = deleteStateInScope({StartAt: 'A', States: {A: {Type: 'Pass', Next: 'B'}, B: {Type: 'Pass', Next: 'C'}, C: {Type: 'Pass', End: true}}}, ['states', 'B'])
expect(linear).toEqual({definition: {StartAt: 'A', States: {A: {Type: 'Pass', Next: 'C'}, C: {Type: 'Pass', End: true}}}})
const terminal = deleteStateInScope({StartAt: 'Choice', States: {
Choice: {Type: 'Choice', Choices: [{Variable: '$.kind', StringEquals: 'purchase', Next: 'Result'}, {Variable: '$.kind', StringEquals: 'refund', Next: 'Result'}], Default: 'Result'},
Purchase: {Type: 'Task', Next: 'Result', Catch: [{ErrorEquals: ['States.ALL'], Next: 'Result'}]},
Refund: {Type: 'Task', Next: 'Result'},
Result: {Type: 'Pass', End: true},
}}, ['states', 'Result'])
expect(terminal).toEqual({definition: {StartAt: 'Choice', States: {
Choice: {Type: 'Choice', Choices: [{Variable: '$.kind', StringEquals: 'purchase'}, {Variable: '$.kind', StringEquals: 'refund'}]},
Purchase: {Type: 'Task', End: true},
Refund: {Type: 'Task', End: true},
}}})
})
test('updates nested Parallel and Map StartAt and rejects sole nested and self-loop deletion', () => {
const source = {StartAt: 'Parallel', States: {
Parallel: {Type: 'Parallel', Branches: [{StartAt: 'First', States: {First: {Type: 'Pass', Next: 'Second'}, Second: {Type: 'Pass', End: true}}}], Next: 'Map'},
Map: {Type: 'Map', ItemProcessor: {StartAt: 'ItemFirst', States: {ItemFirst: {Type: 'Pass', Next: 'ItemSecond'}, ItemSecond: {Type: 'Pass', End: true}}}, End: true},
}}
const parallel = deleteStateInScope(source, ['states', 'Parallel', 'branch-0', 'First'])
expect('definition' in parallel && ((parallel.definition.States as Record<string, Record<string, unknown>>).Parallel.Branches as Array<{StartAt: string}>)[0].StartAt).toBe('Second')
const map = deleteStateInScope('definition' in parallel ? parallel.definition : source, ['states', 'Map', 'items', 'ItemFirst'])
expect('definition' in map && (((map.definition.States as Record<string, Record<string, unknown>>).Map.ItemProcessor as {StartAt: string}).StartAt)).toBe('ItemSecond')
expect(deleteStateInScope({StartAt: 'Parallel', States: {Parallel: {Type: 'Parallel', Branches: [{StartAt: 'Only', States: {Only: {Type: 'Pass', End: true}}}], End: true}}}, ['states', 'Parallel', 'branch-0', 'Only'])).toEqual({error: 'A Parallel branch or Map item scope must contain at least one state.'})
expect(deleteStateInScope({StartAt: 'Loop', States: {Loop: {Type: 'Pass', Next: 'Loop'}, Other: {Type: 'Pass', End: true}}}, ['states', 'Loop'])).toEqual({error: 'Cannot delete Loop because it transitions to itself.'})
})
test('allows a sole root state to become a transient empty draft but prevents saving it', async () => {
const sole = JSON.stringify({StartAt: 'Only', States: {Only: {Type: 'Pass', End: true}}})
const soleResource = {...resource, metadata: {...resource.metadata, definition: sole}}
getCloudResource.mockResolvedValue(soleResource)
listCloudResources.mockResolvedValue([])
const user = userEvent.setup()
renderStudio(soleResource)
await user.click(await screen.findByRole('button', {name: 'Delete Only'}))
expect(screen.getByText(/Workflow must contain at least one state/)).toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Save definition'})).toBeDisabled()
expect(screen.getAllByText('Add your first state')).toHaveLength(2)
})
test('blocks toolbar deletion of boundaries and Choice placeholders with a visible message', async () => {
const choiceResource = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Choice', States: {Choice: {Type: 'Choice', Choices: [{Variable: '$.ok', IsPresent: true}]}}})}}
getCloudResource.mockResolvedValue(choiceResource)
listCloudResources.mockResolvedValue([])
const user = userEvent.setup()
renderStudio(choiceResource)
await user.click(await screen.findByRole('button', {name: 'Start'}))
await user.click(screen.getByRole('button', {name: 'Delete selected state'}))
expect(screen.getByText(/Start, End, empty guidance, and Choice branch placeholders cannot be deleted/)).toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Rule #1: Drop state here'}))
await user.click(screen.getByRole('button', {name: 'Delete selected state'}))
expect(screen.getByRole('button', {name: 'Choice'})).toBeInTheDocument()
expect(screen.getByText(/Start, End, empty guidance, and Choice branch placeholders cannot be deleted/)).toBeInTheDocument()
})
test('imports and renders a complete two-rule Choice workflow converging on one Result Handler', async () => {
const initialResource = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Initial', States: {Initial: {Type: 'Pass', End: true}}})}}
const retry = [{
ErrorEquals: ['Lambda.ServiceException', 'Lambda.AWSLambdaException', 'Lambda.SdkClientException', 'Lambda.TooManyRequestsException'],
IntervalSeconds: 2,
MaxAttempts: 6,
BackoffRate: 2,
}]
const supplied = {
Comment: 'A description of my state machine',
StartAt: 'Choice',
States: {
Choice: {Type: 'Choice', Choices: [{Variable: '$.type', StringEquals: 'PURCHASE', Next: 'Purchase Handler'}, {Variable: '$.type', StringEquals: 'REFUND', Next: 'Refund Handler'}]},
'Purchase Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', OutputPath: '$.Payload', Parameters: {'Payload.$': '$', FunctionName: 'arn:aws:lambda:us-east-1:755314965794:function:PurchaseHandler:$LATEST'}, Retry: retry, Next: 'Result Handler'},
'Result Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', OutputPath: '$.Payload', Parameters: {'Payload.$': '$', FunctionName: 'arn:aws:lambda:us-east-1:755314965794:function:ResultHandler:$LATEST'}, Retry: retry, End: true},
'Refund Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', OutputPath: '$.Payload', Parameters: {'Payload.$': '$', FunctionName: 'arn:aws:lambda:us-east-1:755314965794:function:RefundHandler:$LATEST'}, Retry: retry, Next: 'Result Handler'},
},
}
getCloudResource.mockResolvedValue(initialResource)
listCloudResources.mockResolvedValue([])
updateCloudResource.mockResolvedValue(initialResource)
const user = userEvent.setup()
renderStudio(initialResource)
await user.click(await screen.findByRole('button', {name: 'Definition'}))
fireEvent.change(screen.getByRole('textbox', {name: 'Complete ASL definition'}), {target: {value: JSON.stringify(supplied, null, 2)}})
await user.click(screen.getByRole('button', {name: 'Apply to graph'}))
expect(screen.getByRole('button', {name: 'Purchase Handler'})).toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Refund Handler'})).toBeInTheDocument()
expect(screen.getAllByRole('button', {name: 'Result Handler'})).toHaveLength(1)
const graph = buildFlowGraph(supplied)
expect(graph.edges.filter((edge) => edge.target === 'states/Result Handler')).toHaveLength(2)
expect(graph.nodes.some((node) => node.type === 'placeholder')).toBe(false)
expect(graph.nodes.find((node) => node.data.label === 'Purchase Handler')?.position.x).toBeLessThan(graph.nodes.find((node) => node.data.label === 'Choice')?.position.x ?? 0)
expect(graph.nodes.find((node) => node.data.label === 'Refund Handler')?.position.x).toBeGreaterThan(graph.nodes.find((node) => node.data.label === 'Choice')?.position.x ?? 0)
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
expect(JSON.parse(updateCloudResource.mock.calls[0][3].definition)).toEqual(supplied)
})
test('edits ordinary transitions and Choice targets to existing same-scope states', async () => {
const transitions = {StartAt: 'Choice', States: {
Choice: {Type: 'Choice', Choices: [{Variable: '$.action', StringEquals: 'purchase', Next: 'Purchase Handler'}], Default: 'Refund Handler'},
'Purchase Handler': {Type: 'Pass', End: true},
'Refund Handler': {Type: 'Pass', End: true},
'Result Handler': {Type: 'Succeed'},
}}
const transitionsResource = {...resource, metadata: {...resource.metadata, definition: JSON.stringify(transitions)}}
getCloudResource.mockResolvedValue(transitionsResource)
listCloudResources.mockResolvedValue([])
updateCloudResource.mockResolvedValue(transitionsResource)
const user = userEvent.setup()
renderStudio(transitionsResource)
await user.click(await screen.findByRole('button', {name: 'Choice'}))
await user.selectOptions(screen.getByRole('combobox', {name: 'Rule 1 target'}), 'Result Handler')
await user.selectOptions(screen.getByRole('combobox', {name: 'Default target'}), 'Result Handler')
await user.click(screen.getByRole('button', {name: 'Purchase Handler'}))
await user.selectOptions(screen.getByRole('combobox', {name: 'State transition'}), 'Result Handler')
await user.click(screen.getByRole('button', {name: 'Refund Handler'}))
await user.selectOptions(screen.getByRole('combobox', {name: 'State transition'}), 'Result Handler')
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as typeof transitions
expect(saved.States.Choice).toMatchObject({Choices: [{Next: 'Result Handler'}], Default: 'Result Handler'})
expect(saved.States['Purchase Handler']).toEqual({Type: 'Pass', Next: 'Result Handler'})
expect(saved.States['Refund Handler']).toEqual({Type: 'Pass', Next: 'Result Handler'})
})
test('splices an unattached Lambda into only the selected Choice edge', () => {
const supplied = {StartAt: 'Choice', States: {
Choice: {Type: 'Choice', Choices: [
{Variable: '$.type', StringEquals: 'PURCHASE', Next: 'Purchase Handler'},
{Variable: '$.type', StringEquals: 'REFUND', Next: 'Refund Handler'},
]},
'Purchase Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {'Payload.$': '$', FunctionName: 'purchase'}, Retry: [{ErrorEquals: ['States.ALL'], MaxAttempts: 3}], Next: 'Result Handler'},
'Refund Handler': {Type: 'Task', Resource: 'arn:aws:states:::lambda:invoke', Parameters: {'Payload.$': '$', FunctionName: 'refund'}, Next: 'Result Handler'},
'Result Handler': {Type: 'Succeed'},
}}
const result = spliceLambdaIntoEdge(supplied, {sourcePath: ['states', 'Choice'], kind: 'choice', choiceIndex: 0})
const saved = result?.definition as typeof supplied & {States: Record<string, Record<string, unknown>>}
expect(saved.States.Choice.Choices[0].Next).toBe('LambdaInvoke')
expect(saved.States.Choice.Choices[1]).toEqual(supplied.States.Choice.Choices[1])
expect(saved.States.LambdaInvoke).toMatchObject({Type: 'Task', Next: 'Purchase Handler'})
expect(saved.States['Purchase Handler']).toEqual(supplied.States['Purchase Handler'])
expect(saved.States['Refund Handler']).toEqual(supplied.States['Refund Handler'])
})
test('splices any confirmed service integration into the selected edge', () => {
const source = {StartAt: 'First', States: {First: {Type: 'Pass', Next: 'Last'}, Last: {Type: 'Succeed'}}}
const result = spliceTaskIntoEdge(source, {sourcePath: ['states', 'First'], kind: 'next'}, 'sqs-send-message')
const states = result?.definition.States as Record<string, Record<string, unknown>>
expect(states.First.Next).toBe('SqsSendMessage')
expect(states.SqsSendMessage).toMatchObject({Resource: 'arn:aws:states:::sqs:sendMessage', Next: 'Last'})
expect(states.Last).toEqual(source.States.Last)
})
test('loads the definition once for the selected machine despite a list refresh', async () => {
getCloudResource.mockResolvedValue(resource)
const {queryClient, rerender} = renderStudio()
await screen.findByRole('button', {name: 'Decision'})
rerender(<QueryClientProvider client={queryClient}><WorkflowStudio resource={{...resource, name: 'renamed in list'}}/></QueryClientProvider>)
expect(getCloudResource).toHaveBeenCalledTimes(1)
})
test('shows a describe failure', async () => {
getCloudResource.mockRejectedValue(new Error('DescribeStateMachine failed'))
renderStudio()
expect(await screen.findByRole('alert')).toHaveTextContent('Unable to load state machine definition: DescribeStateMachine failed')
})
test('marks a direct Lambda ARN absent from inventory and rewrites only its Resource', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockResolvedValue([{
id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'},
}, {
id: 'local', name: 'local', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
metadata: {arn: 'arn:aws:lambda:local'},
}])
const user = userEvent.setup()
renderStudio()
await user.click(await screen.findByRole('button', {name: 'DirectMissing'}))
expect(screen.getByRole('alert')).toHaveTextContent('2 Task states reference a Lambda that is not in the current inventory')
await user.selectOptions(await screen.findByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
expect(screen.queryByRole('combobox', {name: 'Lambda function'})).not.toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Definition'}))
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"Resource": "arn:aws:lambda:us-east-1:000000000000:function:orders-worker"')
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"UnknownTaskField":')
})
test('recognizes Lambda service integrations and rewrites only Parameters.FunctionName', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockResolvedValue([{
id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'},
}, {
id: 'local', name: 'local', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
metadata: {arn: 'arn:aws:lambda:local'},
}])
const user = userEvent.setup()
renderStudio()
await user.click(await screen.findByRole('button', {name: 'IntegrationMissing'}))
await user.selectOptions(await screen.findByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
await user.click(screen.getByRole('button', {name: 'Definition'}))
const editor = screen.getByRole('textbox', {name: 'Complete ASL definition'})
expect((editor as HTMLTextAreaElement).value).toContain('"Resource": "arn:aws:states:::lambda:invoke"')
expect((editor as HTMLTextAreaElement).value).toContain('"FunctionName": "arn:aws:lambda:us-east-1:000000000000:function:orders-worker"')
expect((editor as HTMLTextAreaElement).value).toContain('"Payload":')
})
test('edits Lambda retry and catch rules while preserving rule fields and refreshing catch targets', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockResolvedValue([])
const user = userEvent.setup()
renderStudio()
await user.click(await screen.findByRole('button', {name: 'Work'}))
expect(screen.getByRole('heading', {name: 'Task policy'})).toBeInTheDocument()
await user.clear(screen.getByRole('spinbutton', {name: 'Retry 1 IntervalSeconds'}))
await user.type(screen.getByRole('spinbutton', {name: 'Retry 1 IntervalSeconds'}), '5')
await user.selectOptions(screen.getByRole('combobox', {name: 'Catch 1 Next'}), 'Decision')
await user.click(screen.getByRole('button', {name: 'Add retry'}))
await user.click(screen.getByRole('button', {name: 'Add catch'}))
await user.click(screen.getAllByRole('button', {name: 'Remove retry'})[1])
await user.click(screen.getAllByRole('button', {name: 'Remove catch'})[1])
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, Record<string, unknown> & {Retry: Array<Record<string, unknown>>; Catch: Array<Record<string, unknown>>}>}
expect(saved.States.Work.Retry).toEqual([expect.objectContaining({ErrorEquals: ['States.ALL'], IntervalSeconds: 5, UnknownRetryField: {kept: true}})])
expect(saved.States.Work.Catch).toEqual([expect.objectContaining({ErrorEquals: ['States.ALL'], Next: 'Decision', UnknownCatchField: {kept: true}})])
expect(saved.States.Work.UnknownTaskField).toEqual({kept: true})
})
test('attaches an inventory SNS topic to an SNS publish task without replacing Parameters', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'notifications' ? [{
id: 'arn:aws:sns:us-east-1:000000000000:orders', name: 'orders', cloud: 'aws', service: 'notifications', type: 'topic', region: null, createdAt: null, metadata: {},
}] : []))
const user = userEvent.setup()
renderStudio()
await user.click(await screen.findByRole('button', {name: 'PublishNotice'}))
expect(screen.getByRole('heading', {name: 'SNS Publish Task'})).toBeInTheDocument()
expect(listCloudResources).toHaveBeenCalledWith('aws', 'notifications', undefined, expect.any(AbortSignal))
await user.selectOptions(screen.getByRole('combobox', {name: 'SNS topic'}), 'arn:aws:sns:us-east-1:000000000000:orders')
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>}>}
expect(saved.States.PublishNotice.Parameters).toEqual({TopicArn: 'arn:aws:sns:us-east-1:000000000000:orders', Message: 'keep'})
})
test('edits SQS parameters from queue inventory and keeps shared task policy controls', async () => {
const selected = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Send', States: {Send: {Type: 'Task', Resource: 'arn:aws:states:::sqs:sendMessage', Parameters: {QueueUrl: 'old', MessageBody: 'old', Unknown: 'keep'}, End: true}}})}}
getCloudResource.mockResolvedValue(selected)
updateCloudResource.mockResolvedValue(selected)
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'messaging' ? [{id: 'orders', name: 'orders', cloud: 'aws', service: 'messaging', type: 'queue', region: null, createdAt: null, metadata: {queueUrl: 'http://localhost:4566/000000000000/orders'}}] : []))
const user = userEvent.setup()
renderStudio(selected)
expect(await screen.findByRole('heading', {name: 'SQS SendMessage'})).toBeInTheDocument()
await user.selectOptions(screen.getByRole('combobox', {name: 'SQS queue'}), 'http://localhost:4566/000000000000/orders')
await user.clear(screen.getByRole('textbox', {name: 'SQS message body'}))
await user.type(screen.getByRole('textbox', {name: 'SQS message body'}), 'hello from workflow')
await user.click(screen.getByRole('button', {name: 'Add retry'}))
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>; Retry: unknown[]}>}
expect(saved.States.Send.Parameters).toEqual({QueueUrl: 'http://localhost:4566/000000000000/orders', MessageBody: 'hello from workflow', Unknown: 'keep'})
expect(saved.States.Send.Retry).toHaveLength(1)
})
test('edits DynamoDB operation fields with a real table picker', async () => {
const selected = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Put', States: {Put: {Type: 'Task', Resource: 'arn:aws:states:::dynamodb:putItem', Parameters: {TableName: 'old', Item: {id: {S: 'old'}}, Unknown: true}, End: true}}})}}
getCloudResource.mockResolvedValue(selected)
updateCloudResource.mockResolvedValue(selected)
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'nosql' ? [{id: 'orders', name: 'orders', cloud: 'aws', service: 'nosql', type: 'dynamodb-table', region: null, createdAt: null, metadata: {}}] : []))
const user = userEvent.setup()
renderStudio(selected)
expect(await screen.findByRole('heading', {name: 'DynamoDB PutItem'})).toBeInTheDocument()
await user.selectOptions(screen.getByRole('combobox', {name: 'DynamoDB table'}), 'orders')
fireEvent.change(screen.getByRole('textbox', {name: 'Item'}), {target: {value: '{"id":{"S":"new"},"total":{"N":"42"}}'}})
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>}>}
expect(saved.States.Put.Parameters).toEqual({TableName: 'orders', Item: {id: {S: 'new'}, total: {N: '42'}}, Unknown: true})
})
test('edits EventBridge entries without collapsing imported entries', async () => {
const selected = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Events', States: {Events: {Type: 'Task', Resource: 'arn:aws:states:::events:putEvents', Parameters: {Entries: [{EventBusName: 'old', Source: 'old.source', DetailType: 'old', Detail: {before: true}, Unknown: 'keep'}, {EventBusName: 'second', Source: 'second.source', DetailType: 'second', Detail: '{}'}]}, End: true}}})}}
getCloudResource.mockResolvedValue(selected)
updateCloudResource.mockResolvedValue(selected)
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'events' ? [{id: 'event-bus:orders', name: 'orders', cloud: 'aws', service: 'events', type: 'event-bus', region: null, createdAt: null, metadata: {}}] : []))
const user = userEvent.setup()
renderStudio(selected)
expect(await screen.findByRole('heading', {name: 'EventBridge PutEvents'})).toBeInTheDocument()
await user.selectOptions(screen.getByRole('combobox', {name: 'EventBridge entry 1 event bus'}), 'orders')
await user.clear(screen.getByRole('textbox', {name: 'EventBridge entry 1 source'}))
await user.type(screen.getByRole('textbox', {name: 'EventBridge entry 1 source'}), 'orders.created')
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: {Entries: Array<Record<string, unknown>>}}>}
expect(saved.States.Events.Parameters.Entries[0]).toMatchObject({EventBusName: 'orders', Source: 'orders.created', Unknown: 'keep'})
expect(saved.States.Events.Parameters.Entries[1]).toEqual({EventBusName: 'second', Source: 'second.source', DetailType: 'second', Detail: '{}'})
})
test('edits ECS and nested workflow parameters through action-specific forms', async () => {
const selected = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Ecs', States: {Ecs: {Type: 'Task', Resource: 'arn:aws:states:::ecs:runTask.sync', Parameters: {Cluster: 'old', TaskDefinition: 'old'}, Next: 'Nested'}, Nested: {Type: 'Task', Resource: 'arn:aws:states:::states:startExecution', Parameters: {StateMachineArn: 'old', Input: {keep: true}}, End: true}}})}}
const childArn = 'arn:aws:states:us-east-1:000000000000:stateMachine:child'
getCloudResource.mockResolvedValue(selected)
updateCloudResource.mockResolvedValue(selected)
listCloudResources.mockImplementation((_: string, service: string) => Promise.resolve(service === 'workflows' ? [{id: childArn, name: 'child', cloud: 'aws', service: 'workflows', type: 'state-machine', region: null, createdAt: null, metadata: {machineType: 'STANDARD'}}] : []))
const user = userEvent.setup()
renderStudio(selected)
expect(await screen.findByRole('heading', {name: 'ECS RunTask (.sync)'})).toBeInTheDocument()
await user.clear(screen.getByRole('textbox', {name: 'ECS cluster'}))
await user.type(screen.getByRole('textbox', {name: 'ECS cluster'}), 'audit-cluster')
await user.clear(screen.getByRole('textbox', {name: 'ECS task definition'}))
await user.type(screen.getByRole('textbox', {name: 'ECS task definition'}), 'audit-task:1')
await user.click(screen.getByRole('button', {name: 'Nested'}))
expect(screen.getByRole('heading', {name: 'Step Functions StartExecution'})).toBeInTheDocument()
await user.selectOptions(screen.getByRole('combobox', {name: 'Nested state machine'}), childArn)
await user.click(screen.getByRole('button', {name: 'Save definition'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
const saved = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as {States: Record<string, {Parameters: Record<string, unknown>}>}
expect(saved.States.Ecs.Parameters).toMatchObject({Cluster: 'audit-cluster', TaskDefinition: 'audit-task:1'})
expect(saved.States.Nested.Parameters).toEqual({StateMachineArn: childArn, Input: {keep: true}})
})
test('saves the complete local ASL draft through the generic resource update API', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockResolvedValue([{
id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'},
}])
let resolveUpdate: (value: CloudResource) => void = () => undefined
updateCloudResource.mockImplementation(() => new Promise<CloudResource>((resolve) => {
resolveUpdate = resolve
}))
const user = userEvent.setup()
const {queryClient} = renderStudio()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const saveButton = screen.getByRole('button', {name: 'Save definition'})
expect(saveButton).toBeDisabled()
await user.click(await screen.findByRole('button', {name: 'DirectMissing'}))
await user.selectOptions(screen.getByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
await user.click(saveButton)
expect(screen.getByRole('status')).toHaveTextContent('Saving definition.')
const expectedDraft = JSON.parse(definition) as {States: Record<string, Record<string, unknown>>}
expectedDraft.States.DirectMissing.Resource = 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'
const expectedDefinition = JSON.stringify(expectedDraft)
expect(updateCloudResource).toHaveBeenCalledWith('aws', 'workflows', resource.id, {
definition: expectedDefinition,
})
const savedDefinition = JSON.parse(updateCloudResource.mock.calls[0][3].definition) as Record<string, unknown>
const savedStates = savedDefinition.States as Record<string, Record<string, unknown>>
expect(savedStates.DirectMissing).toMatchObject({
Resource: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker',
UnknownTaskField: {kept: true},
})
expect((savedStates.IntegrationMissing.Parameters as Record<string, unknown>).Payload).toEqual({keep: true})
resolveUpdate({...resource, metadata: {...resource.metadata, definition: expectedDefinition}})
expect(await screen.findByText('Definition saved.')).toBeInTheDocument()
expect(saveButton).toBeDisabled()
expect(invalidateQueries).toHaveBeenCalledWith({queryKey: ['cloud-resource', 'aws', 'workflows', resource.id]})
expect(invalidateQueries).toHaveBeenCalledWith({queryKey: ['cloud-console-resources', 'aws', 'workflows']})
})
test('retains the local draft and shows an accessible error when save fails', async () => {
getCloudResource.mockResolvedValue(resource)
listCloudResources.mockResolvedValue([{
id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null,
metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'},
}])
updateCloudResource.mockRejectedValue(new Error('UpdateStateMachine failed'))
const user = userEvent.setup()
renderStudio()
await user.click(await screen.findByRole('button', {name: 'DirectMissing'}))
await user.selectOptions(screen.getByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
await user.click(screen.getByRole('button', {name: 'Save definition'}))
expect(await screen.findByText('Unable to save definition: UpdateStateMachine failed')).toHaveAttribute('role', 'alert')
expect(screen.getByRole('button', {name: 'Save definition'})).toBeEnabled()
await user.click(screen.getByRole('button', {name: 'Definition'}))
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"UnknownTaskField":')
expect((screen.getByRole('textbox', {name: 'Complete ASL definition'}) as HTMLTextAreaElement).value).toContain('"Resource": "arn:aws:lambda:us-east-1:000000000000:function:orders-worker"')
})
test('reloads a clean definition immediately and asks before discarding a draft', async () => {
const fresh = {...resource, metadata: {...resource.metadata, definition: JSON.stringify({StartAt: 'Fresh', States: {Fresh: {Type: 'Pass', End: true}}})}}
getCloudResource.mockResolvedValueOnce(resource).mockResolvedValueOnce(resource).mockResolvedValueOnce(fresh)
listCloudResources.mockResolvedValue([{id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null, metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'}}])
const user = userEvent.setup()
renderStudio()
await user.click(await screen.findByRole('button', {name: 'Reload'}))
expect(await screen.findByRole('button', {name: 'DirectMissing'})).toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'DirectMissing'}))
await user.selectOptions(screen.getByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
await user.click(screen.getByRole('button', {name: 'Reload'}))
expect(screen.getByRole('alertdialog')).toHaveTextContent('Discard local changes?')
await user.click(screen.getByRole('button', {name: 'Reload definition'}))
expect(await screen.findByRole('button', {name: 'Fresh'})).toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Save definition'})).toBeDisabled()
})
test('checks the latest revision before saving and only overwrites after confirmation', async () => {
const loaded = {...resource, metadata: {...resource.metadata, revisionId: 'revision-1'}}
const changed = {...resource, metadata: {...resource.metadata, revisionId: 'revision-2'}}
getCloudResource.mockResolvedValueOnce(loaded).mockResolvedValueOnce(changed)
listCloudResources.mockResolvedValue([{id: 'orders-worker', name: 'orders-worker', cloud: 'aws', service: 'serverless', type: 'lambda', region: null, createdAt: null, metadata: {arn: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker'}}])
updateCloudResource.mockResolvedValue(loaded)
const user = userEvent.setup()
renderStudio()
await user.click(await screen.findByRole('button', {name: 'DirectMissing'}))
await user.selectOptions(screen.getByRole('combobox', {name: 'Lambda function'}), 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker')
await user.click(screen.getByRole('button', {name: 'Save definition'}))
expect(await screen.findByText('Definition changed elsewhere')).toBeInTheDocument()
expect(updateCloudResource).not.toHaveBeenCalled()
expect(getCloudResource).toHaveBeenCalledWith('aws', 'workflows', resource.id)
await user.click(screen.getByRole('button', {name: 'Overwrite'}))
await waitFor(() => expect(updateCloudResource).toHaveBeenCalledTimes(1))
expect(updateCloudResource.mock.calls[0][3]).toEqual(expect.objectContaining({definition: expect.any(String)}))
expect(updateCloudResource.mock.calls[0][3]).not.toHaveProperty('revisionId')
})
})