@teleporthq/teleport-plugin-next-workflows 0.45.13 → 0.45.15

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.
@@ -0,0 +1,257 @@
1
+ // A workflow reads a global state through the state values its file hands the
2
+ // workflow runtime, and a global state is among them only when the file takes
3
+ // it from `useGlobalState()`. A file whose workflows only READ a global state
4
+ // used to get none of that, so the read was undefined: a timer in a component,
5
+ // gated on a global on/off switch that a page flips, never ticked.
6
+
7
+ import generate from '@babel/generator'
8
+ import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
9
+ import { buildStateGetHandlerSource } from '../src/nodes/state/_state-get'
10
+
11
+ const GLOBAL_STATES = {
12
+ TQ_switch: { id: 'TQ_switch', name: 'tickerTimerEnabled', type: 'boolean', defaultValue: true },
13
+ }
14
+
15
+ const COMPONENT = { componentId: 'TQ_ticker', componentName: 'Ticker' }
16
+
17
+ const node = (id: string, type: string, config: Record<string, unknown>) => ({
18
+ id,
19
+ type,
20
+ config,
21
+ stepNumber: 1,
22
+ label: id,
23
+ })
24
+
25
+ const UPDATE_COUNT = node('bump', 'state-update-local-state', { property: 'count', value: 1 })
26
+
27
+ const timerWorkflow = (
28
+ nodes: Array<ReturnType<typeof node>>,
29
+ trigger: Record<string, unknown> = { scope: 'component', config: COMPONENT }
30
+ ) => ({
31
+ id: 'wf-ticker',
32
+ name: 'Ticker Count Update',
33
+ trigger: {
34
+ type: 'event-interval',
35
+ nodeId: 'tick',
36
+ scope: trigger.scope,
37
+ config: { intervalMs: 1000, autoStart: true, ...(trigger.config as object) },
38
+ },
39
+ nodes,
40
+ edges: nodes.map((target, index) => ({
41
+ id: `e${index}`,
42
+ source: index === 0 ? 'tick' : nodes[index - 1].id,
43
+ target: target.id,
44
+ })),
45
+ })
46
+
47
+ const jsxComponentChunk = () => ({
48
+ type: 'chunk-type-ast',
49
+ name: 'jsx-component',
50
+ content: {
51
+ type: 'VariableDeclaration',
52
+ kind: 'const',
53
+ declarations: [
54
+ {
55
+ type: 'VariableDeclarator',
56
+ id: { type: 'Identifier', name: 'Ticker' },
57
+ init: {
58
+ type: 'ArrowFunctionExpression',
59
+ params: [] as unknown[],
60
+ body: {
61
+ type: 'BlockStatement',
62
+ body: [{ type: 'ReturnStatement', argument: null as unknown }],
63
+ directives: [] as unknown[],
64
+ },
65
+ },
66
+ },
67
+ ],
68
+ },
69
+ })
70
+
71
+ interface Generated {
72
+ component: string
73
+ module: string
74
+ dependencies: Record<string, { path?: string }>
75
+ }
76
+
77
+ const generateFile = async (params: {
78
+ workflow: any
79
+ isPage: boolean
80
+ globalStateDefinitions?: Record<string, unknown>
81
+ customNodes?: Record<string, unknown>
82
+ }): Promise<Generated> => {
83
+ const { workflow, isPage, globalStateDefinitions = GLOBAL_STATES, customNodes = {} } = params
84
+ const plugin = createNextWorkflowPlugin({ isPage })
85
+ const structure: any = {
86
+ uidl: {
87
+ name: isPage ? 'Home' : 'Ticker',
88
+ node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
89
+ stateDefinitions: { count: { type: 'number', defaultValue: 0 } },
90
+ ...(isPage ? { outputOptions: { pageId: 'page-1', fileName: 'index' } } : {}),
91
+ },
92
+ chunks: [jsxComponentChunk()],
93
+ options: {
94
+ workflows: { workflows: { [workflow.id]: workflow }, customNodes },
95
+ globalStateDefinitions,
96
+ },
97
+ dependencies: {},
98
+ }
99
+ await plugin(structure)
100
+ const componentChunk = structure.chunks.find((c: any) => c.name === 'jsx-component')
101
+ const moduleChunk = structure.chunks.find((c: any) => c.name === 'workflow-module')
102
+ return {
103
+ component: generate(componentChunk.content).code,
104
+ module: moduleChunk ? String(moduleChunk.content) : '',
105
+ dependencies: structure.dependencies,
106
+ }
107
+ }
108
+
109
+ const HOOK =
110
+ /const \{\s*tickerTimerEnabled,\s*setTickerTimerEnabled,\s*refreshGlobalState\s*\} = useGlobalState\(\)/
111
+ const VALUE_IN_REF =
112
+ /__wfStateRef\.current = \{\s*count: count,\s*tickerTimerEnabled: tickerTimerEnabled\s*\}/
113
+
114
+ const expectGlobalStateWired = (file: Generated) => {
115
+ expect(file.dependencies.useGlobalState).toEqual({
116
+ type: 'local',
117
+ path: '@/global-state-context',
118
+ meta: { namedImport: true },
119
+ })
120
+ expect(file.component).toMatch(HOOK)
121
+ // Assigned on every render, after the hook: a change of the global value
122
+ // re-renders the file, and the next run reads the new value.
123
+ expect(file.component).toMatch(VALUE_IN_REF)
124
+ expect(file.component.indexOf('useGlobalState()')).toBeLessThan(
125
+ file.component.indexOf('__wfStateRef.current =')
126
+ )
127
+ // The runtime is told the state's type, and each run starts from the ref.
128
+ expect(file.component).toMatch(/tickerTimerEnabled: "boolean"/)
129
+ expect(file.module).toContain(
130
+ 'triggerContext.__stateValues = Object.assign({}, stateValuesRef.current);'
131
+ )
132
+ }
133
+
134
+ const expectNoGlobalStateWiring = (file: Generated) => {
135
+ expect(file.dependencies.useGlobalState).toBeUndefined()
136
+ expect(file.component).not.toContain('useGlobalState')
137
+ expect(file.component).not.toContain('tickerTimerEnabled')
138
+ expect(file.component).toMatch(/__wfStateRef\.current = \{\s*count: count\s*\}/)
139
+ }
140
+
141
+ const READ_SWITCH = node('read-switch', 'state-get-global-state', {
142
+ property: 'tickerTimerEnabled',
143
+ })
144
+
145
+ describe('a file whose workflows only read a global state', () => {
146
+ it('a component gets the import, the hook, and the value in its state ref', async () => {
147
+ const file = await generateFile({
148
+ workflow: timerWorkflow([READ_SWITCH, UPDATE_COUNT]),
149
+ isPage: false,
150
+ })
151
+
152
+ expectGlobalStateWired(file)
153
+ })
154
+
155
+ it('a page gets the same', async () => {
156
+ const file = await generateFile({
157
+ workflow: timerWorkflow([READ_SWITCH, UPDATE_COUNT], {
158
+ scope: 'page',
159
+ config: { pageId: 'page-1' },
160
+ }),
161
+ isPage: true,
162
+ })
163
+
164
+ expectGlobalStateWired(file)
165
+ })
166
+
167
+ it('counts a read made inside a custom node the workflow calls', async () => {
168
+ const file = await generateFile({
169
+ workflow: timerWorkflow([
170
+ node('call', 'general-custom-node', { customNodeId: 'shared-check' }),
171
+ UPDATE_COUNT,
172
+ ]),
173
+ isPage: false,
174
+ customNodes: { 'shared-check': { id: 'shared-check', nodes: [READ_SWITCH], edges: [] } },
175
+ })
176
+
177
+ expectGlobalStateWired(file)
178
+ })
179
+
180
+ it.each([
181
+ ['a {{state.<name>}} token', { value: '{{state.tickerTimerEnabled}}' }],
182
+ [
183
+ 'a context reference into the state values',
184
+ {
185
+ value: {
186
+ type: 'workflowContext',
187
+ nodeId: 'tick',
188
+ path: ['tick', '__stateValues', 'tickerTimerEnabled'],
189
+ },
190
+ },
191
+ ],
192
+ [
193
+ 'an expression over the state values',
194
+ { expression: 'context.__stateValues.tickerTimerEnabled === true' },
195
+ ],
196
+ ])('counts a read written as %s', async (_form, config) => {
197
+ const file = await generateFile({
198
+ workflow: timerWorkflow([node('gate', 'general-if-statement', config), UPDATE_COUNT]),
199
+ isPage: false,
200
+ })
201
+
202
+ expectGlobalStateWired(file)
203
+ })
204
+ })
205
+
206
+ describe('a file that uses no global state is generated as before', () => {
207
+ it('a workflow on local state only gets no hook, in a project that has global states', async () => {
208
+ const file = await generateFile({ workflow: timerWorkflow([UPDATE_COUNT]), isPage: false })
209
+
210
+ expectNoGlobalStateWiring(file)
211
+ })
212
+
213
+ it.each([
214
+ ['a token naming a local state', { value: '{{state.count}}' }],
215
+ ['an expression over the state values naming none', { expression: '__stateValues.count > 3' }],
216
+ [
217
+ 'a name that merely contains a global state name',
218
+ { expression: '__stateValues.tickerTimerEnabledLabel' },
219
+ ],
220
+ ])('%s gets no hook', async (_form, config) => {
221
+ const file = await generateFile({
222
+ workflow: timerWorkflow([node('gate', 'general-if-statement', config), UPDATE_COUNT]),
223
+ isPage: false,
224
+ })
225
+
226
+ expectNoGlobalStateWiring(file)
227
+ })
228
+
229
+ it('a read node in a project that declares no global state adds nothing', async () => {
230
+ const file = await generateFile({
231
+ workflow: timerWorkflow([READ_SWITCH, UPDATE_COUNT]),
232
+ isPage: false,
233
+ globalStateDefinitions: {},
234
+ })
235
+
236
+ expect(file.dependencies.useGlobalState).toBeUndefined()
237
+ expect(file.component).not.toContain('useGlobalState')
238
+ })
239
+ })
240
+
241
+ describe('the read node itself', () => {
242
+ it('returns the value the state ref held when the run started', async () => {
243
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
244
+ const handler = new Function(`return ${buildStateGetHandlerSource('state_get_global_state')}`)()
245
+ const stateValuesRef = { current: { tickerTimerEnabled: true } }
246
+ const context = { __stateValues: { ...stateValuesRef.current } }
247
+
248
+ await expect(handler({ property: 'tickerTimerEnabled' }, context)).resolves.toEqual({
249
+ value: true,
250
+ key: 'tickerTimerEnabled',
251
+ })
252
+ await expect(handler({ property: 'tickerTimerEnabled' }, {})).resolves.toEqual({
253
+ value: undefined,
254
+ key: 'tickerTimerEnabled',
255
+ })
256
+ })
257
+ })
@@ -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
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"api-route-generator.d.ts","sourceRoot":"","sources":["../../src/api-route-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,aAAa,EAGb,iBAAiB,EACjB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAE9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAA;AAuEvE,MAAM,WAAW,yBAAyB;IACxC,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;IACpB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAA;CACrD;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAA;AAEhC,wEAAwE;AACxE,eAAO,MAAM,wBAAwB;;;CAAiC,CAAA;AAEtE,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,4CAA4C,CAAA;AA+O7E,eAAO,MAAM,6BAA6B,YAC/B,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MAyXF,CAAA;AAED,eAAO,MAAM,kBAAkB,YAAa,eAAe,KAAG,OAE7D,CAAA;AAED,eAAO,MAAM,sCAAsC,YACxC,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MAycF,CAAA;AA6ID;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,aA4D5B,CAAA;AAEF,qDAAqD;AACrD,MAAM,WAAW,0BAA0B;IACzC,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAA;CAC1B;AAqBD,eAAO,MAAM,oBAAoB,aACrB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,YAC3C,0BAA0B,KAClC,MA0HF,CAAA;AAED,eAAO,MAAM,mBAAmB,eAClB,MAAM,aACP,MAAM,iBACF,MAAM,KACpB,MA4BF,CAAA;AAED,eAAO,MAAM,oBAAoB,aAAc,YAAY,KAAG,MAa7D,CAAA;AAsTD,uHAAuH;AACvH,eAAO,MAAM,uBAAuB,kBAAmB,iBAAiB,KAAG,OAES,CAAA;AAEpF,eAAO,MAAM,+BAA+B,aAChC,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,YAC3C,0BAA0B,KAClC,MAkOF,CAAA;AAkED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,uBAAuB,aACxB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MA8KF,CAAA;AAED,eAAO,MAAM,uBAAuB,aAAc,YAAY,KAAG,MAiBhE,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,cAC1B,aAAa,GAAG,SAAS,cACxB,MAAM,KACjB,MAAM,GAAG,IAYX,CAAA;AAED,eAAO,MAAM,mBAAmB,aAAc,YAAY,KAAG,MAAM,EAqBlE,CAAA"}
1
+ {"version":3,"file":"api-route-generator.d.ts","sourceRoot":"","sources":["../../src/api-route-generator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,aAAa,EAGb,iBAAiB,EACjB,sBAAsB,EACtB,sBAAsB,EACvB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAE9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAA;AAuEvE,MAAM,WAAW,yBAAyB;IACxC,0EAA0E;IAC1E,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;OAIG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;IACpB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAA;CACrD;AAED,OAAO,EAAE,qBAAqB,EAAE,CAAA;AAEhC,wEAAwE;AACxE,eAAO,MAAM,wBAAwB;;;CAAiC,CAAA;AAEtE,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,4CAA4C,CAAA;AA+O7E,eAAO,MAAM,6BAA6B,YAC/B,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MA0XF,CAAA;AAED,eAAO,MAAM,kBAAkB,YAAa,eAAe,KAAG,OAE7D,CAAA;AAED,eAAO,MAAM,sCAAsC,YACxC,eAAe,iBACT,MAAM,eACR,sBAAsB,YAC1B,yBAAyB,KACjC,MA0cF,CAAA;AA6ID;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,aA4D5B,CAAA;AAEF,qDAAqD;AACrD,MAAM,WAAW,0BAA0B;IACzC,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAA;CAC1B;AAqBD,eAAO,MAAM,oBAAoB,aACrB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,YAC3C,0BAA0B,KAClC,MA0HF,CAAA;AAED,eAAO,MAAM,mBAAmB,eAClB,MAAM,aACP,MAAM,iBACF,MAAM,KACpB,MA4BF,CAAA;AAED,eAAO,MAAM,oBAAoB,aAAc,YAAY,KAAG,MAa7D,CAAA;AAuTD,uHAAuH;AACvH,eAAO,MAAM,uBAAuB,kBAAmB,iBAAiB,KAAG,OAES,CAAA;AAEpF,eAAO,MAAM,+BAA+B,aAChC,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,YAC3C,0BAA0B,KAClC,MAkOF,CAAA;AAkED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,uBAAuB,aACxB,YAAY,gBACR,OAAO,MAAM,EAAE,sBAAsB,CAAC,KACnD,MA8KF,CAAA;AAED,eAAO,MAAM,uBAAuB,aAAc,YAAY,KAAG,MAiBhE,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,cAC1B,aAAa,GAAG,SAAS,cACxB,MAAM,KACjB,MAAM,GAAG,IAYX,CAAA;AAED,eAAO,MAAM,mBAAmB,aAAc,YAAY,KAAG,MAAM,EAqBlE,CAAA"}