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.
- package/.env.example +13 -0
- package/CHANGELOG.md +17 -0
- package/CODE_OF_CONDUCT.md +21 -0
- package/CONTRIBUTING.md +29 -0
- package/LICENSE +21 -0
- package/README.md +97 -3
- package/SECURITY.md +13 -0
- package/catalogs/.gitkeep +0 -0
- package/catalogs/api-operations.yaml +21 -0
- package/catalogs/batches.yaml +74 -0
- package/catalogs/queries.yaml +75 -0
- package/config/environments.yaml +13 -0
- package/dist/actions/assert-db.js +3 -0
- package/dist/actions/run-eod.js +3 -0
- package/dist/adapters/api/api-collections.js +296 -0
- package/dist/adapters/api/body-utils.js +9 -0
- package/dist/adapters/api/rest-client.js +557 -0
- package/dist/adapters/api/soap-client.js +5 -0
- package/dist/adapters/db/assertions.js +87 -0
- package/dist/adapters/db/oracle-client.js +115 -0
- package/dist/adapters/db/query-catalog.js +75 -0
- package/dist/adapters/unix/batch-catalog.js +71 -0
- package/dist/adapters/unix/batch-input-files.js +36 -0
- package/dist/adapters/unix/batch-runner.js +382 -0
- package/dist/adapters/unix/ssh-client.js +228 -0
- package/dist/app/server.js +827 -0
- package/dist/cli.js +516 -0
- package/dist/config/environments.js +138 -0
- package/dist/config/registry.js +18 -0
- package/dist/config/secrets.js +123 -0
- package/dist/dsl/parser.js +20 -0
- package/dist/dsl/schema.js +182 -0
- package/dist/dsl/types.js +1 -0
- package/dist/dsl/validator.js +264 -0
- package/dist/engine/captures.js +68 -0
- package/dist/engine/context.js +69 -0
- package/dist/engine/evidence.js +33 -0
- package/dist/engine/known-errors.js +129 -0
- package/dist/engine/retry.js +13 -0
- package/dist/engine/runner.js +710 -0
- package/dist/engine/step-result.js +58 -0
- package/dist/flows/catalog-normalizer.js +72 -0
- package/dist/flows/compiler.js +237 -0
- package/dist/flows/concat.js +130 -0
- package/dist/flows/parser.js +21 -0
- package/dist/flows/schema.js +142 -0
- package/dist/flows/types.js +1 -0
- package/dist/flows/validator.js +470 -0
- package/dist/reports/html-report.js +112 -0
- package/dist/reports/junit-report.js +48 -0
- package/docs/.gitkeep +0 -0
- package/docs/DB_UNIX_OPERATIONS.md +118 -0
- package/docs/FLOW_BUILDER.md +87 -0
- package/flows/account_processing_cycle.flow.yaml +88 -0
- package/flows/new_flow.flow.yaml +22 -0
- package/package.json +98 -11
- package/scenarios/smoke/account-processing-smoke.yaml +44 -0
- package/scenarios/smoke/api-db-batch-check.yaml +40 -0
- package/src/actions/assert-db.ts +6 -0
- package/src/actions/run-eod.ts +6 -0
- package/src/adapters/api/api-collections.ts +375 -0
- package/src/adapters/api/body-utils.ts +10 -0
- package/src/adapters/api/rest-client.ts +587 -0
- package/src/adapters/api/soap-client.ts +7 -0
- package/src/adapters/db/assertions.ts +83 -0
- package/src/adapters/db/oracle-client.ts +133 -0
- package/src/adapters/db/query-catalog.ts +80 -0
- package/src/adapters/unix/batch-catalog.ts +81 -0
- package/src/adapters/unix/batch-input-files.ts +39 -0
- package/src/adapters/unix/batch-runner.ts +456 -0
- package/src/adapters/unix/ssh-client.ts +248 -0
- package/src/app/server.ts +914 -0
- package/src/cli.ts +517 -0
- package/src/config/environments.ts +193 -0
- package/src/config/registry.ts +23 -0
- package/src/config/secrets.ts +128 -0
- package/src/dsl/parser.ts +24 -0
- package/src/dsl/schema.ts +189 -0
- package/src/dsl/types.ts +371 -0
- package/src/dsl/validator.ts +282 -0
- package/src/engine/captures.ts +66 -0
- package/src/engine/context.ts +76 -0
- package/src/engine/evidence.ts +35 -0
- package/src/engine/known-errors.ts +145 -0
- package/src/engine/retry.ts +11 -0
- package/src/engine/runner.ts +746 -0
- package/src/engine/step-result.ts +64 -0
- package/src/flows/catalog-normalizer.ts +86 -0
- package/src/flows/compiler.ts +247 -0
- package/src/flows/concat.ts +149 -0
- package/src/flows/parser.ts +27 -0
- package/src/flows/schema.ts +154 -0
- package/src/flows/types.ts +130 -0
- package/src/flows/validator.ts +468 -0
- package/src/llm/system-prompt.md +9 -0
- package/src/reports/html-report.ts +113 -0
- package/src/reports/junit-report.ts +55 -0
- package/src/types/oracledb.d.ts +1 -0
- package/templates/.gitkeep +0 -0
- package/templates/api/create-test-case.json +5 -0
- package/templates/api/record-test-activity.json +6 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +17 -0
- package/web/index.html +12 -0
- package/web/src/App.tsx +6588 -0
- package/web/src/main.tsx +10 -0
- package/web/src/styles.css +3147 -0
- package/web-dist/assets/elk.bundled-ChwRCIWJ.js +24 -0
- package/web-dist/assets/index-CArbX4zm.css +1 -0
- package/web-dist/assets/index-vDCbj8xB.js +28 -0
- package/web-dist/index.html +13 -0
- package/index.js +0 -1
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { ApiStepEvidence, Catalogs, EvidenceVisibilityMode, LoopDateCursor, LoopDateFormat, QueryCatalogEntry, RunResult, Scenario, ScenarioStep, StepLayer, StepResult, UnixStepEvidence } from "../dsl/types.js";
|
|
5
|
+
import type { EnvironmentConfig } from "../config/environments.js";
|
|
6
|
+
import { EvidenceWriter } from "./evidence.js";
|
|
7
|
+
import { RunContext } from "./context.js";
|
|
8
|
+
import { cancelledStep, durationBetween, failedStep, hashInput, isCancellationError } from "./step-result.js";
|
|
9
|
+
import { RestClient } from "../adapters/api/rest-client.js";
|
|
10
|
+
import { SoapClient } from "../adapters/api/soap-client.js";
|
|
11
|
+
import { OracleClient } from "../adapters/db/oracle-client.js";
|
|
12
|
+
import { assertQueryResult } from "../adapters/db/assertions.js";
|
|
13
|
+
import { SshClient } from "../adapters/unix/ssh-client.js";
|
|
14
|
+
import { BatchRunner } from "../adapters/unix/batch-runner.js";
|
|
15
|
+
import { runBatch } from "../actions/run-eod.js";
|
|
16
|
+
import { writeHtmlReport } from "../reports/html-report.js";
|
|
17
|
+
import { writeJunitReport } from "../reports/junit-report.js";
|
|
18
|
+
import { extractCaptures, mergeCaptureSpecs } from "./captures.js";
|
|
19
|
+
import { applyEvidenceVisibility, evidenceVisibilityMode } from "../config/secrets.js";
|
|
20
|
+
|
|
21
|
+
export interface RunnerOptions {
|
|
22
|
+
rootDir: string;
|
|
23
|
+
dryRun?: boolean;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
onStepStart?: (event: { stepId: string; layer: StepLayer; startedAt: string; index: number }) => void;
|
|
26
|
+
onStepResult?: (result: StepResult) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ScenarioRunner {
|
|
30
|
+
private readonly context: RunContext;
|
|
31
|
+
private readonly evidence: EvidenceWriter;
|
|
32
|
+
private readonly restClient: RestClient;
|
|
33
|
+
private readonly soapClient: SoapClient;
|
|
34
|
+
private readonly oracleClient: OracleClient;
|
|
35
|
+
private readonly batchRunner: BatchRunner;
|
|
36
|
+
private readonly visibility: EvidenceVisibilityMode;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly scenario: Scenario,
|
|
40
|
+
private readonly catalogs: Catalogs,
|
|
41
|
+
private readonly env: EnvironmentConfig,
|
|
42
|
+
private readonly options: RunnerOptions
|
|
43
|
+
) {
|
|
44
|
+
this.context = new RunContext(scenario);
|
|
45
|
+
const runId = `${scenario.id}-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
46
|
+
this.visibility = evidenceVisibilityMode();
|
|
47
|
+
this.evidence = new EvidenceWriter(join(options.rootDir, "evidence", runId), this.visibility);
|
|
48
|
+
this.restClient = new RestClient(env, options.rootDir);
|
|
49
|
+
this.soapClient = new SoapClient();
|
|
50
|
+
this.oracleClient = new OracleClient(env);
|
|
51
|
+
this.batchRunner = new BatchRunner(new SshClient(env), options.rootDir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async run(): Promise<RunResult> {
|
|
55
|
+
await this.evidence.init();
|
|
56
|
+
const startedAt = new Date().toISOString();
|
|
57
|
+
const steps: StepResult[] = [];
|
|
58
|
+
|
|
59
|
+
let cursor = 0;
|
|
60
|
+
for (const step of this.scenario.steps) {
|
|
61
|
+
if (this.isStopped()) {
|
|
62
|
+
steps.push(cancelledStep(step.id, this.layerFor(step), new Date().toISOString(), step.input ?? step.params ?? {}));
|
|
63
|
+
for (const skipped of this.scenario.steps.slice(steps.length)) {
|
|
64
|
+
steps.push(this.skipStep(skipped, `Skipped because run was stopped before step '${step.id}'.`));
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const results = await this.executePlanStep(step, cursor);
|
|
69
|
+
cursor += results.length;
|
|
70
|
+
steps.push(...results);
|
|
71
|
+
const controlResult = results[0] ?? results[results.length - 1];
|
|
72
|
+
if (results.some((result) => result.status === "cancelled")) {
|
|
73
|
+
for (const skipped of this.scenario.steps.slice(steps.length)) {
|
|
74
|
+
steps.push(this.skipStep(skipped, `Skipped because run was stopped during step '${step.id}'.`));
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (controlResult?.status === "failed" && !step.continueOnFailure) {
|
|
79
|
+
for (const skipped of this.scenario.steps.slice(steps.length)) {
|
|
80
|
+
steps.push(this.skipStep(skipped, `Skipped because previous step '${step.id}' failed.`));
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const endedAt = new Date().toISOString();
|
|
87
|
+
const startedMs = Date.parse(startedAt);
|
|
88
|
+
const endedMs = Date.parse(endedAt);
|
|
89
|
+
const durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
|
|
90
|
+
? Math.max(0, endedMs - startedMs)
|
|
91
|
+
: undefined;
|
|
92
|
+
const result: RunResult = {
|
|
93
|
+
scenarioId: this.scenario.id,
|
|
94
|
+
runId: basename(this.evidence.runDir) || this.scenario.id,
|
|
95
|
+
status: steps.some((step) => step.status === "cancelled")
|
|
96
|
+
? "cancelled"
|
|
97
|
+
: steps.some((step) => step.status === "failed") ? "failed" : "passed",
|
|
98
|
+
startedAt,
|
|
99
|
+
endedAt,
|
|
100
|
+
evidenceDir: this.evidence.runDir,
|
|
101
|
+
steps,
|
|
102
|
+
durationMs
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const visibleResult = applyEvidenceVisibility(result, this.visibility);
|
|
106
|
+
const runResultPath = await this.evidence.writeJson("run-result.json", visibleResult);
|
|
107
|
+
const reportPath = await writeHtmlReport(visibleResult);
|
|
108
|
+
const junitPath = await writeJunitReport(visibleResult);
|
|
109
|
+
await this.evidence.writeJson("evidence-manifest.json", buildEvidenceManifest(result, {
|
|
110
|
+
runResultPath,
|
|
111
|
+
reportPath,
|
|
112
|
+
junitPath
|
|
113
|
+
}));
|
|
114
|
+
return visibleResult;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async executePlanStep(step: ScenarioStep, index: number): Promise<StepResult[]> {
|
|
118
|
+
if (step.control === "parallel" || step.action === "__parallel") return this.executeParallelStep(step, index);
|
|
119
|
+
if (step.control === "loop" || step.action === "__loop") return this.executeLoopStep(step, index);
|
|
120
|
+
return [await this.executeStep(step, index)];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async executeParallelStep(step: ScenarioStep, index: number): Promise<StepResult[]> {
|
|
124
|
+
const startedAt = new Date().toISOString();
|
|
125
|
+
const branches = step.branches ?? [];
|
|
126
|
+
const branchResults = await Promise.all(branches.map(async (branch, branchIndex) => {
|
|
127
|
+
const results: StepResult[] = [];
|
|
128
|
+
let localIndex = index + branchIndex + 1;
|
|
129
|
+
for (const branchStep of branch.steps) {
|
|
130
|
+
if (this.isStopped()) {
|
|
131
|
+
results.push(cancelledStep(branchStep.id, this.layerFor(branchStep), new Date().toISOString(), branchStep.input ?? branchStep.params ?? {}));
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
const next = await this.executePlanStep(branchStep, localIndex);
|
|
135
|
+
localIndex += next.length;
|
|
136
|
+
results.push(...next);
|
|
137
|
+
const failed = next[0]?.status === "failed" || next.some((result) => result.status === "cancelled");
|
|
138
|
+
if (failed && !branchStep.continueOnFailure) break;
|
|
139
|
+
}
|
|
140
|
+
return { branch, results };
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
const flattened = branchResults.flatMap((branch) => branch.results);
|
|
144
|
+
const join = step.join ?? "all";
|
|
145
|
+
const branchPassed = branchResults.map((branch) => branch.results.length > 0 && !branch.results.some((result) => result.status === "failed" || result.status === "cancelled"));
|
|
146
|
+
const passed = join === "any" ? branchPassed.some(Boolean) : branchPassed.every(Boolean);
|
|
147
|
+
const endedAt = new Date().toISOString();
|
|
148
|
+
const group: StepResult = {
|
|
149
|
+
stepId: step.id,
|
|
150
|
+
layer: "engine",
|
|
151
|
+
status: flattened.some((result) => result.status === "cancelled") ? "cancelled" : passed ? "passed" : "failed",
|
|
152
|
+
startedAt,
|
|
153
|
+
endedAt,
|
|
154
|
+
durationMs: durationBetween(startedAt, endedAt),
|
|
155
|
+
inputHash: hashInput({ branches: branches.map((branch) => branch.id), join }),
|
|
156
|
+
captures: {},
|
|
157
|
+
evidence: [],
|
|
158
|
+
error: passed ? undefined : {
|
|
159
|
+
message: `Parallel block '${step.id}' failed using '${join}' join semantics.`
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
return [group, ...flattened];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async executeLoopStep(step: ScenarioStep, index: number): Promise<StepResult[]> {
|
|
166
|
+
const startedAt = new Date().toISOString();
|
|
167
|
+
const iterations = this.loopIterations(step);
|
|
168
|
+
const allResults: StepResult[] = [];
|
|
169
|
+
const aggregate = new Map<string, unknown[]>();
|
|
170
|
+
let failed = false;
|
|
171
|
+
const loopSteps = step.steps ?? [];
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < iterations.items.length; i += 1) {
|
|
174
|
+
if (this.isStopped()) {
|
|
175
|
+
allResults.push(cancelledStep(`${step.id}_${i}`, "engine", new Date().toISOString(), {}));
|
|
176
|
+
failed = true;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
this.context.set(`${step.id}.index`, i);
|
|
180
|
+
this.context.set(`${step.id}.number`, i + 1);
|
|
181
|
+
this.context.set(`${step.id}.total`, iterations.items.length);
|
|
182
|
+
this.context.set(`${step.id}.item`, iterations.items[i]);
|
|
183
|
+
if (iterations.itemName) {
|
|
184
|
+
this.context.set(iterations.itemName, iterations.items[i]);
|
|
185
|
+
this.context.set(`${step.id}.${iterations.itemName}`, iterations.items[i]);
|
|
186
|
+
}
|
|
187
|
+
const dateCursor = step.loop?.dateCursor;
|
|
188
|
+
if (dateCursor) {
|
|
189
|
+
const outputName = loopDateOutputName(dateCursor);
|
|
190
|
+
const value = computeLoopDateCursorValue(dateCursor, i, (candidate) => this.context.resolve(candidate));
|
|
191
|
+
this.context.set(outputName, value);
|
|
192
|
+
this.context.set(`${step.id}.${outputName}`, value);
|
|
193
|
+
this.context.set(`${step.id}[${i}].${outputName}`, value);
|
|
194
|
+
this.context.set(`${step.id}.${i}.${outputName}`, value);
|
|
195
|
+
this.context.set(`${step.id}.last.${outputName}`, value);
|
|
196
|
+
const aggregateKey = `${step.id}.all.${outputName}`;
|
|
197
|
+
const values = aggregate.get(aggregateKey) ?? [];
|
|
198
|
+
values.push(value);
|
|
199
|
+
aggregate.set(aggregateKey, values);
|
|
200
|
+
this.context.set(aggregateKey, values);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const child of loopSteps) {
|
|
204
|
+
const scopedChild = scopedLoopStep(child, step.id, i);
|
|
205
|
+
const childResults = await this.executePlanStep(scopedChild, index + allResults.length + 1);
|
|
206
|
+
allResults.push(...childResults);
|
|
207
|
+
const publishable = childResults.filter((result) => result.status === "passed");
|
|
208
|
+
for (const result of publishable) {
|
|
209
|
+
for (const [capture, value] of Object.entries(result.captures)) {
|
|
210
|
+
this.context.set(`${child.id}.${capture}`, value);
|
|
211
|
+
this.context.set(`${step.id}[${i}].${child.id}.${capture}`, value);
|
|
212
|
+
this.context.set(`${step.id}.${i}.${child.id}.${capture}`, value);
|
|
213
|
+
this.context.set(`${step.id}.last.${child.id}.${capture}`, value);
|
|
214
|
+
const aggregateKey = `${step.id}.all.${child.id}.${capture}`;
|
|
215
|
+
const values = aggregate.get(aggregateKey) ?? [];
|
|
216
|
+
values.push(value);
|
|
217
|
+
aggregate.set(aggregateKey, values);
|
|
218
|
+
this.context.set(aggregateKey, values);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (childResults[0]?.status === "failed" && !child.continueOnFailure) {
|
|
222
|
+
failed = true;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (failed && !step.continueOnFailure) break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const endedAt = new Date().toISOString();
|
|
230
|
+
const group: StepResult = {
|
|
231
|
+
stepId: step.id,
|
|
232
|
+
layer: "engine",
|
|
233
|
+
status: allResults.some((result) => result.status === "cancelled") ? "cancelled" : failed ? "failed" : "passed",
|
|
234
|
+
startedAt,
|
|
235
|
+
endedAt,
|
|
236
|
+
durationMs: durationBetween(startedAt, endedAt),
|
|
237
|
+
inputHash: hashInput(step.loop ?? {}),
|
|
238
|
+
captures: Object.fromEntries(aggregate),
|
|
239
|
+
evidence: [],
|
|
240
|
+
error: failed ? { message: `Loop '${step.id}' failed before completing all iterations.` } : undefined
|
|
241
|
+
};
|
|
242
|
+
return [group, ...allResults];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private loopIterations(step: ScenarioStep): { items: unknown[]; itemName?: string } {
|
|
246
|
+
const spec = step.loop ?? { mode: "count", count: 1 };
|
|
247
|
+
const maxIterations = spec.maxIterations ?? 1000;
|
|
248
|
+
if (spec.mode === "count") {
|
|
249
|
+
const resolved = this.context.resolve(spec.count ?? 1);
|
|
250
|
+
const count = Number(resolved);
|
|
251
|
+
if (!Number.isInteger(count) || count < 0) throw new Error(`Loop '${step.id}' count must resolve to a non-negative integer.`);
|
|
252
|
+
if (count > maxIterations) throw new Error(`Loop '${step.id}' count ${count} exceeds maxIterations ${maxIterations}.`);
|
|
253
|
+
return { items: Array.from({ length: count }, (_value, index) => index), itemName: spec.itemName };
|
|
254
|
+
}
|
|
255
|
+
const resolved = this.context.resolve(spec.items);
|
|
256
|
+
const items = Array.isArray(resolved) ? resolved : resolved === undefined || resolved === null ? [] : [resolved];
|
|
257
|
+
if (items.length > maxIterations) throw new Error(`Loop '${step.id}' foreach item count ${items.length} exceeds maxIterations ${maxIterations}.`);
|
|
258
|
+
return { items, itemName: spec.itemName ?? "item" };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async executeStep(step: ScenarioStep, index: number): Promise<StepResult> {
|
|
262
|
+
const startedAt = new Date().toISOString();
|
|
263
|
+
const layer = this.layerFor(step);
|
|
264
|
+
let resolvedInput: unknown = step.input ?? step.params ?? {};
|
|
265
|
+
const evidence: string[] = [];
|
|
266
|
+
let inputEvidenceWritten = false;
|
|
267
|
+
this.options.onStepStart?.({ stepId: step.id, layer, startedAt, index });
|
|
268
|
+
const finish = (result: StepResult): StepResult => {
|
|
269
|
+
this.options.onStepResult?.(result);
|
|
270
|
+
return result;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
resolvedInput = this.context.resolve(step.input ?? step.params ?? {});
|
|
275
|
+
evidence.push(await this.evidence.writeJson(`${step.id}.input.json`, resolvedInput));
|
|
276
|
+
inputEvidenceWritten = true;
|
|
277
|
+
if (this.options.dryRun) {
|
|
278
|
+
const captures = await this.dryRunCaptures(step);
|
|
279
|
+
this.publishCaptures(step.id, captures);
|
|
280
|
+
const dryPayload = { step, resolvedInput, captures, context: this.context.snapshot() };
|
|
281
|
+
evidence.push(await this.evidence.writeJson(`${step.id}.dry-run.json`, dryPayload));
|
|
282
|
+
return finish(this.passedStep(step.id, layer, startedAt, resolvedInput, captures, evidence));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result = await this.dispatch(step, resolvedInput as Record<string, unknown>, evidence, index);
|
|
286
|
+
if (result.status === "failed") {
|
|
287
|
+
return finish(this.failedEvaluatedStep(step.id, layer, startedAt, resolvedInput, result.captures, evidence, result.message ?? "Step failed.", result.api, result.unix));
|
|
288
|
+
}
|
|
289
|
+
this.publishCaptures(step.id, result.captures);
|
|
290
|
+
return finish(this.passedStep(step.id, layer, startedAt, resolvedInput, result.captures, evidence, result.api, result.unix));
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (!inputEvidenceWritten) {
|
|
293
|
+
const unresolvedInput = await this.evidence.writeJson(`${step.id}.input-unresolved.json`, resolvedInput).catch(() => undefined);
|
|
294
|
+
if (unresolvedInput) evidence.push(unresolvedInput);
|
|
295
|
+
}
|
|
296
|
+
if (isCancellationError(error)) {
|
|
297
|
+
return finish(cancelledStep(step.id, layer, startedAt, resolvedInput, evidence));
|
|
298
|
+
}
|
|
299
|
+
const failureEvidence = await this.writeFailureCheckpoint(step, startedAt, resolvedInput, error).catch(() => undefined);
|
|
300
|
+
if (failureEvidence) evidence.push(failureEvidence);
|
|
301
|
+
return finish(failedStep(step.id, layer, startedAt, resolvedInput, error, evidence));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private async dispatch(step: ScenarioStep, input: Record<string, unknown>, evidence: string[], index: number): Promise<DispatchResult> {
|
|
306
|
+
if (step.via === "api") {
|
|
307
|
+
const operation = this.catalogs.apiOperations[step.action];
|
|
308
|
+
if (!operation) throw new Error(`Unknown API operation '${step.action}'.`);
|
|
309
|
+
const request = step.request ? this.context.resolve(step.request) : undefined;
|
|
310
|
+
const result = operation.type === "soap"
|
|
311
|
+
? await this.soapClient.execute(operation, input)
|
|
312
|
+
: await this.restClient.execute(operation, input, request, step.assertions ?? [], step.capture ?? {}, {
|
|
313
|
+
expectedOutcome: step.expectedOutcome,
|
|
314
|
+
captureOnFailure: step.captureOnFailure,
|
|
315
|
+
visibility: this.visibility
|
|
316
|
+
});
|
|
317
|
+
if (operation.type === "soap") {
|
|
318
|
+
const captures = { ...result.captures, ...pickExplicitCaptures(result.response, step.capture ?? {}) };
|
|
319
|
+
evidence.push(await this.evidence.writeJson(`${step.id}.api.json`, result.evidencePayload));
|
|
320
|
+
return { status: "passed", captures };
|
|
321
|
+
}
|
|
322
|
+
const api = result.apiEvidence;
|
|
323
|
+
const stepFolder = stepEvidenceFolder(index, step.id);
|
|
324
|
+
evidence.push(await this.evidence.writeJson(`${step.id}.api.json`, result.evidencePayload));
|
|
325
|
+
if (api.request) evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/request.json`, { request: api.request, resolvedRequest: api.resolvedRequest }));
|
|
326
|
+
if (api.response) evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/response.json`, api.response));
|
|
327
|
+
evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/assertions.json`, api.assertionResults));
|
|
328
|
+
evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/captures.json`, api.evidenceCaptures));
|
|
329
|
+
return {
|
|
330
|
+
status: api.finalStatus,
|
|
331
|
+
captures: result.captures,
|
|
332
|
+
api,
|
|
333
|
+
message: api.failureReason
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (step.action === "db_assert" || step.action === "db_query" || step.action === "db_execute") {
|
|
338
|
+
if (!step.query) throw new Error(`${step.action} requires query.`);
|
|
339
|
+
const entry = this.catalogs.queries[step.query];
|
|
340
|
+
const stepFolder = stepEvidenceFolder(index, step.id);
|
|
341
|
+
if (step.action === "db_execute") {
|
|
342
|
+
if (entry.mode !== "execute") throw new Error(`DB Action Library template '${step.query}' must be marked mode: execute.`);
|
|
343
|
+
const result = await this.oracleClient.execute(entry, input);
|
|
344
|
+
const payload = { query: step.query, ...result };
|
|
345
|
+
const captureResult = evaluateCaptures(payload, mergeCaptureSpecs(entry.captures, step.capture));
|
|
346
|
+
const evidencePayload = { ...payload, captures: captureResult.captures, captureError: errorSummary(captureResult.error) };
|
|
347
|
+
evidence.push(await this.evidence.writeJson(`${step.id}.db-execute.json`, evidencePayload));
|
|
348
|
+
evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/db-execute.json`, evidencePayload));
|
|
349
|
+
if (captureResult.error) {
|
|
350
|
+
return { status: "failed", captures: {}, message: dbCaptureFailureMessage(payload, captureResult.error) };
|
|
351
|
+
}
|
|
352
|
+
return { status: "passed", captures: captureResult.captures };
|
|
353
|
+
}
|
|
354
|
+
const rows = await this.oracleClient.query(entry, input);
|
|
355
|
+
const payload = { query: step.query, rowCount: rows.length, rows };
|
|
356
|
+
const assertionResult = evaluateDbExpectation(step.action, entry, rows);
|
|
357
|
+
const captureResult = evaluateCaptures(payload, mergeCaptureSpecs(entry.captures, step.capture));
|
|
358
|
+
const evidencePayload = {
|
|
359
|
+
...payload,
|
|
360
|
+
assertionError: errorSummary(assertionResult.error),
|
|
361
|
+
captures: captureResult.captures,
|
|
362
|
+
captureError: errorSummary(captureResult.error)
|
|
363
|
+
};
|
|
364
|
+
evidence.push(await this.evidence.writeJson(`${step.id}.db.json`, evidencePayload));
|
|
365
|
+
evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/db.json`, evidencePayload));
|
|
366
|
+
if (assertionResult.error) {
|
|
367
|
+
return { status: "failed", captures: {}, message: dbQueryFailureMessage(payload, assertionResult.error) };
|
|
368
|
+
}
|
|
369
|
+
if (captureResult.error) {
|
|
370
|
+
return { status: "failed", captures: {}, message: dbCaptureFailureMessage(payload, captureResult.error) };
|
|
371
|
+
}
|
|
372
|
+
return { status: "passed", captures: captureResult.captures };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (step.action === "unix_batch") {
|
|
376
|
+
if (!step.batch) throw new Error("unix_batch requires batch.");
|
|
377
|
+
const stepFolder = stepEvidenceFolder(index, step.id);
|
|
378
|
+
const result = await runBatch(this.batchRunner, this.catalogs.batches[step.batch], input, {
|
|
379
|
+
attempts: step.retry?.attempts,
|
|
380
|
+
delayMs: step.retry?.delaySeconds === undefined ? undefined : step.retry.delaySeconds * 1000,
|
|
381
|
+
downloadDir: join(this.evidence.runDir, ...stepFolder.split("/"), "files"),
|
|
382
|
+
signal: this.options.signal
|
|
383
|
+
});
|
|
384
|
+
evidence.push(await this.evidence.writeJson(`${step.id}.unix.json`, result));
|
|
385
|
+
evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/unix.json`, result));
|
|
386
|
+
if (result.status !== "passed") {
|
|
387
|
+
const last = result.attempts[result.attempts.length - 1];
|
|
388
|
+
return {
|
|
389
|
+
status: "failed",
|
|
390
|
+
captures: {},
|
|
391
|
+
unix: result,
|
|
392
|
+
message: last?.error ?? `Batch '${step.batch}' failed.`
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
const captures = extractCaptures(result, mergeCaptureSpecs(this.catalogs.batches[step.batch].captures, step.capture));
|
|
396
|
+
if (Object.keys(captures).length > 0) {
|
|
397
|
+
evidence.push(await this.evidence.writeJson(`${step.id}.unix-captures.json`, captures));
|
|
398
|
+
}
|
|
399
|
+
return { status: "passed", captures, unix: result };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
throw new Error(`No executor for action '${step.action}' via '${step.via ?? ""}'.`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private passedStep(stepId: string, layer: StepLayer, startedAt: string, input: unknown, captures: Record<string, unknown>, evidence: string[], api?: ApiStepEvidence, unix?: UnixStepEvidence): StepResult {
|
|
406
|
+
const endedAt = new Date().toISOString();
|
|
407
|
+
return {
|
|
408
|
+
stepId,
|
|
409
|
+
layer,
|
|
410
|
+
status: "passed",
|
|
411
|
+
startedAt,
|
|
412
|
+
endedAt,
|
|
413
|
+
durationMs: durationBetween(startedAt, endedAt),
|
|
414
|
+
inputHash: hashInput(input),
|
|
415
|
+
captures,
|
|
416
|
+
evidence,
|
|
417
|
+
api,
|
|
418
|
+
unix
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private failedEvaluatedStep(stepId: string, layer: StepLayer, startedAt: string, input: unknown, captures: Record<string, unknown>, evidence: string[], message: string, api?: ApiStepEvidence, unix?: UnixStepEvidence): StepResult {
|
|
423
|
+
const endedAt = new Date().toISOString();
|
|
424
|
+
return {
|
|
425
|
+
stepId,
|
|
426
|
+
layer,
|
|
427
|
+
status: "failed",
|
|
428
|
+
startedAt,
|
|
429
|
+
endedAt,
|
|
430
|
+
durationMs: durationBetween(startedAt, endedAt),
|
|
431
|
+
inputHash: hashInput(input),
|
|
432
|
+
captures,
|
|
433
|
+
evidence,
|
|
434
|
+
api,
|
|
435
|
+
unix,
|
|
436
|
+
error: {
|
|
437
|
+
message,
|
|
438
|
+
rawOutput: api?.response
|
|
439
|
+
? `HTTP ${api.response.status} ${api.response.statusText}`
|
|
440
|
+
: unix
|
|
441
|
+
? unixFailureSummary(unix)
|
|
442
|
+
: undefined
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private skipStep(step: ScenarioStep, reason: string): StepResult {
|
|
448
|
+
const now = new Date().toISOString();
|
|
449
|
+
return {
|
|
450
|
+
stepId: step.id,
|
|
451
|
+
layer: this.layerFor(step),
|
|
452
|
+
status: "skipped",
|
|
453
|
+
startedAt: now,
|
|
454
|
+
endedAt: now,
|
|
455
|
+
durationMs: 0,
|
|
456
|
+
inputHash: hashInput({}),
|
|
457
|
+
captures: {},
|
|
458
|
+
evidence: [],
|
|
459
|
+
error: {
|
|
460
|
+
message: reason
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private async writeFailureCheckpoint(step: ScenarioStep, startedAt: string, input: unknown, error: unknown): Promise<string> {
|
|
466
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
467
|
+
const context = this.context.snapshot();
|
|
468
|
+
|
|
469
|
+
return await this.evidence.writeJson(`${step.id}.failure.json`, {
|
|
470
|
+
step,
|
|
471
|
+
input,
|
|
472
|
+
error: {
|
|
473
|
+
message: err.message,
|
|
474
|
+
stack: err.stack
|
|
475
|
+
},
|
|
476
|
+
context,
|
|
477
|
+
startedAt
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private layerFor(step: ScenarioStep): StepLayer {
|
|
482
|
+
if (step.control || step.via === "control") return "engine";
|
|
483
|
+
if (step.action === "db_assert" || step.action === "db_query" || step.action === "db_execute") return "db";
|
|
484
|
+
if (step.action === "unix_batch") return "unix";
|
|
485
|
+
if (step.via === "api") return "api";
|
|
486
|
+
return "engine";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private async dryRunCaptures(step: ScenarioStep): Promise<Record<string, unknown>> {
|
|
490
|
+
const captures: Record<string, unknown> = {};
|
|
491
|
+
if (step.via === "api") {
|
|
492
|
+
for (const name of Object.keys(this.catalogs.apiOperations[step.action]?.captures ?? {})) {
|
|
493
|
+
captures[name] = `<dry-run:${name}>`;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (step.query) {
|
|
497
|
+
for (const name of Object.keys(this.catalogs.queries[step.query]?.captures ?? {})) {
|
|
498
|
+
captures[name] = `<dry-run:${name}>`;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (step.batch) {
|
|
502
|
+
for (const name of Object.keys(this.catalogs.batches[step.batch]?.captures ?? {})) {
|
|
503
|
+
captures[name] = `<dry-run:${name}>`;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
for (const name of Object.keys(step.capture ?? {})) {
|
|
507
|
+
captures[name] = `<dry-run:${name}>`;
|
|
508
|
+
}
|
|
509
|
+
return captures;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private publishCaptures(stepId: string, captures: Record<string, unknown>): void {
|
|
513
|
+
for (const [name, value] of Object.entries(captures)) {
|
|
514
|
+
this.context.set(name, value);
|
|
515
|
+
this.context.set(`${stepId}.${name}`, value);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private isStopped(): boolean {
|
|
520
|
+
return Boolean(this.options.signal?.aborted);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function scopedLoopStep(step: ScenarioStep, loopId: string, index: number): ScenarioStep {
|
|
525
|
+
const scopedId = `${loopId}_${index}_${step.id}`;
|
|
526
|
+
if (step.control === "parallel" || step.control === "loop" || step.action === "__parallel" || step.action === "__loop") {
|
|
527
|
+
return {
|
|
528
|
+
...step,
|
|
529
|
+
id: scopedId,
|
|
530
|
+
branches: step.branches?.map((branch) => ({
|
|
531
|
+
...branch,
|
|
532
|
+
steps: branch.steps.map((child) => scopedLoopStep(child, loopId, index))
|
|
533
|
+
})),
|
|
534
|
+
steps: step.steps?.map((child) => scopedLoopStep(child, loopId, index))
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
...step,
|
|
539
|
+
id: scopedId
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function loopDateOutputName(cursor: LoopDateCursor): string {
|
|
544
|
+
return cursor.outputName?.trim() || "business_date";
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function computeLoopDateCursorValue(cursor: LoopDateCursor, index: number, resolve: <T>(value: T) => T): string {
|
|
548
|
+
const startValue = resolve(cursor.start ?? "");
|
|
549
|
+
if (typeof startValue !== "string" || !startValue.trim()) {
|
|
550
|
+
throw new Error("Loop business date start value is required.");
|
|
551
|
+
}
|
|
552
|
+
const inputFormat = cursor.inputFormat ?? detectLoopDateFormat(startValue);
|
|
553
|
+
const outputFormat = cursor.outputFormat ?? inputFormat;
|
|
554
|
+
const base = parseLoopDate(startValue, inputFormat);
|
|
555
|
+
const advance = cursor.advance ?? { mode: "months", amount: 1 };
|
|
556
|
+
const amount = Math.max(1, Number(advance.amount ?? 1));
|
|
557
|
+
let next: Date;
|
|
558
|
+
|
|
559
|
+
if (advance.mode === "days") {
|
|
560
|
+
next = addDays(base, amount * index);
|
|
561
|
+
} else if (advance.mode === "months") {
|
|
562
|
+
next = addMonthsClamped(base, amount * index);
|
|
563
|
+
} else {
|
|
564
|
+
const monthBase = addMonthsClamped(new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), 1)), amount * index);
|
|
565
|
+
if (advance.mode === "first_of_month") {
|
|
566
|
+
next = monthBase;
|
|
567
|
+
} else if (advance.mode === "end_of_month") {
|
|
568
|
+
next = new Date(Date.UTC(monthBase.getUTCFullYear(), monthBase.getUTCMonth(), daysInMonth(monthBase.getUTCFullYear(), monthBase.getUTCMonth())));
|
|
569
|
+
} else {
|
|
570
|
+
const requestedDay = Math.max(1, Math.min(31, Number(advance.day ?? base.getUTCDate())));
|
|
571
|
+
next = new Date(Date.UTC(monthBase.getUTCFullYear(), monthBase.getUTCMonth(), Math.min(requestedDay, daysInMonth(monthBase.getUTCFullYear(), monthBase.getUTCMonth()))));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return formatLoopDate(next, outputFormat);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function detectLoopDateFormat(value: string): LoopDateFormat {
|
|
579
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return "YYYY-MM-DD";
|
|
580
|
+
return "DD/MM/YYYY";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function parseLoopDate(value: string, format: LoopDateFormat): Date {
|
|
584
|
+
const trimmed = value.trim();
|
|
585
|
+
let year: number;
|
|
586
|
+
let month: number;
|
|
587
|
+
let day: number;
|
|
588
|
+
if (format === "YYYY-MM-DD") {
|
|
589
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
|
|
590
|
+
if (!match) throw new Error(`Loop business date '${value}' must match YYYY-MM-DD.`);
|
|
591
|
+
year = Number(match[1]);
|
|
592
|
+
month = Number(match[2]);
|
|
593
|
+
day = Number(match[3]);
|
|
594
|
+
} else {
|
|
595
|
+
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(trimmed);
|
|
596
|
+
if (!match) throw new Error(`Loop business date '${value}' must match ${format}.`);
|
|
597
|
+
year = Number(match[3]);
|
|
598
|
+
if (format === "DD/MM/YYYY") {
|
|
599
|
+
day = Number(match[1]);
|
|
600
|
+
month = Number(match[2]);
|
|
601
|
+
} else {
|
|
602
|
+
month = Number(match[1]);
|
|
603
|
+
day = Number(match[2]);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const maxDay = daysInMonth(year, month - 1);
|
|
608
|
+
if (month < 1 || month > 12 || day < 1 || day > maxDay) {
|
|
609
|
+
throw new Error(`Loop business date '${value}' is not a valid calendar date.`);
|
|
610
|
+
}
|
|
611
|
+
return new Date(Date.UTC(year, month - 1, day));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function formatLoopDate(date: Date, format: LoopDateFormat): string {
|
|
615
|
+
const year = String(date.getUTCFullYear()).padStart(4, "0");
|
|
616
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
617
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
618
|
+
if (format === "YYYY-MM-DD") return `${year}-${month}-${day}`;
|
|
619
|
+
if (format === "MM/DD/YYYY") return `${month}/${day}/${year}`;
|
|
620
|
+
return `${day}/${month}/${year}`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function addDays(date: Date, days: number): Date {
|
|
624
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function addMonthsClamped(date: Date, months: number): Date {
|
|
628
|
+
const targetMonth = date.getUTCMonth() + months;
|
|
629
|
+
const first = new Date(Date.UTC(date.getUTCFullYear(), targetMonth, 1));
|
|
630
|
+
const day = Math.min(date.getUTCDate(), daysInMonth(first.getUTCFullYear(), first.getUTCMonth()));
|
|
631
|
+
return new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), day));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function daysInMonth(year: number, zeroBasedMonth: number): number {
|
|
635
|
+
return new Date(Date.UTC(year, zeroBasedMonth + 1, 0)).getUTCDate();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
interface DispatchResult {
|
|
639
|
+
status: "passed" | "failed";
|
|
640
|
+
captures: Record<string, unknown>;
|
|
641
|
+
api?: ApiStepEvidence;
|
|
642
|
+
unix?: UnixStepEvidence;
|
|
643
|
+
message?: string;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function unixFailureSummary(unix: UnixStepEvidence): string | undefined {
|
|
647
|
+
const last = unix.attempts[unix.attempts.length - 1];
|
|
648
|
+
if (!last) return undefined;
|
|
649
|
+
const displayCommand = last.displayCommand ?? unix.displayCommand ?? last.command;
|
|
650
|
+
const parts = [
|
|
651
|
+
`Command: ${displayCommand}`,
|
|
652
|
+
last.command && last.command !== displayCommand
|
|
653
|
+
? `Shell-safe command: ${last.command}`
|
|
654
|
+
: undefined,
|
|
655
|
+
last.exitCode === undefined ? undefined : `Exit code: ${last.exitCode}`,
|
|
656
|
+
last.tracePath ? `Script trace path: ${last.tracePath}` : undefined,
|
|
657
|
+
last.errno ? `Script ERRNO: ${last.errno}` : undefined,
|
|
658
|
+
last.stdout ? `stdout:\n${last.stdout}` : undefined,
|
|
659
|
+
last.stderr ? `stderr:\n${last.stderr}` : undefined
|
|
660
|
+
].filter(Boolean);
|
|
661
|
+
return parts.length ? parts.join("\n\n") : undefined;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function stepEvidenceFolder(index: number, stepId: string): string {
|
|
665
|
+
return `steps/${String(index + 1).padStart(2, "0")}_${stepId}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function pickExplicitCaptures(data: unknown, captureSpec: Record<string, string> | undefined): Record<string, unknown> {
|
|
669
|
+
if (!captureSpec) return {};
|
|
670
|
+
return extractCaptures(data, captureSpec);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function evaluateCaptures(payload: unknown, captureSpec: Record<string, string> | undefined): { captures: Record<string, unknown>; error?: Error } {
|
|
674
|
+
try {
|
|
675
|
+
return { captures: extractCaptures(payload, captureSpec) };
|
|
676
|
+
} catch (error) {
|
|
677
|
+
return { captures: {}, error: error instanceof Error ? error : new Error(String(error)) };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function evaluateDbExpectation(action: string, entry: QueryCatalogEntry, rows: Record<string, unknown>[]): { error?: Error } {
|
|
682
|
+
if (action !== "db_assert") return {};
|
|
683
|
+
try {
|
|
684
|
+
assertQueryResult(entry, rows);
|
|
685
|
+
return {};
|
|
686
|
+
} catch (error) {
|
|
687
|
+
return { error: error instanceof Error ? error : new Error(String(error)) };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function errorSummary(error: Error | undefined): { message: string; stack?: string } | undefined {
|
|
692
|
+
return error ? { message: error.message, stack: error.stack } : undefined;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function dbQueryFailureMessage(payload: { query: string; rowCount?: number; rows?: Record<string, unknown>[] }, error: Error): string {
|
|
696
|
+
return `${error.message} ${dbPayloadDetails(payload)}`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function dbCaptureFailureMessage(payload: { query: string; rowCount?: number; rows?: Record<string, unknown>[] }, error: Error): string {
|
|
700
|
+
return `${error.message} ${dbPayloadDetails(payload)}`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function dbPayloadDetails(payload: { query: string; rowCount?: number; rows?: Record<string, unknown>[] }): string {
|
|
704
|
+
const rowCount = payload.rowCount ?? payload.rows?.length;
|
|
705
|
+
const columns = availableColumns(payload.rows ?? []);
|
|
706
|
+
const parts = [`DB query '${payload.query}' returned ${rowCount ?? 0} row${rowCount === 1 ? "" : "s"}.`];
|
|
707
|
+
if ((rowCount ?? 0) === 0) parts.push("No rows were available to capture from.");
|
|
708
|
+
if (columns.length > 0) parts.push(`Available columns: ${columns.join(", ")}.`);
|
|
709
|
+
return parts.join(" ");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function availableColumns(rows: Record<string, unknown>[]): string[] {
|
|
713
|
+
return [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export async function ensureEvidenceRoot(rootDir: string): Promise<void> {
|
|
717
|
+
await mkdir(join(rootDir, "evidence"), { recursive: true });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export function defaultRootDir(): string {
|
|
721
|
+
return join(fileURLToPath(new URL("../..", import.meta.url)));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function buildEvidenceManifest(result: RunResult, paths: {
|
|
725
|
+
runResultPath: string;
|
|
726
|
+
reportPath: string;
|
|
727
|
+
junitPath: string;
|
|
728
|
+
}): Record<string, unknown> {
|
|
729
|
+
return {
|
|
730
|
+
version: 1,
|
|
731
|
+
scenarioId: result.scenarioId,
|
|
732
|
+
runId: result.runId,
|
|
733
|
+
status: result.status,
|
|
734
|
+
evidenceModel: {
|
|
735
|
+
flowEvidenceDir: result.evidenceDir,
|
|
736
|
+
note: "Flow evidence contains scenario reports and per-step API, DB, and Unix evidence."
|
|
737
|
+
},
|
|
738
|
+
topLevelFiles: paths,
|
|
739
|
+
steps: result.steps.map((step) => ({
|
|
740
|
+
stepId: step.stepId,
|
|
741
|
+
layer: step.layer,
|
|
742
|
+
status: step.status,
|
|
743
|
+
evidence: step.evidence
|
|
744
|
+
}))
|
|
745
|
+
};
|
|
746
|
+
}
|