@teleporthq/teleport-plugin-next-workflows 0.45.15 → 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 (30) 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-nested-loops.test.ts +392 -0
  6. package/__tests__/streaming-route-if-branch.test.ts +107 -0
  7. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  8. package/dist/cjs/api-route-generator.js +57 -251
  9. package/dist/cjs/api-route-generator.js.map +1 -1
  10. package/dist/cjs/executor-generator.d.ts.map +1 -1
  11. package/dist/cjs/executor-generator.js +22 -0
  12. package/dist/cjs/executor-generator.js.map +1 -1
  13. package/dist/cjs/server-runtime-code.d.ts.map +1 -1
  14. package/dist/cjs/server-runtime-code.js +67 -0
  15. package/dist/cjs/server-runtime-code.js.map +1 -1
  16. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  17. package/dist/esm/api-route-generator.d.ts.map +1 -1
  18. package/dist/esm/api-route-generator.js +57 -251
  19. package/dist/esm/api-route-generator.js.map +1 -1
  20. package/dist/esm/executor-generator.d.ts.map +1 -1
  21. package/dist/esm/executor-generator.js +22 -0
  22. package/dist/esm/executor-generator.js.map +1 -1
  23. package/dist/esm/server-runtime-code.d.ts.map +1 -1
  24. package/dist/esm/server-runtime-code.js +67 -0
  25. package/dist/esm/server-runtime-code.js.map +1 -1
  26. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +2 -2
  28. package/src/api-route-generator.ts +57 -251
  29. package/src/executor-generator.ts +22 -0
  30. 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,392 @@
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
+ * A loop inside the body of another loop runs all of its passes on every pass
12
+ * of the outer one. The server routes ran an outer body as one flat list: the
13
+ * inner loop node fell through to a stub and its body ran once per outer pass.
14
+ * Each route kind is booted here and run with nested loops.
15
+ */
16
+
17
+ const OUTER_ID = 'outer-1'
18
+ const MIDDLE_ID = 'middle-1'
19
+ const INNER_ID = 'inner-1'
20
+ const MAKE_ID = 'make-1'
21
+ const IF_ID = 'if-1'
22
+ const BODY_ID = 'body-1'
23
+ const HIGH_ID = 'high-1'
24
+ const LOW_ID = 'low-1'
25
+ const AFTER_ID = 'after-1'
26
+ const REPORT_ID = 'report-1'
27
+ const INNER_ITER_ID = INNER_ID + '_iter'
28
+ const RUNS_KEY = '__serverNestedLoopRuns'
29
+ const MADE_KEY = '__serverNestedLoopMade'
30
+
31
+ type GraphNode = Record<string, unknown> & { id: string }
32
+ type GraphEdge = Record<string, unknown> & { id: string; source: string; target: string }
33
+ interface Graph {
34
+ nodes: GraphNode[]
35
+ edges: GraphEdge[]
36
+ }
37
+ interface Run {
38
+ status: number
39
+ loopResult: (loopId: string) => unknown
40
+ }
41
+
42
+ const runs = (): unknown[] => (globalThis as Record<string, any>)[RUNS_KEY] || []
43
+
44
+ const loopNode = (id: string, stepNumber: number, config: Record<string, unknown>): GraphNode => ({
45
+ id,
46
+ type: 'general-loop',
47
+ config,
48
+ stepNumber,
49
+ label: id,
50
+ })
51
+
52
+ const recordingNode = (id: string, stepNumber: number): GraphNode => ({
53
+ id,
54
+ type: 'general-custom-js',
55
+ config: {
56
+ code: [
57
+ 'function customHandler() {',
58
+ ` globalThis.${RUNS_KEY} = (globalThis.${RUNS_KEY} || []).concat('${id}');`,
59
+ ` return { n: globalThis.${RUNS_KEY}.length };`,
60
+ '}',
61
+ ].join('\n'),
62
+ context: 'server',
63
+ },
64
+ stepNumber,
65
+ label: id,
66
+ })
67
+
68
+ const bodyEntry = (id: string, loopId: string, target: string): GraphEdge => ({
69
+ id,
70
+ source: loopId,
71
+ target,
72
+ sourceHandle: 'loop',
73
+ targetHandle: 'loop-body-in',
74
+ })
75
+
76
+ const loopBack = (id: string, source: string, loopId: string): GraphEdge => ({
77
+ id,
78
+ source,
79
+ target: loopId,
80
+ sourceHandle: 'loop-body-out',
81
+ targetHandle: 'loop-back',
82
+ })
83
+
84
+ const exitTo = (id: string, loopId: string, target: string): GraphEdge => ({
85
+ id,
86
+ source: loopId,
87
+ target,
88
+ sourceHandle: 'exit',
89
+ })
90
+
91
+ const forLoopInForLoop = (): Graph => ({
92
+ nodes: [
93
+ loopNode(OUTER_ID, 1, { loopType: 'for', endIndex: 2 }),
94
+ loopNode(INNER_ID, 2, { loopType: 'for', endIndex: 3 }),
95
+ recordingNode(BODY_ID, 3),
96
+ recordingNode(AFTER_ID, 4),
97
+ ],
98
+ edges: [
99
+ bodyEntry('e1', OUTER_ID, INNER_ID),
100
+ bodyEntry('e2', INNER_ID, BODY_ID),
101
+ loopBack('e3', BODY_ID, INNER_ID),
102
+ loopBack('e4', INNER_ID, OUTER_ID),
103
+ exitTo('e5', OUTER_ID, AFTER_ID),
104
+ ],
105
+ })
106
+
107
+ const threeLoopsDeep = (): Graph => ({
108
+ nodes: [
109
+ loopNode(OUTER_ID, 1, { loopType: 'for', endIndex: 2 }),
110
+ loopNode(MIDDLE_ID, 2, { loopType: 'forEach', collection: ['a', 'b'] }),
111
+ loopNode(INNER_ID, 3, { loopType: 'map', collection: ['x', 'y', 'z'] }),
112
+ recordingNode(BODY_ID, 4),
113
+ recordingNode(AFTER_ID, 5),
114
+ ],
115
+ edges: [
116
+ bodyEntry('e1', OUTER_ID, MIDDLE_ID),
117
+ bodyEntry('e2', MIDDLE_ID, INNER_ID),
118
+ bodyEntry('e3', INNER_ID, BODY_ID),
119
+ loopBack('e4', BODY_ID, INNER_ID),
120
+ loopBack('e5', INNER_ID, MIDDLE_ID),
121
+ loopBack('e6', MIDDLE_ID, OUTER_ID),
122
+ exitTo('e7', OUTER_ID, AFTER_ID),
123
+ ],
124
+ })
125
+
126
+ /**
127
+ * The outer body first makes a collection (one item on the first outer pass,
128
+ * two on the second), then walks it with the inner loop, whose body records
129
+ * the item it was handed.
130
+ */
131
+ const forEachOverWhatTheOuterPassMade = (): Graph => ({
132
+ nodes: [
133
+ loopNode(OUTER_ID, 1, { loopType: 'for', endIndex: 2 }),
134
+ {
135
+ id: MAKE_ID,
136
+ type: 'general-custom-js',
137
+ config: {
138
+ code: [
139
+ 'function customHandler() {',
140
+ ` globalThis.${MADE_KEY} = (globalThis.${MADE_KEY} || 0) + 1;`,
141
+ ` return { items: globalThis.${MADE_KEY} === 1 ? ['a'] : ['b', 'c'] };`,
142
+ '}',
143
+ ].join('\n'),
144
+ context: 'server',
145
+ },
146
+ stepNumber: 2,
147
+ label: 'Make The Items',
148
+ },
149
+ loopNode(INNER_ID, 3, {
150
+ loopType: 'forEach',
151
+ collection: { type: 'workflowContext', nodeId: MAKE_ID, path: [MAKE_ID, 'items'] },
152
+ }),
153
+ {
154
+ id: BODY_ID,
155
+ type: 'general-custom-js',
156
+ config: {
157
+ code: [
158
+ 'function customHandler(params) {',
159
+ ` globalThis.${RUNS_KEY} = (globalThis.${RUNS_KEY} || []).concat(params.item);`,
160
+ ' return { ok: true };',
161
+ '}',
162
+ ].join('\n'),
163
+ params: {
164
+ item: { type: 'workflowContext', nodeId: INNER_ID, path: [INNER_ID, 'currentItem'] },
165
+ },
166
+ context: 'server',
167
+ },
168
+ stepNumber: 4,
169
+ label: 'Record The Item',
170
+ },
171
+ ],
172
+ edges: [
173
+ bodyEntry('e1', OUTER_ID, MAKE_ID),
174
+ { id: 'e2', source: MAKE_ID, target: INNER_ID },
175
+ bodyEntry('e3', INNER_ID, BODY_ID),
176
+ loopBack('e4', BODY_ID, INNER_ID),
177
+ loopBack('e5', INNER_ID, OUTER_ID),
178
+ ],
179
+ })
180
+
181
+ const branchInTheInnerBody = (): Graph => ({
182
+ nodes: [
183
+ loopNode(OUTER_ID, 1, { loopType: 'for', endIndex: 2 }),
184
+ loopNode(INNER_ID, 2, { loopType: 'for', endIndex: 4 }),
185
+ {
186
+ id: IF_ID,
187
+ type: 'general-if-statement',
188
+ config: {
189
+ conditionType: 'simple-comparison',
190
+ leftValue: {
191
+ type: 'workflowContext',
192
+ nodeId: INNER_ITER_ID,
193
+ path: [INNER_ITER_ID, 'index'],
194
+ },
195
+ operator: '>=',
196
+ rightValue: 2,
197
+ },
198
+ stepNumber: 3,
199
+ label: 'Index Is High',
200
+ },
201
+ recordingNode(HIGH_ID, 4),
202
+ recordingNode(LOW_ID, 5),
203
+ recordingNode(AFTER_ID, 6),
204
+ ],
205
+ edges: [
206
+ bodyEntry('e1', OUTER_ID, INNER_ID),
207
+ bodyEntry('e2', INNER_ID, IF_ID),
208
+ { id: 'e3', source: IF_ID, target: HIGH_ID, sourceHandle: 'true' },
209
+ { id: 'e4', source: IF_ID, target: LOW_ID, sourceHandle: 'false' },
210
+ loopBack('e5', HIGH_ID, INNER_ID),
211
+ loopBack('e6', LOW_ID, INNER_ID),
212
+ loopBack('e7', INNER_ID, OUTER_ID),
213
+ exitTo('e8', OUTER_ID, AFTER_ID),
214
+ ],
215
+ })
216
+
217
+ const requireRuntime = (extra: Record<string, unknown> = {}) => {
218
+ const runtime = loadServerRuntime()
219
+ return (id: string): unknown => {
220
+ if (id.endsWith('/utils/workflows/server-runtime')) {
221
+ return runtime
222
+ }
223
+ const known = Object.keys(extra).find((suffix) => id.endsWith(suffix))
224
+ if (known) {
225
+ return extra[known]
226
+ }
227
+ throw new Error('Unexpected require: ' + id)
228
+ }
229
+ }
230
+
231
+ const bootRoute = (source: string, requireFn: (id: string) => unknown) => {
232
+ const routeModule: { exports: any } = { exports: {} }
233
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
234
+ new Function('module', 'exports', 'require', source)(routeModule, routeModule.exports, requireFn)
235
+ return routeModule.exports as (req: unknown, res: unknown) => Promise<void>
236
+ }
237
+
238
+ const jsonReply = () => {
239
+ const reply = { status: 0, body: undefined as any }
240
+ const res = {
241
+ status(code: number) {
242
+ reply.status = code
243
+ return this
244
+ },
245
+ json(payload: unknown) {
246
+ reply.body = payload
247
+ return this
248
+ },
249
+ setHeader: (): void => undefined,
250
+ }
251
+ return { reply, res }
252
+ }
253
+
254
+ const runSegmentRoute =
255
+ (generate: (segment: WorkflowSegment) => string) =>
256
+ async (graph: Graph): Promise<Run> => {
257
+ const segment = {
258
+ id: 'seg-nested-loops',
259
+ env: 'server',
260
+ nodeIds: graph.nodes.map((node) => node.id),
261
+ nodes: graph.nodes,
262
+ edges: graph.edges,
263
+ } as unknown as WorkflowSegment
264
+ const handler = bootRoute(generate(segment), requireRuntime())
265
+ const { reply, res } = jsonReply()
266
+ await handler({ method: 'POST', body: { context: {} }, headers: { host: 'site.example' } }, res)
267
+ return { status: reply.status, loopResult: (loopId) => reply.body.results[loopId] }
268
+ }
269
+
270
+ /**
271
+ * A scheduled route replies with no results, so its last step is a custom node
272
+ * that is handed the result of every loop of the graph.
273
+ */
274
+ const runScheduledRoute = async (graph: Graph): Promise<Run> => {
275
+ const loopIds = graph.nodes.filter((node) => node.type === 'general-loop').map((node) => node.id)
276
+ const parameters: Record<string, unknown> = {}
277
+ loopIds.forEach((loopId) => {
278
+ parameters[loopId] = { type: 'workflowContext', nodeId: loopId, path: [loopId] }
279
+ })
280
+ const reportNode = {
281
+ id: REPORT_ID,
282
+ type: 'general-custom-node',
283
+ config: { customNodeId: 'cn-report', parameters },
284
+ stepNumber: graph.nodes.length + 1,
285
+ label: 'Report The Loops',
286
+ }
287
+ const workflow: any = {
288
+ id: 'wf-scheduled-nested-loops',
289
+ name: 'Scheduled Nested Loops',
290
+ trigger: {
291
+ type: 'event-cron-triggered',
292
+ nodeId: 't',
293
+ scope: 'global',
294
+ config: { schedule: '5 * * * *', urlPath: 'cron_nested_loops' },
295
+ },
296
+ nodes: [...graph.nodes, reportNode],
297
+ edges: [
298
+ { id: 'e0', source: 't', target: OUTER_ID },
299
+ ...graph.edges,
300
+ exitTo('e-report', OUTER_ID, REPORT_ID),
301
+ ],
302
+ }
303
+ const customNodes: Record<string, unknown> = {
304
+ 'cn-report': { id: 'cn-report', name: 'Report', nodes: [], edges: [] },
305
+ }
306
+ let reported: Record<string, unknown> = {}
307
+ const customNodeModule = {
308
+ 'cn-report': async (_context: unknown, handed: Record<string, unknown>) => {
309
+ reported = handed
310
+ return { ok: true }
311
+ },
312
+ }
313
+ const handler = bootRoute(
314
+ generateCronAPIRoute(workflow, customNodes as never),
315
+ requireRuntime({ '/utils/workflows/custom-nodes': customNodeModule })
316
+ )
317
+ const { reply, res } = jsonReply()
318
+ const req = {
319
+ method: 'POST',
320
+ url: '/api/workflows/cron_nested_loops',
321
+ headers: { host: 'site.example', 'x-forwarded-proto': 'https' },
322
+ body: {},
323
+ }
324
+ await withEnv({ TELEPORT_CRON_ALLOW_UNSIGNED: 'true', TELEPORT_CRON_SECRET: undefined }, () =>
325
+ handler(req, res)
326
+ )
327
+ return { status: reply.status, loopResult: (loopId) => reported[loopId] }
328
+ }
329
+
330
+ const clearRecordings = () => {
331
+ delete (globalThis as Record<string, any>)[RUNS_KEY]
332
+ delete (globalThis as Record<string, any>)[MADE_KEY]
333
+ }
334
+
335
+ beforeEach(clearRecordings)
336
+ afterAll(clearRecordings)
337
+
338
+ describe.each<[string, (graph: Graph) => Promise<Run>]>([
339
+ ['a server segment route', runSegmentRoute(generateServerSegmentAPIRoute)],
340
+ ['a streaming server segment route', runSegmentRoute(generateStreamingServerSegmentAPIRoute)],
341
+ ['a scheduled route', runScheduledRoute],
342
+ ])('%s runs a loop inside a loop', (_route, run) => {
343
+ it('runs every pass of the inner loop on every pass of the outer loop', async () => {
344
+ const result = await run(forLoopInForLoop())
345
+
346
+ expect(result.status).toBe(200)
347
+ expect(runs()).toEqual([BODY_ID, BODY_ID, BODY_ID, BODY_ID, BODY_ID, BODY_ID, AFTER_ID])
348
+ expect(result.loopResult(OUTER_ID)).toEqual({ completed: true, iterations: 2, results: [] })
349
+ expect(result.loopResult(INNER_ID)).toEqual({ completed: true, iterations: 3, results: [] })
350
+ })
351
+
352
+ it('runs a loop in a loop in a loop', async () => {
353
+ const result = await run(threeLoopsDeep())
354
+
355
+ expect(result.status).toBe(200)
356
+ expect(runs()).toEqual([...Array(12).fill(BODY_ID), AFTER_ID])
357
+ expect(result.loopResult(OUTER_ID)).toEqual({ completed: true, iterations: 2, results: [] })
358
+ expect(result.loopResult(MIDDLE_ID)).toEqual({ completed: true, iterations: 2, results: [] })
359
+ expect(result.loopResult(INNER_ID)).toEqual({
360
+ completed: true,
361
+ iterations: 3,
362
+ results: [{ n: 10 }, { n: 11 }, { n: 12 }],
363
+ })
364
+ })
365
+
366
+ it('walks the collection the current outer pass made', async () => {
367
+ const result = await run(forEachOverWhatTheOuterPassMade())
368
+
369
+ expect(result.status).toBe(200)
370
+ expect(runs()).toEqual(['a', 'b', 'c'])
371
+ expect(result.loopResult(OUTER_ID)).toEqual({ completed: true, iterations: 2, results: [] })
372
+ expect(result.loopResult(INNER_ID)).toEqual({ completed: true, iterations: 2, results: [] })
373
+ })
374
+
375
+ it('takes one branch of the inner body per inner pass', async () => {
376
+ const result = await run(branchInTheInnerBody())
377
+
378
+ expect(result.status).toBe(200)
379
+ expect(runs()).toEqual([
380
+ LOW_ID,
381
+ LOW_ID,
382
+ HIGH_ID,
383
+ HIGH_ID,
384
+ LOW_ID,
385
+ LOW_ID,
386
+ HIGH_ID,
387
+ HIGH_ID,
388
+ AFTER_ID,
389
+ ])
390
+ expect(result.loopResult(INNER_ID)).toEqual({ completed: true, iterations: 4, results: [] })
391
+ })
392
+ })