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
@@ -59,6 +59,10 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
import {workflowStudioPath} from "@/lib/workflowStudio";
import {useNavigate} from "react-router-dom";
interface DynamicResourceViewProps {
cloud: CloudProvider;
@@ -71,6 +75,11 @@ interface DynamicResourceViewProps {
onOpenInfo: () => void;
}
interface CreateMutationVariables {
values: Record<string, unknown>;
destination?: WorkflowCreationDestination;
}
export function DynamicResourceView({
cloud,
service,
@@ -80,6 +89,7 @@ export function DynamicResourceView({
statusLoading = false,
onOpenInfo,
}: DynamicResourceViewProps) {
const navigate = useNavigate();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [state, setState] = useState("");
@@ -166,7 +176,7 @@ export function DynamicResourceView({
};
const createMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
mutationFn: async ({values}: CreateMutationVariables) => {
const blueprintBucketName = typeof values.blueprintBucketName === "string" ? values.blueprintBucketName.trim() : "";
const blueprintApiName = typeof values.blueprintApiName === "string" ? values.blueprintApiName.trim() : "";
const blueprintRuleName = typeof values.blueprintRuleName === "string" ? values.blueprintRuleName.trim() : "";
@@ -254,12 +264,16 @@ export function DynamicResourceView({
throw error;
}
},
onSuccess: (resource) => {
onSuccess: (resource, variables) => {
setSelected(resource);
setCreateOpen(false);
void qc.invalidateQueries({
queryKey: ["cloud-resources", cloud, service],
});
if (cloud === "aws" && service === "workflows" && variables.destination === "studio") {
const destinationPath = workflowCreationDestinationPath(variables.destination, resource.id);
if (destinationPath) navigate(destinationPath);
}
},
});
@@ -774,14 +788,21 @@ export function DynamicResourceView({
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
onSubmit={(values) => createMut.mutate({values})}
/>
) : service === "loadbalancing" && cloud === "aws" ? (
<LoadBalancerPanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
onSubmit={(values) => createMut.mutate({values})}
/>
) : service === "workflows" && cloud === "aws" ? (
<WorkflowCreatePanel
isSubmitting={createMut.isPending}
submitError={createMut.error instanceof Error ? createMut.error.message : null}
onCancel={() => setCreateOpen(false)}
onSubmit={(submission) => createMut.mutate(submission)}
/>
) : (
<DynamicFormRenderer
@@ -796,7 +817,7 @@ export function DynamicResourceView({
: null
}
onCancel={() => setCreateOpen(false)}
onSubmit={(values) => createMut.mutate(values)}
onSubmit={(values) => createMut.mutate({values})}
/>
)}
</div>
@@ -840,13 +861,16 @@ export function DynamicResourceView({
)}
</section>
{activeSelected && !showDatabaseSnapshots && (
<ResourceInspector
resource={activeSelected}
object={selectedObject}
cloud={cloud}
runtimeReachable={canUseRuntime}
serviceName={schema.displayName}
/>
<div>
{cloud === "aws" && service === "workflows" && <button className="button compact workflow-open-studio" type="button" onClick={() => navigate(workflowStudioPath(activeSelected.id))}>Open Studio</button>}
<ResourceInspector
resource={activeSelected}
object={selectedObject}
cloud={cloud}
runtimeReachable={canUseRuntime}
serviceName={schema.displayName}
/>
</div>
)}
</div>
{service === "storage" && (
@@ -0,0 +1,61 @@
import {fireEvent, render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {describe, expect, test, vi} from 'vitest'
import {WorkflowCreatePanel} from './WorkflowCreatePanel'
function renderPanel(onSubmit = vi.fn()) {
render(<WorkflowCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
return onSubmit
}
describe('WorkflowCreatePanel', () => {
test('offers explicit Studio and ASL creation modes', () => {
renderPanel()
expect(screen.getByRole('button', {name: /workflow studio/i})).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', {name: /asl definition/i})).toHaveAttribute('aria-pressed', 'false')
expect(screen.queryByLabelText(/asl definition/i)).not.toBeInTheDocument()
})
test('submits a name-only Studio creation targeting Studio', async () => {
const user = userEvent.setup()
const onSubmit = renderPanel()
await user.type(screen.getByLabelText(/state machine name/i), ' orders ')
await user.click(screen.getByRole('button', {name: /create and open studio/i}))
expect(onSubmit).toHaveBeenCalledWith({values: {name: 'orders'}, destination: 'studio'})
})
test('submits the exact ASL text targeting Explorer', async () => {
const user = userEvent.setup()
const onSubmit = renderPanel()
const definition = ' {\n "StartAt": "Done",\n "States": {"Done": {"Type": "Succeed"}}\n}\n'
await user.click(screen.getByRole('button', {name: /asl definition/i}))
await user.type(screen.getByLabelText(/state machine name/i), 'orders')
fireEvent.change(screen.getByLabelText(/asl definition/i), {target: {value: definition}})
await user.click(screen.getByRole('button', {name: /create state machine/i}))
expect(onSubmit).toHaveBeenCalledWith({values: {name: 'orders', definition}, destination: 'explorer'})
})
test.each([
['', 'ASL definition is required.'],
['not json', 'ASL definition must be valid JSON.'],
['[]', 'ASL definition must be a JSON object.'],
['{"StartAt":1,"States":{}}', 'ASL definition must include StartAt as a string.'],
['{"StartAt":"Done","States":[]}', 'ASL definition must include States as an object.'],
])('rejects invalid ASL definition %s', async (definition, message) => {
const user = userEvent.setup()
const onSubmit = renderPanel()
await user.click(screen.getByRole('button', {name: /asl definition/i}))
await user.type(screen.getByLabelText(/state machine name/i), 'orders')
if (definition) fireEvent.change(screen.getByLabelText(/asl definition/i), {target: {value: definition}})
await user.click(screen.getByRole('button', {name: /create state machine/i}))
expect(screen.getByRole('alert')).toHaveTextContent(message)
expect(onSubmit).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,51 @@
import {useState} from 'react'
import {Braces, PanelsTopLeft} from 'lucide-react'
import {validateAslDefinition, workflowCreateSubmission, type WorkflowCreateSubmission} from '@/lib/workflowCreate'
type CreationMode = 'studio' | 'asl'
export function WorkflowCreatePanel({isSubmitting, submitError, onCancel, onSubmit}: {isSubmitting: boolean; submitError: string | null; onCancel: () => void; onSubmit: (submission: WorkflowCreateSubmission) => void}) {
const [mode, setMode] = useState<CreationMode>('studio')
const [name, setName] = useState('')
const [definition, setDefinition] = useState('')
const [definitionError, setDefinitionError] = useState<string | null>(null)
const validName = /^[a-zA-Z0-9_-]{1,80}$/.test(name.trim())
return <form className="dynamic-form" onSubmit={(event) => {
event.preventDefault()
if (!validName) return
if (mode === 'asl') {
const error = validateAslDefinition(definition)
setDefinitionError(error)
if (error) return
onSubmit(workflowCreateSubmission(name, 'explorer', definition))
return
}
onSubmit(workflowCreateSubmission(name, 'studio'))
}}>
<div className="workflow-create-modes" role="group" aria-label="Creation mode">
<button className={`workflow-create-mode${mode === 'studio' ? ' active' : ''}`} type="button" aria-pressed={mode === 'studio'} disabled={isSubmitting} onClick={() => { setMode('studio'); setDefinitionError(null) }}>
<PanelsTopLeft size={18}/><span><strong>Workflow Studio</strong><small>Create a valid starter workflow, then design it visually.</small></span>
</button>
<button className={`workflow-create-mode${mode === 'asl' ? ' active' : ''}`} type="button" aria-pressed={mode === 'asl'} disabled={isSubmitting} onClick={() => setMode('asl')}>
<Braces size={18}/><span><strong>ASL definition</strong><small>Create directly from a complete Amazon States Language document.</small></span>
</button>
</div>
<label className="dynamic-field dynamic-field--span">
<span>State Machine Name<em className="field-required">*</em></span>
<input className="input" autoFocus value={name} disabled={isSubmitting} onChange={(event) => setName(event.target.value)} maxLength={80} required pattern="[a-zA-Z0-9_-]+" aria-describedby="workflow-name-help"/>
<small id="workflow-name-help">Up to 80 characters: letters, digits, hyphens, and underscores.</small>
</label>
{mode === 'asl' && <label className="dynamic-field dynamic-field--span">
<span>ASL Definition<em className="field-required">*</em></span>
<textarea className={`textarea workflow-definition-input${definitionError ? ' invalid' : ''}`} value={definition} disabled={isSubmitting} onChange={(event) => { setDefinition(event.target.value); setDefinitionError(null) }} rows={14} spellCheck={false} aria-describedby="workflow-definition-help workflow-definition-error"/>
<small id="workflow-definition-help">Enter the complete JSON definition. It must contain a string <code>StartAt</code> and an object <code>States</code>.</small>
{definitionError && <small className="field-error" id="workflow-definition-error" role="alert">{definitionError}</small>}
</label>}
{submitError && <p className="error-text" role="alert">{submitError}</p>}
<div className="form-actions">
<button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>Cancel</button>
<button className="button primary" type="submit" disabled={!validName || isSubmitting}>{isSubmitting ? 'Creating' : mode === 'studio' ? 'Create and open Studio' : 'Create state machine'}</button>
</div>
</form>
}
@@ -1,5 +1,5 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {afterEach, describe, expect, test, vi} from 'vitest'
import {WorkflowExecutionDebugger} from '@/components/WorkflowExecutionDebugger'
@@ -45,9 +45,24 @@ describe('WorkflowExecutionDebugger', () => {
expect(screen.queryByRole('button', {name: 'Load more history'})).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('JSON input'), {target: {value: '{"orderId":"1"}'}})
await user.click(screen.getByRole('button', {name: 'Start execution'}))
expect(screen.getByRole('alertdialog')).toHaveTextContent('{"orderId":"1"}')
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', {name: 'Start execution'}))
await waitFor(() => expect(startWorkflowExecution).toHaveBeenCalledWith(machine.id, {input: '{"orderId":"1"}'}))
})
test('does not start until the inline confirmation is accepted', async () => {
vi.mocked(listWorkflowExecutions).mockResolvedValue([])
const user = userEvent.setup()
renderPanel()
await user.click(screen.getByRole('button', {name: 'Start execution'}))
expect(screen.getByRole('alertdialog')).toHaveTextContent('may invoke an unavailable Lambda')
expect(startWorkflowExecution).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', {name: 'Cancel'}))
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(startWorkflowExecution).not.toHaveBeenCalled()
})
test('confirms stopping only a running execution', async () => {
vi.stubGlobal('confirm', vi.fn(() => true))
vi.mocked(listWorkflowExecutions).mockResolvedValue([{executionArn, name: 'run-1', status: 'RUNNING', startDate: '2026-07-28T10:01:00.000Z'}])
@@ -25,6 +25,7 @@ export function WorkflowExecutionDebugger({resource, runtimeReachable}: Workflow
const [input, setInput] = useState('{}')
const [name, setName] = useState('')
const [selectedExecutionArn, setSelectedExecutionArn] = useState<string>()
const [startConfirmation, setStartConfirmation] = useState(false)
const isStandard = resource?.metadata.machineType === 'STANDARD'
const enabled = Boolean(resource && runtimeReachable && isStandard)
const executionKey = ['workflow-executions', resource?.id]
@@ -70,8 +71,12 @@ export function WorkflowExecutionDebugger({resource, runtimeReachable}: Workflow
<div className="input-row" style={{alignItems: 'end'}}>
<label className="field"><span>JSON input</span><textarea className="input" aria-label="JSON input" rows={4} value={input} onChange={(event) => setInput(event.target.value)}/></label>
<label className="field"><span>Execution name (optional)</span><input className="input" aria-label="Execution name (optional)" value={name} onChange={(event) => setName(event.target.value)}/></label>
<button className="button" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Start execution'}</button>
<button className="button" type="button" disabled={!enabled || start.isPending} onClick={() => setStartConfirmation(true)}><Play size={13}/>{start.isPending ? 'Starting' : 'Start execution'}</button>
</div>
{startConfirmation && <div className="workflow-confirmation" role="alertdialog" aria-labelledby="workflow-start-title">
<strong id="workflow-start-title">Start this execution?</strong><span>This execution may invoke an unavailable Lambda. Name: {name.trim() || 'generated by AWS'}. Input: {input}</span>
<div><button className="button compact" type="button" disabled={start.isPending} onClick={() => { setStartConfirmation(false); start.mutate() }}>Start execution</button><button className="button compact" type="button" disabled={start.isPending} onClick={() => setStartConfirmation(false)}>Cancel</button></div>
</div>}
<p className="muted compact-text">Execution input, output, and history can contain sensitive data.</p>
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start execution.')}</p>}
{executions.isError && <p className="error-text compact-text">{message(executions.error, 'Unable to load executions.')}</p>}
@@ -132,7 +137,7 @@ function useExecutionHistory(stateMachineArn: string | undefined, executionArn:
let token = nextToken
do {
if (!poll && pages === MAX_HISTORY_PAGES) {
publish(false, limitMessage())
publish(false, limitMessage(true, false))
return false
}
const page = await getWorkflowExecutionHistory(stateMachineArn, executionArn, token, controller.signal)
@@ -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')
})
})
File diff suppressed because it is too large Load Diff