@mamund/tram 0.0.0-stage → 0.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.
@@ -0,0 +1,918 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * assertions.js v0.1
5
+ *
6
+ * Dependency-free assertion engine for the HTTP API test runner.
7
+ *
8
+ * Responsibilities:
9
+ * - Evaluate HTTP status assertions
10
+ * - Evaluate HTTP header assertions
11
+ * - Evaluate JSON body assertions
12
+ * - Support simple JSONPath-like traversal
13
+ * - Support collection assertions through `each`
14
+ * - Return normalized assertion result objects
15
+ */
16
+
17
+ function evaluateExpectations({ expect = {}, response = {} }) {
18
+ const results = [];
19
+
20
+ if (Object.prototype.hasOwnProperty.call(expect, 'status')) {
21
+ results.push(assertStatus(expect.status, response.status));
22
+ }
23
+
24
+ if (Array.isArray(expect.headers)) {
25
+ for (const assertion of expect.headers) {
26
+ results.push(assertHeader(assertion, response.headers || {}));
27
+ }
28
+ }
29
+
30
+ if (Array.isArray(expect.body)) {
31
+ for (const assertion of expect.body) {
32
+ results.push(...assertJson(assertion, response.body));
33
+ }
34
+ }
35
+
36
+ return results;
37
+ }
38
+
39
+ function assertStatus(expected, actual) {
40
+ const passed = actual === expected;
41
+
42
+ return {
43
+ passed,
44
+ type: 'status',
45
+ target: 'http',
46
+ expected,
47
+ actual,
48
+ message: passed
49
+ ? `Status is ${expected}.`
50
+ : `Expected status ${expected}, got ${actual}.`
51
+ };
52
+ }
53
+
54
+ function assertHeader(assertion, headers) {
55
+ const name = assertion.name;
56
+ const normalizedName = String(name || '').toLowerCase();
57
+ const actual = getHeader(headers, normalizedName);
58
+
59
+ if (assertion.exists === true) {
60
+ const passed = actual !== undefined;
61
+ return {
62
+ passed,
63
+ type: 'exists',
64
+ target: 'header',
65
+ name,
66
+ expected: true,
67
+ actual,
68
+ message: passed
69
+ ? `Header ${name} exists.`
70
+ : `Expected header ${name} to exist.`
71
+ };
72
+ }
73
+
74
+ if (Object.prototype.hasOwnProperty.call(assertion, 'equals')) {
75
+ const expected = assertion.equals;
76
+ const passed = actual === expected;
77
+ return {
78
+ passed,
79
+ type: 'equals',
80
+ target: 'header',
81
+ name,
82
+ expected,
83
+ actual,
84
+ message: passed
85
+ ? `Header ${name} equals "${expected}".`
86
+ : `Expected header ${name} to equal "${expected}", got "${actual}".`
87
+ };
88
+ }
89
+
90
+ if (Object.prototype.hasOwnProperty.call(assertion, 'contains')) {
91
+ const expected = assertion.contains;
92
+ const passed = typeof actual === 'string' && actual.includes(expected);
93
+
94
+ return {
95
+ passed,
96
+ type: 'contains',
97
+ target: 'header',
98
+ name,
99
+ expected,
100
+ actual,
101
+ message: passed
102
+ ? `Header ${name} contains "${expected}".`
103
+ : `Expected header ${name} to contain "${expected}", got "${actual}".`
104
+ };
105
+ }
106
+
107
+ return unsupportedAssertion('header', assertion, `Unsupported header assertion for ${name}.`);
108
+ }
109
+
110
+ function assertJson(assertion, body, basePath = '$', options = {}) {
111
+ const path = assertion.path || '$';
112
+ const absolutePath = path === '$' ? basePath : joinPath(basePath, path);
113
+ const value = selectPath(body, path);
114
+ const results = [];
115
+
116
+ if (
117
+ options.allowOptional === true &&
118
+ assertion.optional === true &&
119
+ value.exists === false
120
+ ) {
121
+ return [
122
+ optionalAbsent({
123
+ path: absolutePath,
124
+ type: 'path.optional'
125
+ })
126
+ ];
127
+ }
128
+
129
+ if (Object.prototype.hasOwnProperty.call(assertion, 'exists')) {
130
+ const expected = assertion.exists;
131
+ const passed = value.exists === expected;
132
+
133
+ results.push({
134
+ passed,
135
+ type: 'exists',
136
+ target: 'json',
137
+ path: absolutePath,
138
+ expected,
139
+ actual: value.exists,
140
+ message: passed
141
+ ? expected
142
+ ? `${absolutePath} exists.`
143
+ : `${absolutePath} does not exist.`
144
+ : expected
145
+ ? `Expected ${absolutePath} to exist.`
146
+ : `Expected ${absolutePath} not to exist.`
147
+ });
148
+ }
149
+
150
+ if (Object.prototype.hasOwnProperty.call(assertion, 'equals')) {
151
+ const expected = assertion.equals;
152
+ const actual = value.value;
153
+ const passed = value.exists && deepEqual(actual, expected);
154
+ results.push({
155
+ passed,
156
+ type: 'equals',
157
+ target: 'json',
158
+ path: absolutePath,
159
+ expected,
160
+ actual,
161
+ message: passed
162
+ ? `${absolutePath} equals ${formatValue(expected)}.`
163
+ : `Expected ${absolutePath} to equal ${formatValue(expected)}, got ${formatValue(actual)}.`
164
+ });
165
+ }
166
+
167
+ if (Object.prototype.hasOwnProperty.call(assertion, 'contains')) {
168
+ const expected = assertion.contains;
169
+ const actual = value.value;
170
+ const passed = value.exists && contains(actual, expected);
171
+ results.push({
172
+ passed,
173
+ type: 'contains',
174
+ target: 'json',
175
+ path: absolutePath,
176
+ expected,
177
+ actual,
178
+ message: passed
179
+ ? `${absolutePath} contains ${formatValue(expected)}.`
180
+ : `Expected ${absolutePath} to contain ${formatValue(expected)}, got ${formatValue(actual)}.`
181
+ });
182
+ }
183
+
184
+ if (Array.isArray(assertion.oneOf)) {
185
+ const expected = assertion.oneOf;
186
+ const actual = value.value;
187
+ const passed = value.exists && expected.some((item) => deepEqual(item, actual));
188
+ results.push({
189
+ passed,
190
+ type: 'oneOf',
191
+ target: 'json',
192
+ path: absolutePath,
193
+ expected,
194
+ actual,
195
+ message: passed
196
+ ? `${absolutePath} is one of ${expected.map(formatValue).join(', ')}.`
197
+ : `Expected ${absolutePath} to be one of ${expected.map(formatValue).join(', ')}, got ${formatValue(actual)}.`
198
+ });
199
+ }
200
+
201
+ for (const type of ['anyOf', 'allOf', 'noneOf']) {
202
+ if (Object.prototype.hasOwnProperty.call(assertion, type)) {
203
+ results.push(assertArrayMembership({
204
+ actual: value.value,
205
+ expected: assertion[type],
206
+ exists: value.exists,
207
+ path: absolutePath,
208
+ type
209
+ }));
210
+ }
211
+ }
212
+
213
+ if (Object.prototype.hasOwnProperty.call(assertion, 'type')) {
214
+ results.push(assertType({
215
+ actual: value.value,
216
+ expected: assertion.type,
217
+ exists: value.exists,
218
+ path: absolutePath,
219
+ type: 'type'
220
+ }));
221
+ }
222
+
223
+ if (isRangeAssertion(assertion.range)) {
224
+ const expected = normalizeRange(assertion.range);
225
+ const actual = value.value;
226
+ results.push(assertRange({
227
+ actual,
228
+ expected,
229
+ exists: value.exists,
230
+ path: absolutePath,
231
+ type: 'range'
232
+ }));
233
+ }
234
+
235
+ if (assertion.isArray === true) {
236
+ const actual = value.value;
237
+ const passed = Array.isArray(actual);
238
+ results.push({
239
+ passed,
240
+ type: 'isArray',
241
+ target: 'json',
242
+ path: absolutePath,
243
+ expected: true,
244
+ actual,
245
+ message: passed
246
+ ? `${absolutePath} is an array.`
247
+ : `Expected ${absolutePath} to be an array.`
248
+ });
249
+ }
250
+
251
+ if (Array.isArray(assertion.hasProperties)) {
252
+ const actual = value.value;
253
+ results.push(assertHasProperties({
254
+ actual,
255
+ expected: assertion.hasProperties,
256
+ path: absolutePath,
257
+ type: 'hasProperties'
258
+ }));
259
+ }
260
+
261
+ if (Object.prototype.hasOwnProperty.call(assertion, 'minLength')) {
262
+ const expected = assertion.minLength;
263
+ const actual = value.value;
264
+ const actualLength = actual != null && typeof actual.length === 'number'
265
+ ? actual.length
266
+ : undefined;
267
+ const passed = typeof actualLength === 'number' && actualLength >= expected;
268
+
269
+ results.push({
270
+ passed,
271
+ type: 'minLength',
272
+ target: 'json',
273
+ path: absolutePath,
274
+ expected,
275
+ actual: actualLength,
276
+ message: passed
277
+ ? `${absolutePath} length is at least ${expected}.`
278
+ : `Expected ${absolutePath} length to be at least ${expected}, got ${actualLength}.`
279
+ });
280
+ }
281
+
282
+ if (isLengthAssertion(assertion.length)) {
283
+ results.push(assertLength({
284
+ actual: value.value,
285
+ expected: normalizeLength(assertion.length),
286
+ exists: value.exists,
287
+ path: absolutePath,
288
+ type: 'length'
289
+ }));
290
+ }
291
+
292
+ if (assertion.each) {
293
+ const actual = value.value;
294
+
295
+ if (!Array.isArray(actual)) {
296
+ results.push({
297
+ passed: false,
298
+ type: 'each',
299
+ target: 'json',
300
+ path: absolutePath,
301
+ expected: 'array',
302
+ actual,
303
+ message: `Expected ${absolutePath} to be an array for each assertion.`
304
+ });
305
+ } else {
306
+ actual.forEach((item, index) => {
307
+ const itemPath = `${absolutePath}[${index}]`;
308
+ results.push(...assertEach(assertion.each, item, itemPath));
309
+ });
310
+ }
311
+ }
312
+
313
+ if (assertion.eachProperty) {
314
+ const actual = value.value;
315
+
316
+ const isObjectMap =
317
+ actual &&
318
+ typeof actual === 'object' &&
319
+ !Array.isArray(actual);
320
+
321
+ if (!isObjectMap) {
322
+ results.push({
323
+ passed: false,
324
+ type: 'eachProperty',
325
+ target: 'json',
326
+ path: absolutePath,
327
+ expected: 'object',
328
+ actual,
329
+ message: `Expected ${absolutePath} to be an object for eachProperty assertion.`
330
+ });
331
+ } else {
332
+ for (const [propertyName, propertyValue] of Object.entries(actual)) {
333
+ const propertyPath = `${absolutePath}.${propertyName}`;
334
+
335
+ results.push(
336
+ ...assertJson(
337
+ assertion.eachProperty,
338
+ propertyValue,
339
+ propertyPath,
340
+ { allowOptional: true }
341
+ )
342
+ );
343
+ }
344
+ }
345
+ }
346
+
347
+ if (results.length === 0) {
348
+ results.push(unsupportedAssertion('json', assertion, `Unsupported JSON assertion at ${absolutePath}.`));
349
+ }
350
+
351
+ return results;
352
+ }
353
+
354
+ function assertEach(eachAssertion, item, itemPath) {
355
+ const results = [];
356
+
357
+ if (Array.isArray(eachAssertion.hasProperties)) {
358
+ results.push(assertHasProperties({
359
+ actual: item,
360
+ expected: eachAssertion.hasProperties,
361
+ path: itemPath,
362
+ type: 'each.hasProperties'
363
+ }));
364
+ }
365
+
366
+ if (eachAssertion.property) {
367
+ const propertyPath = `${itemPath}.${eachAssertion.property}`;
368
+
369
+ const exists =
370
+ item != null &&
371
+ Object.prototype.hasOwnProperty.call(
372
+ Object(item),
373
+ eachAssertion.property
374
+ );
375
+
376
+ const actual = exists
377
+ ? item[eachAssertion.property]
378
+ : undefined;
379
+
380
+ if (!exists && eachAssertion.optional === true) {
381
+ results.push(optionalAbsent({
382
+ path: propertyPath,
383
+ type: 'each.property.optional'
384
+ }));
385
+
386
+ return results;
387
+ }
388
+
389
+ if (Object.prototype.hasOwnProperty.call(eachAssertion, 'equals')) {
390
+ const expected = eachAssertion.equals;
391
+ const passed = exists && deepEqual(actual, expected);
392
+ results.push({
393
+ passed,
394
+ type: 'each.property.equals',
395
+ target: 'json',
396
+ path: propertyPath,
397
+ expected,
398
+ actual,
399
+ message: passed
400
+ ? `${propertyPath} equals ${formatValue(expected)}.`
401
+ : `Expected ${propertyPath} to equal ${formatValue(expected)}, got ${formatValue(actual)}.`
402
+ });
403
+ }
404
+
405
+ if (Object.prototype.hasOwnProperty.call(eachAssertion, 'contains')) {
406
+ const expected = eachAssertion.contains;
407
+ const passed = exists && contains(actual, expected);
408
+ results.push({
409
+ passed,
410
+ type: 'each.property.contains',
411
+ target: 'json',
412
+ path: propertyPath,
413
+ expected,
414
+ actual,
415
+ message: passed
416
+ ? `${propertyPath} contains ${formatValue(expected)}.`
417
+ : `Expected ${propertyPath} to contain ${formatValue(expected)}, got ${formatValue(actual)}.`
418
+ });
419
+ }
420
+
421
+ if (Array.isArray(eachAssertion.oneOf)) {
422
+ const expected = eachAssertion.oneOf;
423
+ const passed = exists && expected.some((value) => deepEqual(value, actual));
424
+ results.push({
425
+ passed,
426
+ type: 'each.property.oneOf',
427
+ target: 'json',
428
+ path: propertyPath,
429
+ expected,
430
+ actual,
431
+ message: passed
432
+ ? `${propertyPath} is one of ${expected.map(formatValue).join(', ')}.`
433
+ : `Expected ${propertyPath} to be one of ${expected.map(formatValue).join(', ')}, got ${formatValue(actual)}.`
434
+ });
435
+ }
436
+
437
+ for (const type of ['anyOf', 'allOf', 'noneOf']) {
438
+ if (Object.prototype.hasOwnProperty.call(eachAssertion, type)) {
439
+ results.push(assertArrayMembership({
440
+ actual,
441
+ expected: eachAssertion[type],
442
+ exists,
443
+ path: propertyPath,
444
+ type: `each.property.${type}`
445
+ }));
446
+ }
447
+ }
448
+
449
+ if (Object.prototype.hasOwnProperty.call(eachAssertion, 'type')) {
450
+ results.push(assertType({
451
+ actual,
452
+ expected: eachAssertion.type,
453
+ exists,
454
+ path: propertyPath,
455
+ type: 'each.property.type'
456
+ }));
457
+ }
458
+
459
+ if (isRangeAssertion(eachAssertion.range)) {
460
+ const expected = normalizeRange(eachAssertion.range);
461
+ results.push(assertRange({
462
+ actual,
463
+ expected,
464
+ exists,
465
+ path: propertyPath,
466
+ type: 'each.property.range'
467
+ }));
468
+ }
469
+
470
+ if (isLengthAssertion(eachAssertion.length)) {
471
+ results.push(assertLength({
472
+ actual,
473
+ expected: normalizeLength(eachAssertion.length),
474
+ exists,
475
+ path: propertyPath,
476
+ type: 'each.property.length'
477
+ }));
478
+ }
479
+ }
480
+
481
+ if (eachAssertion.path) {
482
+ results.push(...assertJson(eachAssertion, item, itemPath));
483
+ }
484
+
485
+ if (results.length === 0) {
486
+ results.push(unsupportedAssertion('json', eachAssertion, `Unsupported each assertion at ${itemPath}.`));
487
+ }
488
+
489
+ return results;
490
+ }
491
+
492
+ function assertRange({ actual, expected, exists, path, type }) {
493
+ const isNumber = typeof actual === 'number' && Number.isFinite(actual);
494
+ const hasMin = Object.prototype.hasOwnProperty.call(expected, 'min');
495
+ const hasMax = Object.prototype.hasOwnProperty.call(expected, 'max');
496
+
497
+ let passed = exists && isNumber;
498
+
499
+ if (passed && hasMin) {
500
+ passed = actual >= expected.min;
501
+ }
502
+
503
+ if (passed && hasMax) {
504
+ passed = actual <= expected.max;
505
+ }
506
+
507
+ return {
508
+ passed,
509
+ type,
510
+ target: 'json',
511
+ path,
512
+ expected,
513
+ actual,
514
+ message: passed
515
+ ? `${path} is ${rangeDescription(expected)}.`
516
+ : rangeFailureMessage({ actual, expected, exists, isNumber, path })
517
+ };
518
+ }
519
+
520
+ function isRangeAssertion(range) {
521
+ return range && typeof range === 'object' && !Array.isArray(range)
522
+ && (Object.prototype.hasOwnProperty.call(range, 'min')
523
+ || Object.prototype.hasOwnProperty.call(range, 'max'));
524
+ }
525
+
526
+ function normalizeRange(range) {
527
+ const normalized = {};
528
+
529
+ if (Object.prototype.hasOwnProperty.call(range, 'min')) {
530
+ normalized.min = range.min;
531
+ }
532
+
533
+ if (Object.prototype.hasOwnProperty.call(range, 'max')) {
534
+ normalized.max = range.max;
535
+ }
536
+
537
+ return normalized;
538
+ }
539
+
540
+ function rangeDescription(range) {
541
+ const hasMin = Object.prototype.hasOwnProperty.call(range, 'min');
542
+ const hasMax = Object.prototype.hasOwnProperty.call(range, 'max');
543
+
544
+ if (hasMin && hasMax) {
545
+ return `between ${range.min} and ${range.max}`;
546
+ }
547
+
548
+ if (hasMin) {
549
+ return `at least ${range.min}`;
550
+ }
551
+
552
+ if (hasMax) {
553
+ return `at most ${range.max}`;
554
+ }
555
+
556
+ return 'within range';
557
+ }
558
+
559
+ function rangeFailureMessage({ actual, expected, exists, isNumber, path }) {
560
+ if (!exists) {
561
+ return `Expected ${path} to be ${rangeDescription(expected)}, but the path does not exist.`;
562
+ }
563
+
564
+ if (!isNumber) {
565
+ return `Expected ${path} to be numeric and ${rangeDescription(expected)}, got ${formatValue(actual)}.`;
566
+ }
567
+
568
+ return `Expected ${path} to be ${rangeDescription(expected)}, got ${formatValue(actual)}.`;
569
+ }
570
+
571
+ function assertLength({ actual, expected, exists, path, type }) {
572
+ const hasLength = typeof actual === 'string' || Array.isArray(actual);
573
+ const actualLength = hasLength ? actual.length : undefined;
574
+ const hasMin = Object.prototype.hasOwnProperty.call(expected, 'min');
575
+ const hasMax = Object.prototype.hasOwnProperty.call(expected, 'max');
576
+
577
+ let passed = exists && hasLength;
578
+
579
+ if (passed && hasMin) {
580
+ passed = actualLength >= expected.min;
581
+ }
582
+
583
+ if (passed && hasMax) {
584
+ passed = actualLength <= expected.max;
585
+ }
586
+
587
+ return {
588
+ passed,
589
+ type,
590
+ target: 'json',
591
+ path,
592
+ expected,
593
+ actual: actualLength,
594
+ message: passed
595
+ ? `${path} length is ${lengthDescription(expected)}.`
596
+ : lengthFailureMessage({ actual, actualLength, expected, exists, hasLength, path })
597
+ };
598
+ }
599
+
600
+ function isLengthAssertion(length) {
601
+ if (typeof length === 'number') {
602
+ return Number.isInteger(length) && length >= 0;
603
+ }
604
+
605
+ return length && typeof length === 'object' && !Array.isArray(length)
606
+ && (Object.prototype.hasOwnProperty.call(length, 'min')
607
+ || Object.prototype.hasOwnProperty.call(length, 'max'));
608
+ }
609
+
610
+ function normalizeLength(length) {
611
+ if (typeof length === 'number') {
612
+ return { min: length, max: length };
613
+ }
614
+
615
+ const normalized = {};
616
+
617
+ if (Object.prototype.hasOwnProperty.call(length, 'min')) {
618
+ normalized.min = length.min;
619
+ }
620
+
621
+ if (Object.prototype.hasOwnProperty.call(length, 'max')) {
622
+ normalized.max = length.max;
623
+ }
624
+
625
+ return normalized;
626
+ }
627
+
628
+ function lengthDescription(length) {
629
+ const hasMin = Object.prototype.hasOwnProperty.call(length, 'min');
630
+ const hasMax = Object.prototype.hasOwnProperty.call(length, 'max');
631
+
632
+ if (hasMin && hasMax && length.min === length.max) {
633
+ return `exactly ${length.min}`;
634
+ }
635
+
636
+ if (hasMin && hasMax) {
637
+ return `between ${length.min} and ${length.max}`;
638
+ }
639
+
640
+ if (hasMin) {
641
+ return `at least ${length.min}`;
642
+ }
643
+
644
+ if (hasMax) {
645
+ return `at most ${length.max}`;
646
+ }
647
+
648
+ return 'within length bounds';
649
+ }
650
+
651
+ function lengthFailureMessage({ actual, actualLength, expected, exists, hasLength, path }) {
652
+ if (!exists) {
653
+ return `Expected ${path} length to be ${lengthDescription(expected)}, but the path does not exist.`;
654
+ }
655
+
656
+ if (!hasLength) {
657
+ return `Expected ${path} to be an array or string with length ${lengthDescription(expected)}, got ${formatValue(actual)}.`;
658
+ }
659
+
660
+ return `Expected ${path} length to be ${lengthDescription(expected)}, got ${actualLength}.`;
661
+ }
662
+
663
+ function assertType({ actual, expected, exists, path, type }) {
664
+ const actualType = getValueType(actual);
665
+ const supported = isSupportedType(expected);
666
+ const passed = exists && supported && actualType === expected;
667
+
668
+ return {
669
+ passed,
670
+ type,
671
+ target: 'json',
672
+ path,
673
+ expected,
674
+ actual: actualType,
675
+ message: passed
676
+ ? `${path} is type ${expected}.`
677
+ : typeFailureMessage({ expected, actualType, exists, supported, path })
678
+ };
679
+ }
680
+
681
+ function getValueType(value) {
682
+ if (value === null) return 'null';
683
+ if (Array.isArray(value)) return 'array';
684
+ return typeof value;
685
+ }
686
+
687
+ function isSupportedType(value) {
688
+ return [
689
+ 'string',
690
+ 'number',
691
+ 'boolean',
692
+ 'array',
693
+ 'object',
694
+ 'null'
695
+ ].includes(value);
696
+ }
697
+
698
+ function typeFailureMessage({ expected, actualType, exists, supported, path }) {
699
+ if (!exists) {
700
+ return `Expected ${path} to be type ${expected}, but the path does not exist.`;
701
+ }
702
+
703
+ if (!supported) {
704
+ return `Unsupported type assertion "${expected}" at ${path}.`;
705
+ }
706
+
707
+ return `Expected ${path} to be type ${expected}, got ${actualType}.`;
708
+ }
709
+
710
+ function assertHasProperties({ actual, expected, path, type }) {
711
+ const missing = expected.filter((property) => {
712
+ return !Object.prototype.hasOwnProperty.call(Object(actual || {}), property);
713
+ });
714
+ const passed = missing.length === 0;
715
+
716
+ return {
717
+ passed,
718
+ type,
719
+ target: 'json',
720
+ path,
721
+ expected,
722
+ actual,
723
+ message: passed
724
+ ? `${path} has properties ${expected.join(', ')}.`
725
+ : `Expected ${path} to have properties ${expected.join(', ')}. Missing: ${missing.join(', ')}.`
726
+ };
727
+ }
728
+
729
+ function assertArrayMembership({ actual, expected, exists, path, type }) {
730
+ const assertionName = String(type).split('.').pop();
731
+
732
+ if (!Array.isArray(expected)) {
733
+ return {
734
+ passed: false,
735
+ type,
736
+ target: 'json',
737
+ path,
738
+ expected: 'array',
739
+ actual: expected,
740
+ message: `Expected ${assertionName} assertion at ${path} to use an array of expected values.`
741
+ };
742
+ }
743
+
744
+ if (!exists) {
745
+ return {
746
+ passed: false,
747
+ type,
748
+ target: 'json',
749
+ path,
750
+ expected,
751
+ actual: undefined,
752
+ message: `Expected ${path} to exist for ${assertionName} assertion.`
753
+ };
754
+ }
755
+
756
+ if (!Array.isArray(actual)) {
757
+ return {
758
+ passed: false,
759
+ type,
760
+ target: 'json',
761
+ path,
762
+ expected,
763
+ actual,
764
+ message: `Expected ${path} to be an array for ${assertionName} assertion.`
765
+ };
766
+ }
767
+
768
+ const hasMatch = (expectedValue) => {
769
+ return actual.some((actualValue) => deepEqual(actualValue, expectedValue));
770
+ };
771
+
772
+ let passed;
773
+
774
+ if (assertionName === 'anyOf') {
775
+ passed = expected.some(hasMatch);
776
+ } else if (assertionName === 'allOf') {
777
+ passed = expected.every(hasMatch);
778
+ } else {
779
+ passed = expected.every((expectedValue) => !hasMatch(expectedValue));
780
+ }
781
+
782
+ return {
783
+ passed,
784
+ type,
785
+ target: 'json',
786
+ path,
787
+ expected,
788
+ actual,
789
+ message: passed
790
+ ? `${path} satisfies ${assertionName} ${expected.map(formatValue).join(', ')}.`
791
+ : `Expected ${path} to satisfy ${assertionName} ${expected.map(formatValue).join(', ')}, got ${formatValue(actual)}.`
792
+ };
793
+ }
794
+
795
+ function getHeader(headers, name) {
796
+ if (!headers) return undefined;
797
+
798
+ for (const [key, value] of Object.entries(headers)) {
799
+ if (key.toLowerCase() === name) {
800
+ return Array.isArray(value) ? value.join(', ') : String(value);
801
+ }
802
+ }
803
+
804
+ return undefined;
805
+ }
806
+
807
+ function selectPath(source, path) {
808
+ if (path === '$') {
809
+ return { exists: source !== undefined, value: source };
810
+ }
811
+
812
+ if (typeof path !== 'string' || !path.startsWith('$.')) {
813
+ return { exists: false, value: undefined };
814
+ }
815
+
816
+ const tokens = tokenizePath(path);
817
+ let current = source;
818
+
819
+ for (const token of tokens) {
820
+ if (current === null || current === undefined) {
821
+ return { exists: false, value: undefined };
822
+ }
823
+
824
+ if (typeof token === 'number') {
825
+ if (!Array.isArray(current) || token < 0 || token >= current.length) {
826
+ return { exists: false, value: undefined };
827
+ }
828
+ current = current[token];
829
+ } else {
830
+ if (!Object.prototype.hasOwnProperty.call(Object(current), token)) {
831
+ return { exists: false, value: undefined };
832
+ }
833
+ current = current[token];
834
+ }
835
+ }
836
+
837
+ return { exists: true, value: current };
838
+ }
839
+
840
+ function tokenizePath(path) {
841
+ const stripped = path.replace(/^\$\.?/, '');
842
+ if (!stripped) return [];
843
+
844
+ const tokens = [];
845
+ const parts = stripped.split('.');
846
+
847
+ for (const part of parts) {
848
+ const re = /([^\[\]]+)|\[(\d+)\]/g;
849
+ let match;
850
+
851
+ while ((match = re.exec(part)) !== null) {
852
+ if (match[1]) tokens.push(match[1]);
853
+ if (match[2]) tokens.push(Number(match[2]));
854
+ }
855
+ }
856
+
857
+ return tokens;
858
+ }
859
+
860
+ function contains(actual, expected) {
861
+ if (typeof actual === 'string') {
862
+ return actual.includes(String(expected));
863
+ }
864
+
865
+ if (Array.isArray(actual)) {
866
+ return actual.some((item) => deepEqual(item, expected));
867
+ }
868
+
869
+ return false;
870
+ }
871
+
872
+ function deepEqual(a, b) {
873
+ return JSON.stringify(a) === JSON.stringify(b);
874
+ }
875
+
876
+ function joinPath(basePath, path) {
877
+ if (basePath === '$') return path;
878
+ if (path === '$') return basePath;
879
+ if (path.startsWith('$.')) return `${basePath}.${path.slice(2)}`;
880
+ return `${basePath}.${path}`;
881
+ }
882
+
883
+ function formatValue(value) {
884
+ if (typeof value === 'string') return `"${value}"`;
885
+ return JSON.stringify(value);
886
+ }
887
+
888
+ function optionalAbsent({ path, type = 'optional' }) {
889
+ return {
890
+ passed: true,
891
+ type,
892
+ target: 'json',
893
+ path,
894
+ expected: 'optional property',
895
+ actual: undefined,
896
+ message: `${path} is optional and absent.`
897
+ };
898
+ }
899
+
900
+ function unsupportedAssertion(target, assertion, message) {
901
+ return {
902
+ passed: false,
903
+ type: 'unsupported',
904
+ target,
905
+ expected: assertion,
906
+ actual: undefined,
907
+ message
908
+ };
909
+ }
910
+
911
+ module.exports = {
912
+ evaluateExpectations,
913
+ assertStatus,
914
+ assertHeader,
915
+ assertJson,
916
+ selectPath,
917
+ tokenizePath
918
+ };