callman-core 1.16.0 → 1.18.0
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/contract/contractValidator.d.ts.map +1 -1
- package/dist/contract/contractValidator.js +19 -2
- package/dist/contract/contractValidator.js.map +1 -1
- package/dist/contract/xpathResolver.d.ts +7 -0
- package/dist/contract/xpathResolver.d.ts.map +1 -0
- package/dist/contract/xpathResolver.js +69 -0
- package/dist/contract/xpathResolver.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/scenario-runner/adapters/helpers/request-helpers.d.ts.map +1 -1
- package/dist/scenario-runner/adapters/helpers/request-helpers.js +8 -0
- package/dist/scenario-runner/adapters/helpers/request-helpers.js.map +1 -1
- package/dist/scenario-runner/adapters/request-executor.d.ts.map +1 -1
- package/dist/scenario-runner/adapters/request-executor.js +35 -0
- package/dist/scenario-runner/adapters/request-executor.js.map +1 -1
- package/dist/scenario-runner/adapters/types.d.ts +5 -0
- package/dist/scenario-runner/adapters/types.d.ts.map +1 -1
- package/dist/scenario-runner/condition.d.ts.map +1 -1
- package/dist/scenario-runner/condition.js +67 -3
- package/dist/scenario-runner/condition.js.map +1 -1
- package/dist/scenario-runner/graphTraversal.d.ts +5 -0
- package/dist/scenario-runner/graphTraversal.d.ts.map +1 -1
- package/dist/scenario-runner/graphTraversal.js +11 -2
- package/dist/scenario-runner/graphTraversal.js.map +1 -1
- package/dist/scenario-runner/nodePolicy.d.ts +2 -2
- package/dist/scenario-runner/nodePolicy.d.ts.map +1 -1
- package/dist/scenario-runner/nodePolicy.js +6 -0
- package/dist/scenario-runner/nodePolicy.js.map +1 -1
- package/dist/scenario-runner/runScenario.d.ts.map +1 -1
- package/dist/scenario-runner/runScenario.js +492 -4
- package/dist/scenario-runner/runScenario.js.map +1 -1
- package/dist/scenario-runner/types.d.ts +89 -4
- package/dist/scenario-runner/types.d.ts.map +1 -1
- package/dist/script/types.d.ts +4 -0
- package/dist/script/types.d.ts.map +1 -1
- package/dist/soap/envelope.d.ts +4 -0
- package/dist/soap/envelope.d.ts.map +1 -0
- package/dist/soap/envelope.js +25 -0
- package/dist/soap/envelope.js.map +1 -0
- package/dist/soap/transport.d.ts +22 -0
- package/dist/soap/transport.d.ts.map +1 -0
- package/dist/soap/transport.js +58 -0
- package/dist/soap/transport.js.map +1 -0
- package/dist/soap/wsSecurity.d.ts +19 -0
- package/dist/soap/wsSecurity.d.ts.map +1 -0
- package/dist/soap/wsSecurity.js +66 -0
- package/dist/soap/wsSecurity.js.map +1 -0
- package/dist-cjs/contract/contractValidator.js +19 -2
- package/dist-cjs/contract/contractValidator.js.map +1 -1
- package/dist-cjs/contract/xpathResolver.js +76 -0
- package/dist-cjs/contract/xpathResolver.js.map +1 -0
- package/dist-cjs/index.js +13 -1
- package/dist-cjs/index.js.map +1 -1
- package/dist-cjs/scenario-runner/adapters/helpers/request-helpers.js +8 -0
- package/dist-cjs/scenario-runner/adapters/helpers/request-helpers.js.map +1 -1
- package/dist-cjs/scenario-runner/adapters/request-executor.js +35 -0
- package/dist-cjs/scenario-runner/adapters/request-executor.js.map +1 -1
- package/dist-cjs/scenario-runner/condition.js +67 -3
- package/dist-cjs/scenario-runner/condition.js.map +1 -1
- package/dist-cjs/scenario-runner/graphTraversal.js +12 -3
- package/dist-cjs/scenario-runner/graphTraversal.js.map +1 -1
- package/dist-cjs/scenario-runner/nodePolicy.js +6 -0
- package/dist-cjs/scenario-runner/nodePolicy.js.map +1 -1
- package/dist-cjs/scenario-runner/runScenario.js +491 -3
- package/dist-cjs/scenario-runner/runScenario.js.map +1 -1
- package/dist-cjs/soap/envelope.js +29 -0
- package/dist-cjs/soap/envelope.js.map +1 -0
- package/dist-cjs/soap/transport.js +62 -0
- package/dist-cjs/soap/transport.js.map +1 -0
- package/dist-cjs/soap/wsSecurity.js +74 -0
- package/dist-cjs/soap/wsSecurity.js.map +1 -0
- package/package.json +4 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildConditionOutputPayload, evaluateConditionConfig, evaluateConditionRule, resolveConditionRuntimeValue, validateConditionConfig, } from "./condition.js";
|
|
2
2
|
import { applyExtract } from "./extraction.js";
|
|
3
3
|
import { evaluateAssertionConfig, validateAssertionConfig, } from "./assertion.js";
|
|
4
|
-
import { createSubScenarioStepId, buildRuntimeStepDescriptors, DEFAULT_END_LOOP_DELAY_MS, DEFAULT_END_LOOP_ITERATIONS, flattenScenarioNodesForContext, MAX_END_LOOP_ITERATIONS, } from "./graphTraversal.js";
|
|
4
|
+
import { createSubScenarioStepId, buildRuntimeStepDescriptors, DEFAULT_END_LOOP_DELAY_MS, DEFAULT_END_LOOP_ITERATIONS, flattenScenarioNodesForContext, MAX_END_LOOP_ITERATIONS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_POLL_MAX_ATTEMPTS, MAX_POLL_ATTEMPTS, DEFAULT_FOREACH_MAX_ITERATIONS, MAX_FOREACH_ITERATIONS, } from "./graphTraversal.js";
|
|
5
5
|
import { executeWithScenarioNodePolicy } from "./executionPolicyRunner.js";
|
|
6
6
|
import { getLegacyScenarioNodeExecutionPolicy, getScenarioNodeExecutionPolicy, supportsScenarioNodeExecutionPolicy, } from "./nodePolicy.js";
|
|
7
7
|
import { buildScenarioExecutionGraph, buildScenarioTemplateValues, collectExclusiveBranchNodeIds, evaluateScenarioExpression, resolveScenarioJsonTemplateString, resolveScenarioTemplateString, } from "./templateResolver.js";
|
|
@@ -33,9 +33,18 @@ const buildTitle = (node) => {
|
|
|
33
33
|
return `${node.type[0]?.toUpperCase() ?? ""}${node.type.slice(1)}`;
|
|
34
34
|
};
|
|
35
35
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
-
// `loop`/`loops` are the loop-iteration template roots
|
|
36
|
+
// `loop`/`loops` are the loop-iteration template roots and `item`/`each`/
|
|
37
|
+
// `eaches` the foreach-iteration ones (see the loop-frame and foreach
|
|
37
38
|
// machinery in runScenario) — node labels must not shadow them.
|
|
38
|
-
const RESERVED_WORKFLOW_ROOT_LABELS = new Set([
|
|
39
|
+
const RESERVED_WORKFLOW_ROOT_LABELS = new Set([
|
|
40
|
+
"workflow",
|
|
41
|
+
"__meta",
|
|
42
|
+
"loop",
|
|
43
|
+
"loops",
|
|
44
|
+
"item",
|
|
45
|
+
"each",
|
|
46
|
+
"eaches",
|
|
47
|
+
]);
|
|
39
48
|
const cloneNodeExecutionMeta = (meta) => ({
|
|
40
49
|
...meta,
|
|
41
50
|
...(meta.error ? { error: { ...meta.error } } : {}),
|
|
@@ -400,6 +409,31 @@ const updateRecordFromEvent = (records, event) => {
|
|
|
400
409
|
});
|
|
401
410
|
return;
|
|
402
411
|
}
|
|
412
|
+
if (event.type === "step:poll") {
|
|
413
|
+
// Tick progress only — the terminal success/fail event owns the rest.
|
|
414
|
+
records.set(event.stepId, {
|
|
415
|
+
...current,
|
|
416
|
+
stepType: event.stepType,
|
|
417
|
+
title: event.title,
|
|
418
|
+
attempts: event.attempt,
|
|
419
|
+
currentAttempt: event.attempt,
|
|
420
|
+
maxAttempts: event.maxAttempts,
|
|
421
|
+
startedAt: current.startedAt ?? event.at,
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (event.type === "step:iteration") {
|
|
426
|
+
// Iteration progress marker only — the terminal success/fail event owns
|
|
427
|
+
// the record. Without this early return the generic fallback below would
|
|
428
|
+
// stamp status:"failed" on the foreach step.
|
|
429
|
+
records.set(event.stepId, {
|
|
430
|
+
...current,
|
|
431
|
+
stepType: event.stepType,
|
|
432
|
+
title: event.title,
|
|
433
|
+
startedAt: current.startedAt ?? event.at,
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
403
437
|
if (event.type === "step:condition") {
|
|
404
438
|
records.set(event.stepId, {
|
|
405
439
|
...current,
|
|
@@ -492,6 +526,12 @@ const callHandler = async (handlers, event) => {
|
|
|
492
526
|
case "step:retry":
|
|
493
527
|
await handlers?.onNodeRetry?.(event);
|
|
494
528
|
return;
|
|
529
|
+
case "step:poll":
|
|
530
|
+
await handlers?.onPollTick?.(event);
|
|
531
|
+
return;
|
|
532
|
+
case "step:iteration":
|
|
533
|
+
await handlers?.onForeachIteration?.(event);
|
|
534
|
+
return;
|
|
495
535
|
case "step:success":
|
|
496
536
|
await handlers?.onNodeSuccess?.(event);
|
|
497
537
|
return;
|
|
@@ -802,9 +842,18 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
|
|
|
802
842
|
localStatuses.set(stepId, "running");
|
|
803
843
|
const title = buildTitle(node);
|
|
804
844
|
const nodeLabel = node.data.label.trim();
|
|
805
|
-
|
|
845
|
+
let executionPolicy = supportsScenarioNodeExecutionPolicy(node)
|
|
806
846
|
? withNotificationFailurePolicyOverride(node, getScenarioNodeExecutionPolicy(node))
|
|
807
847
|
: getLegacyScenarioNodeExecutionPolicy();
|
|
848
|
+
if (node.type === "poll" || node.type === "foreach") {
|
|
849
|
+
// The poll/item loop REPLACES per-node retry (nested retry loops would
|
|
850
|
+
// be unexplainable). onFailure — incl. error edges — still governs the
|
|
851
|
+
// final outcome.
|
|
852
|
+
executionPolicy = {
|
|
853
|
+
...executionPolicy,
|
|
854
|
+
retry: { enabled: false, maxAttempts: 0, delayMs: 0 },
|
|
855
|
+
};
|
|
856
|
+
}
|
|
808
857
|
const executionStartedAtMs = Date.now();
|
|
809
858
|
let inputData = createInputSnapshot(runtimeContext);
|
|
810
859
|
let statusCode = null;
|
|
@@ -1022,6 +1071,223 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
|
|
|
1022
1071
|
throw new ScenarioNodeFailureError(redisResult.failureMessage);
|
|
1023
1072
|
}
|
|
1024
1073
|
}
|
|
1074
|
+
if (node.type === "poll") {
|
|
1075
|
+
const pollConfig = node.data.config;
|
|
1076
|
+
const blockingPollIssue = validateConditionConfig(pollConfig.successCondition).find((issue) => issue.severity === "error");
|
|
1077
|
+
if (blockingPollIssue) {
|
|
1078
|
+
throw new ScenarioNodeFailureError(`Poll success condition is invalid: ${blockingPollIssue.message}`);
|
|
1079
|
+
}
|
|
1080
|
+
const pollStartedAtMs = Date.now();
|
|
1081
|
+
const pollMaxAttempts = Math.min(MAX_POLL_ATTEMPTS, Math.max(1, Math.round(pollConfig.maxAttempts ?? DEFAULT_POLL_MAX_ATTEMPTS)));
|
|
1082
|
+
const pollBaseIntervalMs = Math.max(0, Math.round(pollConfig.intervalMs ?? DEFAULT_POLL_INTERVAL_MS));
|
|
1083
|
+
const pollTimeoutMs = typeof pollConfig.timeoutMs === "number" && pollConfig.timeoutMs > 0
|
|
1084
|
+
? Math.round(pollConfig.timeoutMs)
|
|
1085
|
+
: null;
|
|
1086
|
+
// Thinned emission keeps stored events bounded (~100 tick events).
|
|
1087
|
+
const pollEmitStride = Math.max(1, Math.ceil(pollMaxAttempts / 100));
|
|
1088
|
+
// One tick = the embedded action through the SAME host adapters real
|
|
1089
|
+
// nodes use (desktop IPC / backend drivers both covered), publishing
|
|
1090
|
+
// results into the runtime context exactly like the real node would,
|
|
1091
|
+
// so the success condition AND downstream nodes read the last
|
|
1092
|
+
// attempt's data. Returns the action's error message, or null.
|
|
1093
|
+
const runPollTick = async () => {
|
|
1094
|
+
const tickValues = buildScenarioTemplateValues({
|
|
1095
|
+
environment: runtimeContext.environment,
|
|
1096
|
+
globals: runtimeContext.globals,
|
|
1097
|
+
workflowContext: runtimeContext.workflowContext,
|
|
1098
|
+
nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
|
|
1099
|
+
responseRoot: runtimeContext.responseRoot,
|
|
1100
|
+
dbResult: runtimeContext.dbResult,
|
|
1101
|
+
kafkaEvent: runtimeContext.kafkaEvent,
|
|
1102
|
+
redisResult: runtimeContext.redisResult,
|
|
1103
|
+
});
|
|
1104
|
+
if (pollConfig.actionType === "request") {
|
|
1105
|
+
if (!pollConfig.request) {
|
|
1106
|
+
throw new ScenarioNodeFailureError("Poll node has no request configuration.");
|
|
1107
|
+
}
|
|
1108
|
+
const tickResult = await runtimeAdapters.requestExecutor({
|
|
1109
|
+
runId,
|
|
1110
|
+
stepId,
|
|
1111
|
+
node: {
|
|
1112
|
+
id: node.id,
|
|
1113
|
+
type: "request",
|
|
1114
|
+
position: node.position,
|
|
1115
|
+
data: { label: node.data.label, config: pollConfig.request },
|
|
1116
|
+
},
|
|
1117
|
+
context: createContextSnapshot(runtimeContext),
|
|
1118
|
+
kafkaCapturePlans: [],
|
|
1119
|
+
captureFromTimestampMs: startedAtMs,
|
|
1120
|
+
signal,
|
|
1121
|
+
});
|
|
1122
|
+
if (tickResult.cancelled) {
|
|
1123
|
+
throw new ScenarioStopError();
|
|
1124
|
+
}
|
|
1125
|
+
runtimeContext.environment = { ...tickResult.environment };
|
|
1126
|
+
runtimeContext.globals = { ...tickResult.globals };
|
|
1127
|
+
runtimeContext.lastResponse = tickResult.response;
|
|
1128
|
+
runtimeContext.responseRoot = tickResult.responseRoot;
|
|
1129
|
+
response = tickResult.response;
|
|
1130
|
+
statusCode = tickResult.response.status;
|
|
1131
|
+
testResults = tickResult.testResults;
|
|
1132
|
+
contractResults = tickResult.contractResults;
|
|
1133
|
+
contractSummary = tickResult.contractSummary;
|
|
1134
|
+
contractValidationError = tickResult.contractValidationError;
|
|
1135
|
+
dbAssertionResults = tickResult.dbAssertionResults;
|
|
1136
|
+
redisAssertionResults = tickResult.redisAssertionResults;
|
|
1137
|
+
return tickResult.failureMessage ?? null;
|
|
1138
|
+
}
|
|
1139
|
+
if (pollConfig.actionType === "db") {
|
|
1140
|
+
if (!pollConfig.db) {
|
|
1141
|
+
throw new ScenarioNodeFailureError("Poll node has no database configuration.");
|
|
1142
|
+
}
|
|
1143
|
+
const resolvedQuery = resolveScenarioTemplateString(pollConfig.db.query, tickValues);
|
|
1144
|
+
const resolvedParams = (pollConfig.db.params ?? []).map((param) => resolveScenarioTemplateString(param, tickValues));
|
|
1145
|
+
const tickResult = await runtimeAdapters.dbExecutor({
|
|
1146
|
+
runId,
|
|
1147
|
+
stepId,
|
|
1148
|
+
node: {
|
|
1149
|
+
id: node.id,
|
|
1150
|
+
type: "db",
|
|
1151
|
+
position: node.position,
|
|
1152
|
+
data: { label: node.data.label, config: pollConfig.db },
|
|
1153
|
+
},
|
|
1154
|
+
resolvedQuery,
|
|
1155
|
+
resolvedParams,
|
|
1156
|
+
context: createContextSnapshot(runtimeContext),
|
|
1157
|
+
signal,
|
|
1158
|
+
});
|
|
1159
|
+
runtimeContext.dbResult = tickResult.dbResult;
|
|
1160
|
+
return tickResult.failureMessage ?? null;
|
|
1161
|
+
}
|
|
1162
|
+
if (pollConfig.actionType === "redis") {
|
|
1163
|
+
if (!pollConfig.redis) {
|
|
1164
|
+
throw new ScenarioNodeFailureError("Poll node has no Redis configuration.");
|
|
1165
|
+
}
|
|
1166
|
+
const resolvedCommands = pollConfig.redis.commands
|
|
1167
|
+
.map((command) => resolveScenarioTemplateString(command, tickValues))
|
|
1168
|
+
.filter((command) => command.trim().length > 0);
|
|
1169
|
+
const tickResult = await runtimeAdapters.redisExecutor({
|
|
1170
|
+
runId,
|
|
1171
|
+
stepId,
|
|
1172
|
+
node: {
|
|
1173
|
+
id: node.id,
|
|
1174
|
+
type: "redis",
|
|
1175
|
+
position: node.position,
|
|
1176
|
+
data: { label: node.data.label, config: pollConfig.redis },
|
|
1177
|
+
},
|
|
1178
|
+
resolvedCommands,
|
|
1179
|
+
context: createContextSnapshot(runtimeContext),
|
|
1180
|
+
signal,
|
|
1181
|
+
});
|
|
1182
|
+
runtimeContext.redisResult = tickResult.redisResult;
|
|
1183
|
+
return tickResult.failureMessage ?? null;
|
|
1184
|
+
}
|
|
1185
|
+
if (!pollConfig.script) {
|
|
1186
|
+
throw new ScenarioNodeFailureError("Poll node has no script configuration.");
|
|
1187
|
+
}
|
|
1188
|
+
const tickResult = await runtimeAdapters.scriptExecutor({
|
|
1189
|
+
runId,
|
|
1190
|
+
stepId,
|
|
1191
|
+
node: {
|
|
1192
|
+
id: node.id,
|
|
1193
|
+
type: "script",
|
|
1194
|
+
position: node.position,
|
|
1195
|
+
data: { label: node.data.label, config: pollConfig.script },
|
|
1196
|
+
},
|
|
1197
|
+
context: createContextSnapshot(runtimeContext),
|
|
1198
|
+
signal,
|
|
1199
|
+
});
|
|
1200
|
+
scriptSource = tickResult.scriptSource;
|
|
1201
|
+
scriptTimeoutMs = tickResult.timeoutMs;
|
|
1202
|
+
scriptLogs = tickResult.logs;
|
|
1203
|
+
testResults = tickResult.testResults;
|
|
1204
|
+
applyVariableMutationsToContext(runtimeContext, tickResult.variableMutations);
|
|
1205
|
+
return tickResult.failureMessage ?? null;
|
|
1206
|
+
};
|
|
1207
|
+
let pollAttempt = 0;
|
|
1208
|
+
let pollPassed = false;
|
|
1209
|
+
let lastTickError = null;
|
|
1210
|
+
for (;;) {
|
|
1211
|
+
throwIfStopped(signal);
|
|
1212
|
+
pollAttempt += 1;
|
|
1213
|
+
lastTickError = await runPollTick();
|
|
1214
|
+
if (lastTickError === null) {
|
|
1215
|
+
const evaluation = evaluateConditionConfig(pollConfig.successCondition, {
|
|
1216
|
+
environment: runtimeContext.environment,
|
|
1217
|
+
globals: runtimeContext.globals,
|
|
1218
|
+
workflowContext: runtimeContext.workflowContext,
|
|
1219
|
+
nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
|
|
1220
|
+
responseRoot: runtimeContext.responseRoot,
|
|
1221
|
+
dbResult: runtimeContext.dbResult,
|
|
1222
|
+
kafkaEvent: runtimeContext.kafkaEvent,
|
|
1223
|
+
redisResult: runtimeContext.redisResult,
|
|
1224
|
+
});
|
|
1225
|
+
const conditionPayload = buildConditionOutputPayload(pollConfig.successCondition, evaluation);
|
|
1226
|
+
conditionResult = evaluation.result;
|
|
1227
|
+
conditionExpression = conditionPayload.expression;
|
|
1228
|
+
conditionPreview = conditionPayload.preview;
|
|
1229
|
+
conditionMode = conditionPayload.mode;
|
|
1230
|
+
conditionDebug = conditionPayload.debug;
|
|
1231
|
+
pollPassed = evaluation.result;
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
conditionResult = false;
|
|
1235
|
+
}
|
|
1236
|
+
const pollElapsedNow = Date.now() - pollStartedAtMs;
|
|
1237
|
+
const budgetExhausted = pollAttempt >= pollMaxAttempts ||
|
|
1238
|
+
(pollTimeoutMs !== null && pollElapsedNow >= pollTimeoutMs);
|
|
1239
|
+
const pollBackoff = pollConfig.backoffMultiplier && pollConfig.backoffMultiplier > 1
|
|
1240
|
+
? Math.min(10, pollConfig.backoffMultiplier)
|
|
1241
|
+
: 1;
|
|
1242
|
+
const nextDelayMs = pollPassed || budgetExhausted
|
|
1243
|
+
? null
|
|
1244
|
+
: Math.min(pollConfig.maxDelayMs && pollConfig.maxDelayMs > 0
|
|
1245
|
+
? Math.round(pollConfig.maxDelayMs)
|
|
1246
|
+
: Number.MAX_SAFE_INTEGER, Math.round(pollBaseIntervalMs * Math.pow(pollBackoff, pollAttempt - 1)));
|
|
1247
|
+
if (pollPassed ||
|
|
1248
|
+
budgetExhausted ||
|
|
1249
|
+
pollAttempt === 1 ||
|
|
1250
|
+
pollAttempt % pollEmitStride === 0) {
|
|
1251
|
+
await emit({
|
|
1252
|
+
type: "step:poll",
|
|
1253
|
+
runId,
|
|
1254
|
+
scenarioId: scenario.id,
|
|
1255
|
+
stepId,
|
|
1256
|
+
stepType: node.type,
|
|
1257
|
+
title,
|
|
1258
|
+
at: new Date().toISOString(),
|
|
1259
|
+
attempt: pollAttempt,
|
|
1260
|
+
maxAttempts: pollMaxAttempts,
|
|
1261
|
+
elapsedMs: pollElapsedNow,
|
|
1262
|
+
timeoutMs: pollTimeoutMs,
|
|
1263
|
+
conditionResult: pollPassed,
|
|
1264
|
+
tickError: lastTickError,
|
|
1265
|
+
nextPollDelayMs: nextDelayMs,
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
if (pollPassed || budgetExhausted) {
|
|
1269
|
+
break;
|
|
1270
|
+
}
|
|
1271
|
+
await waitForDuration(nextDelayMs ?? pollBaseIntervalMs);
|
|
1272
|
+
}
|
|
1273
|
+
const pollElapsedMs = Date.now() - pollStartedAtMs;
|
|
1274
|
+
outputData = {
|
|
1275
|
+
attempts: pollAttempt,
|
|
1276
|
+
maxAttempts: pollMaxAttempts,
|
|
1277
|
+
elapsedMs: pollElapsedMs,
|
|
1278
|
+
timedOut: !pollPassed,
|
|
1279
|
+
conditionResult: pollPassed,
|
|
1280
|
+
...(lastTickError ? { lastTickError } : {}),
|
|
1281
|
+
};
|
|
1282
|
+
if (!pollPassed) {
|
|
1283
|
+
if ((pollConfig.onTimeout ?? "fail") === "continue") {
|
|
1284
|
+
shouldMarkPartialSuccess = true;
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
throw new ScenarioNodeFailureError(`Poll did not succeed within ${pollAttempt} attempt(s) (${pollElapsedMs}ms).${lastTickError ? ` Last error: ${lastTickError}` : ""}`);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1025
1291
|
if (node.type === "notification") {
|
|
1026
1292
|
const resolvedMessage = renderNotificationTemplate(node.data.config.messageTemplate, templateValues).trim();
|
|
1027
1293
|
const resolvedChannelOverride = node.data.config.channelOverride?.trim()
|
|
@@ -1291,6 +1557,228 @@ export const runScenario = async ({ scenario, environment = {}, globals = {}, ru
|
|
|
1291
1557
|
shouldMarkPartialSuccess = degradedStepIds.size > degradedCountBefore;
|
|
1292
1558
|
outputData = groupedOutput;
|
|
1293
1559
|
}
|
|
1560
|
+
if (node.type === "foreach") {
|
|
1561
|
+
const foreachConfig = node.data.config;
|
|
1562
|
+
// ── Resolve the items array ──────────────────────────────────────
|
|
1563
|
+
let items;
|
|
1564
|
+
if (foreachConfig.sourceMode === "inline") {
|
|
1565
|
+
const rawInline = (foreachConfig.itemsInline ?? "").trim();
|
|
1566
|
+
if (!rawInline) {
|
|
1567
|
+
throw new ScenarioNodeFailureError("Foreach inline items are empty.");
|
|
1568
|
+
}
|
|
1569
|
+
const resolvedInline = resolveScenarioJsonTemplateString(rawInline, templateValues);
|
|
1570
|
+
let parsedInline;
|
|
1571
|
+
try {
|
|
1572
|
+
parsedInline = JSON.parse(resolvedInline);
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
throw new ScenarioNodeFailureError("Foreach inline items are not valid JSON after template resolution.");
|
|
1576
|
+
}
|
|
1577
|
+
if (!Array.isArray(parsedInline)) {
|
|
1578
|
+
throw new ScenarioNodeFailureError("Foreach inline items must be a JSON array.");
|
|
1579
|
+
}
|
|
1580
|
+
items = parsedInline;
|
|
1581
|
+
}
|
|
1582
|
+
else {
|
|
1583
|
+
const itemsPath = (foreachConfig.itemsPath ?? "").trim();
|
|
1584
|
+
if (!itemsPath) {
|
|
1585
|
+
throw new ScenarioNodeFailureError("Foreach items path is required.");
|
|
1586
|
+
}
|
|
1587
|
+
const resolved = resolveConditionRuntimeValue({
|
|
1588
|
+
environment: runtimeContext.environment,
|
|
1589
|
+
globals: runtimeContext.globals,
|
|
1590
|
+
workflowContext: runtimeContext.workflowContext,
|
|
1591
|
+
nodeMetaByLabel: runtimeContext.nodeMetaByLabel,
|
|
1592
|
+
responseRoot: runtimeContext.responseRoot,
|
|
1593
|
+
dbResult: runtimeContext.dbResult,
|
|
1594
|
+
kafkaEvent: runtimeContext.kafkaEvent,
|
|
1595
|
+
redisResult: runtimeContext.redisResult,
|
|
1596
|
+
}, itemsPath);
|
|
1597
|
+
if (!resolved.exists || typeof resolved.value === "undefined") {
|
|
1598
|
+
throw new ScenarioNodeFailureError(`Foreach items path "${itemsPath}" resolved to nothing.`);
|
|
1599
|
+
}
|
|
1600
|
+
if (!Array.isArray(resolved.value)) {
|
|
1601
|
+
throw new ScenarioNodeFailureError(`Foreach items path "${itemsPath}" must resolve to an array (got ${typeof resolved.value}).`);
|
|
1602
|
+
}
|
|
1603
|
+
items = resolved.value;
|
|
1604
|
+
}
|
|
1605
|
+
const itemAlias = (foreachConfig.itemAlias ?? "").trim();
|
|
1606
|
+
if (itemAlias && RESERVED_WORKFLOW_ROOT_LABELS.has(itemAlias)) {
|
|
1607
|
+
throw new ScenarioNodeFailureError(`Foreach item alias "${itemAlias}" is a reserved name.`);
|
|
1608
|
+
}
|
|
1609
|
+
const maxIterations = Math.min(MAX_FOREACH_ITERATIONS, Math.max(1, Math.round(foreachConfig.maxIterations ?? DEFAULT_FOREACH_MAX_ITERATIONS)));
|
|
1610
|
+
const processLimit = Math.min(items.length, maxIterations);
|
|
1611
|
+
const iterationDelayMs = Math.max(0, Math.round(foreachConfig.delayMs ?? 0));
|
|
1612
|
+
const onItemFailure = foreachConfig.onItemFailure ?? "stop";
|
|
1613
|
+
const collectResults = foreachConfig.collectResults !== false;
|
|
1614
|
+
// Thinned emission keeps stored events bounded (~100 iteration markers).
|
|
1615
|
+
const iterationEmitStride = Math.max(1, Math.ceil(processLimit / 100));
|
|
1616
|
+
const internalGraph = buildScenarioExecutionGraph(foreachConfig.nodes, foreachConfig.edges);
|
|
1617
|
+
const internalScope = {
|
|
1618
|
+
graph: internalGraph,
|
|
1619
|
+
stepIdForNode: (childNodeId) => createSubScenarioStepId(stepId, childNodeId),
|
|
1620
|
+
stepIdForEdge: (edgeId) => createSubScenarioStepId(stepId, edgeId),
|
|
1621
|
+
};
|
|
1622
|
+
// ALL descendant step ids (nested containers included) — reset
|
|
1623
|
+
// targets between iterations.
|
|
1624
|
+
const internalStepIds = buildRuntimeStepDescriptors(foreachConfig.nodes, stepId).map((descriptor) => descriptor.stepId);
|
|
1625
|
+
const childLabels = flattenScenarioNodesForContext(foreachConfig.nodes)
|
|
1626
|
+
.map((childNode) => childNode.data.label.trim())
|
|
1627
|
+
.filter((childLabel) => childLabel.length > 0);
|
|
1628
|
+
const workflowRoots = runtimeContext.workflowContext;
|
|
1629
|
+
// Save the enclosing iteration roots so a NESTED foreach restores
|
|
1630
|
+
// its parent's {{item}}/{{each}} when it finishes mid-iteration.
|
|
1631
|
+
const hadItemRoot = Object.prototype.hasOwnProperty.call(workflowRoots, "item");
|
|
1632
|
+
const previousItemRoot = workflowRoots.item;
|
|
1633
|
+
const hadEachRoot = Object.prototype.hasOwnProperty.call(workflowRoots, "each");
|
|
1634
|
+
const previousEachRoot = workflowRoots.each;
|
|
1635
|
+
const hadAliasRoot = itemAlias
|
|
1636
|
+
? Object.prototype.hasOwnProperty.call(workflowRoots, itemAlias)
|
|
1637
|
+
: false;
|
|
1638
|
+
const previousAliasRoot = itemAlias ? workflowRoots[itemAlias] : undefined;
|
|
1639
|
+
const nodeLabelKey = nodeLabel;
|
|
1640
|
+
const previousEaches = workflowRoots.eaches;
|
|
1641
|
+
const degradedCountBefore = degradedStepIds.size;
|
|
1642
|
+
const results = [];
|
|
1643
|
+
let failedItems = 0;
|
|
1644
|
+
try {
|
|
1645
|
+
for (let index = 0; index < processLimit; index += 1) {
|
|
1646
|
+
throwIfStopped(signal);
|
|
1647
|
+
if (index > 0) {
|
|
1648
|
+
// Fresh pass: every internal step back to idle (including the
|
|
1649
|
+
// "skipped" stamped on unreached nodes last pass), degraded
|
|
1650
|
+
// marks cleared, records reset via the bulk step:reset event.
|
|
1651
|
+
for (const internalStepId of internalStepIds) {
|
|
1652
|
+
localStatuses.set(internalStepId, "idle");
|
|
1653
|
+
degradedStepIds.delete(internalStepId);
|
|
1654
|
+
}
|
|
1655
|
+
await emit({
|
|
1656
|
+
type: "step:reset",
|
|
1657
|
+
runId,
|
|
1658
|
+
scenarioId: scenario.id,
|
|
1659
|
+
stepIds: internalStepIds,
|
|
1660
|
+
at: new Date().toISOString(),
|
|
1661
|
+
reason: `Item ${index + 1}/${processLimit}: reset foreach body`,
|
|
1662
|
+
});
|
|
1663
|
+
if (iterationDelayMs > 0) {
|
|
1664
|
+
await waitForDuration(iterationDelayMs);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
// Fresh-pass isolation: a failed/short-circuited item must not
|
|
1668
|
+
// inherit the previous iteration's child outputs in its results
|
|
1669
|
+
// snapshot. The final iteration's children stay published after
|
|
1670
|
+
// the loop (last-iteration-wins for downstream reads).
|
|
1671
|
+
for (const childLabel of childLabels) {
|
|
1672
|
+
delete workflowRoots[childLabel];
|
|
1673
|
+
}
|
|
1674
|
+
const item = items[index];
|
|
1675
|
+
const frame = {
|
|
1676
|
+
index,
|
|
1677
|
+
iteration: index + 1,
|
|
1678
|
+
total: processLimit,
|
|
1679
|
+
remaining: processLimit - (index + 1),
|
|
1680
|
+
};
|
|
1681
|
+
workflowRoots.item = item;
|
|
1682
|
+
if (itemAlias) {
|
|
1683
|
+
workflowRoots[itemAlias] = item;
|
|
1684
|
+
}
|
|
1685
|
+
workflowRoots.each = frame;
|
|
1686
|
+
if (nodeLabelKey) {
|
|
1687
|
+
const eachesRoot = workflowRoots.eaches && typeof workflowRoots.eaches === "object"
|
|
1688
|
+
? workflowRoots.eaches
|
|
1689
|
+
: {};
|
|
1690
|
+
workflowRoots.eaches = { ...eachesRoot, [nodeLabelKey]: frame };
|
|
1691
|
+
}
|
|
1692
|
+
if (index === 0 ||
|
|
1693
|
+
index === processLimit - 1 ||
|
|
1694
|
+
(index + 1) % iterationEmitStride === 0) {
|
|
1695
|
+
const itemPreview = (() => {
|
|
1696
|
+
try {
|
|
1697
|
+
const serialized = JSON.stringify(item);
|
|
1698
|
+
if (typeof serialized !== "string")
|
|
1699
|
+
return String(item);
|
|
1700
|
+
return serialized.length > 2048 ? `${serialized.slice(0, 2048)}…` : serialized;
|
|
1701
|
+
}
|
|
1702
|
+
catch {
|
|
1703
|
+
return null;
|
|
1704
|
+
}
|
|
1705
|
+
})();
|
|
1706
|
+
await emit({
|
|
1707
|
+
type: "step:iteration",
|
|
1708
|
+
runId,
|
|
1709
|
+
scenarioId: scenario.id,
|
|
1710
|
+
stepId,
|
|
1711
|
+
stepType: node.type,
|
|
1712
|
+
title,
|
|
1713
|
+
at: new Date().toISOString(),
|
|
1714
|
+
index,
|
|
1715
|
+
iteration: index + 1,
|
|
1716
|
+
total: processLimit,
|
|
1717
|
+
itemPreview,
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
try {
|
|
1721
|
+
for (const startNodeId of internalGraph.startNodeIds) {
|
|
1722
|
+
throwIfStopped(signal);
|
|
1723
|
+
await executeNode(startNodeId, null, {}, internalScope);
|
|
1724
|
+
}
|
|
1725
|
+
await markScopeRemainingNodesSkipped(internalScope, `Internal node was not reached inside ${title} (item ${index + 1}).`);
|
|
1726
|
+
}
|
|
1727
|
+
catch (error) {
|
|
1728
|
+
if (error instanceof ScenarioStopError) {
|
|
1729
|
+
throw error;
|
|
1730
|
+
}
|
|
1731
|
+
failedItems += 1;
|
|
1732
|
+
if (onItemFailure === "stop") {
|
|
1733
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1734
|
+
throw new ScenarioNodeFailureError(`Foreach item ${index + 1}/${processLimit} failed: ${message}`);
|
|
1735
|
+
}
|
|
1736
|
+
// continue: unreached internal nodes stay consistent for the
|
|
1737
|
+
// report, the run degrades to partial success below.
|
|
1738
|
+
await markScopeRemainingNodesSkipped(internalScope, `Internal node was not reached inside ${title} (item ${index + 1} failed).`);
|
|
1739
|
+
}
|
|
1740
|
+
if (collectResults) {
|
|
1741
|
+
const iterationOutput = {};
|
|
1742
|
+
for (const childLabel of childLabels) {
|
|
1743
|
+
if (Object.prototype.hasOwnProperty.call(workflowRoots, childLabel)) {
|
|
1744
|
+
iterationOutput[childLabel] = workflowRoots[childLabel];
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
results.push(iterationOutput);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
finally {
|
|
1752
|
+
// Restore the enclosing iteration roots (or clear ours).
|
|
1753
|
+
if (hadItemRoot)
|
|
1754
|
+
workflowRoots.item = previousItemRoot;
|
|
1755
|
+
else
|
|
1756
|
+
delete workflowRoots.item;
|
|
1757
|
+
if (hadEachRoot)
|
|
1758
|
+
workflowRoots.each = previousEachRoot;
|
|
1759
|
+
else
|
|
1760
|
+
delete workflowRoots.each;
|
|
1761
|
+
if (itemAlias) {
|
|
1762
|
+
if (hadAliasRoot)
|
|
1763
|
+
workflowRoots[itemAlias] = previousAliasRoot;
|
|
1764
|
+
else
|
|
1765
|
+
delete workflowRoots[itemAlias];
|
|
1766
|
+
}
|
|
1767
|
+
if (typeof previousEaches === "undefined")
|
|
1768
|
+
delete workflowRoots.eaches;
|
|
1769
|
+
else
|
|
1770
|
+
workflowRoots.eaches = previousEaches;
|
|
1771
|
+
}
|
|
1772
|
+
shouldMarkPartialSuccess =
|
|
1773
|
+
degradedStepIds.size > degradedCountBefore || failedItems > 0;
|
|
1774
|
+
outputData = {
|
|
1775
|
+
...(collectResults ? { results } : {}),
|
|
1776
|
+
itemCount: items.length,
|
|
1777
|
+
processedCount: processLimit,
|
|
1778
|
+
truncated: items.length > processLimit,
|
|
1779
|
+
failedItems,
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1294
1782
|
if (node.type === "end") {
|
|
1295
1783
|
if (node.data.config.loop && node.data.config.loopTargetNodeId) {
|
|
1296
1784
|
const loopTargetNode = scope.graph.nodeMap.get(node.data.config.loopTargetNodeId);
|