@teleporthq/teleport-plugin-next-workflows 0.45.14 → 0.45.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/__tests__/cron-route-loop-templateparams.test.ts +5 -4
  2. package/__tests__/custom-js-loop-scope.test.ts +10 -5
  3. package/__tests__/loop-collection-unwrap.test.ts +6 -1
  4. package/__tests__/server-loop-body-branches.test.ts +188 -0
  5. package/__tests__/server-loop-types.test.ts +371 -0
  6. package/__tests__/server-nested-loops.test.ts +392 -0
  7. package/__tests__/streaming-route-if-branch.test.ts +107 -0
  8. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  9. package/dist/cjs/api-route-generator.js +57 -248
  10. package/dist/cjs/api-route-generator.js.map +1 -1
  11. package/dist/cjs/executor-generator.d.ts.map +1 -1
  12. package/dist/cjs/executor-generator.js +84 -27
  13. package/dist/cjs/executor-generator.js.map +1 -1
  14. package/dist/cjs/server-runtime-code.d.ts.map +1 -1
  15. package/dist/cjs/server-runtime-code.js +67 -0
  16. package/dist/cjs/server-runtime-code.js.map +1 -1
  17. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  18. package/dist/esm/api-route-generator.d.ts.map +1 -1
  19. package/dist/esm/api-route-generator.js +57 -248
  20. package/dist/esm/api-route-generator.js.map +1 -1
  21. package/dist/esm/executor-generator.d.ts.map +1 -1
  22. package/dist/esm/executor-generator.js +84 -27
  23. package/dist/esm/executor-generator.js.map +1 -1
  24. package/dist/esm/server-runtime-code.d.ts.map +1 -1
  25. package/dist/esm/server-runtime-code.js +67 -0
  26. package/dist/esm/server-runtime-code.js.map +1 -1
  27. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +2 -2
  29. package/src/api-route-generator.ts +57 -248
  30. package/src/executor-generator.ts +84 -27
  31. package/src/server-runtime-code.ts +67 -0
@@ -6,6 +6,7 @@
6
6
  // fields and its loop body would never run.
7
7
  import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
8
8
  import { generateCronAPIRoute } from '../src/api-route-generator'
9
+ import { generateServerRuntimeCode } from '../src/server-runtime-code'
9
10
 
10
11
  const TRIGGER_ID = 'cron-1'
11
12
  const SCAN_ID = 'scan-1'
@@ -124,10 +125,10 @@ describe('generateCronAPIRoute — loop + templateParams support', () => {
124
125
  })
125
126
 
126
127
  it('uses the loop-capable executor (handles general-loop body discovery)', () => {
127
- // The shared execution loop keys the loop body off the 'loop' sourceHandle
128
- // and walks 'loop-body-out' edges — markers absent from the old naive loop.
129
- expect(route).toContain("e.sourceHandle === 'loop'")
130
- expect(route).toContain('loop-body-out')
128
+ // The route runs a loop through the server runtime, which finds its body
129
+ // by walking from the 'loop' edge up to the loop-back edge.
130
+ expect(route).toContain('utils.runServerLoop(node, resolved, sortedNodes, ')
131
+ expect(generateServerRuntimeCode()).toContain('utils.getLoopBodyNodeIds(loopNode.id, edges)')
131
132
  })
132
133
 
