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,557 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
5
|
+
import axios from "axios";
|
|
6
|
+
import { JSONPath } from "jsonpath-plus";
|
|
7
|
+
import { evidenceVisibilityMode } from "../../config/secrets.js";
|
|
8
|
+
import { mergeApiRequest } from "./api-collections.js";
|
|
9
|
+
import { normalizeJsonRawBody } from "./body-utils.js";
|
|
10
|
+
const DEFAULT_RETRY_ATTEMPTS = 2;
|
|
11
|
+
const DEFAULT_RETRY_DELAY_MS = 500;
|
|
12
|
+
const TRANSIENT_STATUS_CODES = new Set([502, 503, 504]);
|
|
13
|
+
const TRANSIENT_NETWORK_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "ECONNABORTED"]);
|
|
14
|
+
const RESPONSE_PREVIEW_LIMIT_BYTES = 128_000;
|
|
15
|
+
export class RestClient {
|
|
16
|
+
env;
|
|
17
|
+
rootDir;
|
|
18
|
+
client;
|
|
19
|
+
constructor(env, rootDir) {
|
|
20
|
+
this.env = env;
|
|
21
|
+
this.rootDir = rootDir;
|
|
22
|
+
this.client = axios.create({
|
|
23
|
+
baseURL: env.apiBaseUrl,
|
|
24
|
+
timeout: 60_000,
|
|
25
|
+
httpsAgent: env.apiTlsInsecure ? new HttpsAgent({ rejectUnauthorized: false }) : undefined
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async execute(operation, input, requestOverride, assertions = [], explicitCaptures = {}, options = {}) {
|
|
29
|
+
if (!this.env.apiBaseUrl)
|
|
30
|
+
throw new Error("ADFINEM_API_BASE_URL is required for API execution.");
|
|
31
|
+
if (operation.type !== "rest")
|
|
32
|
+
throw new Error(`Operation type '${operation.type}' is not supported by RestClient.`);
|
|
33
|
+
const request = withGeneratedHeaders(renderRequestSpec(mergeApiRequest(operation, requestOverride), input));
|
|
34
|
+
if (!request.method || !request.path)
|
|
35
|
+
throw new Error("REST operation must define method and path.");
|
|
36
|
+
const body = operation.requestTemplate && !request.rawBody && request.body === undefined
|
|
37
|
+
? await this.loadTemplate(operation.requestTemplate, input)
|
|
38
|
+
: requestBody(request, input);
|
|
39
|
+
const response = await this.requestWithRetry(operation, request, body);
|
|
40
|
+
const responseEvidence = normalizeHttpResponse(response);
|
|
41
|
+
const expectedOutcome = options.expectedOutcome ?? "positive";
|
|
42
|
+
const acceptStatuses = normalizeAcceptStatuses(request);
|
|
43
|
+
const statusAccepted = isStatusAccepted(response.status, acceptStatuses);
|
|
44
|
+
const assertionResults = evaluateApiAssertions(response, [...(operation.assertions ?? []), ...assertions]);
|
|
45
|
+
const assertionsPassed = assertionResults.every((assertion) => assertion.passed);
|
|
46
|
+
const captureForEvidence = statusAccepted && assertionsPassed ? true : options.captureOnFailure !== false;
|
|
47
|
+
const evidenceCaptures = captureForEvidence
|
|
48
|
+
? extractJsonCaptureResults(response.data, operation.captures ?? {}, explicitCaptures)
|
|
49
|
+
: skippedCaptureResults(operation.captures ?? {}, explicitCaptures, "Step failed before capture publishing and captureOnFailure is false.");
|
|
50
|
+
const requiredCaptureFailures = evidenceCaptures.filter((capture) => capture.required && capture.status !== "extracted" && captureForEvidence);
|
|
51
|
+
const passed = statusAccepted && assertionsPassed && requiredCaptureFailures.length === 0;
|
|
52
|
+
const publishedCaptures = passed ? captureResultsToRecord(evidenceCaptures) : {};
|
|
53
|
+
for (const capture of evidenceCaptures) {
|
|
54
|
+
capture.published = Object.prototype.hasOwnProperty.call(publishedCaptures, capture.name);
|
|
55
|
+
}
|
|
56
|
+
const failureReason = passed
|
|
57
|
+
? undefined
|
|
58
|
+
: failureReasonFor(response.status, acceptStatuses, assertionResults, requiredCaptureFailures);
|
|
59
|
+
const requestEvidence = requestEvidenceFor(request, body);
|
|
60
|
+
const apiEvidence = {
|
|
61
|
+
visibility: options.visibility ?? evidenceVisibilityMode(),
|
|
62
|
+
expectedOutcome,
|
|
63
|
+
acceptStatuses,
|
|
64
|
+
statusAccepted,
|
|
65
|
+
request: requestEvidence,
|
|
66
|
+
resolvedRequest: requestEvidence,
|
|
67
|
+
response: responseEvidence,
|
|
68
|
+
assertionResults,
|
|
69
|
+
evidenceCaptures,
|
|
70
|
+
publishedCaptures,
|
|
71
|
+
finalStatus: passed ? "passed" : "failed",
|
|
72
|
+
failureReason
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
response: response.data,
|
|
76
|
+
captures: publishedCaptures,
|
|
77
|
+
evidencePayload: apiEvidence,
|
|
78
|
+
apiEvidence
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async requestWithRetry(operation, request, body) {
|
|
82
|
+
const method = request.method;
|
|
83
|
+
const allowRetry = operation.idempotent !== false;
|
|
84
|
+
const maxAttempts = allowRetry ? DEFAULT_RETRY_ATTEMPTS : 0;
|
|
85
|
+
let lastError;
|
|
86
|
+
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
|
|
87
|
+
try {
|
|
88
|
+
const started = Date.now();
|
|
89
|
+
const response = await this.client.request({
|
|
90
|
+
method,
|
|
91
|
+
url: requestPathForBase(request.path, this.env.apiBaseUrl),
|
|
92
|
+
headers: request.headers,
|
|
93
|
+
data: ["POST", "PUT", "PATCH"].includes(method) ? body : undefined,
|
|
94
|
+
params: method === "GET" || method === "DELETE" || method === "HEAD"
|
|
95
|
+
? { ...(request.query ?? {}), ...(body && typeof body === "object" && !Array.isArray(body) ? body : {}) }
|
|
96
|
+
: request.query,
|
|
97
|
+
validateStatus: () => true
|
|
98
|
+
});
|
|
99
|
+
response.durationMs = Date.now() - started;
|
|
100
|
+
if (allowRetry && TRANSIENT_STATUS_CODES.has(response.status) && attempt < maxAttempts) {
|
|
101
|
+
await sleep(DEFAULT_RETRY_DELAY_MS * (attempt + 1));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
return response;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
lastError = error;
|
|
108
|
+
if (!allowRetry || !isTransientError(error) || attempt >= maxAttempts)
|
|
109
|
+
throw error;
|
|
110
|
+
await sleep(DEFAULT_RETRY_DELAY_MS * (attempt + 1));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
throw lastError ?? new Error("REST request failed without an error instance.");
|
|
114
|
+
}
|
|
115
|
+
async loadTemplate(relativePath, input) {
|
|
116
|
+
const path = join(this.rootDir, relativePath);
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = await readFile(path, "utf8");
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const err = error;
|
|
123
|
+
if (err.code === "ENOENT") {
|
|
124
|
+
throw new Error(`API request template '${relativePath}' was not found at ${path}.`);
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
const rendered = raw.replace(/\$\{([A-Za-z0-9_.-]+)\}/g, (_match, name) => {
|
|
129
|
+
const value = input[name];
|
|
130
|
+
if (value === undefined || value === null)
|
|
131
|
+
throw new Error(`API template variable '${name}' was not supplied.`);
|
|
132
|
+
return jsonEscape(String(value));
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(rendered);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
139
|
+
throw new Error(`API request template '${relativePath}' did not parse as JSON after substitution: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function requestEvidenceFor(request, body) {
|
|
144
|
+
return {
|
|
145
|
+
method: request.method,
|
|
146
|
+
path: request.path,
|
|
147
|
+
headers: request.headers,
|
|
148
|
+
query: request.query,
|
|
149
|
+
auth: request.auth,
|
|
150
|
+
body
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function normalizeHttpResponse(response) {
|
|
154
|
+
const contentType = headerValue(response, "content-type") ?? "";
|
|
155
|
+
const { body, bodyText, bodyJson, sizeBytes, bodyTruncated, bodyPreviewKind } = responseBodyPreview(response.data, contentType);
|
|
156
|
+
return {
|
|
157
|
+
status: response.status,
|
|
158
|
+
statusText: response.statusText,
|
|
159
|
+
headers: Object.fromEntries(Object.entries(response.headers ?? {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value])),
|
|
160
|
+
body,
|
|
161
|
+
bodyText,
|
|
162
|
+
bodyJson,
|
|
163
|
+
contentType,
|
|
164
|
+
durationMs: response.durationMs ?? 0,
|
|
165
|
+
sizeBytes,
|
|
166
|
+
bodyTruncated,
|
|
167
|
+
bodyPreviewKind
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function responseBodyPreview(data, contentType) {
|
|
171
|
+
if (data === undefined || data === null || data === "") {
|
|
172
|
+
return { body: data, sizeBytes: 0, bodyPreviewKind: "empty" };
|
|
173
|
+
}
|
|
174
|
+
const binary = isBinaryContentType(contentType);
|
|
175
|
+
const rawText = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
176
|
+
const sizeBytes = Buffer.byteLength(rawText ?? "", "utf8");
|
|
177
|
+
const bodyTruncated = sizeBytes > RESPONSE_PREVIEW_LIMIT_BYTES;
|
|
178
|
+
if (binary) {
|
|
179
|
+
return {
|
|
180
|
+
body: `[binary ${contentType || "application/octet-stream"}; ${sizeBytes} bytes]`,
|
|
181
|
+
bodyText: undefined,
|
|
182
|
+
bodyJson: undefined,
|
|
183
|
+
sizeBytes,
|
|
184
|
+
bodyTruncated,
|
|
185
|
+
bodyPreviewKind: "binary"
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const previewText = bodyTruncated ? rawText.slice(0, RESPONSE_PREVIEW_LIMIT_BYTES) : rawText;
|
|
189
|
+
if (typeof data === "object") {
|
|
190
|
+
return {
|
|
191
|
+
body: bodyTruncated ? previewText : data,
|
|
192
|
+
bodyText: previewText,
|
|
193
|
+
bodyJson: bodyTruncated ? undefined : data,
|
|
194
|
+
sizeBytes,
|
|
195
|
+
bodyTruncated,
|
|
196
|
+
bodyPreviewKind: "json"
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (looksLikeJsonText(data)) {
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(data);
|
|
202
|
+
return {
|
|
203
|
+
body: bodyTruncated ? previewText : parsed,
|
|
204
|
+
bodyText: previewText,
|
|
205
|
+
bodyJson: bodyTruncated ? undefined : parsed,
|
|
206
|
+
sizeBytes,
|
|
207
|
+
bodyTruncated,
|
|
208
|
+
bodyPreviewKind: "json"
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Fall through to text.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
body: previewText,
|
|
217
|
+
bodyText: previewText,
|
|
218
|
+
sizeBytes,
|
|
219
|
+
bodyTruncated,
|
|
220
|
+
bodyPreviewKind: "text"
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function isBinaryContentType(contentType) {
|
|
224
|
+
const normalized = contentType.toLowerCase();
|
|
225
|
+
if (!normalized)
|
|
226
|
+
return false;
|
|
227
|
+
if (normalized.includes("json") || normalized.startsWith("text/") || normalized.includes("xml") || normalized.includes("html"))
|
|
228
|
+
return false;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
function looksLikeJsonText(value) {
|
|
232
|
+
return typeof value === "string" && /^[\s\r\n]*[{\[]/.test(value);
|
|
233
|
+
}
|
|
234
|
+
function normalizeAcceptStatuses(request) {
|
|
235
|
+
return [...new Set([...(request.acceptStatuses ?? request.acceptedStatuses ?? [])]
|
|
236
|
+
.map((status) => Number(status))
|
|
237
|
+
.filter((status) => Number.isInteger(status) && status >= 100 && status <= 599))];
|
|
238
|
+
}
|
|
239
|
+
function isStatusAccepted(status, acceptStatuses) {
|
|
240
|
+
return acceptStatuses.length > 0 ? acceptStatuses.includes(status) : status >= 200 && status < 300;
|
|
241
|
+
}
|
|
242
|
+
function failureReasonFor(status, acceptStatuses, assertions, captureFailures) {
|
|
243
|
+
if (!isStatusAccepted(status, acceptStatuses)) {
|
|
244
|
+
return `Status ${status} is not in Accepted statuses ${acceptStatuses.length ? `[${acceptStatuses.join(", ")}]` : "2xx"}.`;
|
|
245
|
+
}
|
|
246
|
+
const failedAssertion = assertions.find((assertion) => !assertion.passed);
|
|
247
|
+
if (failedAssertion)
|
|
248
|
+
return failedAssertion.message ?? "One or more response assertions failed.";
|
|
249
|
+
const failedCapture = captureFailures[0];
|
|
250
|
+
if (failedCapture)
|
|
251
|
+
return failedCapture.message ?? `Required capture '${failedCapture.name}' was not extracted.`;
|
|
252
|
+
return "API step failed.";
|
|
253
|
+
}
|
|
254
|
+
function jsonEscape(value) {
|
|
255
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
256
|
+
}
|
|
257
|
+
function isTransientError(error) {
|
|
258
|
+
if (!error || typeof error !== "object")
|
|
259
|
+
return false;
|
|
260
|
+
const candidate = error;
|
|
261
|
+
if (candidate.response?.status && TRANSIENT_STATUS_CODES.has(candidate.response.status))
|
|
262
|
+
return true;
|
|
263
|
+
return Boolean(candidate.code && TRANSIENT_NETWORK_CODES.has(candidate.code));
|
|
264
|
+
}
|
|
265
|
+
function sleep(ms) {
|
|
266
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
267
|
+
}
|
|
268
|
+
function requestBody(request, input) {
|
|
269
|
+
if (request.rawBody !== undefined) {
|
|
270
|
+
const rawBody = request.bodyMode === "json" ? normalizeJsonRawBody(request.rawBody) : request.rawBody;
|
|
271
|
+
const rendered = renderTemplateString(rawBody, input);
|
|
272
|
+
if (request.bodyMode === "json") {
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(rendered);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
278
|
+
throw new Error(`API request JSON body did not parse after variable substitution: ${err.message}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return rendered;
|
|
282
|
+
}
|
|
283
|
+
if (request.body !== undefined)
|
|
284
|
+
return renderValue(request.body, input);
|
|
285
|
+
return input;
|
|
286
|
+
}
|
|
287
|
+
function renderRequestSpec(request, input) {
|
|
288
|
+
return renderValue(request, input);
|
|
289
|
+
}
|
|
290
|
+
function renderValue(value, input) {
|
|
291
|
+
if (typeof value === "string")
|
|
292
|
+
return renderTemplateString(value, input);
|
|
293
|
+
if (Array.isArray(value))
|
|
294
|
+
return value.map((entry) => renderValue(entry, input));
|
|
295
|
+
if (value && typeof value === "object") {
|
|
296
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, renderValue(entry, input)]));
|
|
297
|
+
}
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
function renderTemplateString(value, input) {
|
|
301
|
+
return value
|
|
302
|
+
.replace(/\$\{([A-Za-z0-9_.-]+)\}/g, (_match, name) => renderedInputValue(input, name))
|
|
303
|
+
.replace(/\{\{([A-Za-z0-9_$.-]+)\}\}/g, (_match, name) => renderedInputValue(input, name));
|
|
304
|
+
}
|
|
305
|
+
function renderedInputValue(input, name) {
|
|
306
|
+
const generated = postmanGeneratedValue(name);
|
|
307
|
+
if (generated !== undefined)
|
|
308
|
+
return generated;
|
|
309
|
+
const value = input[name];
|
|
310
|
+
if (value === undefined || value === null)
|
|
311
|
+
throw new Error(`API request variable '${name}' was not supplied.`);
|
|
312
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
313
|
+
}
|
|
314
|
+
function postmanGeneratedValue(name) {
|
|
315
|
+
if (name === "$guid" || name === "$randomUUID")
|
|
316
|
+
return randomUUID();
|
|
317
|
+
if (name === "$timestamp")
|
|
318
|
+
return String(Math.floor(Date.now() / 1000));
|
|
319
|
+
if (name === "$isoTimestamp")
|
|
320
|
+
return new Date().toISOString();
|
|
321
|
+
if (name === "$randomInt")
|
|
322
|
+
return String(Math.floor(Math.random() * 1000));
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
export function withGeneratedHeaders(request) {
|
|
326
|
+
const headers = {
|
|
327
|
+
...generatedHeadersFor(request),
|
|
328
|
+
...(request.headers ?? {})
|
|
329
|
+
};
|
|
330
|
+
return Object.keys(headers).length ? { ...request, headers } : request;
|
|
331
|
+
}
|
|
332
|
+
function generatedHeadersFor(request) {
|
|
333
|
+
const headers = { Accept: "*/*", "Cache-Control": "no-cache" };
|
|
334
|
+
if (request.bodyMode === "json")
|
|
335
|
+
headers["Content-Type"] = "application/json";
|
|
336
|
+
if (request.bodyMode === "urlencoded")
|
|
337
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
338
|
+
return headers;
|
|
339
|
+
}
|
|
340
|
+
export function requestPathForBase(path, baseUrl) {
|
|
341
|
+
if (!baseUrl || !path.startsWith("/"))
|
|
342
|
+
return path;
|
|
343
|
+
try {
|
|
344
|
+
const basePath = new URL(baseUrl).pathname.replace(/\/+$/, "");
|
|
345
|
+
if (!basePath || basePath === "/")
|
|
346
|
+
return path;
|
|
347
|
+
const lowerPath = path.toLowerCase();
|
|
348
|
+
const lowerBase = basePath.toLowerCase();
|
|
349
|
+
if (lowerPath === lowerBase)
|
|
350
|
+
return "/";
|
|
351
|
+
if (lowerPath.startsWith(`${lowerBase}/`))
|
|
352
|
+
return path.slice(basePath.length) || "/";
|
|
353
|
+
return path;
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return path;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
export function evaluateApiAssertions(response, assertions) {
|
|
360
|
+
return assertions.map((assertion) => evaluateApiAssertion(response, assertion));
|
|
361
|
+
}
|
|
362
|
+
function evaluateApiAssertion(response, assertion) {
|
|
363
|
+
try {
|
|
364
|
+
if (assertion.type === "status") {
|
|
365
|
+
const accepted = Array.isArray(assertion.value) ? assertion.value : [assertion.value];
|
|
366
|
+
const operator = assertion.operator ?? (Array.isArray(assertion.value) ? "in" : "=");
|
|
367
|
+
const passed = operator === "in" ? accepted.includes(response.status) : response.status === assertion.value;
|
|
368
|
+
return {
|
|
369
|
+
assertion,
|
|
370
|
+
passed,
|
|
371
|
+
expected: accepted,
|
|
372
|
+
actual: response.status,
|
|
373
|
+
message: passed ? undefined : `API status assertion failed: got ${response.status}, expected ${accepted.join(", ")}.`
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (assertion.type === "jsonpath_exists") {
|
|
377
|
+
const values = jsonPathValues(response.data, assertion.path);
|
|
378
|
+
return {
|
|
379
|
+
assertion,
|
|
380
|
+
passed: values.length > 0,
|
|
381
|
+
expected: "exists",
|
|
382
|
+
actual: values.length,
|
|
383
|
+
message: values.length > 0 ? undefined : `API JSONPath assertion failed: '${assertion.path}' did not match.`
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (assertion.type === "jsonpath_equals") {
|
|
387
|
+
const values = jsonPathValues(response.data, assertion.path);
|
|
388
|
+
const passed = values.some((value) => stableJson(value) === stableJson(assertion.value));
|
|
389
|
+
return {
|
|
390
|
+
assertion,
|
|
391
|
+
passed,
|
|
392
|
+
expected: assertion.value,
|
|
393
|
+
actual: values.length === 1 ? values[0] : values,
|
|
394
|
+
message: passed ? undefined : `API JSONPath assertion failed: '${assertion.path}' did not equal ${JSON.stringify(assertion.value)}.`
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
if (assertion.type === "jsonpath_contains") {
|
|
398
|
+
const values = jsonPathValues(response.data, assertion.path);
|
|
399
|
+
const passed = values.some((value) => String(value).includes(String(assertion.value)));
|
|
400
|
+
return {
|
|
401
|
+
assertion,
|
|
402
|
+
passed,
|
|
403
|
+
expected: assertion.value,
|
|
404
|
+
actual: values.length === 1 ? values[0] : values,
|
|
405
|
+
message: passed ? undefined : `API JSONPath assertion failed: '${assertion.path}' did not contain ${JSON.stringify(assertion.value)}.`
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (assertion.type === "header_exists") {
|
|
409
|
+
const actual = headerValue(response, assertion.header);
|
|
410
|
+
return {
|
|
411
|
+
assertion,
|
|
412
|
+
passed: actual !== undefined,
|
|
413
|
+
expected: "present",
|
|
414
|
+
actual,
|
|
415
|
+
message: actual !== undefined ? undefined : `API header assertion failed: '${assertion.header}' was not present.`
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (assertion.type === "header_equals") {
|
|
419
|
+
const actual = headerValue(response, assertion.header);
|
|
420
|
+
return {
|
|
421
|
+
assertion,
|
|
422
|
+
passed: actual === assertion.value,
|
|
423
|
+
expected: assertion.value,
|
|
424
|
+
actual,
|
|
425
|
+
message: actual === assertion.value ? undefined : `API header assertion failed: '${assertion.header}' was ${JSON.stringify(actual)}, expected ${JSON.stringify(assertion.value)}.`
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (assertion.type === "body_contains" || assertion.type === "body_not_contains") {
|
|
429
|
+
const body = typeof response.data === "string" ? response.data : JSON.stringify(response.data);
|
|
430
|
+
const contains = body.includes(assertion.value);
|
|
431
|
+
const passed = assertion.type === "body_contains" ? contains : !contains;
|
|
432
|
+
return {
|
|
433
|
+
assertion,
|
|
434
|
+
passed,
|
|
435
|
+
expected: assertion.type === "body_contains" ? `contains ${assertion.value}` : `does not contain ${assertion.value}`,
|
|
436
|
+
actual: contains ? "contains" : "does not contain",
|
|
437
|
+
message: passed ? undefined : `API body assertion failed: body ${assertion.type === "body_contains" ? "did not contain" : "contained"} ${JSON.stringify(assertion.value)}.`
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
443
|
+
return { assertion, passed: false, message: err.message };
|
|
444
|
+
}
|
|
445
|
+
return { assertion, passed: false, message: `Unsupported assertion type '${assertion.type ?? "unknown"}'.` };
|
|
446
|
+
}
|
|
447
|
+
export function assertApiResponse(response, assertions) {
|
|
448
|
+
const failed = evaluateApiAssertions(response, assertions).find((result) => !result.passed);
|
|
449
|
+
if (failed)
|
|
450
|
+
throw new Error(failed.message ?? "API response assertion failed.");
|
|
451
|
+
}
|
|
452
|
+
function jsonPathValues(data, path) {
|
|
453
|
+
return JSONPath({ path, json: data, wrap: true });
|
|
454
|
+
}
|
|
455
|
+
function headerValue(response, name) {
|
|
456
|
+
const normalized = name.toLowerCase();
|
|
457
|
+
for (const [header, value] of Object.entries(response.headers ?? {})) {
|
|
458
|
+
if (header.toLowerCase() !== normalized)
|
|
459
|
+
continue;
|
|
460
|
+
return Array.isArray(value) ? value.join(", ") : value === undefined ? undefined : String(value);
|
|
461
|
+
}
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
function stableJson(value) {
|
|
465
|
+
if (!value || typeof value !== "object")
|
|
466
|
+
return JSON.stringify(value);
|
|
467
|
+
if (Array.isArray(value))
|
|
468
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
469
|
+
return `{${Object.entries(value)
|
|
470
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
471
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
|
472
|
+
.join(",")}}`;
|
|
473
|
+
}
|
|
474
|
+
export function extractJsonCaptures(data, captureSpec) {
|
|
475
|
+
return captureResultsToRecord(extractJsonCaptureResults(data, {}, captureSpec));
|
|
476
|
+
}
|
|
477
|
+
export function extractJsonCaptureResults(data, catalogCaptures, explicitCaptures) {
|
|
478
|
+
const results = [];
|
|
479
|
+
const explicit = new Set(Object.keys(explicitCaptures));
|
|
480
|
+
for (const [name, rawExpr] of Object.entries({ ...catalogCaptures, ...explicitCaptures })) {
|
|
481
|
+
const parsed = parseCaptureExpression(rawExpr);
|
|
482
|
+
const required = explicit.has(name) && parsed.required;
|
|
483
|
+
try {
|
|
484
|
+
const result = JSONPath({ path: parsed.path, json: data, wrap: true });
|
|
485
|
+
if (result.length === 0) {
|
|
486
|
+
results.push({
|
|
487
|
+
name,
|
|
488
|
+
expression: rawExpr,
|
|
489
|
+
source: "bodyJson",
|
|
490
|
+
required,
|
|
491
|
+
status: "missing",
|
|
492
|
+
published: false,
|
|
493
|
+
message: `Capture '${name}' did not match '${parsed.path}'.${responseFieldHint(data)}`
|
|
494
|
+
});
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const value = parsed.mode === "array" ? result : parsed.mode === "scalar" ? result[0] : result.length === 1 ? result[0] : result;
|
|
498
|
+
results.push({ name, expression: rawExpr, source: "bodyJson", required, status: "extracted", published: false, value });
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
502
|
+
results.push({ name, expression: rawExpr, source: "bodyJson", required, status: "error", published: false, message: err.message });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return results;
|
|
506
|
+
}
|
|
507
|
+
function skippedCaptureResults(catalogCaptures, explicitCaptures, message) {
|
|
508
|
+
const explicit = new Set(Object.keys(explicitCaptures));
|
|
509
|
+
return Object.entries({ ...catalogCaptures, ...explicitCaptures }).map(([name, expression]) => ({
|
|
510
|
+
name,
|
|
511
|
+
expression,
|
|
512
|
+
source: "bodyJson",
|
|
513
|
+
required: explicit.has(name) && !expression.trim().startsWith("optional:"),
|
|
514
|
+
status: "missing",
|
|
515
|
+
published: false,
|
|
516
|
+
message
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
function captureResultsToRecord(results) {
|
|
520
|
+
return Object.fromEntries(results
|
|
521
|
+
.filter((result) => result.status === "extracted")
|
|
522
|
+
.map((result) => [result.name, result.value]));
|
|
523
|
+
}
|
|
524
|
+
function parseCaptureExpression(expression) {
|
|
525
|
+
let trimmed = expression.trim();
|
|
526
|
+
let required = true;
|
|
527
|
+
if (trimmed.startsWith("optional:")) {
|
|
528
|
+
required = false;
|
|
529
|
+
trimmed = trimmed.slice("optional:".length).trim();
|
|
530
|
+
}
|
|
531
|
+
if (trimmed.startsWith("array:"))
|
|
532
|
+
return { path: trimmed.slice("array:".length).trim(), mode: "array", required };
|
|
533
|
+
if (trimmed.startsWith("scalar:"))
|
|
534
|
+
return { path: trimmed.slice("scalar:".length).trim(), mode: "scalar", required };
|
|
535
|
+
return { path: trimmed, mode: "auto", required };
|
|
536
|
+
}
|
|
537
|
+
function responseFieldHint(data) {
|
|
538
|
+
const hints = collectJsonPathHints(data, "$", 0).slice(0, 10);
|
|
539
|
+
return hints.length ? ` Available response fields include: ${hints.join(", ")}.` : "";
|
|
540
|
+
}
|
|
541
|
+
function collectJsonPathHints(value, prefix, depth) {
|
|
542
|
+
if (!value || typeof value !== "object" || depth > 1)
|
|
543
|
+
return [];
|
|
544
|
+
if (Array.isArray(value)) {
|
|
545
|
+
const first = value[0];
|
|
546
|
+
return first && typeof first === "object" ? collectJsonPathHints(first, `${prefix}[0]`, depth + 1) : [];
|
|
547
|
+
}
|
|
548
|
+
const hints = [];
|
|
549
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
550
|
+
const path = `${prefix}.${key}`;
|
|
551
|
+
hints.push(path);
|
|
552
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
553
|
+
hints.push(...collectJsonPathHints(entry, path, depth + 1));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return hints;
|
|
557
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export function assertQueryResult(entry, rows) {
|
|
2
|
+
const expectation = entry.expect;
|
|
3
|
+
if (!expectation)
|
|
4
|
+
return;
|
|
5
|
+
let actual;
|
|
6
|
+
if (expectation.type === "rowCount") {
|
|
7
|
+
actual = rows.length;
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
if (!expectation.column) {
|
|
11
|
+
throw new Error(`DB expectation type '${expectation.type}' requires a column.`);
|
|
12
|
+
}
|
|
13
|
+
if (rows.length === 0) {
|
|
14
|
+
throw new Error(`DB assertion '${expectation.column} ${expectation.operator} ${formatValue(expectation.value)}' failed: query returned no rows.`);
|
|
15
|
+
}
|
|
16
|
+
actual = rows[0][expectation.column];
|
|
17
|
+
}
|
|
18
|
+
if (!compare(actual, expectation.operator, expectation.value)) {
|
|
19
|
+
const target = expectation.type === "rowCount" ? "rowCount" : expectation.column;
|
|
20
|
+
throw new Error(`DB assertion failed: expected ${target} ${expectation.operator} ${formatValue(expectation.value)}, got ${formatValue(actual)}.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function compare(actual, operator, expected) {
|
|
24
|
+
switch (operator) {
|
|
25
|
+
case "=": return equalsLoose(actual, expected);
|
|
26
|
+
case "!=": return !equalsLoose(actual, expected);
|
|
27
|
+
case ">": return numericCompare(actual, expected, (a, b) => a > b);
|
|
28
|
+
case ">=": return numericCompare(actual, expected, (a, b) => a >= b);
|
|
29
|
+
case "<": return numericCompare(actual, expected, (a, b) => a < b);
|
|
30
|
+
case "<=": return numericCompare(actual, expected, (a, b) => a <= b);
|
|
31
|
+
case "contains": {
|
|
32
|
+
if (actual === null || actual === undefined)
|
|
33
|
+
return false;
|
|
34
|
+
const actualNumber = toFiniteNumber(actual);
|
|
35
|
+
const expectedNumber = toFiniteNumber(expected);
|
|
36
|
+
if (actualNumber !== undefined && expectedNumber !== undefined) {
|
|
37
|
+
return String(actualNumber).includes(String(expectedNumber));
|
|
38
|
+
}
|
|
39
|
+
return String(actual).includes(String(expected ?? ""));
|
|
40
|
+
}
|
|
41
|
+
default: throw new Error(`Unsupported assertion operator '${operator}'.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function equalsLoose(actual, expected) {
|
|
45
|
+
if (actual === expected)
|
|
46
|
+
return true;
|
|
47
|
+
if (actual === null || actual === undefined)
|
|
48
|
+
return expected === actual;
|
|
49
|
+
if (expected === null || expected === undefined)
|
|
50
|
+
return false;
|
|
51
|
+
const actualNumber = toFiniteNumber(actual);
|
|
52
|
+
const expectedNumber = toFiniteNumber(expected);
|
|
53
|
+
if (actualNumber !== undefined && expectedNumber !== undefined) {
|
|
54
|
+
return actualNumber === expectedNumber;
|
|
55
|
+
}
|
|
56
|
+
return String(actual) === String(expected);
|
|
57
|
+
}
|
|
58
|
+
function numericCompare(actual, expected, op) {
|
|
59
|
+
const actualNumber = toFiniteNumber(actual);
|
|
60
|
+
const expectedNumber = toFiniteNumber(expected);
|
|
61
|
+
if (actualNumber === undefined || expectedNumber === undefined)
|
|
62
|
+
return false;
|
|
63
|
+
return op(actualNumber, expectedNumber);
|
|
64
|
+
}
|
|
65
|
+
function toFiniteNumber(value) {
|
|
66
|
+
if (typeof value === "number")
|
|
67
|
+
return Number.isFinite(value) ? value : undefined;
|
|
68
|
+
if (typeof value === "boolean")
|
|
69
|
+
return value ? 1 : 0;
|
|
70
|
+
if (typeof value === "string") {
|
|
71
|
+
const trimmed = value.trim();
|
|
72
|
+
if (!trimmed)
|
|
73
|
+
return undefined;
|
|
74
|
+
const parsed = Number(trimmed);
|
|
75
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
function formatValue(value) {
|
|
80
|
+
if (value === null)
|
|
81
|
+
return "null";
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
return "undefined";
|
|
84
|
+
if (typeof value === "string")
|
|
85
|
+
return JSON.stringify(value);
|
|
86
|
+
return String(value);
|
|
87
|
+
}
|