@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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +15 -0
- package/README.md +996 -2
- package/bin/tram +1117 -0
- package/docs/behavioral-modeling-for-apis.md +749 -0
- package/docs/beta-status.md +29 -0
- package/docs/explainer.md +256 -0
- package/docs/images/tram-logo.png +0 -0
- package/docs/images/tram-test-run.png +0 -0
- package/docs/manifest-spec.md +1410 -0
- package/docs/quick-start.md +92 -0
- package/docs/roadmap.md +557 -0
- package/docs/tasks-api-tutorial.md +115 -0
- package/lib/assertions.js +918 -0
- package/package.json +47 -4
|
@@ -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
|
+
};
|