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 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.4",
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
 
@@ -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 };