callman-core 1.0.2 → 1.0.3
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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/runner/runCollection.d.ts.map +1 -1
- package/dist/runner/runCollection.js.map +1 -1
- package/dist/scenario-runner/condition.d.ts +67 -0
- package/dist/scenario-runner/condition.d.ts.map +1 -0
- package/dist/scenario-runner/condition.js +1508 -0
- package/dist/scenario-runner/condition.js.map +1 -0
- package/dist/scenario-runner/conditionTypes.d.ts +78 -0
- package/dist/scenario-runner/conditionTypes.d.ts.map +1 -0
- package/dist/scenario-runner/conditionTypes.js +2 -0
- package/dist/scenario-runner/conditionTypes.js.map +1 -0
- package/dist/scenario-runner/executionPolicyRunner.d.ts +24 -0
- package/dist/scenario-runner/executionPolicyRunner.d.ts.map +1 -0
- package/dist/scenario-runner/executionPolicyRunner.js +48 -0
- package/dist/scenario-runner/executionPolicyRunner.js.map +1 -0
- package/dist/scenario-runner/graphTraversal.d.ts +12 -0
- package/dist/scenario-runner/graphTraversal.d.ts.map +1 -0
- package/dist/scenario-runner/graphTraversal.js +21 -0
- package/dist/scenario-runner/graphTraversal.js.map +1 -0
- package/dist/scenario-runner/index.d.ts +7 -0
- package/dist/scenario-runner/index.d.ts.map +1 -0
- package/dist/scenario-runner/index.js +7 -0
- package/dist/scenario-runner/index.js.map +1 -0
- package/dist/scenario-runner/nodePolicy.d.ts +11 -0
- package/dist/scenario-runner/nodePolicy.d.ts.map +1 -0
- package/dist/scenario-runner/nodePolicy.js +107 -0
- package/dist/scenario-runner/nodePolicy.js.map +1 -0
- package/dist/scenario-runner/retryExecutor.d.ts +30 -0
- package/dist/scenario-runner/retryExecutor.d.ts.map +1 -0
- package/dist/scenario-runner/retryExecutor.js +50 -0
- package/dist/scenario-runner/retryExecutor.js.map +1 -0
- package/dist/scenario-runner/runScenario.d.ts +3 -0
- package/dist/scenario-runner/runScenario.d.ts.map +1 -0
- package/dist/scenario-runner/runScenario.js +1133 -0
- package/dist/scenario-runner/runScenario.js.map +1 -0
- package/dist/scenario-runner/templateResolver.d.ts +36 -0
- package/dist/scenario-runner/templateResolver.d.ts.map +1 -0
- package/dist/scenario-runner/templateResolver.js +440 -0
- package/dist/scenario-runner/templateResolver.js.map +1 -0
- package/dist/scenario-runner/types.d.ts +596 -0
- package/dist/scenario-runner/types.d.ts.map +1 -0
- package/dist/scenario-runner/types.js +2 -0
- package/dist/scenario-runner/types.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
import { buildConditionOutputPayload, evaluateConditionConfig, validateConditionConfig, } from "./condition.js";
|
|
2
|
+
import { createSubScenarioStepId, buildRuntimeStepDescriptors, DEFAULT_END_LOOP_DELAY_MS, DEFAULT_END_LOOP_ITERATIONS, flattenScenarioNodesForContext, MAX_END_LOOP_ITERATIONS, } from "./graphTraversal.js";
|
|
3
|
+
import { executeWithScenarioNodePolicy } from "./executionPolicyRunner.js";
|
|
4
|
+
import { getLegacyScenarioNodeExecutionPolicy, getScenarioNodeExecutionPolicy, supportsScenarioNodeExecutionPolicy, } from "./nodePolicy.js";
|
|
5
|
+
import { buildScenarioExecutionGraph, buildScenarioTemplateValues, collectExclusiveBranchNodeIds, evaluateScenarioExpression, resolveScenarioJsonTemplateString, resolveScenarioTemplateString, } from "./templateResolver.js";
|
|
6
|
+
class ScenarioStopError extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("Scenario execution stopped");
|
|
9
|
+
this.name = "ScenarioStopError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
class ScenarioNodeFailureError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ScenarioNodeFailureError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_MIN_NODE_VISUAL_STATE_MS = 180;
|
|
19
|
+
const createId = (prefix) => {
|
|
20
|
+
const randomUuid = globalThis.crypto?.randomUUID?.();
|
|
21
|
+
return randomUuid ?? `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
22
|
+
};
|
|
23
|
+
const createRunId = () => createId("scenario-run");
|
|
24
|
+
const buildTitle = (node) => {
|
|
25
|
+
const label = node.data.label.trim();
|
|
26
|
+
if (label) {
|
|
27
|
+
return label;
|
|
28
|
+
}
|
|
29
|
+
return `${node.type[0]?.toUpperCase() ?? ""}${node.type.slice(1)}`;
|
|
30
|
+
};
|
|
31
|
+
const isKafkaNode = (node) => node?.type === "kafka";
|
|
32
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
const createInputSnapshot = (context) => ({
|
|
34
|
+
env: context.environment,
|
|
35
|
+
global: context.globals,
|
|
36
|
+
workflow: context.workflowContext,
|
|
37
|
+
response: context.responseRoot,
|
|
38
|
+
db: {
|
|
39
|
+
result: context.dbResult ?? null,
|
|
40
|
+
},
|
|
41
|
+
kafkaEvent: context.kafkaEvent ?? null,
|
|
42
|
+
redis: {
|
|
43
|
+
result: context.redisResult ?? null,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const createContextSnapshot = (context) => ({
|
|
47
|
+
environment: { ...context.environment },
|
|
48
|
+
globals: { ...context.globals },
|
|
49
|
+
workflowContext: { ...context.workflowContext },
|
|
50
|
+
responseRoot: context.responseRoot ? { ...context.responseRoot } : null,
|
|
51
|
+
lastResponse: context.lastResponse,
|
|
52
|
+
dbResult: context.dbResult,
|
|
53
|
+
kafkaEvent: context.kafkaEvent,
|
|
54
|
+
kafkaCapture: context.kafkaCapture,
|
|
55
|
+
redisResult: context.redisResult,
|
|
56
|
+
});
|
|
57
|
+
const buildKafkaEventValue = (message) => {
|
|
58
|
+
const fallbackValue = {
|
|
59
|
+
value: message.valueJson ?? message.valueText,
|
|
60
|
+
text: message.valueText,
|
|
61
|
+
key: message.key,
|
|
62
|
+
partition: message.partition,
|
|
63
|
+
offset: message.offset,
|
|
64
|
+
timestamp: message.timestamp,
|
|
65
|
+
headers: Object.fromEntries(message.headers.map((header) => [header.key, header.value])),
|
|
66
|
+
};
|
|
67
|
+
if (isRecord(message.valueJson)) {
|
|
68
|
+
return {
|
|
69
|
+
...message.valueJson,
|
|
70
|
+
meta: fallbackValue,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return fallbackValue;
|
|
74
|
+
};
|
|
75
|
+
const buildKafkaFailureMessage = (result, usedMatch) => {
|
|
76
|
+
if (result.errorMessage?.trim()) {
|
|
77
|
+
return result.errorMessage;
|
|
78
|
+
}
|
|
79
|
+
if (result.timedOut) {
|
|
80
|
+
return usedMatch
|
|
81
|
+
? "Kafka node timed out without a matching event."
|
|
82
|
+
: "Kafka node timed out without receiving any events.";
|
|
83
|
+
}
|
|
84
|
+
return usedMatch
|
|
85
|
+
? "No Kafka event matched the configured rule."
|
|
86
|
+
: "Kafka node did not receive any events.";
|
|
87
|
+
};
|
|
88
|
+
const safeStringifyKafkaMessage = (message) => {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.stringify(message);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return message.valueText;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const buildScenarioRegex = (pattern) => {
|
|
97
|
+
const trimmedPattern = pattern.trim();
|
|
98
|
+
const literalMatch = trimmedPattern.match(/^\/(.+)\/([a-z]*)$/i);
|
|
99
|
+
if (literalMatch) {
|
|
100
|
+
return new RegExp(literalMatch[1] ?? "", literalMatch[2] ?? "");
|
|
101
|
+
}
|
|
102
|
+
return new RegExp(trimmedPattern);
|
|
103
|
+
};
|
|
104
|
+
const applyVariableMutationsToContext = (context, mutations) => {
|
|
105
|
+
for (const mutation of mutations) {
|
|
106
|
+
if (mutation.type === "set") {
|
|
107
|
+
if (mutation.scope === "environment") {
|
|
108
|
+
context.environment[mutation.key] = mutation.value ?? "";
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
context.globals[mutation.key] = mutation.value ?? "";
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (mutation.scope === "environment") {
|
|
116
|
+
delete context.environment[mutation.key];
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
delete context.globals[mutation.key];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const throwIfStopped = (signal) => {
|
|
124
|
+
if (signal?.aborted) {
|
|
125
|
+
throw new ScenarioStopError();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const createStepExecutionRecord = ({ stepId, stepType, title, }) => ({
|
|
129
|
+
stepId,
|
|
130
|
+
stepType,
|
|
131
|
+
title,
|
|
132
|
+
status: "idle",
|
|
133
|
+
attempts: 0,
|
|
134
|
+
currentAttempt: null,
|
|
135
|
+
maxAttempts: 1,
|
|
136
|
+
retryCount: 0,
|
|
137
|
+
retryEnabled: false,
|
|
138
|
+
retryDelayMs: 0,
|
|
139
|
+
failurePolicy: null,
|
|
140
|
+
continuedAfterFailure: false,
|
|
141
|
+
errors: [],
|
|
142
|
+
durationMs: null,
|
|
143
|
+
statusCode: null,
|
|
144
|
+
errorMessage: null,
|
|
145
|
+
response: null,
|
|
146
|
+
testResults: [],
|
|
147
|
+
contractResults: [],
|
|
148
|
+
contractSummary: null,
|
|
149
|
+
contractValidationError: null,
|
|
150
|
+
dbAssertionResults: [],
|
|
151
|
+
redisAssertionResults: [],
|
|
152
|
+
scriptSource: null,
|
|
153
|
+
scriptTimeoutMs: null,
|
|
154
|
+
scriptLogs: [],
|
|
155
|
+
conditionExpression: null,
|
|
156
|
+
conditionPreview: null,
|
|
157
|
+
conditionMode: null,
|
|
158
|
+
conditionResult: null,
|
|
159
|
+
conditionDebug: null,
|
|
160
|
+
activeBranch: null,
|
|
161
|
+
inputData: null,
|
|
162
|
+
outputData: null,
|
|
163
|
+
incomingEdgeId: null,
|
|
164
|
+
startedAt: null,
|
|
165
|
+
completedAt: null,
|
|
166
|
+
});
|
|
167
|
+
const isTerminalStatus = (status) => status !== "idle" && status !== "running" && status !== "retrying";
|
|
168
|
+
const updateRecordFromEvent = (records, event) => {
|
|
169
|
+
if (event.type === "scenario:start" ||
|
|
170
|
+
event.type === "scenario:end" ||
|
|
171
|
+
event.type === "step:reset") {
|
|
172
|
+
if (event.type !== "step:reset") {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const stepId of event.stepIds) {
|
|
176
|
+
const current = records.get(stepId);
|
|
177
|
+
if (!current) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
records.set(stepId, {
|
|
181
|
+
...current,
|
|
182
|
+
status: "idle",
|
|
183
|
+
currentAttempt: null,
|
|
184
|
+
errorMessage: null,
|
|
185
|
+
startedAt: null,
|
|
186
|
+
completedAt: null,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const current = records.get(event.stepId) ??
|
|
192
|
+
createStepExecutionRecord({
|
|
193
|
+
stepId: event.stepId,
|
|
194
|
+
stepType: "stepType" in event ? event.stepType : "condition",
|
|
195
|
+
title: event.title,
|
|
196
|
+
});
|
|
197
|
+
if (event.type === "step:start") {
|
|
198
|
+
records.set(event.stepId, {
|
|
199
|
+
...current,
|
|
200
|
+
stepType: event.stepType,
|
|
201
|
+
title: event.title,
|
|
202
|
+
status: "running",
|
|
203
|
+
attempts: event.attempt ?? 1,
|
|
204
|
+
currentAttempt: event.attempt ?? 1,
|
|
205
|
+
maxAttempts: event.maxAttempts ?? 1,
|
|
206
|
+
retryCount: Math.max(0, (event.attempt ?? 1) - 1),
|
|
207
|
+
retryEnabled: event.retryEnabled ?? false,
|
|
208
|
+
retryDelayMs: event.retryDelayMs ?? 0,
|
|
209
|
+
failurePolicy: event.failurePolicy ?? null,
|
|
210
|
+
errors: (event.attempt ?? 1) > 1 ? current.errors : [],
|
|
211
|
+
inputData: event.inputData ?? null,
|
|
212
|
+
incomingEdgeId: event.incomingEdgeId ?? null,
|
|
213
|
+
errorMessage: null,
|
|
214
|
+
startedAt: current.startedAt ?? event.at,
|
|
215
|
+
completedAt: null,
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (event.type === "step:retry") {
|
|
220
|
+
records.set(event.stepId, {
|
|
221
|
+
...current,
|
|
222
|
+
stepType: event.stepType,
|
|
223
|
+
title: event.title,
|
|
224
|
+
status: "retrying",
|
|
225
|
+
attempts: event.attempt,
|
|
226
|
+
currentAttempt: event.attempt,
|
|
227
|
+
maxAttempts: event.maxAttempts,
|
|
228
|
+
retryCount: Math.max(0, event.attempt - 1),
|
|
229
|
+
retryEnabled: true,
|
|
230
|
+
retryDelayMs: event.retryDelayMs,
|
|
231
|
+
failurePolicy: event.failurePolicy,
|
|
232
|
+
errors: event.errors,
|
|
233
|
+
errorMessage: event.errorMessage,
|
|
234
|
+
inputData: event.inputData ?? current.inputData,
|
|
235
|
+
incomingEdgeId: event.incomingEdgeId ?? current.incomingEdgeId,
|
|
236
|
+
startedAt: current.startedAt ?? event.at,
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (event.type === "step:condition") {
|
|
241
|
+
records.set(event.stepId, {
|
|
242
|
+
...current,
|
|
243
|
+
title: event.title,
|
|
244
|
+
conditionExpression: event.conditionExpression,
|
|
245
|
+
conditionPreview: event.conditionPreview,
|
|
246
|
+
conditionMode: event.conditionMode,
|
|
247
|
+
conditionResult: event.result,
|
|
248
|
+
conditionDebug: event.conditionDebug,
|
|
249
|
+
activeBranch: event.activeBranch,
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (event.type === "step:skip") {
|
|
254
|
+
records.set(event.stepId, {
|
|
255
|
+
...current,
|
|
256
|
+
stepType: event.stepType,
|
|
257
|
+
title: event.title,
|
|
258
|
+
status: "skipped",
|
|
259
|
+
inputData: event.inputData ?? current.inputData,
|
|
260
|
+
incomingEdgeId: event.incomingEdgeId ?? current.incomingEdgeId,
|
|
261
|
+
startedAt: current.startedAt ?? event.at,
|
|
262
|
+
completedAt: event.at,
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const isSuccess = event.type === "step:success";
|
|
267
|
+
records.set(event.stepId, {
|
|
268
|
+
...current,
|
|
269
|
+
stepType: event.stepType,
|
|
270
|
+
title: event.title,
|
|
271
|
+
status: event.status ?? (isSuccess ? "success" : "failed"),
|
|
272
|
+
attempts: event.attempts ?? current.attempts,
|
|
273
|
+
currentAttempt: event.currentAttempt ?? current.currentAttempt,
|
|
274
|
+
maxAttempts: event.maxAttempts ?? current.maxAttempts,
|
|
275
|
+
retryCount: event.retryCount ?? current.retryCount,
|
|
276
|
+
retryEnabled: event.retryEnabled ?? current.retryEnabled,
|
|
277
|
+
retryDelayMs: event.retryDelayMs ?? current.retryDelayMs,
|
|
278
|
+
failurePolicy: event.failurePolicy ?? current.failurePolicy,
|
|
279
|
+
continuedAfterFailure: event.continuedAfterFailure ?? current.continuedAfterFailure,
|
|
280
|
+
errors: event.errors ?? current.errors,
|
|
281
|
+
durationMs: event.durationMs ?? current.durationMs,
|
|
282
|
+
statusCode: event.statusCode ?? current.statusCode,
|
|
283
|
+
errorMessage: event.type === "step:fail"
|
|
284
|
+
? event.errorMessage
|
|
285
|
+
: current.errorMessage,
|
|
286
|
+
response: event.response ?? current.response,
|
|
287
|
+
testResults: event.testResults ?? current.testResults,
|
|
288
|
+
contractResults: event.contractResults ?? current.contractResults,
|
|
289
|
+
contractSummary: event.contractSummary ?? current.contractSummary,
|
|
290
|
+
contractValidationError: event.contractValidationError ?? current.contractValidationError,
|
|
291
|
+
dbAssertionResults: event.dbAssertionResults ?? current.dbAssertionResults,
|
|
292
|
+
redisAssertionResults: event.redisAssertionResults ?? current.redisAssertionResults,
|
|
293
|
+
scriptSource: event.scriptSource ?? current.scriptSource,
|
|
294
|
+
scriptTimeoutMs: event.scriptTimeoutMs ?? current.scriptTimeoutMs,
|
|
295
|
+
scriptLogs: event.scriptLogs ?? current.scriptLogs,
|
|
296
|
+
conditionExpression: event.conditionExpression ?? current.conditionExpression,
|
|
297
|
+
conditionPreview: event.conditionPreview ?? current.conditionPreview,
|
|
298
|
+
conditionMode: event.conditionMode ?? current.conditionMode,
|
|
299
|
+
conditionResult: event.conditionResult ?? current.conditionResult,
|
|
300
|
+
conditionDebug: event.conditionDebug ?? current.conditionDebug,
|
|
301
|
+
activeBranch: event.activeBranch ?? current.activeBranch,
|
|
302
|
+
inputData: event.inputData ?? current.inputData,
|
|
303
|
+
outputData: event.outputData ?? current.outputData,
|
|
304
|
+
incomingEdgeId: event.incomingEdgeId ?? current.incomingEdgeId,
|
|
305
|
+
startedAt: current.startedAt ?? event.at,
|
|
306
|
+
completedAt: event.at,
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
const callHandler = async (handlers, event) => {
|
|
310
|
+
await handlers?.onEvent?.(event);
|
|
311
|
+
switch (event.type) {
|
|
312
|
+
case "scenario:start":
|
|
313
|
+
await handlers?.onScenarioStart?.(event);
|
|
314
|
+
return;
|
|
315
|
+
case "scenario:end":
|
|
316
|
+
return;
|
|
317
|
+
case "step:start":
|
|
318
|
+
await handlers?.onNodeStart?.(event);
|
|
319
|
+
return;
|
|
320
|
+
case "step:retry":
|
|
321
|
+
await handlers?.onNodeRetry?.(event);
|
|
322
|
+
return;
|
|
323
|
+
case "step:success":
|
|
324
|
+
await handlers?.onNodeSuccess?.(event);
|
|
325
|
+
return;
|
|
326
|
+
case "step:fail":
|
|
327
|
+
await handlers?.onNodeFail?.(event);
|
|
328
|
+
return;
|
|
329
|
+
case "step:skip":
|
|
330
|
+
await handlers?.onNodeSkip?.(event);
|
|
331
|
+
return;
|
|
332
|
+
case "step:reset":
|
|
333
|
+
await handlers?.onNodeReset?.(event);
|
|
334
|
+
return;
|
|
335
|
+
case "step:condition":
|
|
336
|
+
await handlers?.onConditionEvaluated?.(event);
|
|
337
|
+
return;
|
|
338
|
+
default:
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
export const runScenario = async ({ scenario, environment = {}, globals = {}, runtimeAdapters, eventHandlers, signal, minNodeVisualStateMs = DEFAULT_MIN_NODE_VISUAL_STATE_MS, }) => {
|
|
343
|
+
const runId = createRunId();
|
|
344
|
+
const startedAtMs = Date.now();
|
|
345
|
+
const startedAt = new Date(startedAtMs).toISOString();
|
|
346
|
+
const graph = buildScenarioExecutionGraph(scenario.nodes, scenario.edges);
|
|
347
|
+
const orderedStartNodeIds = graph.startNodeIds;
|
|
348
|
+
const runtimeStepDescriptors = buildRuntimeStepDescriptors(scenario.nodes);
|
|
349
|
+
const runtimeStepIds = runtimeStepDescriptors.map((entry) => entry.stepId);
|
|
350
|
+
const localStatuses = new Map(runtimeStepIds.map((stepId) => [stepId, "idle"]));
|
|
351
|
+
const records = new Map();
|
|
352
|
+
const events = [];
|
|
353
|
+
const runtimeContext = {
|
|
354
|
+
environment: { ...environment },
|
|
355
|
+
globals: { ...globals },
|
|
356
|
+
workflowContext: {},
|
|
357
|
+
responseRoot: null,
|
|
358
|
+
lastResponse: null,
|
|
359
|
+
dbResult: null,
|
|
360
|
+
kafkaEvent: null,
|
|
361
|
+
kafkaCapture: null,
|
|
362
|
+
redisResult: null,
|
|
363
|
+
};
|
|
364
|
+
const prefetchedKafkaResults = new Map();
|
|
365
|
+
const degradedStepIds = new Set();
|
|
366
|
+
const loopIterationCounts = new Map();
|
|
367
|
+
const rootScope = {
|
|
368
|
+
graph,
|
|
369
|
+
stepIdForNode: (nodeId) => nodeId,
|
|
370
|
+
stepIdForEdge: (edgeId) => edgeId,
|
|
371
|
+
};
|
|
372
|
+
const emit = async (event) => {
|
|
373
|
+
events.push(event);
|
|
374
|
+
updateRecordFromEvent(records, event);
|
|
375
|
+
await callHandler(eventHandlers, event);
|
|
376
|
+
};
|
|
377
|
+
const waitForDuration = async (delayMs) => {
|
|
378
|
+
const normalizedDelayMs = Math.max(0, Math.round(delayMs));
|
|
379
|
+
if (normalizedDelayMs === 0) {
|
|
380
|
+
throwIfStopped(signal);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
await new Promise((resolve, reject) => {
|
|
384
|
+
const timeoutId = globalThis.setTimeout(() => {
|
|
385
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
386
|
+
resolve();
|
|
387
|
+
}, normalizedDelayMs);
|
|
388
|
+
const onAbort = () => {
|
|
389
|
+
globalThis.clearTimeout(timeoutId);
|
|
390
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
391
|
+
reject(new ScenarioStopError());
|
|
392
|
+
};
|
|
393
|
+
if (signal?.aborted) {
|
|
394
|
+
onAbort();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
const ensureMinimumVisualDuration = async (attemptStartedAtMs) => {
|
|
401
|
+
const elapsedMs = Date.now() - attemptStartedAtMs;
|
|
402
|
+
if (elapsedMs >= minNodeVisualStateMs) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
await waitForDuration(minNodeVisualStateMs - elapsedMs);
|
|
406
|
+
};
|
|
407
|
+
const markNodeIdsSkipped = async (scope, nodeIds, reason) => {
|
|
408
|
+
for (const skippedNodeId of nodeIds) {
|
|
409
|
+
const skippedNode = scope.graph.nodeMap.get(skippedNodeId);
|
|
410
|
+
if (!skippedNode) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const stepId = scope.stepIdForNode(skippedNodeId);
|
|
414
|
+
const currentStatus = localStatuses.get(stepId) ?? "idle";
|
|
415
|
+
if (currentStatus !== "idle") {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
localStatuses.set(stepId, "skipped");
|
|
419
|
+
await emit({
|
|
420
|
+
type: "step:skip",
|
|
421
|
+
runId,
|
|
422
|
+
scenarioId: scenario.id,
|
|
423
|
+
stepId,
|
|
424
|
+
stepType: skippedNode.type,
|
|
425
|
+
title: buildTitle(skippedNode),
|
|
426
|
+
at: new Date().toISOString(),
|
|
427
|
+
reason,
|
|
428
|
+
inputData: createInputSnapshot(runtimeContext),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const markRemainingNodesSkipped = async (reason) => {
|
|
433
|
+
await markNodeIdsSkipped(rootScope, rootScope.graph.nodeMap.keys(), reason);
|
|
434
|
+
};
|
|
435
|
+
const markScopeRemainingNodesSkipped = async (scope, reason) => {
|
|
436
|
+
await markNodeIdsSkipped(scope, scope.graph.nodeMap.keys(), reason);
|
|
437
|
+
};
|
|
438
|
+
const isNodeReady = (scope, nodeId) => {
|
|
439
|
+
const incomingEdges = scope.graph.incomingMap.get(nodeId) ?? [];
|
|
440
|
+
if (incomingEdges.length === 0) {
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
return incomingEdges.every((edge) => {
|
|
444
|
+
const sourceStatus = localStatuses.get(scope.stepIdForNode(edge.source)) ?? "idle";
|
|
445
|
+
return (sourceStatus === "success" ||
|
|
446
|
+
sourceStatus === "skipped" ||
|
|
447
|
+
sourceStatus === "failed_continued" ||
|
|
448
|
+
sourceStatus === "partial_success");
|
|
449
|
+
});
|
|
450
|
+
};
|
|
451
|
+
const resetReachableNodesForLoop = (scope, targetNodeId) => {
|
|
452
|
+
const stack = [targetNodeId];
|
|
453
|
+
const visited = new Set();
|
|
454
|
+
while (stack.length > 0) {
|
|
455
|
+
const currentNodeId = stack.pop();
|
|
456
|
+
if (!currentNodeId || visited.has(currentNodeId)) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
visited.add(currentNodeId);
|
|
460
|
+
if (scope.graph.nodeMap.has(currentNodeId)) {
|
|
461
|
+
localStatuses.set(scope.stepIdForNode(currentNodeId), "idle");
|
|
462
|
+
}
|
|
463
|
+
for (const edge of scope.graph.outgoingMap.get(currentNodeId) ?? []) {
|
|
464
|
+
stack.push(edge.target);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return Array.from(visited).map((nodeId) => scope.stepIdForNode(nodeId));
|
|
468
|
+
};
|
|
469
|
+
const executeNode = async (nodeId, incomingEdgeId, options = {}, scope = rootScope) => {
|
|
470
|
+
throwIfStopped(signal);
|
|
471
|
+
const stepId = scope.stepIdForNode(nodeId);
|
|
472
|
+
const currentStatus = localStatuses.get(stepId) ?? "idle";
|
|
473
|
+
if (currentStatus !== "idle") {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (!options.force && !isNodeReady(scope, nodeId)) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const node = scope.graph.nodeMap.get(nodeId);
|
|
480
|
+
if (!node) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const title = buildTitle(node);
|
|
484
|
+
const executionPolicy = supportsScenarioNodeExecutionPolicy(node)
|
|
485
|
+
? getScenarioNodeExecutionPolicy(node)
|
|
486
|
+
: getLegacyScenarioNodeExecutionPolicy();
|
|
487
|
+
const executionStartedAtMs = Date.now();
|
|
488
|
+
let inputData = createInputSnapshot(runtimeContext);
|
|
489
|
+
let statusCode = null;
|
|
490
|
+
let response = null;
|
|
491
|
+
let outputData = null;
|
|
492
|
+
let testResults = [];
|
|
493
|
+
let contractResults = [];
|
|
494
|
+
let contractSummary = null;
|
|
495
|
+
let contractValidationError = null;
|
|
496
|
+
let dbAssertionResults = [];
|
|
497
|
+
let redisAssertionResults = [];
|
|
498
|
+
let scriptSource = null;
|
|
499
|
+
let scriptTimeoutMs = null;
|
|
500
|
+
let scriptLogs = [];
|
|
501
|
+
let scriptHasReturnValue = false;
|
|
502
|
+
let scriptReturnValue = null;
|
|
503
|
+
let conditionResult = null;
|
|
504
|
+
let activeBranch = null;
|
|
505
|
+
let conditionExpression = null;
|
|
506
|
+
let conditionPreview = null;
|
|
507
|
+
let conditionMode = null;
|
|
508
|
+
let conditionDebug = null;
|
|
509
|
+
let shouldMarkPartialSuccess = false;
|
|
510
|
+
const resetAttemptArtifacts = () => {
|
|
511
|
+
statusCode = null;
|
|
512
|
+
response = null;
|
|
513
|
+
outputData = null;
|
|
514
|
+
testResults = [];
|
|
515
|
+
contractResults = [];
|
|
516
|
+
contractSummary = null;
|
|
517
|
+
contractValidationError = null;
|
|
518
|
+
dbAssertionResults = [];
|
|
519
|
+
redisAssertionResults = [];
|
|
520
|
+
scriptSource = null;
|
|
521
|
+
scriptTimeoutMs = null;
|
|
522
|
+
scriptLogs = [];
|
|
523
|
+
scriptHasReturnValue = false;
|
|
524
|
+
scriptReturnValue = null;
|
|
525
|
+
conditionResult = null;
|
|
526
|
+
activeBranch = null;
|
|
527
|
+
conditionExpression = null;
|
|
528
|
+
conditionPreview = null;
|
|
529
|
+
conditionMode = null;
|
|
530
|
+
conditionDebug = null;
|
|
531
|
+
shouldMarkPartialSuccess = false;
|
|
532
|
+
};
|
|
533
|
+
const executeNodeAttempt = async (attempt, maxAttempts) => {
|
|
534
|
+
throwIfStopped(signal);
|
|
535
|
+
resetAttemptArtifacts();
|
|
536
|
+
const attemptStartedAtMs = Date.now();
|
|
537
|
+
inputData = createInputSnapshot(runtimeContext);
|
|
538
|
+
localStatuses.set(stepId, "running");
|
|
539
|
+
await emit({
|
|
540
|
+
type: "step:start",
|
|
541
|
+
runId,
|
|
542
|
+
scenarioId: scenario.id,
|
|
543
|
+
stepId,
|
|
544
|
+
stepType: node.type,
|
|
545
|
+
title,
|
|
546
|
+
at: new Date().toISOString(),
|
|
547
|
+
attempt,
|
|
548
|
+
maxAttempts,
|
|
549
|
+
retryEnabled: executionPolicy.retry.enabled,
|
|
550
|
+
retryDelayMs: executionPolicy.retry.delayMs,
|
|
551
|
+
failurePolicy: executionPolicy.onFailure,
|
|
552
|
+
inputData,
|
|
553
|
+
incomingEdgeId: incomingEdgeId ?? null,
|
|
554
|
+
});
|
|
555
|
+
const templateValues = buildScenarioTemplateValues({
|
|
556
|
+
environment: runtimeContext.environment,
|
|
557
|
+
globals: runtimeContext.globals,
|
|
558
|
+
workflowContext: runtimeContext.workflowContext,
|
|
559
|
+
responseRoot: runtimeContext.responseRoot,
|
|
560
|
+
dbResult: runtimeContext.dbResult,
|
|
561
|
+
kafkaEvent: runtimeContext.kafkaEvent,
|
|
562
|
+
redisResult: runtimeContext.redisResult,
|
|
563
|
+
});
|
|
564
|
+
try {
|
|
565
|
+
if (node.type === "request") {
|
|
566
|
+
const kafkaCapturePlans = (scope.graph.outgoingMap.get(nodeId) ?? [])
|
|
567
|
+
.map((edge) => scope.graph.nodeMap.get(edge.target))
|
|
568
|
+
.filter(isKafkaNode)
|
|
569
|
+
.map((kafkaNode) => {
|
|
570
|
+
const refreshedTemplateValues = buildScenarioTemplateValues({
|
|
571
|
+
environment: runtimeContext.environment,
|
|
572
|
+
globals: runtimeContext.globals,
|
|
573
|
+
workflowContext: runtimeContext.workflowContext,
|
|
574
|
+
responseRoot: runtimeContext.responseRoot,
|
|
575
|
+
dbResult: runtimeContext.dbResult,
|
|
576
|
+
kafkaEvent: runtimeContext.kafkaEvent,
|
|
577
|
+
redisResult: runtimeContext.redisResult,
|
|
578
|
+
});
|
|
579
|
+
return {
|
|
580
|
+
stepId: scope.stepIdForNode(kafkaNode.id),
|
|
581
|
+
connectionId: kafkaNode.data.config.connectionId || null,
|
|
582
|
+
topic: resolveScenarioTemplateString(kafkaNode.data.config.topic, refreshedTemplateValues).trim(),
|
|
583
|
+
};
|
|
584
|
+
})
|
|
585
|
+
.filter((entry) => entry.connectionId && entry.topic);
|
|
586
|
+
const requestResult = await runtimeAdapters.requestExecutor({
|
|
587
|
+
runId,
|
|
588
|
+
stepId,
|
|
589
|
+
node,
|
|
590
|
+
context: createContextSnapshot(runtimeContext),
|
|
591
|
+
kafkaCapturePlans,
|
|
592
|
+
signal,
|
|
593
|
+
});
|
|
594
|
+
if (requestResult.cancelled) {
|
|
595
|
+
throw new ScenarioStopError();
|
|
596
|
+
}
|
|
597
|
+
runtimeContext.environment = { ...requestResult.environment };
|
|
598
|
+
runtimeContext.globals = { ...requestResult.globals };
|
|
599
|
+
runtimeContext.lastResponse = requestResult.response;
|
|
600
|
+
runtimeContext.responseRoot = requestResult.responseRoot;
|
|
601
|
+
response = requestResult.response;
|
|
602
|
+
statusCode = requestResult.response.status;
|
|
603
|
+
outputData = requestResult.outputData;
|
|
604
|
+
testResults = requestResult.testResults;
|
|
605
|
+
contractResults = requestResult.contractResults;
|
|
606
|
+
contractSummary = requestResult.contractSummary;
|
|
607
|
+
contractValidationError = requestResult.contractValidationError;
|
|
608
|
+
dbAssertionResults = requestResult.dbAssertionResults;
|
|
609
|
+
redisAssertionResults = requestResult.redisAssertionResults;
|
|
610
|
+
for (const entry of requestResult.prefetchedKafkaResults ?? []) {
|
|
611
|
+
prefetchedKafkaResults.set(entry.stepId, entry.result);
|
|
612
|
+
}
|
|
613
|
+
if (requestResult.failureMessage) {
|
|
614
|
+
throw new ScenarioNodeFailureError(requestResult.failureMessage);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (node.type === "db") {
|
|
618
|
+
const resolvedQuery = resolveScenarioTemplateString(node.data.config.query, templateValues);
|
|
619
|
+
const dbResult = await runtimeAdapters.dbExecutor({
|
|
620
|
+
runId,
|
|
621
|
+
stepId,
|
|
622
|
+
node,
|
|
623
|
+
resolvedQuery,
|
|
624
|
+
context: createContextSnapshot(runtimeContext),
|
|
625
|
+
signal,
|
|
626
|
+
});
|
|
627
|
+
runtimeContext.dbResult = dbResult.dbResult;
|
|
628
|
+
outputData = dbResult.outputData;
|
|
629
|
+
if (dbResult.failureMessage) {
|
|
630
|
+
throw new ScenarioNodeFailureError(dbResult.failureMessage);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (node.type === "script") {
|
|
634
|
+
const scriptResult = await runtimeAdapters.scriptExecutor({
|
|
635
|
+
runId,
|
|
636
|
+
stepId,
|
|
637
|
+
node,
|
|
638
|
+
context: createContextSnapshot(runtimeContext),
|
|
639
|
+
signal,
|
|
640
|
+
});
|
|
641
|
+
scriptSource = scriptResult.scriptSource;
|
|
642
|
+
scriptTimeoutMs = scriptResult.timeoutMs;
|
|
643
|
+
scriptLogs = scriptResult.logs;
|
|
644
|
+
testResults = scriptResult.testResults;
|
|
645
|
+
scriptHasReturnValue = scriptResult.hasReturnValue;
|
|
646
|
+
scriptReturnValue = scriptResult.returnValue;
|
|
647
|
+
outputData = scriptResult.outputData;
|
|
648
|
+
applyVariableMutationsToContext(runtimeContext, scriptResult.variableMutations);
|
|
649
|
+
if (scriptResult.failureMessage) {
|
|
650
|
+
throw new ScenarioNodeFailureError(scriptResult.failureMessage);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (node.type === "redis") {
|
|
654
|
+
const resolvedCommands = node.data.config.commands
|
|
655
|
+
.map((command) => resolveScenarioTemplateString(command, templateValues))
|
|
656
|
+
.filter((command) => command.trim().length > 0);
|
|
657
|
+
const redisResult = await runtimeAdapters.redisExecutor({
|
|
658
|
+
runId,
|
|
659
|
+
stepId,
|
|
660
|
+
node,
|
|
661
|
+
resolvedCommands,
|
|
662
|
+
context: createContextSnapshot(runtimeContext),
|
|
663
|
+
signal,
|
|
664
|
+
});
|
|
665
|
+
runtimeContext.redisResult = redisResult.redisResult;
|
|
666
|
+
outputData = redisResult.outputData;
|
|
667
|
+
if (redisResult.failureMessage) {
|
|
668
|
+
throw new ScenarioNodeFailureError(redisResult.failureMessage);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (node.type === "kafka") {
|
|
672
|
+
const resolvedTopic = resolveScenarioTemplateString(node.data.config.topic, templateValues);
|
|
673
|
+
const rules = node.data.config.rules?.filter((rule) => rule.trim()) ?? [];
|
|
674
|
+
const prefetchedCaptureResult = prefetchedKafkaResults.get(stepId);
|
|
675
|
+
const captureResult = prefetchedCaptureResult ??
|
|
676
|
+
(await runtimeAdapters.kafkaExecutor({
|
|
677
|
+
runId,
|
|
678
|
+
stepId,
|
|
679
|
+
node,
|
|
680
|
+
resolvedTopic,
|
|
681
|
+
matcher: rules.length > 0
|
|
682
|
+
? {
|
|
683
|
+
mode: node.data.config.matchMode,
|
|
684
|
+
rules,
|
|
685
|
+
templateValues,
|
|
686
|
+
}
|
|
687
|
+
: null,
|
|
688
|
+
signal,
|
|
689
|
+
})).captureResult;
|
|
690
|
+
throwIfStopped(signal);
|
|
691
|
+
let matchedMessage = captureResult.messages[0] ?? null;
|
|
692
|
+
if (node.data.config.matchMode === "regex" && rules[0]) {
|
|
693
|
+
const regex = buildScenarioRegex(rules[0]);
|
|
694
|
+
matchedMessage =
|
|
695
|
+
captureResult.messages.find((message) => {
|
|
696
|
+
regex.lastIndex = 0;
|
|
697
|
+
return regex.test(safeStringifyKafkaMessage(message));
|
|
698
|
+
}) ?? null;
|
|
699
|
+
}
|
|
700
|
+
if (node.data.config.matchMode === "rules" && rules.length > 0) {
|
|
701
|
+
matchedMessage =
|
|
702
|
+
captureResult.messages.find((message) => {
|
|
703
|
+
const kafkaEventValue = buildKafkaEventValue(message);
|
|
704
|
+
const scopedValues = buildScenarioTemplateValues({
|
|
705
|
+
environment: runtimeContext.environment,
|
|
706
|
+
globals: runtimeContext.globals,
|
|
707
|
+
workflowContext: runtimeContext.workflowContext,
|
|
708
|
+
responseRoot: runtimeContext.responseRoot,
|
|
709
|
+
dbResult: runtimeContext.dbResult,
|
|
710
|
+
kafkaEvent: kafkaEventValue,
|
|
711
|
+
redisResult: runtimeContext.redisResult,
|
|
712
|
+
});
|
|
713
|
+
return rules.every((rule) => evaluateScenarioExpression(rule, scopedValues).result);
|
|
714
|
+
}) ?? null;
|
|
715
|
+
}
|
|
716
|
+
if (!matchedMessage) {
|
|
717
|
+
outputData = captureResult;
|
|
718
|
+
throw new ScenarioNodeFailureError(buildKafkaFailureMessage(captureResult, Boolean(rules.length)));
|
|
719
|
+
}
|
|
720
|
+
runtimeContext.kafkaCapture = captureResult;
|
|
721
|
+
runtimeContext.kafkaEvent = buildKafkaEventValue(matchedMessage);
|
|
722
|
+
outputData = runtimeContext.kafkaEvent;
|
|
723
|
+
}
|
|
724
|
+
if (node.type === "condition") {
|
|
725
|
+
const conditionIssues = validateConditionConfig(node.data.config);
|
|
726
|
+
const blockingConditionIssue = conditionIssues.find((issue) => issue.severity === "error");
|
|
727
|
+
if (blockingConditionIssue) {
|
|
728
|
+
throw new ScenarioNodeFailureError(blockingConditionIssue.message);
|
|
729
|
+
}
|
|
730
|
+
const evaluation = evaluateConditionConfig(node.data.config, {
|
|
731
|
+
environment: runtimeContext.environment,
|
|
732
|
+
globals: runtimeContext.globals,
|
|
733
|
+
workflowContext: runtimeContext.workflowContext,
|
|
734
|
+
responseRoot: runtimeContext.responseRoot,
|
|
735
|
+
dbResult: runtimeContext.dbResult,
|
|
736
|
+
kafkaEvent: runtimeContext.kafkaEvent,
|
|
737
|
+
redisResult: runtimeContext.redisResult,
|
|
738
|
+
});
|
|
739
|
+
const conditionPayload = buildConditionOutputPayload(node.data.config, evaluation);
|
|
740
|
+
conditionResult = evaluation.result;
|
|
741
|
+
activeBranch = evaluation.result ? "if" : "else";
|
|
742
|
+
conditionExpression = conditionPayload.expression;
|
|
743
|
+
conditionPreview = conditionPayload.preview;
|
|
744
|
+
conditionMode = conditionPayload.mode;
|
|
745
|
+
conditionDebug = conditionPayload.debug;
|
|
746
|
+
outputData = conditionPayload;
|
|
747
|
+
await emit({
|
|
748
|
+
type: "step:condition",
|
|
749
|
+
runId,
|
|
750
|
+
scenarioId: scenario.id,
|
|
751
|
+
stepId,
|
|
752
|
+
title,
|
|
753
|
+
at: new Date().toISOString(),
|
|
754
|
+
conditionExpression: conditionPayload.expression,
|
|
755
|
+
conditionPreview: conditionPayload.preview,
|
|
756
|
+
conditionMode: conditionPayload.mode,
|
|
757
|
+
conditionDebug: conditionPayload.debug,
|
|
758
|
+
result: evaluation.result,
|
|
759
|
+
activeBranch,
|
|
760
|
+
});
|
|
761
|
+
const inactiveBranch = evaluation.result ? "no" : "yes";
|
|
762
|
+
const inactiveBranchNodeIds = collectExclusiveBranchNodeIds({
|
|
763
|
+
graph: scope.graph,
|
|
764
|
+
conditionNodeId: node.id,
|
|
765
|
+
inactiveBranch,
|
|
766
|
+
});
|
|
767
|
+
await markNodeIdsSkipped(scope, inactiveBranchNodeIds, `Skipped because condition evaluated ${evaluation.result ? "TRUE" : "FALSE"}.`);
|
|
768
|
+
}
|
|
769
|
+
if (node.type === "subscenario") {
|
|
770
|
+
const degradedCountBefore = degradedStepIds.size;
|
|
771
|
+
const internalGraph = buildScenarioExecutionGraph(node.data.config.nodes, node.data.config.edges);
|
|
772
|
+
const internalScope = {
|
|
773
|
+
graph: internalGraph,
|
|
774
|
+
stepIdForNode: (childNodeId) => createSubScenarioStepId(stepId, childNodeId),
|
|
775
|
+
stepIdForEdge: (edgeId) => createSubScenarioStepId(stepId, edgeId),
|
|
776
|
+
};
|
|
777
|
+
for (const startNodeId of internalGraph.startNodeIds) {
|
|
778
|
+
throwIfStopped(signal);
|
|
779
|
+
await executeNode(startNodeId, null, {}, internalScope);
|
|
780
|
+
}
|
|
781
|
+
await markScopeRemainingNodesSkipped(internalScope, `Internal node was not reached inside ${title}.`);
|
|
782
|
+
const groupedOutput = {};
|
|
783
|
+
for (const childNode of flattenScenarioNodesForContext(node.data.config.nodes)) {
|
|
784
|
+
const childLabel = childNode.data.label.trim();
|
|
785
|
+
if (childLabel &&
|
|
786
|
+
Object.prototype.hasOwnProperty.call(runtimeContext.workflowContext, childLabel)) {
|
|
787
|
+
groupedOutput[childLabel] = runtimeContext.workflowContext[childLabel];
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
shouldMarkPartialSuccess = degradedStepIds.size > degradedCountBefore;
|
|
791
|
+
outputData = groupedOutput;
|
|
792
|
+
}
|
|
793
|
+
if (node.type === "end") {
|
|
794
|
+
if (node.data.config.loop && node.data.config.loopTargetNodeId) {
|
|
795
|
+
const loopTargetNode = scope.graph.nodeMap.get(node.data.config.loopTargetNodeId);
|
|
796
|
+
if (!loopTargetNode) {
|
|
797
|
+
throw new ScenarioNodeFailureError("Loop target node could not be found.");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
outputData = { ...runtimeContext.workflowContext };
|
|
801
|
+
}
|
|
802
|
+
if (node.type === "aggregator") {
|
|
803
|
+
const resolvedTemplate = resolveScenarioJsonTemplateString(node.data.config.template, templateValues);
|
|
804
|
+
try {
|
|
805
|
+
outputData = JSON.parse(resolvedTemplate);
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
throw new ScenarioNodeFailureError(`Aggregator "${node.data.label}" produced invalid JSON after template resolution.`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
throwIfStopped(signal);
|
|
812
|
+
const nodeLabel = node.data.label.trim();
|
|
813
|
+
const workflowOutput = node.type === "script"
|
|
814
|
+
? scriptHasReturnValue
|
|
815
|
+
? scriptReturnValue
|
|
816
|
+
: undefined
|
|
817
|
+
: outputData;
|
|
818
|
+
if (nodeLabel &&
|
|
819
|
+
typeof workflowOutput !== "undefined" &&
|
|
820
|
+
(node.type === "script" || workflowOutput !== null)) {
|
|
821
|
+
runtimeContext.workflowContext[nodeLabel] = workflowOutput;
|
|
822
|
+
}
|
|
823
|
+
await ensureMinimumVisualDuration(attemptStartedAtMs);
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
if (error instanceof ScenarioStopError) {
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
await ensureMinimumVisualDuration(attemptStartedAtMs);
|
|
830
|
+
throw error instanceof ScenarioNodeFailureError
|
|
831
|
+
? error
|
|
832
|
+
: new ScenarioNodeFailureError(error instanceof Error ? error.message : "Scenario node failed");
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
try {
|
|
836
|
+
const policyResult = await executeWithScenarioNodePolicy({
|
|
837
|
+
policy: executionPolicy,
|
|
838
|
+
execute: executeNodeAttempt,
|
|
839
|
+
wait: waitForDuration,
|
|
840
|
+
onRetry: async (event) => {
|
|
841
|
+
localStatuses.set(stepId, "retrying");
|
|
842
|
+
await emit({
|
|
843
|
+
type: "step:retry",
|
|
844
|
+
runId,
|
|
845
|
+
scenarioId: scenario.id,
|
|
846
|
+
stepId,
|
|
847
|
+
stepType: node.type,
|
|
848
|
+
title,
|
|
849
|
+
at: new Date().toISOString(),
|
|
850
|
+
attempt: event.nextAttempt,
|
|
851
|
+
maxAttempts: event.maxAttempts,
|
|
852
|
+
retryDelayMs: event.delayMs,
|
|
853
|
+
retryAt: event.retryAt,
|
|
854
|
+
failurePolicy: event.failurePolicy,
|
|
855
|
+
errorMessage: event.errorMessage,
|
|
856
|
+
errors: event.errors,
|
|
857
|
+
inputData,
|
|
858
|
+
incomingEdgeId: incomingEdgeId ?? null,
|
|
859
|
+
});
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
const durationMs = Math.round(Date.now() - executionStartedAtMs);
|
|
863
|
+
if (policyResult.outcome === "failed") {
|
|
864
|
+
localStatuses.set(stepId, "failed");
|
|
865
|
+
await emit({
|
|
866
|
+
type: "step:fail",
|
|
867
|
+
runId,
|
|
868
|
+
scenarioId: scenario.id,
|
|
869
|
+
stepId,
|
|
870
|
+
stepType: node.type,
|
|
871
|
+
title,
|
|
872
|
+
status: "failed",
|
|
873
|
+
at: new Date().toISOString(),
|
|
874
|
+
attempts: policyResult.attempts,
|
|
875
|
+
currentAttempt: policyResult.attempts,
|
|
876
|
+
maxAttempts: policyResult.maxAttempts,
|
|
877
|
+
retryCount: policyResult.retryCount,
|
|
878
|
+
retryEnabled: executionPolicy.retry.enabled,
|
|
879
|
+
retryDelayMs: executionPolicy.retry.delayMs,
|
|
880
|
+
failurePolicy: executionPolicy.onFailure,
|
|
881
|
+
continuedAfterFailure: false,
|
|
882
|
+
errors: policyResult.errors,
|
|
883
|
+
errorMessage: policyResult.finalErrorMessage ?? "Scenario node failed",
|
|
884
|
+
durationMs,
|
|
885
|
+
statusCode,
|
|
886
|
+
response,
|
|
887
|
+
testResults,
|
|
888
|
+
contractResults,
|
|
889
|
+
contractSummary,
|
|
890
|
+
contractValidationError,
|
|
891
|
+
dbAssertionResults,
|
|
892
|
+
redisAssertionResults,
|
|
893
|
+
scriptSource,
|
|
894
|
+
scriptTimeoutMs,
|
|
895
|
+
scriptLogs,
|
|
896
|
+
conditionExpression,
|
|
897
|
+
conditionPreview,
|
|
898
|
+
conditionMode,
|
|
899
|
+
conditionResult,
|
|
900
|
+
conditionDebug,
|
|
901
|
+
activeBranch,
|
|
902
|
+
inputData,
|
|
903
|
+
outputData,
|
|
904
|
+
incomingEdgeId: incomingEdgeId ?? null,
|
|
905
|
+
});
|
|
906
|
+
throw new ScenarioNodeFailureError(policyResult.finalErrorMessage ?? "Scenario node failed");
|
|
907
|
+
}
|
|
908
|
+
if (policyResult.outcome === "continued") {
|
|
909
|
+
degradedStepIds.add(stepId);
|
|
910
|
+
localStatuses.set(stepId, "failed_continued");
|
|
911
|
+
await emit({
|
|
912
|
+
type: "step:fail",
|
|
913
|
+
runId,
|
|
914
|
+
scenarioId: scenario.id,
|
|
915
|
+
stepId,
|
|
916
|
+
stepType: node.type,
|
|
917
|
+
title,
|
|
918
|
+
status: "failed_continued",
|
|
919
|
+
at: new Date().toISOString(),
|
|
920
|
+
attempts: policyResult.attempts,
|
|
921
|
+
currentAttempt: policyResult.attempts,
|
|
922
|
+
maxAttempts: policyResult.maxAttempts,
|
|
923
|
+
retryCount: policyResult.retryCount,
|
|
924
|
+
retryEnabled: executionPolicy.retry.enabled,
|
|
925
|
+
retryDelayMs: executionPolicy.retry.delayMs,
|
|
926
|
+
failurePolicy: executionPolicy.onFailure,
|
|
927
|
+
continuedAfterFailure: true,
|
|
928
|
+
errors: policyResult.errors,
|
|
929
|
+
errorMessage: policyResult.finalErrorMessage ??
|
|
930
|
+
"Scenario node failed but execution continued.",
|
|
931
|
+
durationMs,
|
|
932
|
+
statusCode,
|
|
933
|
+
response,
|
|
934
|
+
testResults,
|
|
935
|
+
contractResults,
|
|
936
|
+
contractSummary,
|
|
937
|
+
contractValidationError,
|
|
938
|
+
dbAssertionResults,
|
|
939
|
+
redisAssertionResults,
|
|
940
|
+
scriptSource,
|
|
941
|
+
scriptTimeoutMs,
|
|
942
|
+
scriptLogs,
|
|
943
|
+
conditionExpression,
|
|
944
|
+
conditionPreview,
|
|
945
|
+
conditionMode,
|
|
946
|
+
conditionResult,
|
|
947
|
+
conditionDebug,
|
|
948
|
+
activeBranch,
|
|
949
|
+
inputData,
|
|
950
|
+
outputData,
|
|
951
|
+
incomingEdgeId: incomingEdgeId ?? null,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
const successStatus = shouldMarkPartialSuccess ? "partial_success" : "success";
|
|
956
|
+
if (successStatus === "partial_success") {
|
|
957
|
+
degradedStepIds.add(stepId);
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
degradedStepIds.delete(stepId);
|
|
961
|
+
}
|
|
962
|
+
localStatuses.set(stepId, successStatus);
|
|
963
|
+
await emit({
|
|
964
|
+
type: "step:success",
|
|
965
|
+
runId,
|
|
966
|
+
scenarioId: scenario.id,
|
|
967
|
+
stepId,
|
|
968
|
+
stepType: node.type,
|
|
969
|
+
title,
|
|
970
|
+
status: successStatus,
|
|
971
|
+
at: new Date().toISOString(),
|
|
972
|
+
attempts: policyResult.attempts,
|
|
973
|
+
currentAttempt: policyResult.attempts,
|
|
974
|
+
maxAttempts: policyResult.maxAttempts,
|
|
975
|
+
retryCount: policyResult.retryCount,
|
|
976
|
+
retryEnabled: executionPolicy.retry.enabled,
|
|
977
|
+
retryDelayMs: executionPolicy.retry.delayMs,
|
|
978
|
+
failurePolicy: executionPolicy.onFailure,
|
|
979
|
+
continuedAfterFailure: false,
|
|
980
|
+
errors: policyResult.errors,
|
|
981
|
+
durationMs,
|
|
982
|
+
statusCode,
|
|
983
|
+
response,
|
|
984
|
+
testResults,
|
|
985
|
+
contractResults,
|
|
986
|
+
contractSummary,
|
|
987
|
+
contractValidationError,
|
|
988
|
+
dbAssertionResults,
|
|
989
|
+
redisAssertionResults,
|
|
990
|
+
scriptSource,
|
|
991
|
+
scriptTimeoutMs,
|
|
992
|
+
scriptLogs,
|
|
993
|
+
conditionExpression,
|
|
994
|
+
conditionPreview,
|
|
995
|
+
conditionMode,
|
|
996
|
+
conditionResult,
|
|
997
|
+
conditionDebug,
|
|
998
|
+
activeBranch,
|
|
999
|
+
inputData,
|
|
1000
|
+
outputData,
|
|
1001
|
+
incomingEdgeId: incomingEdgeId ?? null,
|
|
1002
|
+
});
|
|
1003
|
+
if (node.type === "end" && node.data.config.loop && node.data.config.loopTargetNodeId) {
|
|
1004
|
+
const loopTargetNode = scope.graph.nodeMap.get(node.data.config.loopTargetNodeId);
|
|
1005
|
+
if (!loopTargetNode) {
|
|
1006
|
+
throw new ScenarioNodeFailureError("Loop target node could not be found.");
|
|
1007
|
+
}
|
|
1008
|
+
const configuredLoopIterations = Math.min(MAX_END_LOOP_ITERATIONS, Math.max(0, Math.round(node.data.config.loopIterations ?? DEFAULT_END_LOOP_ITERATIONS)));
|
|
1009
|
+
const currentLoopIteration = loopIterationCounts.get(node.id) ?? 0;
|
|
1010
|
+
if (currentLoopIteration < configuredLoopIterations) {
|
|
1011
|
+
const nextLoopIteration = currentLoopIteration + 1;
|
|
1012
|
+
loopIterationCounts.set(node.id, nextLoopIteration);
|
|
1013
|
+
if (nextLoopIteration > MAX_END_LOOP_ITERATIONS) {
|
|
1014
|
+
throw new ScenarioNodeFailureError(`Loop safety limit reached (${MAX_END_LOOP_ITERATIONS} iterations). Stop the run or choose a narrower loop target.`);
|
|
1015
|
+
}
|
|
1016
|
+
const resetStepIds = resetReachableNodesForLoop(scope, loopTargetNode.id);
|
|
1017
|
+
for (const resetStepId of resetStepIds) {
|
|
1018
|
+
degradedStepIds.delete(resetStepId);
|
|
1019
|
+
}
|
|
1020
|
+
await emit({
|
|
1021
|
+
type: "step:reset",
|
|
1022
|
+
runId,
|
|
1023
|
+
scenarioId: scenario.id,
|
|
1024
|
+
stepIds: resetStepIds,
|
|
1025
|
+
at: new Date().toISOString(),
|
|
1026
|
+
reason: `Loop ${nextLoopIteration}/${configuredLoopIterations}: reset workflow branch`,
|
|
1027
|
+
});
|
|
1028
|
+
await waitForDuration(node.data.config.loopDelayMs ?? DEFAULT_END_LOOP_DELAY_MS);
|
|
1029
|
+
await executeNode(loopTargetNode.id, null, { force: true }, scope);
|
|
1030
|
+
}
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
const outgoingEdges = scope.graph.outgoingMap.get(nodeId) ?? [];
|
|
1035
|
+
const nextEdges = node.type === "condition"
|
|
1036
|
+
? outgoingEdges.filter((edge) => (conditionResult ? "yes" : "no") === (edge.type ?? "default"))
|
|
1037
|
+
: outgoingEdges;
|
|
1038
|
+
for (const edge of nextEdges) {
|
|
1039
|
+
throwIfStopped(signal);
|
|
1040
|
+
await executeNode(edge.target, scope.stepIdForEdge(edge.id), {}, scope);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
if (error instanceof ScenarioStopError) {
|
|
1045
|
+
const stepState = localStatuses.get(stepId);
|
|
1046
|
+
if (stepState === "running" || stepState === "retrying") {
|
|
1047
|
+
localStatuses.set(stepId, "skipped");
|
|
1048
|
+
await emit({
|
|
1049
|
+
type: "step:skip",
|
|
1050
|
+
runId,
|
|
1051
|
+
scenarioId: scenario.id,
|
|
1052
|
+
stepId,
|
|
1053
|
+
stepType: node.type,
|
|
1054
|
+
title,
|
|
1055
|
+
at: new Date().toISOString(),
|
|
1056
|
+
reason: "Execution stopped",
|
|
1057
|
+
inputData,
|
|
1058
|
+
incomingEdgeId: incomingEdgeId ?? null,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
throw error;
|
|
1062
|
+
}
|
|
1063
|
+
throw error instanceof ScenarioNodeFailureError
|
|
1064
|
+
? error
|
|
1065
|
+
: new ScenarioNodeFailureError(error instanceof Error ? error.message : "Scenario node failed");
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
await emit({
|
|
1069
|
+
type: "scenario:start",
|
|
1070
|
+
runId,
|
|
1071
|
+
scenarioId: scenario.id,
|
|
1072
|
+
scenarioName: scenario.name,
|
|
1073
|
+
totalSteps: runtimeStepIds.length,
|
|
1074
|
+
stepIds: runtimeStepIds,
|
|
1075
|
+
at: startedAt,
|
|
1076
|
+
});
|
|
1077
|
+
let finalStatus = "success";
|
|
1078
|
+
try {
|
|
1079
|
+
for (const startNodeId of orderedStartNodeIds) {
|
|
1080
|
+
throwIfStopped(signal);
|
|
1081
|
+
await executeNode(startNodeId, null);
|
|
1082
|
+
}
|
|
1083
|
+
await markRemainingNodesSkipped("Node was not reached during this run.");
|
|
1084
|
+
finalStatus = degradedStepIds.size > 0 ? "completed_with_failures" : "success";
|
|
1085
|
+
await emit({
|
|
1086
|
+
type: "scenario:end",
|
|
1087
|
+
runId,
|
|
1088
|
+
scenarioId: scenario.id,
|
|
1089
|
+
status: finalStatus,
|
|
1090
|
+
at: new Date().toISOString(),
|
|
1091
|
+
durationMs: Date.now() - startedAtMs,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
const isStopped = error instanceof ScenarioStopError || Boolean(signal?.aborted);
|
|
1096
|
+
await markRemainingNodesSkipped(isStopped ? "Skipped because execution was stopped." : "Skipped after failure.");
|
|
1097
|
+
finalStatus = isStopped ? "stopped" : "failed";
|
|
1098
|
+
await emit({
|
|
1099
|
+
type: "scenario:end",
|
|
1100
|
+
runId,
|
|
1101
|
+
scenarioId: scenario.id,
|
|
1102
|
+
status: finalStatus,
|
|
1103
|
+
at: new Date().toISOString(),
|
|
1104
|
+
durationMs: Date.now() - startedAtMs,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
const endedAt = new Date().toISOString();
|
|
1108
|
+
const report = {
|
|
1109
|
+
runId,
|
|
1110
|
+
scenarioId: scenario.id,
|
|
1111
|
+
scenarioName: scenario.name,
|
|
1112
|
+
scenarioStatus: finalStatus,
|
|
1113
|
+
startedAt,
|
|
1114
|
+
endedAt,
|
|
1115
|
+
durationMs: Date.now() - startedAtMs,
|
|
1116
|
+
totalSteps: runtimeStepIds.length,
|
|
1117
|
+
completedSteps: Array.from(localStatuses.values()).filter(isTerminalStatus).length,
|
|
1118
|
+
context: createContextSnapshot(runtimeContext),
|
|
1119
|
+
nodeExecutions: runtimeStepIds.map((stepId) => {
|
|
1120
|
+
const descriptor = runtimeStepDescriptors.find((entry) => entry.stepId === stepId);
|
|
1121
|
+
return (records.get(stepId) ??
|
|
1122
|
+
createStepExecutionRecord({
|
|
1123
|
+
stepId,
|
|
1124
|
+
stepType: descriptor?.node.type ?? "script",
|
|
1125
|
+
title: descriptor ? buildTitle(descriptor.node) : stepId,
|
|
1126
|
+
}));
|
|
1127
|
+
}),
|
|
1128
|
+
events,
|
|
1129
|
+
};
|
|
1130
|
+
await eventHandlers?.onScenarioComplete?.(report);
|
|
1131
|
+
return report;
|
|
1132
|
+
};
|
|
1133
|
+
//# sourceMappingURL=runScenario.js.map
|