adfinem 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +17 -0
  3. package/CODE_OF_CONDUCT.md +21 -0
  4. package/CONTRIBUTING.md +29 -0
  5. package/LICENSE +21 -0
  6. package/README.md +97 -3
  7. package/SECURITY.md +13 -0
  8. package/catalogs/.gitkeep +0 -0
  9. package/catalogs/api-operations.yaml +21 -0
  10. package/catalogs/batches.yaml +74 -0
  11. package/catalogs/queries.yaml +75 -0
  12. package/config/environments.yaml +13 -0
  13. package/dist/actions/assert-db.js +3 -0
  14. package/dist/actions/run-eod.js +3 -0
  15. package/dist/adapters/api/api-collections.js +296 -0
  16. package/dist/adapters/api/body-utils.js +9 -0
  17. package/dist/adapters/api/rest-client.js +557 -0
  18. package/dist/adapters/api/soap-client.js +5 -0
  19. package/dist/adapters/db/assertions.js +87 -0
  20. package/dist/adapters/db/oracle-client.js +115 -0
  21. package/dist/adapters/db/query-catalog.js +75 -0
  22. package/dist/adapters/unix/batch-catalog.js +71 -0
  23. package/dist/adapters/unix/batch-input-files.js +36 -0
  24. package/dist/adapters/unix/batch-runner.js +382 -0
  25. package/dist/adapters/unix/ssh-client.js +228 -0
  26. package/dist/app/server.js +827 -0
  27. package/dist/cli.js +516 -0
  28. package/dist/config/environments.js +138 -0
  29. package/dist/config/registry.js +18 -0
  30. package/dist/config/secrets.js +123 -0
  31. package/dist/dsl/parser.js +20 -0
  32. package/dist/dsl/schema.js +182 -0
  33. package/dist/dsl/types.js +1 -0
  34. package/dist/dsl/validator.js +264 -0
  35. package/dist/engine/captures.js +68 -0
  36. package/dist/engine/context.js +69 -0
  37. package/dist/engine/evidence.js +33 -0
  38. package/dist/engine/known-errors.js +129 -0
  39. package/dist/engine/retry.js +13 -0
  40. package/dist/engine/runner.js +710 -0
  41. package/dist/engine/step-result.js +58 -0
  42. package/dist/flows/catalog-normalizer.js +72 -0
  43. package/dist/flows/compiler.js +237 -0
  44. package/dist/flows/concat.js +130 -0
  45. package/dist/flows/parser.js +21 -0
  46. package/dist/flows/schema.js +142 -0
  47. package/dist/flows/types.js +1 -0
  48. package/dist/flows/validator.js +470 -0
  49. package/dist/reports/html-report.js +112 -0
  50. package/dist/reports/junit-report.js +48 -0
  51. package/docs/.gitkeep +0 -0
  52. package/docs/DB_UNIX_OPERATIONS.md +118 -0
  53. package/docs/FLOW_BUILDER.md +87 -0
  54. package/flows/account_processing_cycle.flow.yaml +88 -0
  55. package/flows/new_flow.flow.yaml +22 -0
  56. package/package.json +98 -11
  57. package/scenarios/smoke/account-processing-smoke.yaml +44 -0
  58. package/scenarios/smoke/api-db-batch-check.yaml +40 -0
  59. package/src/actions/assert-db.ts +6 -0
  60. package/src/actions/run-eod.ts +6 -0
  61. package/src/adapters/api/api-collections.ts +375 -0
  62. package/src/adapters/api/body-utils.ts +10 -0
  63. package/src/adapters/api/rest-client.ts +587 -0
  64. package/src/adapters/api/soap-client.ts +7 -0
  65. package/src/adapters/db/assertions.ts +83 -0
  66. package/src/adapters/db/oracle-client.ts +133 -0
  67. package/src/adapters/db/query-catalog.ts +80 -0
  68. package/src/adapters/unix/batch-catalog.ts +81 -0
  69. package/src/adapters/unix/batch-input-files.ts +39 -0
  70. package/src/adapters/unix/batch-runner.ts +456 -0
  71. package/src/adapters/unix/ssh-client.ts +248 -0
  72. package/src/app/server.ts +914 -0
  73. package/src/cli.ts +517 -0
  74. package/src/config/environments.ts +193 -0
  75. package/src/config/registry.ts +23 -0
  76. package/src/config/secrets.ts +128 -0
  77. package/src/dsl/parser.ts +24 -0
  78. package/src/dsl/schema.ts +189 -0
  79. package/src/dsl/types.ts +371 -0
  80. package/src/dsl/validator.ts +282 -0
  81. package/src/engine/captures.ts +66 -0
  82. package/src/engine/context.ts +76 -0
  83. package/src/engine/evidence.ts +35 -0
  84. package/src/engine/known-errors.ts +145 -0
  85. package/src/engine/retry.ts +11 -0
  86. package/src/engine/runner.ts +746 -0
  87. package/src/engine/step-result.ts +64 -0
  88. package/src/flows/catalog-normalizer.ts +86 -0
  89. package/src/flows/compiler.ts +247 -0
  90. package/src/flows/concat.ts +149 -0
  91. package/src/flows/parser.ts +27 -0
  92. package/src/flows/schema.ts +154 -0
  93. package/src/flows/types.ts +130 -0
  94. package/src/flows/validator.ts +468 -0
  95. package/src/llm/system-prompt.md +9 -0
  96. package/src/reports/html-report.ts +113 -0
  97. package/src/reports/junit-report.ts +55 -0
  98. package/src/types/oracledb.d.ts +1 -0
  99. package/templates/.gitkeep +0 -0
  100. package/templates/api/create-test-case.json +5 -0
  101. package/templates/api/record-test-activity.json +6 -0
  102. package/tsconfig.json +15 -0
  103. package/vite.config.ts +17 -0
  104. package/web/index.html +12 -0
  105. package/web/src/App.tsx +6588 -0
  106. package/web/src/main.tsx +10 -0
  107. package/web/src/styles.css +3147 -0
  108. package/web-dist/assets/elk.bundled-ChwRCIWJ.js +24 -0
  109. package/web-dist/assets/index-CArbX4zm.css +1 -0
  110. package/web-dist/assets/index-vDCbj8xB.js +28 -0
  111. package/web-dist/index.html +13 -0
  112. package/index.js +0 -1
