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}>; edges: Array<{id: string; type?: string; data?: Record}>; nodeTypes: Record}>>; edgeTypes: Record>>; onNodeClick: (event: MouseEvent, node: {data: Record}) => void}) =>
{nodes.map((node) => { const NodeType = node.type ? nodeTypes[node.type] : undefined if (node.type === 'placeholder' && NodeType) return
onNodeClick(event, node)}>
return NodeType ?
: })}{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 return EdgeType ? : null })}
, })) 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()} } 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, 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>} 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>} 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>} 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>} 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 & {Choices?: Array>}>} 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>} 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>} 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>} 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>).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>).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>} 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> 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() 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 & {Retry: Array>; Catch: Array>}>} 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.clear(screen.getByRole('textbox', {name: 'SNS message'})) await user.type(screen.getByRole('textbox', {name: 'SNS message'}), 'Order succeeded') 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}>} expect(saved.States.PublishNotice.Parameters).toEqual({TopicArn: 'arn:aws:sns:us-east-1:000000000000:orders', Message: 'Order succeeded'}) }) 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; 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}>} 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>}}>} 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}>} 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((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>} 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 const savedStates = savedDefinition.States as Record> expect(savedStates.DirectMissing).toMatchObject({ Resource: 'arn:aws:lambda:us-east-1:000000000000:function:orders-worker', UnknownTaskField: {kept: true}, }) expect((savedStates.IntegrationMissing.Parameters as Record).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') }) })