yamltest 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -2
- package/package.json +3 -1
- package/src/core.js +5 -1
- package/src/index.js +4 -0
- package/src/runner.js +5 -0
- package/src/validate.js +628 -0
package/README.md
CHANGED
|
@@ -82,10 +82,31 @@ Exit codes: `0` = all passed, `1` = one or more failed.
|
|
|
82
82
|
|
|
83
83
|
---
|
|
84
84
|
|
|
85
|
+
## Input validation
|
|
86
|
+
|
|
87
|
+
All test definitions are validated against a strict schema **before** any test executes. Invalid input produces clear error messages pointing to the exact location of the problem:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
Validation failed:
|
|
91
|
+
|
|
92
|
+
Test #1 ("login") /http/method: must be one of: GET, POST, PUT, DELETE, PATCH
|
|
93
|
+
Test #3: must define exactly one of: http, command, wait, httpBodyComparison
|
|
94
|
+
Test #5 ("check pods") /source: missing required property "selector"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Validation checks include:
|
|
98
|
+
- Exactly one test type per definition (`http`, `command`, `wait`, or `httpBodyComparison`)
|
|
99
|
+
- Required fields per test type (e.g., `command.command`, `wait.target`)
|
|
100
|
+
- Value types and allowed enums (e.g., `source.type` must be `local` or `pod`)
|
|
101
|
+
- Conditional requirements (e.g., `source.type: pod` requires `selector`; `setVars` requires `expect` for HTTP/command tests)
|
|
102
|
+
- Unknown property detection on sub-objects (catches typos like `htpp` instead of `http`)
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
85
106
|
## Programmatic API
|
|
86
107
|
|
|
87
108
|
```js
|
|
88
|
-
const { runTests, executeTest } = require('yamltest');
|
|
109
|
+
const { runTests, executeTest, validateTestDefinitions } = require('yamltest');
|
|
89
110
|
|
|
90
111
|
// Run one or more tests from a YAML string (array or single object)
|
|
91
112
|
const result = await runTests(yamlString);
|
|
@@ -94,6 +115,13 @@ console.log(result.passed, result.failed, result.skipped, result.total);
|
|
|
94
115
|
|
|
95
116
|
// Run a single test (low-level)
|
|
96
117
|
await executeTest(yamlString); // returns true or throws
|
|
118
|
+
|
|
119
|
+
// Validate test definitions without executing them
|
|
120
|
+
const yaml = require('js-yaml');
|
|
121
|
+
const definitions = Array.isArray(yaml.load(yamlString))
|
|
122
|
+
? yaml.load(yamlString)
|
|
123
|
+
: [yaml.load(yamlString)];
|
|
124
|
+
validateTestDefinitions(definitions); // throws on validation errors
|
|
97
125
|
```
|
|
98
126
|
|
|
99
127
|
---
|
|
@@ -550,7 +578,8 @@ Prints full request/response details, comparison results, and kubectl commands.
|
|
|
550
578
|
```
|
|
551
579
|
src/
|
|
552
580
|
core.js # Test execution engine (HTTP, command, wait, comparison)
|
|
553
|
-
runner.js # Multi-test orchestration (YAML parsing, fail-fast, retry)
|
|
581
|
+
runner.js # Multi-test orchestration (YAML parsing, validation, fail-fast, retry)
|
|
582
|
+
validate.js # JSON Schema validation (Ajv)
|
|
554
583
|
index.js # Public API
|
|
555
584
|
cli.js # YAMLTest binary entry point
|
|
556
585
|
test/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yamltest",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Declarative YAML-based HTTP, command, and Kubernetes test runner",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"author": "",
|
|
35
35
|
"license": "MIT",
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"ajv": "^8.18.0",
|
|
38
|
+
"ajv-errors": "^3.0.0",
|
|
37
39
|
"axios": "^1.6.0",
|
|
38
40
|
"deep-diff": "^1.0.2",
|
|
39
41
|
"fs-extra": "^11.2.0",
|
package/src/core.js
CHANGED
|
@@ -81,6 +81,10 @@ async function executeTest(yamlDefinition) {
|
|
|
81
81
|
throw new Error('Invalid test definition: expected a YAML object');
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Validate against schema before execution
|
|
85
|
+
const { validateTestDefinitions } = require('./validate');
|
|
86
|
+
validateTestDefinitions([testConfig]);
|
|
87
|
+
|
|
84
88
|
// Dispatch to the appropriate test function based on what's defined
|
|
85
89
|
if (testConfig.http) {
|
|
86
90
|
debugLog('Detected HTTP test, dispatching to executeHttpTest');
|
|
@@ -612,7 +616,7 @@ async function executeHttpTest(test) {
|
|
|
612
616
|
test.http.url = `${parsedUrl.protocol}//${parsedUrl.host}`;
|
|
613
617
|
// Prepend the extracted path to any explicit path (avoid double slashes)
|
|
614
618
|
const explicitPath = test.http.path || '';
|
|
615
|
-
test.http.path = urlPath.replace(/\/$/, '') + (explicitPath.startsWith('/') ? explicitPath : '/' + explicitPath);
|
|
619
|
+
test.http.path = urlPath.replace(/\/$/, '') + (explicitPath ? (explicitPath.startsWith('/') ? explicitPath : '/' + explicitPath) : '');
|
|
616
620
|
}
|
|
617
621
|
}
|
|
618
622
|
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
// Multi-test orchestration layer
|
|
19
19
|
const { runTests, parseTestDefinitions, runSingleTest } = require('./runner');
|
|
20
20
|
|
|
21
|
+
// Schema validation
|
|
22
|
+
const { validateTestDefinitions } = require('./validate');
|
|
23
|
+
|
|
21
24
|
// Low-level core exports (single-test API)
|
|
22
25
|
const {
|
|
23
26
|
executeTest,
|
|
@@ -35,6 +38,7 @@ module.exports = {
|
|
|
35
38
|
runTests,
|
|
36
39
|
parseTestDefinitions,
|
|
37
40
|
runSingleTest,
|
|
41
|
+
validateTestDefinitions,
|
|
38
42
|
|
|
39
43
|
// Low-level single-test API (v2.js re-exports)
|
|
40
44
|
executeTest,
|
package/src/runner.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const yaml = require('js-yaml');
|
|
4
4
|
const { executeTest } = require('./core');
|
|
5
|
+
const { validateTestDefinitions } = require('./validate');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Parse a YAML string into an array of test definitions.
|
|
@@ -123,6 +124,10 @@ async function runSingleTest(def, index) {
|
|
|
123
124
|
*/
|
|
124
125
|
async function runTests(yamlString) {
|
|
125
126
|
const definitions = parseTestDefinitions(yamlString);
|
|
127
|
+
|
|
128
|
+
// Validate all definitions before executing any test
|
|
129
|
+
validateTestDefinitions(definitions);
|
|
130
|
+
|
|
126
131
|
const total = definitions.length;
|
|
127
132
|
const results = [];
|
|
128
133
|
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Ajv = require('ajv');
|
|
4
|
+
const ajvErrors = require('ajv-errors');
|
|
5
|
+
|
|
6
|
+
// ── Reusable sub-schemas ─────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const comparatorEnum = {
|
|
9
|
+
type: 'string',
|
|
10
|
+
enum: ['equals', 'contains', 'matches', 'exists', 'greaterThan', 'lessThan'],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const jsonPathExpectationItem = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
required: ['path', 'comparator'],
|
|
16
|
+
properties: {
|
|
17
|
+
path: { type: 'string' },
|
|
18
|
+
comparator: comparatorEnum,
|
|
19
|
+
value: {}, // any type
|
|
20
|
+
negate: { type: 'boolean' },
|
|
21
|
+
},
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const headerExpectationItem = {
|
|
26
|
+
type: 'object',
|
|
27
|
+
required: ['name', 'comparator'],
|
|
28
|
+
properties: {
|
|
29
|
+
name: { type: 'string' },
|
|
30
|
+
comparator: { type: 'string', enum: ['equals', 'contains', 'matches', 'exists'] },
|
|
31
|
+
value: { type: 'string' },
|
|
32
|
+
negate: { type: 'boolean' },
|
|
33
|
+
},
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// bodyContains / bodyRegex accept: string | object | array of (string|object)
|
|
38
|
+
const bodyContainsItem = {
|
|
39
|
+
oneOf: [
|
|
40
|
+
{ type: 'string' },
|
|
41
|
+
{
|
|
42
|
+
type: 'object',
|
|
43
|
+
required: ['value'],
|
|
44
|
+
properties: {
|
|
45
|
+
value: { type: 'string' },
|
|
46
|
+
negate: { type: 'boolean' },
|
|
47
|
+
matchword: { type: 'boolean' },
|
|
48
|
+
},
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const bodyContainsSchema = {
|
|
55
|
+
oneOf: [
|
|
56
|
+
{ type: 'string' },
|
|
57
|
+
{
|
|
58
|
+
type: 'object',
|
|
59
|
+
required: ['value'],
|
|
60
|
+
properties: {
|
|
61
|
+
value: { type: 'string' },
|
|
62
|
+
negate: { type: 'boolean' },
|
|
63
|
+
matchword: { type: 'boolean' },
|
|
64
|
+
},
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'array',
|
|
69
|
+
items: bodyContainsItem,
|
|
70
|
+
minItems: 1,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const bodyRegexItem = {
|
|
76
|
+
oneOf: [
|
|
77
|
+
{ type: 'string' },
|
|
78
|
+
{
|
|
79
|
+
type: 'object',
|
|
80
|
+
required: ['value'],
|
|
81
|
+
properties: {
|
|
82
|
+
value: { type: 'string' },
|
|
83
|
+
negate: { type: 'boolean' },
|
|
84
|
+
},
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const bodyRegexSchema = {
|
|
91
|
+
oneOf: [
|
|
92
|
+
{ type: 'string' },
|
|
93
|
+
{
|
|
94
|
+
type: 'object',
|
|
95
|
+
required: ['value'],
|
|
96
|
+
properties: {
|
|
97
|
+
value: { type: 'string' },
|
|
98
|
+
negate: { type: 'boolean' },
|
|
99
|
+
},
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'array',
|
|
104
|
+
items: bodyRegexItem,
|
|
105
|
+
minItems: 1,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Output expectation for command stdout/stderr/output
|
|
111
|
+
const outputExpectationItem = {
|
|
112
|
+
oneOf: [
|
|
113
|
+
{ type: 'string' },
|
|
114
|
+
{
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
contains: { type: 'string' },
|
|
118
|
+
equals: { type: 'string' },
|
|
119
|
+
matches: { type: 'string' },
|
|
120
|
+
regex: { type: 'string' },
|
|
121
|
+
exists: { type: 'boolean' },
|
|
122
|
+
negate: { type: 'boolean' },
|
|
123
|
+
comparator: comparatorEnum,
|
|
124
|
+
value: {},
|
|
125
|
+
},
|
|
126
|
+
additionalProperties: false,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: 'array',
|
|
130
|
+
items: {
|
|
131
|
+
oneOf: [
|
|
132
|
+
{ type: 'string' },
|
|
133
|
+
{
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
contains: { type: 'string' },
|
|
137
|
+
equals: { type: 'string' },
|
|
138
|
+
matches: { type: 'string' },
|
|
139
|
+
regex: { type: 'string' },
|
|
140
|
+
exists: { type: 'boolean' },
|
|
141
|
+
negate: { type: 'boolean' },
|
|
142
|
+
comparator: comparatorEnum,
|
|
143
|
+
value: {},
|
|
144
|
+
},
|
|
145
|
+
additionalProperties: false,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
minItems: 1,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// ── K8s selector ─────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
const k8sSelector = {
|
|
157
|
+
type: 'object',
|
|
158
|
+
required: ['kind', 'metadata'],
|
|
159
|
+
properties: {
|
|
160
|
+
kind: { type: 'string' },
|
|
161
|
+
context: { type: 'string' },
|
|
162
|
+
metadata: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
namespace: { type: 'string' },
|
|
166
|
+
name: { type: 'string' },
|
|
167
|
+
labels: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
additionalProperties: { type: 'string' },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
anyOf: [
|
|
173
|
+
{ required: ['name'] },
|
|
174
|
+
{ required: ['labels'] },
|
|
175
|
+
],
|
|
176
|
+
additionalProperties: false,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
additionalProperties: false,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// ── Source ────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
const sourceSchema = {
|
|
185
|
+
type: 'object',
|
|
186
|
+
required: ['type'],
|
|
187
|
+
properties: {
|
|
188
|
+
type: { type: 'string', enum: ['local', 'pod'] },
|
|
189
|
+
selector: k8sSelector,
|
|
190
|
+
container: { type: 'string' },
|
|
191
|
+
usePortForward: { type: 'boolean' },
|
|
192
|
+
usePodExec: { type: 'boolean' },
|
|
193
|
+
},
|
|
194
|
+
if: { properties: { type: { const: 'pod' } } },
|
|
195
|
+
then: { required: ['selector'] },
|
|
196
|
+
additionalProperties: false,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ── HTTP config ──────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const httpConfigSchema = {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
url: { type: 'string' },
|
|
205
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'get', 'post', 'put', 'delete', 'patch'] },
|
|
206
|
+
path: { type: 'string' },
|
|
207
|
+
headers: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
additionalProperties: { type: 'string' },
|
|
210
|
+
},
|
|
211
|
+
params: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
additionalProperties: {},
|
|
214
|
+
},
|
|
215
|
+
body: {}, // string or object
|
|
216
|
+
skipSslVerification: { type: 'boolean' },
|
|
217
|
+
maxRedirects: { type: 'number' },
|
|
218
|
+
cert: { type: 'string' },
|
|
219
|
+
key: { type: 'string' },
|
|
220
|
+
ca: { type: 'string' },
|
|
221
|
+
scheme: { type: 'string', enum: ['http', 'https'] },
|
|
222
|
+
port: { oneOf: [{ type: 'number' }, { type: 'string' }] },
|
|
223
|
+
},
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// ── HTTP expect ──────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
const httpExpectSchema = {
|
|
230
|
+
type: 'object',
|
|
231
|
+
properties: {
|
|
232
|
+
statusCode: {
|
|
233
|
+
oneOf: [
|
|
234
|
+
{ type: 'number' },
|
|
235
|
+
{ type: 'array', items: { type: 'number' }, minItems: 1 },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
body: {}, // any type for exact match
|
|
239
|
+
bodyContains: bodyContainsSchema,
|
|
240
|
+
bodyRegex: bodyRegexSchema,
|
|
241
|
+
bodyJsonPath: {
|
|
242
|
+
type: 'array',
|
|
243
|
+
items: jsonPathExpectationItem,
|
|
244
|
+
minItems: 1,
|
|
245
|
+
},
|
|
246
|
+
headers: {
|
|
247
|
+
type: 'array',
|
|
248
|
+
items: headerExpectationItem,
|
|
249
|
+
minItems: 1,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ── HTTP setVars ─────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
const httpSetVarRule = {
|
|
258
|
+
type: 'object',
|
|
259
|
+
properties: {
|
|
260
|
+
jsonPath: { type: 'string' },
|
|
261
|
+
header: { type: 'string' },
|
|
262
|
+
statusCode: { const: true },
|
|
263
|
+
body: { const: true },
|
|
264
|
+
regex: {
|
|
265
|
+
type: 'object',
|
|
266
|
+
required: ['pattern'],
|
|
267
|
+
properties: {
|
|
268
|
+
pattern: { type: 'string' },
|
|
269
|
+
group: { type: 'number' },
|
|
270
|
+
},
|
|
271
|
+
additionalProperties: false,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
additionalProperties: false,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const httpSetVarsSchema = {
|
|
278
|
+
type: 'object',
|
|
279
|
+
additionalProperties: httpSetVarRule,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// ── Command config ───────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
const commandConfigSchema = {
|
|
285
|
+
type: 'object',
|
|
286
|
+
required: ['command'],
|
|
287
|
+
properties: {
|
|
288
|
+
command: { type: 'string' },
|
|
289
|
+
parseJson: { type: 'boolean' },
|
|
290
|
+
env: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
additionalProperties: { type: 'string' },
|
|
293
|
+
},
|
|
294
|
+
workingDir: { type: 'string' },
|
|
295
|
+
},
|
|
296
|
+
additionalProperties: false,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// ── Command expect ───────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
const commandExpectSchema = {
|
|
302
|
+
type: 'object',
|
|
303
|
+
properties: {
|
|
304
|
+
exitCode: { type: 'number' },
|
|
305
|
+
stdout: outputExpectationItem,
|
|
306
|
+
stderr: outputExpectationItem,
|
|
307
|
+
output: outputExpectationItem,
|
|
308
|
+
json: {}, // any type for direct comparison
|
|
309
|
+
jsonPath: {
|
|
310
|
+
type: 'array',
|
|
311
|
+
items: jsonPathExpectationItem,
|
|
312
|
+
minItems: 1,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
additionalProperties: false,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// ── Command setVars ──────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
const commandSetVarRule = {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
jsonPath: { type: 'string' },
|
|
324
|
+
stdout: { const: true },
|
|
325
|
+
stderr: { const: true },
|
|
326
|
+
exitCode: { const: true },
|
|
327
|
+
regex: {
|
|
328
|
+
type: 'object',
|
|
329
|
+
required: ['pattern'],
|
|
330
|
+
properties: {
|
|
331
|
+
pattern: { type: 'string' },
|
|
332
|
+
group: { type: 'number' },
|
|
333
|
+
source: { type: 'string', enum: ['stdout', 'stderr'] },
|
|
334
|
+
},
|
|
335
|
+
additionalProperties: false,
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
additionalProperties: false,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const commandSetVarsSchema = {
|
|
342
|
+
type: 'object',
|
|
343
|
+
additionalProperties: commandSetVarRule,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// ── Wait config ──────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
const waitConfigSchema = {
|
|
349
|
+
type: 'object',
|
|
350
|
+
required: ['target'],
|
|
351
|
+
properties: {
|
|
352
|
+
target: k8sSelector,
|
|
353
|
+
jsonPath: { type: 'string' },
|
|
354
|
+
jsonPathExpectation: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
required: ['comparator'],
|
|
357
|
+
properties: {
|
|
358
|
+
comparator: comparatorEnum,
|
|
359
|
+
value: {},
|
|
360
|
+
negate: { type: 'boolean' },
|
|
361
|
+
},
|
|
362
|
+
additionalProperties: false,
|
|
363
|
+
},
|
|
364
|
+
polling: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
timeoutSeconds: { type: 'number' },
|
|
368
|
+
intervalSeconds: { type: 'number' },
|
|
369
|
+
maxRetries: { type: 'number' },
|
|
370
|
+
},
|
|
371
|
+
additionalProperties: false,
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
additionalProperties: false,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// ── Wait setVars ─────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
const waitSetVarRule = {
|
|
380
|
+
type: 'object',
|
|
381
|
+
properties: {
|
|
382
|
+
value: { const: true },
|
|
383
|
+
},
|
|
384
|
+
additionalProperties: false,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const waitSetVarsSchema = {
|
|
388
|
+
type: 'object',
|
|
389
|
+
additionalProperties: waitSetVarRule,
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// ── HTTP body comparison config ──────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
const httpBodyComparisonRequestSchema = {
|
|
395
|
+
type: 'object',
|
|
396
|
+
required: ['http', 'source'],
|
|
397
|
+
properties: {
|
|
398
|
+
http: httpConfigSchema,
|
|
399
|
+
source: sourceSchema,
|
|
400
|
+
},
|
|
401
|
+
additionalProperties: false,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const httpBodyComparisonConfigSchema = {
|
|
405
|
+
type: 'object',
|
|
406
|
+
required: ['request1', 'request2'],
|
|
407
|
+
properties: {
|
|
408
|
+
request1: httpBodyComparisonRequestSchema,
|
|
409
|
+
request2: httpBodyComparisonRequestSchema,
|
|
410
|
+
parseAsJson: { type: 'boolean' },
|
|
411
|
+
delaySeconds: { type: 'number' },
|
|
412
|
+
removeJsonPaths: {
|
|
413
|
+
type: 'array',
|
|
414
|
+
items: { type: 'string' },
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
additionalProperties: false,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// ── Main test definition schema ──────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
const testDefinitionSchema = {
|
|
423
|
+
type: 'object',
|
|
424
|
+
required: ['source'],
|
|
425
|
+
properties: {
|
|
426
|
+
name: { type: 'string' },
|
|
427
|
+
retries: { type: 'integer', minimum: 0 },
|
|
428
|
+
source: sourceSchema,
|
|
429
|
+
http: httpConfigSchema,
|
|
430
|
+
command: commandConfigSchema,
|
|
431
|
+
wait: waitConfigSchema,
|
|
432
|
+
httpBodyComparison: httpBodyComparisonConfigSchema,
|
|
433
|
+
expect: {}, // validated conditionally per test type
|
|
434
|
+
setVars: {}, // validated conditionally per test type
|
|
435
|
+
},
|
|
436
|
+
// Exactly one test type must be present
|
|
437
|
+
oneOf: [
|
|
438
|
+
{
|
|
439
|
+
required: ['http'],
|
|
440
|
+
not: { anyOf: [{ required: ['command'] }, { required: ['wait'] }, { required: ['httpBodyComparison'] }] },
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
required: ['command'],
|
|
444
|
+
not: { anyOf: [{ required: ['http'] }, { required: ['wait'] }, { required: ['httpBodyComparison'] }] },
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
required: ['wait'],
|
|
448
|
+
not: { anyOf: [{ required: ['http'] }, { required: ['command'] }, { required: ['httpBodyComparison'] }] },
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
required: ['httpBodyComparison'],
|
|
452
|
+
not: { anyOf: [{ required: ['http'] }, { required: ['command'] }, { required: ['wait'] }] },
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
// Conditional validation of expect and setVars per test type
|
|
456
|
+
allOf: [
|
|
457
|
+
// HTTP: validate expect and setVars shapes
|
|
458
|
+
{
|
|
459
|
+
if: { required: ['http'] },
|
|
460
|
+
then: {
|
|
461
|
+
properties: {
|
|
462
|
+
expect: httpExpectSchema,
|
|
463
|
+
setVars: httpSetVarsSchema,
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
// Command: validate expect and setVars shapes
|
|
468
|
+
{
|
|
469
|
+
if: { required: ['command'] },
|
|
470
|
+
then: {
|
|
471
|
+
properties: {
|
|
472
|
+
expect: commandExpectSchema,
|
|
473
|
+
setVars: commandSetVarsSchema,
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
// Wait: no expect, setVars uses wait-specific rules
|
|
478
|
+
{
|
|
479
|
+
if: { required: ['wait'] },
|
|
480
|
+
then: {
|
|
481
|
+
properties: {
|
|
482
|
+
setVars: waitSetVarsSchema,
|
|
483
|
+
},
|
|
484
|
+
not: { required: ['expect'] },
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
// HTTP body comparison: no expect, no setVars
|
|
488
|
+
{
|
|
489
|
+
if: { required: ['httpBodyComparison'] },
|
|
490
|
+
then: {
|
|
491
|
+
not: { anyOf: [{ required: ['expect'] }, { required: ['setVars'] }] },
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
// setVars requires expect (for http and command)
|
|
495
|
+
{
|
|
496
|
+
if: {
|
|
497
|
+
required: ['setVars'],
|
|
498
|
+
anyOf: [{ required: ['http'] }, { required: ['command'] }],
|
|
499
|
+
},
|
|
500
|
+
then: {
|
|
501
|
+
required: ['expect'],
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
additionalProperties: false,
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const rootSchema = {
|
|
509
|
+
type: 'array',
|
|
510
|
+
items: testDefinitionSchema,
|
|
511
|
+
minItems: 1,
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// ── Compile schema ───────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
const ajv = new Ajv({ allErrors: true, verbose: true });
|
|
517
|
+
ajvErrors(ajv);
|
|
518
|
+
|
|
519
|
+
const validate = ajv.compile(rootSchema);
|
|
520
|
+
|
|
521
|
+
// ── Error formatting ─────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Format Ajv validation errors into human-readable messages.
|
|
525
|
+
* @param {Array} errors - Ajv error objects
|
|
526
|
+
* @param {Array} definitions - The original test definitions array
|
|
527
|
+
* @returns {string} - Formatted error message
|
|
528
|
+
*/
|
|
529
|
+
function formatValidationErrors(errors, definitions) {
|
|
530
|
+
// Deduplicate and filter out noise from oneOf/anyOf wrappers
|
|
531
|
+
const seen = new Set();
|
|
532
|
+
const meaningful = [];
|
|
533
|
+
|
|
534
|
+
for (const err of errors) {
|
|
535
|
+
// Skip generic wrapper messages that aren't actionable
|
|
536
|
+
if (err.keyword === 'if' || err.keyword === 'ifThen') continue;
|
|
537
|
+
|
|
538
|
+
const key = `${err.instancePath}|${err.keyword}|${err.message}`;
|
|
539
|
+
if (seen.has(key)) continue;
|
|
540
|
+
seen.add(key);
|
|
541
|
+
meaningful.push(err);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const lines = [];
|
|
545
|
+
|
|
546
|
+
for (const err of meaningful) {
|
|
547
|
+
// Extract test index from path like /0/http/method
|
|
548
|
+
const pathMatch = err.instancePath.match(/^\/(\d+)(\/.*)?$/);
|
|
549
|
+
let prefix;
|
|
550
|
+
if (pathMatch) {
|
|
551
|
+
const idx = parseInt(pathMatch[0].split('/')[1], 10);
|
|
552
|
+
const name = definitions[idx]?.name;
|
|
553
|
+
const subPath = pathMatch[2] || '';
|
|
554
|
+
prefix = name ? `Test #${idx + 1} ("${name}")` : `Test #${idx + 1}`;
|
|
555
|
+
prefix += subPath ? ` ${subPath}` : '';
|
|
556
|
+
} else {
|
|
557
|
+
prefix = err.instancePath || '(root)';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
let message;
|
|
561
|
+
switch (err.keyword) {
|
|
562
|
+
case 'oneOf': {
|
|
563
|
+
// Check context: is this about test type selection?
|
|
564
|
+
if (err.instancePath.match(/^\/\d+$/) || err.instancePath === '') {
|
|
565
|
+
message = 'must define exactly one of: http, command, wait, httpBodyComparison';
|
|
566
|
+
} else {
|
|
567
|
+
message = err.message;
|
|
568
|
+
}
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
case 'enum':
|
|
572
|
+
message = `must be one of: ${err.params.allowedValues.join(', ')}`;
|
|
573
|
+
break;
|
|
574
|
+
case 'required':
|
|
575
|
+
message = `missing required property "${err.params.missingProperty}"`;
|
|
576
|
+
break;
|
|
577
|
+
case 'additionalProperties':
|
|
578
|
+
message = `unknown property "${err.params.additionalProperty}"`;
|
|
579
|
+
break;
|
|
580
|
+
case 'type':
|
|
581
|
+
message = `must be ${err.params.type}`;
|
|
582
|
+
break;
|
|
583
|
+
case 'anyOf':
|
|
584
|
+
// Often comes from metadata needing name or labels
|
|
585
|
+
if (err.instancePath.includes('metadata')) {
|
|
586
|
+
message = 'must have either "name" or "labels"';
|
|
587
|
+
} else {
|
|
588
|
+
message = err.message;
|
|
589
|
+
}
|
|
590
|
+
break;
|
|
591
|
+
case 'not':
|
|
592
|
+
// Comes from forbidden combinations
|
|
593
|
+
if (err.instancePath.match(/^\/\d+$/)) {
|
|
594
|
+
message = 'must define exactly one of: http, command, wait, httpBodyComparison';
|
|
595
|
+
} else {
|
|
596
|
+
message = err.message;
|
|
597
|
+
}
|
|
598
|
+
break;
|
|
599
|
+
default:
|
|
600
|
+
message = err.message;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
lines.push(` ${prefix}: ${message}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Deduplicate final lines
|
|
607
|
+
const uniqueLines = [...new Set(lines)];
|
|
608
|
+
return `Validation failed:\n\n${uniqueLines.join('\n')}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Validate an array of parsed test definitions against the schema.
|
|
615
|
+
* Throws an Error with a descriptive multi-line message if validation fails.
|
|
616
|
+
*
|
|
617
|
+
* @param {Array<object>} definitions - Array of parsed test definition objects
|
|
618
|
+
* @throws {Error} If any definition fails validation
|
|
619
|
+
*/
|
|
620
|
+
function validateTestDefinitions(definitions) {
|
|
621
|
+
const valid = validate(definitions);
|
|
622
|
+
if (!valid) {
|
|
623
|
+
const message = formatValidationErrors(validate.errors, definitions);
|
|
624
|
+
throw new Error(message);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
module.exports = { validateTestDefinitions };
|