133
134
  it('merges templateParams into email body + subject', () => {
@@ -1,6 +1,7 @@
1
1
  import { generateServerSegmentAPIRoute, generateSharedRuntimeUtilsCode } from '../src'
2
2
  import type { WorkflowSegment } from '../src/types'
3
3
  import { loadHandler, HandlerFn } from './_helpers/load-handler'
4
+ import { generateServerRuntimeCode } from '../src/server-runtime-code'
4
5
 
5
6
  // Regression guard for the "loop body custom-js sees the wrong innerParams"
6
7
  // bug.
@@ -500,16 +501,20 @@ describe('runtime emission — loop scope stack is pushed and popped', () => {
500
501
  })
501
502
 
502
503
  it('pushes a loop scope before iterating and pops after', () => {
504
+ // The route hands its loops to runServerLoop, which manages the stack.
503
505
  const route = generateServerSegmentAPIRoute(buildSegment())
504
- expect(route).toMatch(
505
- /context\.__loopScopeStack\.push\(\{\s*loopNodeId:\s*node\.id,\s*bodyNodeIds:\s*bodyNodeIds\s*\}\)/
506
+ expect(route).toContain('utils.runServerLoop(node, resolved, sortedNodes, ')
507
+ const runtime = generateServerRuntimeCode()
508
+ expect(runtime).toMatch(
509
+ /context\.__loopScopeStack\.push\(\{\s*loopNodeId:\s*loopNode\.id,\s*bodyNodeIds:\s*bodyNodeIds\s*\}\)/
506
510
  )
507
- expect(route).toContain('context.__loopScopeStack.pop();')
511
+ expect(runtime).toContain('context.__loopScopeStack.pop();')
508
512
  })
509
513
 
510
514
  it('initialises __loopScopeStack lazily (does not assume caller seeded it)', () => {
511
- const route = generateServerSegmentAPIRoute(buildSegment())
512
- expect(route).toMatch(/if \(!context\.__loopScopeStack\) context\.__loopScopeStack = \[\];/)
515
+ expect(generateServerRuntimeCode()).toMatch(
516
+ /if \(!context\.__loopScopeStack\) context\.__loopScopeStack = \[\];/
517
+ )
513
518
  })
514
519
  })
515
520
 
@@ -1,5 +1,6 @@
1
1
  import { generateSharedRuntimeUtilsCode, generateServerSegmentAPIRoute } from '../src'
2
2
  import type { WorkflowSegment } from '../src/types'
3
+ import { generateServerRuntimeCode } from '../src/server-runtime-code'
3
4
 
4
5
  // Regression guard for the "loop iterates 0 times even though the
5
6
  // upstream node returned data" bug.
@@ -317,8 +318,12 @@ describe('every loop emit site routes through unwrapWorkflowCollection', () => {
317
318
  })
318
319
 
319
320
  it('regular server segment uses utils.unwrapWorkflowCollection for the loop collection', () => {
321
+ // Every server route runs its loops through runServerLoop, which unwraps.
320
322
  const src = generateServerSegmentAPIRoute(buildLoopSegment(), 'T')
321
- expect(src).toContain('utils.unwrapWorkflowCollection(resolved.collection)')
323
+ expect(src).toContain('utils.runServerLoop(node, resolved, sortedNodes, ')
324
+ expect(generateServerRuntimeCode()).toContain(
325
+ 'utils.unwrapWorkflowCollection(config.collection)'
326
+ )
322
327
  })
323
328
 
324
329
  it('client-side runtime uses unwrapWorkflowCollection in executeLoop', () => {
@@ -0,0 +1,188 @@
1
+ import {
2
+ generateCronAPIRoute,
3
+ generateServerSegmentAPIRoute,
4
+ generateStreamingServerSegmentAPIRoute,
5
+ } from '../src/api-route-generator'
6
+ import type { WorkflowSegment } from '../src/types'
7
+ import { loadServerRuntime } from './_helpers/load-server-runtime'
8
+ import { withEnv } from './_helpers/fake-fetch'
9
+
10
+ /**
11
+ * An if-statement inside a loop body takes one branch per pass. The server
12
+ * routes ran the body as a flat list, so both branches ran on every pass.
13
+ * Each route kind runs a four-pass loop whose body branches on the pass index.
14
+ */
15
+
16
+ const LOOP_ID = 'loop-1'
17
+ const IF_ID = 'if-1'
18
+ const HIGH_ID = 'high-1'
19
+ const LOW_ID = 'low-1'
20
+ const AFTER_ID = 'after-1'
21
+ const ITER_ID = LOOP_ID + '_iter'
22
+ const RUNS_KEY = '__serverLoopBranchRuns'
23
+
24
+ const runs = (): string[] => (globalThis as Record<string, any>)[RUNS_KEY] || []
25
+
26
+ const recordingNode = (id: string, stepNumber: number) => ({
27
+ id,
28
+ type: 'general-custom-js',
29
+ config: {
30
+ code: [
31
+ 'function customHandler() {',
32
+ ` globalThis.${RUNS_KEY} = (globalThis.${RUNS_KEY} || []).concat('${id}');`,
33
+ ' return { ok: true };',
34
+ '}',
35
+ ].join('\n'),
36
+ context: 'server',
37
+ },
38
+ stepNumber,
39
+ label: id,
40
+ })
41
+
42
+ const nodes = [
43
+ {
44
+ id: LOOP_ID,
45
+ type: 'general-loop',
46
+ config: { loopType: 'for', endIndex: 4 },
47
+ stepNumber: 1,
48
+ label: 'Loop',
49
+ },
50
+ {
51
+ id: IF_ID,
52
+ type: 'general-if-statement',
53
+ config: {
54
+ conditionType: 'simple-comparison',
55
+ leftValue: { type: 'workflowContext', nodeId: ITER_ID, path: [ITER_ID, 'index'] },
56
+ operator: '>=',
57
+ rightValue: 2,
58
+ },
59
+ stepNumber: 2,
60
+ label: 'Index Is High',
61
+ },
62
+ recordingNode(HIGH_ID, 3),
63
+ recordingNode(LOW_ID, 4),
64
+ recordingNode(AFTER_ID, 5),
65
+ ]
66
+
67
+ const edges = [
68
+ { id: 'e1', source: LOOP_ID, target: IF_ID, sourceHandle: 'loop', targetHandle: 'loop-body-in' },
69
+ { id: 'e2', source: IF_ID, target: HIGH_ID, sourceHandle: 'true' },
70
+ { id: 'e3', source: IF_ID, target: LOW_ID, sourceHandle: 'false' },
71
+ {
72
+ id: 'e4',
73
+ source: HIGH_ID,
74
+ target: LOOP_ID,
75
+ sourceHandle: 'loop-body-out',
76
+ targetHandle: 'loop-back',
77
+ },
78
+ {
79
+ id: 'e5',
80
+ source: LOW_ID,
81
+ target: LOOP_ID,
82
+ sourceHandle: 'loop-body-out',
83
+ targetHandle: 'loop-back',
84
+ },
85
+ { id: 'e6', source: LOOP_ID, target: AFTER_ID, sourceHandle: 'exit' },
86
+ ]
87
+
88
+ const ONE_BRANCH_PER_PASS = [LOW_ID, LOW_ID, HIGH_ID, HIGH_ID, AFTER_ID]
89
+
90
+ const requireRuntime = () => {
91
+ const runtime = loadServerRuntime()
92
+ return (id: string): unknown => {
93
+ if (id.endsWith('/utils/workflows/server-runtime')) {
94
+ return runtime
95
+ }
96
+ throw new Error('Unexpected require: ' + id)
97
+ }
98
+ }
99
+
100
+ const bootRoute = (source: string) => {
101
+ const routeModule: { exports: any } = { exports: {} }
102
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
103
+ new Function('module', 'exports', 'require', source)(
104
+ routeModule,
105
+ routeModule.exports,
106
+ requireRuntime()
107
+ )
108
+ return routeModule.exports as (req: unknown, res: unknown) => Promise<void>
109
+ }
110
+
111
+ const jsonReply = () => {
112
+ const reply = { status: 0, body: undefined as any }
113
+ const res = {
114
+ status(code: number) {
115
+ reply.status = code
116
+ return this
117
+ },
118
+ json(payload: unknown) {
119
+ reply.body = payload
120
+ return this
121
+ },
122
+ setHeader: (): void => undefined,
123
+ }
124
+ return { reply, res }
125
+ }
126
+
127
+ const segment = {
128
+ id: 'seg-loop-branches',
129
+ env: 'server',
130
+ nodeIds: nodes.map((node) => node.id),
131
+ nodes,
132
+ edges,
133
+ } as unknown as WorkflowSegment
134
+
135
+ beforeEach(() => {
136
+ delete (globalThis as Record<string, any>)[RUNS_KEY]
137
+ })
138
+
139
+ afterAll(() => {
140
+ delete (globalThis as Record<string, any>)[RUNS_KEY]
141
+ })
142
+
143
+ describe.each<[string, () => string]>([
144
+ ['a server segment route', () => generateServerSegmentAPIRoute(segment)],
145
+ ['a streaming server segment route', () => generateStreamingServerSegmentAPIRoute(segment)],
146
+ ])('%s runs one branch of a loop body per pass', (_route, generate) => {
147
+ it('skips the branch the if-statement did not take, on that pass only', async () => {
148
+ const handler = bootRoute(generate())
149
+ const { reply, res } = jsonReply()
150
+
151
+ await handler({ method: 'POST', body: { context: {} }, headers: { host: 'site.example' } }, res)
152
+
153
+ expect(reply.status).toBe(200)
154
+ expect(runs()).toEqual(ONE_BRANCH_PER_PASS)
155
+ })
156
+ })
157
+
158
+ describe('a scheduled route runs one branch of a loop body per pass', () => {
159
+ it('skips the branch the if-statement did not take, on that pass only', async () => {
160
+ const workflow: any = {
161
+ id: 'wf-scheduled-loop-branches',
162
+ name: 'Scheduled Loop Branches',
163
+ trigger: {
164
+ type: 'event-cron-triggered',
165
+ nodeId: 't',
166
+ scope: 'global',
167
+ config: { schedule: '5 * * * *', urlPath: 'cron_loop_branches' },
168
+ },
169
+ nodes,
170
+ edges: [{ id: 'e0', source: 't', target: LOOP_ID }, ...edges],
171
+ }
172
+ const handler = bootRoute(generateCronAPIRoute(workflow, undefined as never))
173
+ const { reply, res } = jsonReply()
174
+ const req = {
175
+ method: 'POST',
176
+ url: '/api/workflows/cron_loop_branches',
177
+ headers: { host: 'site.example', 'x-forwarded-proto': 'https' },
178
+ body: {},
179
+ }
180
+
181
+ await withEnv({ TELEPORT_CRON_ALLOW_UNSIGNED: 'true', TELEPORT_CRON_SECRET: undefined }, () =>
182
+ handler(req, res)
183
+ )
184
+
185
+ expect(reply.status).toBe(200)
186
+ expect(runs()).toEqual(ONE_BRANCH_PER_PASS)
187
+ })
188
+ })
@@ -0,0 +1,371 @@
1
+ import {
2
+ generateCronAPIRoute,
3
+ generateServerSegmentAPIRoute,
4
+ generateStreamingServerSegmentAPIRoute,
5
+ } from '../src/api-route-generator'
6
+ import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
7
+ import type { WorkflowSegment } from '../src/types'
8
+ import { loadServerRuntime } from './_helpers/load-server-runtime'
9
+ import { withEnv } from './_helpers/fake-fetch'
10
+
11
+ /**
12
+ * A loop steps by its loopType wherever it runs. The server routes walked
13
+ * `collection` for every type, so a for, while or until loop whose body ran on
14
+ * the server made no pass at all. Each route kind is booted here and run with
15
+ * every loop type, next to the client executor running the same loops.
16
+ */
17
+
18
+ const LOOP_ID = 'loop-1'
19
+ const BODY_ID = 'body-1'
20
+ const ITER_ID = LOOP_ID + '_iter'
21
+ const PASSES_KEY = '__serverLoopTypePasses'
22
+
23
+ const passes = (): number => (globalThis as Record<string, any>)[PASSES_KEY] || 0
24
+
25
+ const COUNT_PASSES_CODE = [
26
+ 'function customHandler() {',
27
+ ` globalThis.${PASSES_KEY} = (globalThis.${PASSES_KEY} || 0) + 1;`,
28
+ ` return { n: globalThis.${PASSES_KEY} };`,
29
+ '}',
30
+ ].join('\n')
31
+
32
+ const loopEdges = [
33
+ {
34
+ id: 'e1',
35
+ source: LOOP_ID,
36
+ target: BODY_ID,
37
+ sourceHandle: 'loop',
38
+ targetHandle: 'loop-body-in',
39
+ },
40
+ {
41
+ id: 'e2',
42
+ source: BODY_ID,
43
+ target: LOOP_ID,
44
+ sourceHandle: 'loop-body-out',
45
+ targetHandle: 'loop-back',
46
+ },
47
+ ]
48
+
49
+ const loopNode = (config: Record<string, unknown>) => ({
50
+ id: LOOP_ID,
51
+ type: 'general-loop',
52
+ config,
53
+ stepNumber: 1,
54
+ label: 'Loop',
55
+ })
56
+
57
+ const countingBody = () => ({
58
+ id: BODY_ID,
59
+ type: 'general-custom-js',
60
+ config: { code: COUNT_PASSES_CODE, context: 'server' },
61
+ stepNumber: 2,
62
+ label: 'Count The Pass',
63
+ })
64
+
65
+ const BODY_RAN_FEWER_THAN_THREE_TIMES = `!context['${BODY_ID}'] || context['${BODY_ID}'].n < 3`
66
+ const BODY_RAN_THREE_TIMES = `!!context['${BODY_ID}'] && context['${BODY_ID}'].n >= 3`
67
+
68
+ interface LoopCase {
69
+ name: string
70
+ config: Record<string, unknown>
71
+ expectedPasses: number
72
+ }
73
+
74
+ const NON_COLLECTION_LOOPS: LoopCase[] = [
75
+ {
76
+ name: 'a for loop up to endIndex',
77
+ config: { loopType: 'for', endIndex: 4 },
78
+ expectedPasses: 4,
79
+ },
80
+ {
81
+ name: 'a for loop from startIndex by step',
82
+ config: { loopType: 'for', startIndex: 2, endIndex: 9, step: 3 },
83
+ expectedPasses: 3,
84
+ },
85
+ {
86
+ name: 'a for loop counting down',
87
+ config: { loopType: 'for', startIndex: 3, endIndex: 0, step: -1 },
88
+ expectedPasses: 3,
89
+ },
90
+ {
91
+ name: 'a for loop whose bounds arrive as text',
92
+ config: { loopType: 'for', startIndex: '0', endIndex: '3' },
93
+ expectedPasses: 3,
94
+ },
95
+ {
96
+ name: 'a while loop, its condition read again before every pass',
97
+ config: { loopType: 'while', condition: BODY_RAN_FEWER_THAN_THREE_TIMES },
98
+ expectedPasses: 3,
99
+ },
100
+ {
101
+ name: 'an until loop, its condition read again before every pass',
102
+ config: { loopType: 'until', condition: BODY_RAN_THREE_TIMES },
103
+ expectedPasses: 3,
104
+ },
105
+ {
106
+ name: 'a while loop that never ends, stopped at maxIterations',
107
+ config: { loopType: 'while', condition: 'true', maxIterations: 7 },
108
+ expectedPasses: 7,
109
+ },
110
+ {
111
+ name: 'an until loop that never ends, stopped at the default cap',
112
+ config: { loopType: 'until', condition: 'false' },
113
+ expectedPasses: 1000,
114
+ },
115
+ ]
116
+
117
+ const NON_COLLECTION_LOOP_ROWS = NON_COLLECTION_LOOPS.map(
118
+ (loop): [string, Record<string, unknown>, number] => [loop.name, loop.config, loop.expectedPasses]
119
+ )
120
+
121
+ const requireRuntime = (extra: Record<string, unknown> = {}) => {
122
+ const runtime = loadServerRuntime()
123
+ return (id: string): unknown => {
124
+ if (id.endsWith('/utils/workflows/server-runtime')) {
125
+ return runtime
126
+ }
127
+ const known = Object.keys(extra).find((suffix) => id.endsWith(suffix))
128
+ if (known) {
129
+ return extra[known]
130
+ }
131
+ throw new Error('Unexpected require: ' + id)
132
+ }
133
+ }
134
+
135
+ const bootRoute = (source: string, requireFn: (id: string) => unknown) => {
136
+ const routeModule: { exports: any } = { exports: {} }
137
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
138
+ new Function('module', 'exports', 'require', source)(routeModule, routeModule.exports, requireFn)
139
+ return routeModule.exports as (req: unknown, res: unknown) => Promise<void>
140
+ }
141
+
142
+ const jsonReply = () => {
143
+ const reply = { status: 0, body: undefined as any }
144
+ const res = {
145
+ status(code: number) {
146
+ reply.status = code
147
+ return this
148
+ },
149
+ json(payload: unknown) {
150
+ reply.body = payload
151
+ return this
152
+ },
153
+ setHeader: (): void => undefined,
154
+ }
155
+ return { reply, res }
156
+ }
157
+
158
+ const segmentOf = (config: Record<string, unknown>): WorkflowSegment =>
159
+ ({
160
+ id: 'seg-loop',
161
+ env: 'server',
162
+ nodeIds: [LOOP_ID, BODY_ID],
163
+ nodes: [loopNode(config), countingBody()],
164
+ edges: loopEdges,
165
+ } as unknown as WorkflowSegment)
166
+
167
+ type SegmentRouteGenerator = (segment: WorkflowSegment) => string
168
+
169
+ const runSegmentRoute = async (
170
+ generate: SegmentRouteGenerator,
171
+ config: Record<string, unknown>
172
+ ) => {
173
+ const handler = bootRoute(generate(segmentOf(config)), requireRuntime())
174
+ const { reply, res } = jsonReply()
175
+ await handler({ method: 'POST', body: { context: {} }, headers: { host: 'site.example' } }, res)
176
+ return reply
177
+ }
178
+
179
+ const scheduledWorkflow = (
180
+ config: Record<string, unknown>,
181
+ body: Record<string, unknown>
182
+ ): any => ({
183
+ id: 'wf-scheduled-loop',
184
+ name: 'Scheduled Loop',
185
+ trigger: {
186
+ type: 'event-cron-triggered',
187
+ nodeId: 't',
188
+ scope: 'global',
189
+ config: { schedule: '5 * * * *', urlPath: 'cron_loop' },
190
+ },
191
+ nodes: [loopNode(config), body],
192
+ edges: [{ id: 'e0', source: 't', target: LOOP_ID }, ...loopEdges],
193
+ })
194
+
195
+ const runScheduledRoute = async (
196
+ workflow: any,
197
+ customNodes?: Record<string, unknown>,
198
+ customNodeModule: Record<string, unknown> = {}
199
+ ) => {
200
+ const handler = bootRoute(
201
+ generateCronAPIRoute(workflow, customNodes as never),
202
+ requireRuntime({ '/utils/workflows/custom-nodes': customNodeModule })
203
+ )
204
+ const { reply, res } = jsonReply()
205
+ const req = {
206
+ method: 'POST',
207
+ url: '/api/workflows/cron_loop',
208
+ headers: { host: 'site.example', 'x-forwarded-proto': 'https' },
209
+ body: {},
210
+ }
211
+ await withEnv({ TELEPORT_CRON_ALLOW_UNSIGNED: 'true', TELEPORT_CRON_SECRET: undefined }, () =>
212
+ handler(req, res)
213
+ )
214
+ return reply
215
+ }
216
+
217
+ const runOnClient = async (config: Record<string, unknown>) => {
218
+ const shared: { exports: Record<string, any> } = { exports: {} }
219
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
220
+ new Function('module', 'exports', generateSharedRuntimeUtilsCode())(shared, shared.exports)
221
+ const context: Record<string, any> = {}
222
+ let ran = 0
223
+ const handlers = {
224
+ 'general-custom-js': async () => {
225
+ ran++
226
+ return { n: ran }
227
+ },
228
+ }
229
+ await shared.exports.executeNodes(
230
+ [loopNode(config), countingBody()],
231
+ loopEdges,
232
+ context,
233
+ handlers,
234
+ {},
235
+ null,
236
+ 'exec-1'
237
+ )
238
+ return { ran, context }
239
+ }
240
+
241
+ beforeEach(() => {
242
+ delete (globalThis as Record<string, any>)[PASSES_KEY]
243
+ })
244
+
245
+ afterAll(() => {
246
+ delete (globalThis as Record<string, any>)[PASSES_KEY]
247
+ })
248
+
249
+ describe.each<[string, SegmentRouteGenerator]>([
250
+ ['a server segment route', (segment) => generateServerSegmentAPIRoute(segment)],
251
+ [
252
+ 'a streaming server segment route',
253
+ (segment) => generateStreamingServerSegmentAPIRoute(segment),
254
+ ],
255
+ ])('%s steps a loop by its type', (_route, generate) => {
256
+ it.each(NON_COLLECTION_LOOP_ROWS)('runs %s', async (_name, config, expectedPasses) => {
257
+ const reply = await runSegmentRoute(generate, config)
258
+
259
+ expect(reply.status).toBe(200)
260
+ expect(passes()).toBe(expectedPasses)
261
+ expect(reply.body.results[LOOP_ID]).toEqual({
262
+ completed: true,
263
+ iterations: expectedPasses,
264
+ results: [],
265
+ })
266
+ })
267
+
268
+ it('still walks the collection of a forEach and a map loop', async () => {
269
+ const forEach = await runSegmentRoute(generate, { loopType: 'forEach', collection: ['a', 'b'] })
270
+ expect(passes()).toBe(2)
271
+ expect(forEach.body.results[LOOP_ID]).toEqual({ completed: true, iterations: 2, results: [] })
272
+
273
+ const map = await runSegmentRoute(generate, { loopType: 'map', collection: ['a', 'b', 'c'] })
274
+ expect(passes()).toBe(5)
275
+ expect(map.body.results[LOOP_ID]).toEqual({
276
+ completed: true,
277
+ iterations: 3,
278
+ results: [{ n: 3 }, { n: 4 }, { n: 5 }],
279
+ })
280
+ })
281
+ })
282
+
283
+ describe('a scheduled route steps a loop by its type', () => {
284
+ it.each(NON_COLLECTION_LOOP_ROWS)('runs %s', async (_name, config, expectedPasses) => {
285
+ const reply = await runScheduledRoute(scheduledWorkflow(config, countingBody()))
286
+
287
+ expect(reply.status).toBe(200)
288
+ expect(passes()).toBe(expectedPasses)
289
+ })
290
+
291
+ const customNodeBody = (parameters: Record<string, unknown>) => ({
292
+ id: BODY_ID,
293
+ type: 'general-custom-node',
294
+ config: { customNodeId: 'cn-record', parameters },
295
+ stepNumber: 2,
296
+ label: 'Record The Pass',
297
+ })
298
+ const customNodes: Record<string, unknown> = {
299
+ 'cn-record': { id: 'cn-record', name: 'Record', nodes: [], edges: [] },
300
+ }
301
+ const iterationRef = (field: string) => ({
302
+ type: 'workflowContext',
303
+ nodeId: ITER_ID,
304
+ path: [ITER_ID, field],
305
+ })
306
+ const recordingModule = () => {
307
+ const seen: unknown[] = []
308
+ const module = {
309
+ 'cn-record': async (_context: unknown, parameters: unknown) => {
310
+ seen.push(parameters)
311
+ return { n: seen.length }
312
+ },
313
+ }
314
+ return { seen, module }
315
+ }
316
+
317
+ it('hands a for loop body the index of its pass', async () => {
318
+ const { seen, module } = recordingModule()
319
+ const workflow = scheduledWorkflow(
320
+ { loopType: 'for', startIndex: 1, endIndex: 7, step: 2 },
321
+ customNodeBody({ index: iterationRef('index') })
322
+ )
323
+
324
+ const reply = await runScheduledRoute(workflow, customNodes, module)
325
+
326
+ expect(reply.status).toBe(200)
327
+ expect(seen).toEqual([{ index: 1 }, { index: 3 }, { index: 5 }])
328
+ })
329
+
330
+ it('hands a while loop body the number of its pass, and its condition too', async () => {
331
+ const { seen, module } = recordingModule()
332
+ const workflow = scheduledWorkflow(
333
+ { loopType: 'while', condition: `context['${ITER_ID}'].iteration < 2` },
334
+ customNodeBody({ iteration: iterationRef('iteration') })
335
+ )
336
+
337
+ const reply = await runScheduledRoute(workflow, customNodes, module)
338
+
339
+ expect(reply.status).toBe(200)
340
+ expect(seen).toEqual([{ iteration: 0 }, { iteration: 1 }])
341
+ })
342
+
343
+ it('still hands a forEach loop body the item of its pass', async () => {
344
+ const { seen, module } = recordingModule()
345
+ const workflow = scheduledWorkflow(
346
+ { loopType: 'forEach', collection: [{ id: 'a' }, { id: 'b' }] },
347
+ customNodeBody({
348
+ id: { type: 'workflowContext', nodeId: LOOP_ID, path: [LOOP_ID, 'currentItem', 'id'] },
349
+ index: { type: 'workflowContext', nodeId: LOOP_ID, path: [LOOP_ID, 'index'] },
350
+ isLast: { type: 'workflowContext', nodeId: LOOP_ID, path: [LOOP_ID, 'isLast'] },
351
+ })
352
+ )
353
+
354
+ const reply = await runScheduledRoute(workflow, customNodes, module)
355
+
356
+ expect(reply.status).toBe(200)
357
+ expect(seen).toEqual([
358
+ { id: 'a', index: 0, isLast: false },
359
+ { id: 'b', index: 1, isLast: true },
360
+ ])
361
+ })
362
+ })
363
+
364
+ describe('the client executor and the server routes make the same passes', () => {
365
+ it.each(NON_COLLECTION_LOOP_ROWS)('for %s', async (_name, config, expectedPasses) => {
366
+ const client = await runOnClient(config)
367
+
368
+ expect(client.ran).toBe(expectedPasses)
369
+ expect(client.context[LOOP_ID]).toEqual({ completed: true, iterations: expectedPasses })
370
+ })
371
+ })