1/**
2 * Live test for `StateMachine.fromProgram`: deploy a typed `Sfn` program
3 * (Lambda invoke + retry, inline Map, Choice, typed Fail + catchTag) as an
4 * EXPRESS workflow, execute it synchronously, and assert the execution
5 * output — alongside a raw-ASL `StateMachine` in the same stack proving the
6 * low-level `definition` path is unchanged.
7 */
8import * as AWS from "@/AWS";
9import * as Lambda from "@/AWS/Lambda";
10import { StateMachine } from "@/AWS/StepFunctions";
11import * as Test from "@/Test/Alchemy";
12import * as sfn from "@distilled.cloud/aws/sfn";
13import { expect } from "alchemy-test";
14import * as Data from "effect/Data";
15import * as Duration from "effect/Duration";
16import * as Effect from "effect/Effect";
17import * as Redacted from "effect/Redacted";
18import * as Schedule from "effect/Schedule";
19import {
20 makeOrderProgram,
21 type OrderOutput,
22} from "./fixtures/order-program.ts";
23
24const { test } = Test.make({ providers: AWS.providers() });
25
26// Keep this a file:// URL — `.pathname` yields `/D:/...` on Windows, which
27// resolveMainPath cannot stat; it converts URLs via fileURLToPath itself.
28const doublerMain = new URL("./fixtures/doubler.ts", import.meta.url).href;
29
30const plain = (
31 value: string | Redacted.Redacted<string> | undefined,
32): string | undefined =>
33 value === undefined
34 ? undefined
35 : typeof value === "string"
36 ? value
37 : Redacted.value(value);
38
39/**
40 * A sync execution finished in a non-SUCCEEDED state. Fresh execution
41 * roles propagate eventually — the first executions can fail on IAM — so
42 * the caller retries this tag on a bounded schedule.
43 */
44class ExecutionNotSucceeded extends Data.TaggedError("ExecutionNotSucceeded")<{
45 readonly status: string;
46 readonly error: string | undefined;
47 readonly cause: string | undefined;
48}> {}
49
50test.provider(
51 "fromProgram deploys and executes a typed program; raw definition path unchanged",
52 (stack) =>
53 Effect.gen(function* () {
54 const outputs = yield* stack.deploy(
55 Effect.gen(function* () {
56 const doubler = yield* Lambda.Function("SfnDoubler", {
57 main: doublerMain,
58 handler: "handler",
59 isExternal: true,
60 functionUrl: false,
61 timeout: Duration.seconds(15),
62 });
63 const machine = yield* StateMachine.fromProgram("OrderProgram", {
64 type: "EXPRESS",
65 program: makeOrderProgram(doubler),
66 });
67 // raw ASL path, same stack — stays first-class underneath
68 const raw = yield* StateMachine("RawWorkflow", {
69 definition: {
70 Comment: "raw",
71 StartAt: "Done",
72 States: {
73 Done: { Type: "Pass", Result: { ok: true }, End: true },
74 },
75 },
76 });
77 return { machine, raw };
78 }),
79 );
80
81 expect(outputs.machine.stateMachineArn).toContain(":stateMachine:");
82 expect(outputs.machine.type).toBe("EXPRESS");
83
84 // the compiled definition round-trips as JSONata ASL
85 const described = yield* sfn.describeStateMachine({
86 stateMachineArn: outputs.machine.stateMachineArn,
87 });
88 const definition = JSON.parse(plain(described.definition)!) as {
89 QueryLanguage: string;
90 StartAt: string;
91 States: Record<string, { Type: string }>;
92 };
93 expect(definition.QueryLanguage).toBe("JSONata");
94 const stateTypes = Object.values(definition.States).map((s) => s.Type);
95 expect(stateTypes).toContain("Task");
96 expect(stateTypes).toContain("Map");
97 expect(stateTypes).toContain("Choice");
98
99 // execute synchronously and assert the program's output end-to-end;
100 // retry non-SUCCEEDED results through IAM propagation on the fresh
101 // execution role (bounded)
102 const result = yield* sfn
103 .startSyncExecution({
104 stateMachineArn: outputs.machine.stateMachineArn,
105 input: JSON.stringify({ value: 6, items: [1, 2, 3] }),
106 })
107 .pipe(
108 Effect.flatMap((execution) =>
109 execution.status === "SUCCEEDED"
110 ? Effect.succeed(execution)
111 : Effect.fail(
112 new ExecutionNotSucceeded({
113 status: execution.status,
114 error: plain(execution.error),
115 cause: plain(execution.cause),
116 }),
117 ),
118 ),
119 Effect.retry({
120 while: (e) => e._tag === "ExecutionNotSucceeded",
121 schedule: Schedule.max([
122 Schedule.fixed("3 seconds"),
123 Schedule.recurs(10),
124 ]),
125 }),
126 );
127 const output = JSON.parse(plain(result.output)!) as OrderOutput;
128 expect(output).toEqual({
129 doubled: 12,
130 items: [2, 4, 6],
131 size: "big",
132 recovered: "no stock",
133 });
134
135 // Choice's false branch: small input
136 const small = yield* sfn
137 .startSyncExecution({
138 stateMachineArn: outputs.machine.stateMachineArn,
139 input: JSON.stringify({ value: 2, items: [] }),
140 })
141 .pipe(
142 Effect.flatMap((execution) =>
143 execution.status === "SUCCEEDED"
144 ? Effect.succeed(execution)
145 : Effect.fail(
146 new ExecutionNotSucceeded({
147 status: execution.status,
148 error: plain(execution.error),
149 cause: plain(execution.cause),
150 }),
151 ),
152 ),
153 Effect.retry({
154 while: (e) => e._tag === "ExecutionNotSucceeded",
155 schedule: Schedule.max([
156 Schedule.fixed("3 seconds"),
157 Schedule.recurs(5),
158 ]),
159 }),
160 );
161 const smallOutput = JSON.parse(plain(small.output)!) as OrderOutput;
162 expect(smallOutput.size).toBe("small");
163 expect(smallOutput.items).toEqual([]);
164
165 // raw path: definition stored verbatim (modulo formatting)
166 const rawDescribed = yield* sfn.describeStateMachine({
167 stateMachineArn: outputs.raw.stateMachineArn,
168 });
169 expect(JSON.parse(plain(rawDescribed.definition)!).Comment).toBe("raw");
170
171 yield* stack.destroy();
172 }),
173 {
174 tags: [
175 "provider:aws",
176 "provider:aws:lambda",
177 "provider:aws:stepfunctions",
178 "live",
179 ],
180 timeout: 180_000,
181 },
182);