@@ -0,0 +1,470 @@
1
+ import { normalizeJsonRawBody } from "../adapters/api/body-utils.js";
2
+ import { normalizeBindParamRecord } from "../adapters/db/query-catalog.js";
3
+ import { batchArgParamsForValidation, batchInputFiles, hasBatchInputFilePayload } from "../adapters/unix/batch-input-files.js";
4
+ import { applyFlowEnvironmentInputs, compileFlow, isCompleteLinearEdgeChain, orderedNodes } from "./compiler.js";
5
+ import { validateScenarioReferences } from "../dsl/validator.js";
6
+ export async function validateFlow(flow, catalogs, rootDir, environment = flow.environment) {
7
+ const effectiveFlow = applyFlowEnvironmentInputs(flow, environment);
8
+ const errors = [];
9
+ const warnings = [];
10
+ const seenIds = new Set();
11
+ const nodeIds = new Set(effectiveFlow.nodes.map((node) => node.id));
12
+ for (const node of effectiveFlow.nodes) {
13
+ validateUniqueId(node.id, seenIds, errors);
14
+ if (node.disabled) {
15
+ warnings.push(`Flow node '${node.id}' is disabled and will be skipped.`);
16
+ }
17
+ if (node.type === "api_operation") {
18
+ if (!catalogs.apiOperations[node.operation]) {
19
+ errors.push(`Flow node '${node.id}' references unknown API operation '${node.operation}'.`);
20
+ }
21
+ for (const postAction of node.postActions ?? []) {
22
+ validateUniqueId(postAction.id, seenIds, errors);
23
+ if (postAction.disabled)
24
+ warnings.push(`Flow action '${postAction.id}' is disabled and will be skipped.`);
25
+ validateAction(postAction, catalogs, errors);
26
+ }
27
+ }
28
+ else if (node.type === "parallel" || node.type === "loop") {
29
+ validateControlNode(node, catalogs, errors, warnings, seenIds);
30
+ }
31
+ else {
32
+ validateAction(node, catalogs, errors);
33
+ }
34
+ }
35
+ validateEdges(effectiveFlow, nodeIds, errors, warnings);
36
+ validateReferences(effectiveFlow, catalogs, errors, warnings);
37
+ const compiled = compileFlow(flow, { environment });
38
+ if (compiled.scenario.steps.length > 0) {
39
+ const scenarioValidation = validateScenarioReferences(compiled.scenario, catalogs);
40
+ for (const error of scenarioValidation.errors)
41
+ errors.push(`Compiled scenario: ${error}`);
42
+ }
43
+ else {
44
+ warnings.push("Flow has no enabled steps.");
45
+ }
46
+ return { ok: errors.length === 0, errors, warnings };
47
+ }
48
+ function validateUniqueId(id, seenIds, errors) {
49
+ if (seenIds.has(id))
50
+ errors.push(`Duplicate flow node/action id '${id}'.`);
51
+ seenIds.add(id);
52
+ }
53
+ function validateAction(node, catalogs, errors) {
54
+ if ((node.type === "db_query" || node.type === "db_assert" || node.type === "db_execute") && !catalogs.queries[node.query]) {
55
+ errors.push(`Flow action '${node.id}' references unknown query '${node.query}'.`);
56
+ }
57
+ if (node.type === "db_assert" && catalogs.queries[node.query] && !catalogs.queries[node.query].expect) {
58
+ errors.push(`Flow action '${node.id}' uses db_assert but query '${node.query}' has no expect block.`);
59
+ }
60
+ if (node.type === "db_execute" && catalogs.queries[node.query] && catalogs.queries[node.query].mode !== "execute") {
61
+ errors.push(`Flow action '${node.id}' uses db_execute but query '${node.query}' is not marked mode: execute.`);
62
+ }
63
+ if (node.type === "unix_batch" && !catalogs.batches[node.batch]) {
64
+ errors.push(`Flow action '${node.id}' references unknown batch '${node.batch}'.`);
65
+ }
66
+ }
67
+ function validateEdges(flow, nodeIds, errors, warnings) {
68
+ if (!flow.edges?.length)
69
+ return;
70
+ for (const edge of flow.edges) {
71
+ if (!nodeIds.has(edge.from))
72
+ errors.push(`Flow edge references unknown source node '${edge.from}'.`);
73
+ if (!nodeIds.has(edge.to))
74
+ errors.push(`Flow edge references unknown target node '${edge.to}'.`);
75
+ }
76
+ if (flow.ui?.manualEdges) {
77
+ warnings.push("Manual canvas edges are view-only. Execution follows saved node order unless steps are wrapped in explicit Parallel or Loop blocks.");
78
+ return;
79
+ }
80
+ if (flow.edges.length !== Math.max(0, flow.nodes.length - 1)) {
81
+ warnings.push("Edges are non-linear; fan-out/fan-in is displayed on the canvas while top-level execution still follows node order unless using explicit Parallel blocks.");
82
+ return;
83
+ }
84
+ if (!isCompleteLinearEdgeChain(flow)) {
85
+ warnings.push("Edges do not form one complete top-level sequence; the canvas will display them, but execution follows the saved node order unless using explicit Parallel or Loop blocks.");
86
+ }
87
+ }
88
+ function validateControlNode(node, catalogs, errors, warnings, seenIds, loopAncestors = []) {
89
+ if (node.type === "parallel") {
90
+ if (node.branches.length === 0)
91
+ errors.push(`Parallel node '${node.id}' must contain at least one branch.`);
92
+ for (const branch of node.branches) {
93
+ if (branch.nodes.length === 0)
94
+ warnings.push(`Parallel node '${node.id}' branch '${branch.id}' has no steps.`);
95
+ for (const child of branch.nodes) {
96
+ validateUniqueId(child.id, seenIds, errors);
97
+ if (child.type === "parallel" || child.type === "loop")
98
+ validateControlNode(child, catalogs, errors, warnings, seenIds, loopAncestors);
99
+ else if (child.type === "api_operation") {
100
+ if (!catalogs.apiOperations[child.operation])
101
+ errors.push(`Flow node '${child.id}' references unknown API operation '${child.operation}'.`);
102
+ }
103
+ else
104
+ validateAction(child, catalogs, errors);
105
+ }
106
+ }
107
+ }
108
+ if (node.type === "loop") {
109
+ const parentLoop = loopAncestors[loopAncestors.length - 1];
110
+ if (parentLoop) {
111
+ warnings.push(`Nested repeat: ${loopDesignerName(node)} is inside ${loopDesignerName(parentLoop)}. Counts multiply across nested repeats; unwrap one repeat if this was accidental.`);
112
+ }
113
+ if (node.nodes.length === 0)
114
+ errors.push(`Loop node '${node.id}' must contain at least one child step.`);
115
+ if (node.loop.mode === "count" && node.loop.count === undefined)
116
+ errors.push(`Loop node '${node.id}' count mode requires count.`);
117
+ if (node.loop.mode === "foreach" && node.loop.items === undefined)
118
+ errors.push(`Loop node '${node.id}' foreach mode requires items.`);
119
+ for (const child of node.nodes) {
120
+ validateUniqueId(child.id, seenIds, errors);
121
+ if (child.type === "parallel" || child.type === "loop")
122
+ validateControlNode(child, catalogs, errors, warnings, seenIds, [...loopAncestors, node]);
123
+ else if (child.type === "api_operation") {
124
+ if (!catalogs.apiOperations[child.operation])
125
+ errors.push(`Flow node '${child.id}' references unknown API operation '${child.operation}'.`);
126
+ }
127
+ else
128
+ validateAction(child, catalogs, errors);
129
+ }
130
+ }
131
+ }
132
+ function loopDesignerName(node) {
133
+ const summary = node.type === "loop" ? loopSummary(node) : node.label ?? node.id;
134
+ const firstAction = node.type === "loop" ? firstVisibleLoopChildName(node) : undefined;
135
+ return firstAction ? `${summary} repeat around '${firstAction}'` : `${summary} repeat`;
136
+ }
137
+ function loopSummary(node) {
138
+ if (node.type !== "loop")
139
+ return node.label ?? node.id;
140
+ if (node.loop.mode === "foreach")
141
+ return `foreach ${String(node.loop.items ?? "")}`.trim();
142
+ return `count ${String(node.loop.count ?? 0)}`;
143
+ }
144
+ function firstVisibleLoopChildName(node) {
145
+ if (node.type !== "loop")
146
+ return undefined;
147
+ for (const child of node.nodes ?? []) {
148
+ if (child.type === "loop") {
149
+ const nested = firstVisibleLoopChildName(child);
150
+ if (nested)
151
+ return nested;
152
+ }
153
+ else {
154
+ return child.label ?? child.id;
155
+ }
156
+ }
157
+ return undefined;
158
+ }
159
+ function validateReferences(flow, catalogs, errors, warnings) {
160
+ const availableBare = new Set(Object.keys(flow.variables ?? {}));
161
+ const availableNodeIds = new Set();
162
+ const ordered = orderedNodes(flow);
163
+ const allNodeIds = new Set(ordered.flatMap((node) => [node.id, ...(node.type === "api_operation" ? (node.postActions ?? []).map((action) => action.id) : [])]));
164
+ const loopNodes = new Map(allFlowNodes(flow.nodes).filter((node) => node.type === "loop").map((node) => [node.id, node]));
165
+ const disabledNodeIds = new Set(ordered.flatMap((node) => [
166
+ ...(node.disabled ? [node.id] : []),
167
+ ...(node.type === "api_operation" ? (node.postActions ?? []).filter((action) => action.disabled).map((action) => action.id) : [])
168
+ ]));
169
+ const outputsByNode = new Map();
170
+ for (const node of ordered) {
171
+ outputsByNode.set(node.id, outputNamesFor(node, catalogs));
172
+ if (node.type === "api_operation") {
173
+ for (const action of node.postActions ?? [])
174
+ outputsByNode.set(action.id, outputNamesFor(action, catalogs));
175
+ }
176
+ }
177
+ for (const node of ordered) {
178
+ if (node.disabled)
179
+ continue;
180
+ if (node.type === "parallel" || node.type === "loop") {
181
+ publishExpectedOutputs(node, catalogs, availableBare, warnings);
182
+ availableNodeIds.add(node.id);
183
+ continue;
184
+ }
185
+ validateNodeReferences(node, availableBare, availableNodeIds, allNodeIds, disabledNodeIds, outputsByNode, loopNodes, catalogs, errors, warnings);
186
+ validateRequiredInputs(node, catalogs, errors, warnings);
187
+ validateApiStepIntent(node, warnings);
188
+ publishExpectedOutputs(node, catalogs, availableBare, warnings);
189
+ availableNodeIds.add(node.id);
190
+ if (node.type === "api_operation") {
191
+ for (const postAction of node.postActions ?? []) {
192
+ if (postAction.disabled)
193
+ continue;
194
+ validateNodeReferences(postAction, availableBare, availableNodeIds, allNodeIds, disabledNodeIds, outputsByNode, loopNodes, catalogs, errors, warnings);
195
+ validateRequiredInputs(postAction, catalogs, errors, warnings);
196
+ validateApiStepIntent(postAction, warnings);
197
+ publishExpectedOutputs(postAction, catalogs, availableBare, warnings);
198
+ availableNodeIds.add(postAction.id);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ function validateNodeReferences(node, availableBare, availableNodeIds, allNodeIds, disabledNodeIds, outputsByNode, loopNodes, catalogs, errors, warnings) {
204
+ const localInputs = new Set(Object.keys(node.input ?? node.params ?? {}));
205
+ for (const ref of findRefs(referencePayload(node))) {
206
+ const loopRef = parseLoopReference(ref);
207
+ if (loopRef) {
208
+ const loopNode = loopNodes.get(loopRef.loopId);
209
+ if (loopNode) {
210
+ if (!availableNodeIds.has(loopRef.loopId)) {
211
+ errors.push(`Flow node '${node.id}' references '${ref}' before loop '${loopRef.loopId}' has produced outputs.`);
212
+ continue;
213
+ }
214
+ if (!isKnownLoopOutput(loopNode, loopRef.outputName, catalogs)) {
215
+ errors.push(`Flow node '${node.id}' references unknown loop output '${loopRef.outputName}' from '${loopRef.loopId}'.`);
216
+ }
217
+ continue;
218
+ }
219
+ }
220
+ const dot = ref.indexOf(".");
221
+ if (dot > 0) {
222
+ const nodeId = ref.slice(0, dot);
223
+ const outputName = ref.slice(dot + 1);
224
+ if (!allNodeIds.has(nodeId)) {
225
+ errors.push(`Flow node '${node.id}' references unknown step '${nodeId}' in '${ref}'.${nearestSuggestion(nodeId, [...allNodeIds])}`);
226
+ continue;
227
+ }
228
+ if (disabledNodeIds.has(nodeId)) {
229
+ warnings.push(`Flow node '${node.id}' references disabled step '${nodeId}' in '${ref}'.`);
230
+ }
231
+ if (!availableNodeIds.has(nodeId)) {
232
+ errors.push(`Flow node '${node.id}' references '${ref}' before '${nodeId}' has produced outputs.`);
233
+ continue;
234
+ }
235
+ const outputs = outputsByNode.get(nodeId) ?? [];
236
+ if (outputs.length > 0 && !outputs.includes(outputName)) {
237
+ errors.push(`Flow node '${node.id}' references unknown output '${outputName}' from '${nodeId}'. Available outputs: ${outputs.join(", ")}.${nearestSuggestion(outputName, outputs)}`);
238
+ }
239
+ continue;
240
+ }
241
+ if (!availableBare.has(ref) && !localInputs.has(ref)) {
242
+ errors.push(`Flow node '${node.id}' references unknown variable or capture '${ref}'. Use a namespaced reference like previous_node.${ref} when possible.`);
243
+ }
244
+ }
245
+ }
246
+ function parseLoopReference(ref) {
247
+ const indexed = ref.match(/^([A-Za-z0-9_-]+)\[\d+\]\.(.+)$/);
248
+ if (indexed)
249
+ return { loopId: indexed[1], outputName: indexed[2] };
250
+ const dot = ref.indexOf(".");
251
+ if (dot <= 0)
252
+ return undefined;
253
+ return { loopId: ref.slice(0, dot), outputName: ref.slice(dot + 1) };
254
+ }
255
+ function isKnownLoopOutput(loopNode, outputName, catalogs) {
256
+ if (loopNode.type !== "loop")
257
+ return false;
258
+ const normalized = outputName.replace(/^(last|all|\d+)\./, "");
259
+ const known = new Set();
260
+ const dateOutput = loopDateOutputName(loopNode);
261
+ if (dateOutput)
262
+ known.add(dateOutput);
263
+ for (const child of allFlowNodes(loopNode.nodes ?? [])) {
264
+ for (const output of outputNamesFor(child, catalogs)) {
265
+ known.add(`${child.id}.${output}`);
266
+ }
267
+ }
268
+ return known.has(outputName) || known.has(normalized);
269
+ }
270
+ function allFlowNodes(nodes) {
271
+ const result = [];
272
+ for (const node of nodes) {
273
+ result.push(node);
274
+ if (node.type === "api_operation")
275
+ result.push(...(node.postActions ?? []));
276
+ if (node.type === "parallel") {
277
+ for (const branch of node.branches)
278
+ result.push(...allFlowNodes(branch.nodes));
279
+ }
280
+ if (node.type === "loop")
281
+ result.push(...allFlowNodes(node.nodes));
282
+ }
283
+ return result;
284
+ }
285
+ function outputNamesFor(node, catalogs) {
286
+ if (node.type === "api_operation")
287
+ return uniqueStrings([...Object.keys(catalogs.apiOperations[node.operation]?.captures ?? {}), ...Object.keys(node.capture ?? {})]);
288
+ if (node.type === "db_query" || node.type === "db_assert" || node.type === "db_execute")
289
+ return uniqueStrings([...Object.keys(catalogs.queries[node.query]?.captures ?? {}), ...Object.keys(node.capture ?? {})]);
290
+ if (node.type === "unix_batch")
291
+ return uniqueStrings([...Object.keys(catalogs.batches[node.batch]?.captures ?? {}), ...Object.keys(node.capture ?? {})]);
292
+ if (node.type === "loop")
293
+ return uniqueStrings([loopDateOutputName(node) ?? "", ...Object.keys(node.capture ?? {})]);
294
+ if (node.type === "parallel")
295
+ return uniqueStrings([...Object.keys(node.capture ?? {})]);
296
+ return [];
297
+ }
298
+ function uniqueStrings(values) {
299
+ return [...new Set(values.filter(Boolean))];
300
+ }
301
+ function nearestSuggestion(value, candidates) {
302
+ const nearest = candidates
303
+ .map((candidate) => ({ candidate, distance: levenshtein(value, candidate) }))
304
+ .sort((a, b) => a.distance - b.distance)[0];
305
+ return nearest && nearest.distance <= Math.max(2, Math.floor(value.length / 3)) ? ` Did you mean '${nearest.candidate}'?` : "";
306
+ }
307
+ function levenshtein(a, b) {
308
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
309
+ for (let j = 1; j <= b.length; j++)
310
+ dp[0][j] = j;
311
+ for (let i = 1; i <= a.length; i++) {
312
+ for (let j = 1; j <= b.length; j++) {
313
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
314
+ }
315
+ }
316
+ return dp[a.length][b.length];
317
+ }
318
+ function referencePayload(node) {
319
+ if (node.type !== "api_operation")
320
+ return node;
321
+ const { postActions: _postActions, ...payload } = node;
322
+ return payload;
323
+ }
324
+ function validateRequiredInputs(node, catalogs, errors, warnings) {
325
+ const params = node.params ?? node.input ?? {};
326
+ if (node.type === "api_operation" && !catalogs.apiOperations[node.operation]) {
327
+ errors.push(`Flow node '${node.id}' references unknown API operation '${node.operation}'.`);
328
+ }
329
+ if (node.type === "api_operation") {
330
+ validateParams(node.id, params, catalogs.apiOperations[node.operation]?.params, errors, "API variable");
331
+ validateApiRequestBody(node, errors);
332
+ validateTemplateBinding(node, catalogs, warnings);
333
+ }
334
+ if (node.type === "db_query" || node.type === "db_assert" || node.type === "db_execute") {
335
+ validateParams(node.id, normalizeBindParamRecord(params), normalizeBindParamRecord(catalogs.queries[node.query]?.params ?? {}), errors, "query param");
336
+ }
337
+ if (node.type === "unix_batch") {
338
+ const batch = catalogs.batches[node.batch];
339
+ validateBatchInputFiles(node.id, params, batch, errors);
340
+ validateArgs(node.id, batchArgParamsForValidation(params, batch), batch?.args ?? [], errors);
341
+ }
342
+ }
343
+ function validateParams(nodeId, params, specs, errors, label) {
344
+ for (const [name, spec] of Object.entries(specs ?? {})) {
345
+ const value = params[name];
346
+ if (spec.required && (value === undefined || value === null || value === "")) {
347
+ errors.push(`Flow node '${nodeId}' is missing required ${label} '${name}'.`);
348
+ }
349
+ }
350
+ }
351
+ function validateArgs(nodeId, params, args, errors) {
352
+ for (const arg of args) {
353
+ if (!Object.prototype.hasOwnProperty.call(params, arg.name))
354
+ continue;
355
+ const value = params[arg.name];
356
+ if (arg.required !== false && (value === undefined || value === null || value === "")) {
357
+ errors.push(`Flow node '${nodeId}' is missing required batch arg '${arg.name}'.`);
358
+ }
359
+ }
360
+ }
361
+ function validateBatchInputFiles(nodeId, params, batch, errors) {
362
+ for (const file of batchInputFiles(batch)) {
363
+ const value = params[file.name];
364
+ if (file.required !== false && !hasBatchInputFilePayload(value)) {
365
+ errors.push(`Flow node '${nodeId}' is missing required batch input file '${file.name}'.`);
366
+ }
367
+ const remotePath = value && typeof value === "object" && !Array.isArray(value)
368
+ ? value.remotePath
369
+ : undefined;
370
+ if (hasBatchInputFilePayload(value) && !file.remotePath && !remotePath) {
371
+ errors.push(`Flow node '${nodeId}' batch input file '${file.name}' needs a remote path.`);
372
+ }
373
+ }
374
+ }
375
+ function publishExpectedOutputs(node, catalogs, availableBare, _warnings) {
376
+ if (node.type === "api_operation") {
377
+ for (const key of Object.keys(catalogs.apiOperations[node.operation]?.captures ?? {}))
378
+ availableBare.add(key);
379
+ }
380
+ if (node.type === "db_query" || node.type === "db_assert" || node.type === "db_execute") {
381
+ for (const key of Object.keys(catalogs.queries[node.query]?.captures ?? {}))
382
+ availableBare.add(key);
383
+ }
384
+ if (node.type === "unix_batch") {
385
+ for (const key of Object.keys(catalogs.batches[node.batch]?.captures ?? {}))
386
+ availableBare.add(key);
387
+ }
388
+ if (node.type === "loop") {
389
+ const outputName = loopDateOutputName(node);
390
+ if (outputName)
391
+ availableBare.add(outputName);
392
+ }
393
+ for (const key of Object.keys(node.capture ?? {}))
394
+ availableBare.add(key);
395
+ }
396
+ function loopDateOutputName(node) {
397
+ return node.type === "loop" && node.loop.dateCursor ? node.loop.dateCursor.outputName?.trim() || "business_date" : undefined;
398
+ }
399
+ function findRefs(value) {
400
+ const refs = [];
401
+ const visit = (current) => {
402
+ if (typeof current === "string") {
403
+ for (const match of current.matchAll(/\$\{([^}]+)\}/g))
404
+ refs.push(match[1]);
405
+ for (const match of current.matchAll(/\{\{([A-Za-z0-9_.-]+)\}\}/g))
406
+ refs.push(match[1]);
407
+ }
408
+ else if (Array.isArray(current)) {
409
+ current.forEach(visit);
410
+ }
411
+ else if (current && typeof current === "object") {
412
+ Object.values(current).forEach(visit);
413
+ }
414
+ };
415
+ visit(value);
416
+ return refs;
417
+ }
418
+ function validateTemplateBinding(node, catalogs, warnings) {
419
+ if (node.type !== "api_operation" || !node.request)
420
+ return;
421
+ const template = catalogs.apiOperations[node.operation];
422
+ if (!template)
423
+ return;
424
+ if (node.request.method && template.method && node.request.path && template.path) {
425
+ const sameMethod = node.request.method === template.method;
426
+ const samePath = normalizeTemplatePath(node.request.path) === normalizeTemplatePath(template.path);
427
+ if (!sameMethod && !samePath) {
428
+ warnings.push(`Flow node '${node.id}' may be bound to the wrong source template: workflow request is ${node.request.method} ${node.request.path}, template is ${template.method} ${template.path}.`);
429
+ }
430
+ else if (!samePath && template.source?.collectionId) {
431
+ warnings.push(`Flow node '${node.id}' path differs from imported source template: workflow path is ${node.request.path}, template path is ${template.path}. Review before trusting override counts.`);
432
+ }
433
+ }
434
+ }
435
+ function normalizeTemplatePath(path) {
436
+ return path.toLowerCase().replace(/\/+/g, "/").replace(/\/$/, "");
437
+ }
438
+ function validateApiRequestBody(node, errors) {
439
+ if (node.type !== "api_operation" || node.request?.bodyMode !== "json" || node.request.rawBody === undefined)
440
+ return;
441
+ const placeholder = "null";
442
+ const rendered = normalizeJsonRawBody(node.request.rawBody)
443
+ .replace(/\$\{[A-Za-z0-9_.-]+\}/g, placeholder)
444
+ .replace(/\{\{[A-Za-z0-9_.-]+\}\}/g, placeholder);
445
+ try {
446
+ JSON.parse(rendered);
447
+ }
448
+ catch (error) {
449
+ const err = error instanceof Error ? error : new Error(String(error));
450
+ errors.push(`Flow node '${node.id}' has invalid JSON request body: ${err.message}`);
451
+ }
452
+ }
453
+ function validateApiStepIntent(node, warnings) {
454
+ if (node.type !== "api_operation")
455
+ return;
456
+ if (node.expectedOutcome === "negative") {
457
+ if (!node.request?.acceptStatuses?.length) {
458
+ warnings.push(`Flow node '${node.id}' is a negative API test but has no Accepted statuses configured.`);
459
+ }
460
+ if (!node.assertions?.length) {
461
+ warnings.push(`Flow node '${node.id}' is a negative API test but has no response assertions.`);
462
+ }
463
+ }
464
+ const authorization = node.request?.headers
465
+ ? Object.entries(node.request.headers).find(([key]) => key.toLowerCase() === "authorization")?.[1]
466
+ : undefined;
467
+ if (typeof authorization === "string" && /\$\{[^}]*token[^}]*\}/i.test(authorization) && !/^Bearer\s+\$\{/i.test(authorization.trim())) {
468
+ warnings.push(`Flow node '${node.id}' Authorization header uses a token reference without a Bearer prefix.`);
469
+ }
470
+ }
@@ -0,0 +1,112 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { basename, join, relative } from "node:path";
3
+ export async function writeHtmlReport(result) {
4
+ const totals = countByStatus(result.steps);
5
+ const rows = result.steps.map((step) => {
6
+ const duration = formatDuration(stepDurationMs(step));
7
+ const evidence = renderEvidenceLinks(step, result.evidenceDir);
8
+ const error = step.error?.message ? `<pre class="error">${escapeHtml(step.error.message)}</pre>` : "";
9
+ return ` <tr>
10
+ <td>${escapeHtml(step.stepId)}</td>
11
+ <td>${escapeHtml(step.layer)}</td>
12
+ <td class="${step.status}">${escapeHtml(step.status)}</td>
13
+ <td>${duration}</td>
14
+ <td>${evidence}</td>
15
+ <td>${error}</td>
16
+ </tr>`;
17
+ }).join("\n");
18
+ const html = `<!doctype html>
19
+ <html>
20
+ <head>
21
+ <meta charset="utf-8">
22
+ <title>${escapeHtml(result.scenarioId)} ${escapeHtml(result.status)}</title>
23
+ <style>
24
+ body { font-family: Arial, sans-serif; margin: 24px; color: #222; }
25
+ h1 { margin-bottom: 4px; }
26
+ .meta { color: #555; margin-bottom: 16px; }
27
+ .summary { display: flex; gap: 12px; margin: 12px 0 18px; flex-wrap: wrap; }
28
+ .summary span { padding: 4px 10px; border-radius: 4px; background: #f1f3f5; font-size: 13px; }
29
+ table { border-collapse: collapse; width: 100%; font-size: 14px; }
30
+ th, td { border: 1px solid #ccc; padding: 8px; text-align: left; vertical-align: top; }
31
+ th { background: #fafafa; }
32
+ .passed { color: #087a2f; font-weight: bold; }
33
+ .failed { color: #b00020; font-weight: bold; }
34
+ .skipped { color: #777; font-weight: bold; }
35
+ pre.error { white-space: pre-wrap; margin: 0; color: #b00020; font-family: Consolas, Menlo, monospace; font-size: 12px; }
36
+ ul.evidence { margin: 0; padding-left: 16px; font-size: 12px; }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <h1>${escapeHtml(result.scenarioId)}</h1>
41
+ <div class="meta">Run <strong>${escapeHtml(result.runId)}</strong> &mdash; status <strong class="${result.status}">${escapeHtml(result.status)}</strong></div>
42
+ <div class="meta">Started ${escapeHtml(result.startedAt)} &middot; ended ${escapeHtml(result.endedAt)} &middot; duration ${formatDuration(result.durationMs)}</div>
43
+ <div class="summary">
44
+ <span>total: ${result.steps.length}</span>
45
+ <span class="passed">passed: ${totals.passed}</span>
46
+ <span class="failed">failed: ${totals.failed}</span>
47
+ <span class="skipped">skipped: ${totals.skipped}</span>
48
+ </div>
49
+ <table>
50
+ <thead><tr><th>Step</th><th>Layer</th><th>Status</th><th>Duration</th><th>Evidence</th><th>Error</th></tr></thead>
51
+ <tbody>
52
+ ${rows}
53
+ </tbody>
54
+ </table>
55
+ </body>
56
+ </html>`;
57
+ const path = join(result.evidenceDir, "report.html");
58
+ await writeFile(path, html, "utf8");
59
+ return path;
60
+ }
61
+ function countByStatus(steps) {
62
+ let passed = 0;
63
+ let failed = 0;
64
+ let skipped = 0;
65
+ for (const step of steps) {
66
+ if (step.status === "passed")
67
+ passed += 1;
68
+ else if (step.status === "failed")
69
+ failed += 1;
70
+ else if (step.status === "skipped")
71
+ skipped += 1;
72
+ }
73
+ return { passed, failed, skipped };
74
+ }
75
+ function stepDurationMs(step) {
76
+ const startedMs = Date.parse(step.startedAt);
77
+ const endedMs = Date.parse(step.endedAt);
78
+ if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs))
79
+ return undefined;
80
+ return Math.max(0, endedMs - startedMs);
81
+ }
82
+ function formatDuration(ms) {
83
+ if (ms === undefined)
84
+ return "&mdash;";
85
+ if (ms < 1000)
86
+ return `${ms} ms`;
87
+ return `${(ms / 1000).toFixed(2)} s`;
88
+ }
89
+ function renderEvidenceLinks(step, evidenceDir) {
90
+ if (!step.evidence?.length)
91
+ return "&mdash;";
92
+ const items = step.evidence.map((path) => {
93
+ const href = relativeOrAbsolute(path, evidenceDir);
94
+ const label = basename(path);
95
+ return `<li><a href="${escapeHtml(href)}">${escapeHtml(label)}</a></li>`;
96
+ }).join("");
97
+ return `<ul class="evidence">${items}</ul>`;
98
+ }
99
+ function relativeOrAbsolute(target, base) {
100
+ try {
101
+ const rel = relative(base, target);
102
+ if (!rel || rel.startsWith(".."))
103
+ return target;
104
+ return rel.replace(/\\/g, "/");
105
+ }
106
+ catch {
107
+ return target;
108
+ }
109
+ }
110
+ function escapeHtml(value) {
111
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#039;" }[char] ?? char));
112
+ }
@@ -0,0 +1,48 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function writeJunitReport(result) {
4
+ const failures = result.steps.filter((step) => step.status === "failed").length;
5
+ const skipped = result.steps.filter((step) => step.status === "skipped").length;
6
+ const totalSeconds = formatSeconds(result.durationMs);
7
+ const cases = result.steps.map((step) => {
8
+ const time = formatSeconds(stepDurationMs(step));
9
+ const body = step.status === "failed"
10
+ ? `<failure message="${xml(step.error?.message ?? "failed")}">${xml(step.error?.stack ?? "")}</failure>`
11
+ : step.status === "skipped"
12
+ ? `<skipped message="${xml(step.error?.message ?? "skipped")}"/>`
13
+ : "";
14
+ const timeAttr = time === undefined ? "" : ` time="${time}"`;
15
+ return ` <testcase classname="${xml(result.scenarioId)}" name="${xml(step.stepId)}"${timeAttr}>${body}</testcase>`;
16
+ }).join("\n");
17
+ const suiteAttrs = [
18
+ `name="${xml(result.scenarioId)}"`,
19
+ `tests="${result.steps.length}"`,
20
+ `failures="${failures}"`,
21
+ `skipped="${skipped}"`,
22
+ `timestamp="${xml(result.startedAt)}"`,
23
+ totalSeconds === undefined ? "" : `time="${totalSeconds}"`
24
+ ].filter(Boolean).join(" ");
25
+ const xmlText = `<?xml version="1.0" encoding="UTF-8"?>
26
+ <testsuite ${suiteAttrs}>
27
+ ${cases}
28
+ </testsuite>
29
+ `;
30
+ const path = join(result.evidenceDir, "junit.xml");
31
+ await writeFile(path, xmlText, "utf8");
32
+ return path;
33
+ }
34
+ function stepDurationMs(step) {
35
+ const startedMs = Date.parse(step.startedAt);
36
+ const endedMs = Date.parse(step.endedAt);
37
+ if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs))
38
+ return undefined;
39
+ return Math.max(0, endedMs - startedMs);
40
+ }
41
+ function formatSeconds(ms) {
42
+ if (ms === undefined)
43
+ return undefined;
44
+ return (ms / 1000).toFixed(3);
45
+ }
46
+ function xml(value) {
47
+ return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&apos;" }[char] ?? char));
48
+ }
package/docs/.gitkeep ADDED
File without changes