@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.
- package/__tests__/cron-route-loop-templateparams.test.ts +5 -4
- package/__tests__/custom-js-loop-scope.test.ts +10 -5
- package/__tests__/loop-collection-unwrap.test.ts +6 -1
- package/__tests__/server-loop-body-branches.test.ts +188 -0
- package/__tests__/server-loop-types.test.ts +371 -0
- package/__tests__/server-nested-loops.test.ts +392 -0
- package/__tests__/streaming-route-if-branch.test.ts +107 -0
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +57 -248
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +84 -27
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/server-runtime-code.d.ts.map +1 -1
- package/dist/cjs/server-runtime-code.js +67 -0
- package/dist/cjs/server-runtime-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +57 -248
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +84 -27
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/server-runtime-code.d.ts.map +1 -1
- package/dist/esm/server-runtime-code.js +67 -0
- package/dist/esm/server-runtime-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/api-route-generator.ts +57 -248
- package/src/executor-generator.ts +84 -27
- 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
|
|
128
|
-
//
|
|
129
|
-
expect(route).toContain(
|
|
130
|
-
expect(
|
|
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).
|
|
505
|
-
|
|
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(
|
|
511
|
+
expect(runtime).toContain('context.__loopScopeStack.pop();')
|
|
508
512
|
})
|
|
509
513
|
|
|
510
514
|
it('initialises __loopScopeStack lazily (does not assume caller seeded it)', () => {
|
|
511
|
-
|
|
512
|
-
|
|
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.
|
|
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
|
+
})
|