@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
package/bin/tram
ADDED
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* tram.js v0.1
|
|
6
|
+
*
|
|
7
|
+
* Dependency-free HTTP API test runner.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* tram api-tests.json --validate
|
|
11
|
+
* tram api-tests.json
|
|
12
|
+
* tram api-tests.json --verbose
|
|
13
|
+
* tram api-tests.json --report results.json
|
|
14
|
+
* tram api-tests.json --transcript transcript.http
|
|
15
|
+
*
|
|
16
|
+
* Requires:
|
|
17
|
+
* Node.js 18+
|
|
18
|
+
* ./assertions.js
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { evaluateExpectations } = require('../lib/assertions');
|
|
24
|
+
|
|
25
|
+
const SUPPORTED_MANIFEST_VERSIONS = ['0.1', '0.2'];
|
|
26
|
+
const CURRENT_MANIFEST_VERSION = '0.2';
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
try {
|
|
30
|
+
const args = parseArgs(process.argv.slice(2));
|
|
31
|
+
const manifestPath = validateManifestPath(args.manifestPath);
|
|
32
|
+
|
|
33
|
+
validateOutputPath(args.reportPath, 'Report');
|
|
34
|
+
validateOutputPath(args.transcriptPath, 'Transcript');
|
|
35
|
+
|
|
36
|
+
const manifest = loadManifest(manifestPath);
|
|
37
|
+
validateManifest(manifest);
|
|
38
|
+
|
|
39
|
+
if (args.validate) {
|
|
40
|
+
console.log("Manifest is valid. No HTTP requests were made.");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const suiteResult = await runSuite(manifest);
|
|
45
|
+
|
|
46
|
+
printConsoleReport(suiteResult, { verbose: args.verbose });
|
|
47
|
+
|
|
48
|
+
if (args.reportPath) {
|
|
49
|
+
writeJsonReport(args.reportPath, suiteResult);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (args.transcriptPath) {
|
|
53
|
+
writeHttpTranscript(args.transcriptPath, suiteResult);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
process.exit(suiteResult.summary.failed > 0 ? 1 : 0);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`ERROR: ${error.message}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv) {
|
|
64
|
+
const args = {
|
|
65
|
+
manifestPath: null,
|
|
66
|
+
verbose: false,
|
|
67
|
+
validate: false,
|
|
68
|
+
reportPath: null,
|
|
69
|
+
transcriptPath: null
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (argv.length === 0) {
|
|
73
|
+
printUsage();
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
78
|
+
const arg = argv[i];
|
|
79
|
+
|
|
80
|
+
if (arg === '--help' || arg === '-h') {
|
|
81
|
+
printUsage();
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (arg === '--verbose' || arg === '-v') {
|
|
86
|
+
args.verbose = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (arg === "--validate" || arg === '-c') {
|
|
91
|
+
args.validate = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (arg === '--report' || arg === '-r') {
|
|
96
|
+
const reportPath = requireOptionValue(arg, argv[i + 1]);
|
|
97
|
+
args.reportPath = reportPath;
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (arg === '--transcript' || arg === '-t') {
|
|
103
|
+
const transcriptPath = requireOptionValue(arg, argv[i + 1]);
|
|
104
|
+
args.transcriptPath = transcriptPath;
|
|
105
|
+
i += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (arg.startsWith('-')) {
|
|
110
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!args.manifestPath) {
|
|
114
|
+
args.manifestPath = arg;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!args.manifestPath) {
|
|
122
|
+
throw new Error('Missing manifest file path.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return args;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function requireOptionValue(option, value) {
|
|
129
|
+
if (!value || value.startsWith('-')) {
|
|
130
|
+
throw new Error(`${option} requires a file path.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function printUsage() {
|
|
137
|
+
console.log('Usage:');
|
|
138
|
+
console.log(' tram <manifest-file> [options]');
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log('Options:');
|
|
141
|
+
console.log(' -v, --verbose Print passing assertion details');
|
|
142
|
+
console.log(' -r, --report <file> Write JSON report to file');
|
|
143
|
+
console.log(' -t, --transcript <file> Write HTTP transcript to file');
|
|
144
|
+
console.log(' -c, --validate Validate the manifest without making HTTP requests');
|
|
145
|
+
console.log(' -h, --help Show help');
|
|
146
|
+
console.log('');
|
|
147
|
+
console.log('Examples:');
|
|
148
|
+
console.log(' tram api-tests.json --validate');
|
|
149
|
+
console.log(' tram api-tests.json');
|
|
150
|
+
console.log(' tram api-tests.json --verbose');
|
|
151
|
+
console.log(' tram api-tests.json --report results.json');
|
|
152
|
+
console.log(' tram api-tests.json --transcript transcript.http');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validateManifestPath(manifestPath) {
|
|
156
|
+
const fullPath = path.resolve(process.cwd(), manifestPath);
|
|
157
|
+
|
|
158
|
+
if (!fs.existsSync(fullPath)) {
|
|
159
|
+
throw new Error(`Manifest file not found: ${fullPath}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const stats = fs.statSync(fullPath);
|
|
163
|
+
|
|
164
|
+
if (!stats.isFile()) {
|
|
165
|
+
throw new Error(`Manifest path is not a file: ${fullPath}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return fullPath;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validateOutputPath(outputPath, label) {
|
|
172
|
+
if (!outputPath) return;
|
|
173
|
+
|
|
174
|
+
const fullPath = path.resolve(process.cwd(), outputPath);
|
|
175
|
+
const directory = path.dirname(fullPath);
|
|
176
|
+
|
|
177
|
+
if (!fs.existsSync(directory)) {
|
|
178
|
+
throw new Error(`${label} directory does not exist: ${directory}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const stats = fs.statSync(directory);
|
|
182
|
+
|
|
183
|
+
if (!stats.isDirectory()) {
|
|
184
|
+
throw new Error(`${label} path parent is not a directory: ${directory}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function loadManifest(manifestPath) {
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw new Error(`Unable to read manifest JSON: ${error.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
197
|
+
const VALID_BODY_TYPES = ['json', 'form', 'text'];
|
|
198
|
+
|
|
199
|
+
function validateManifest(manifest) {
|
|
200
|
+
const errors = [];
|
|
201
|
+
|
|
202
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
203
|
+
errors.push('Manifest must be a JSON object.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
manifest?.manifestVersion &&
|
|
208
|
+
!SUPPORTED_MANIFEST_VERSIONS.includes(manifest.manifestVersion)
|
|
209
|
+
) {
|
|
210
|
+
errors.push(
|
|
211
|
+
`Unsupported manifestVersion ${manifest.manifestVersion}. ` +
|
|
212
|
+
`Supported versions are ${SUPPORTED_MANIFEST_VERSIONS.join(', ')}.`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!manifest?.config || typeof manifest.config !== 'object' || Array.isArray(manifest.config)) {
|
|
217
|
+
errors.push('Manifest requires config object.');
|
|
218
|
+
} else if (!manifest.config.baseUrl) {
|
|
219
|
+
errors.push('Manifest requires config.baseUrl.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!Array.isArray(manifest?.tests)) {
|
|
223
|
+
errors.push('Manifest requires tests array.');
|
|
224
|
+
} else {
|
|
225
|
+
validateTests(manifest.tests, errors);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (errors.length > 0) {
|
|
229
|
+
throw new Error(`Manifest validation failed:\n- ${errors.join('\n- ')}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function validateTests(tests, errors) {
|
|
234
|
+
const seenIds = new Set();
|
|
235
|
+
|
|
236
|
+
tests.forEach((test, index) => {
|
|
237
|
+
const prefix = `tests[${index}]`;
|
|
238
|
+
|
|
239
|
+
if (!test || typeof test !== 'object' || Array.isArray(test)) {
|
|
240
|
+
errors.push(`${prefix} must be an object.`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (test.id) {
|
|
245
|
+
if (seenIds.has(test.id)) {
|
|
246
|
+
errors.push(`${prefix}.id duplicates "${test.id}".`);
|
|
247
|
+
}
|
|
248
|
+
seenIds.add(test.id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!test.name) {
|
|
252
|
+
errors.push(`${prefix} missing name.`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!test.method) {
|
|
256
|
+
errors.push(`${prefix} missing method.`);
|
|
257
|
+
} else if (!VALID_METHODS.includes(String(test.method).toUpperCase())) {
|
|
258
|
+
errors.push(`${prefix}.method unsupported HTTP method: ${test.method}.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!test.path) {
|
|
262
|
+
errors.push(`${prefix} missing path.`);
|
|
263
|
+
} else if (typeof test.path !== 'string') {
|
|
264
|
+
errors.push(`${prefix}.path must be a string.`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!test.expect || typeof test.expect !== 'object' || Array.isArray(test.expect)) {
|
|
268
|
+
errors.push(`${prefix} missing expect object.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (test.bodyType && !VALID_BODY_TYPES.includes(test.bodyType)) {
|
|
272
|
+
errors.push(`${prefix}.bodyType unsupported value: ${test.bodyType}.`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
validateCapture(test.capture, `${prefix}.capture`, errors);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function validateCapture(capture, prefix, errors) {
|
|
280
|
+
if (capture === undefined) return;
|
|
281
|
+
|
|
282
|
+
if (!capture || typeof capture !== 'object' || Array.isArray(capture)) {
|
|
283
|
+
errors.push(`${prefix} must be an object.`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const [name, spec] of Object.entries(capture)) {
|
|
288
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
289
|
+
errors.push(`${prefix}.${name} has invalid capture name.`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (typeof spec === 'string') {
|
|
293
|
+
validateCapturePath(spec, `${prefix}.${name}`, errors);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
298
|
+
errors.push(`${prefix}.${name} must be a string or object.`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (typeof spec.from !== 'string') {
|
|
303
|
+
errors.push(`${prefix}.${name}.from must be a string.`);
|
|
304
|
+
} else {
|
|
305
|
+
validateCapturePath(spec.from, `${prefix}.${name}.from`, errors);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (spec.optional !== undefined && typeof spec.optional !== 'boolean') {
|
|
309
|
+
errors.push(`${prefix}.${name}.optional must be a boolean.`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function validateCapturePath(capturePath, prefix, errors) {
|
|
315
|
+
if (!capturePath) {
|
|
316
|
+
errors.push(`${prefix} capture path must not be empty.`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (
|
|
321
|
+
capturePath !== 'status' &&
|
|
322
|
+
capturePath !== 'rawBody' &&
|
|
323
|
+
!capturePath.startsWith('body.') &&
|
|
324
|
+
!capturePath.startsWith('headers.')
|
|
325
|
+
) {
|
|
326
|
+
errors.push(`${prefix} unsupported capture path: ${capturePath}.`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function runSuite(manifest) {
|
|
331
|
+
const results = [];
|
|
332
|
+
const tests = manifest.tests || [];
|
|
333
|
+
const runtimeData = initializeRuntimeData(manifest.data || {});
|
|
334
|
+
const runtimeCapture = {};
|
|
335
|
+
|
|
336
|
+
for (let index = 0; index < tests.length; index += 1) {
|
|
337
|
+
const test = tests[index];
|
|
338
|
+
const testResult = await runTest({ manifest, runtimeData, runtimeCapture, test, index });
|
|
339
|
+
results.push(testResult);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
name: manifest.name || 'HTTP API Tests',
|
|
344
|
+
manifestVersion: manifest.manifestVersion || SUPPORTED_MANIFEST_VERSION,
|
|
345
|
+
version: manifest.version || null,
|
|
346
|
+
description: manifest.description || null,
|
|
347
|
+
author: manifest.author || null,
|
|
348
|
+
created: manifest.created || null,
|
|
349
|
+
updated: manifest.updated || null,
|
|
350
|
+
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
|
|
351
|
+
summary: summarize(results),
|
|
352
|
+
results
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function runTest({ manifest, runtimeData, runtimeCapture, test, index }) {
|
|
357
|
+
const id = test.id || `test-${index + 1}`;
|
|
358
|
+
const enabled = test.enabled !== false;
|
|
359
|
+
|
|
360
|
+
if (!enabled) {
|
|
361
|
+
return {
|
|
362
|
+
id,
|
|
363
|
+
name: test.name || id,
|
|
364
|
+
enabled: false,
|
|
365
|
+
skipped: true,
|
|
366
|
+
passed: true,
|
|
367
|
+
method: test.method || null,
|
|
368
|
+
path: test.path || null,
|
|
369
|
+
status: null,
|
|
370
|
+
durationMs: 0,
|
|
371
|
+
assertions: []
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
validateTest(test, index);
|
|
376
|
+
|
|
377
|
+
const started = Date.now();
|
|
378
|
+
let request = null;
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
request = buildRequest({ manifest, runtimeData, runtimeCapture, test });
|
|
382
|
+
const response = await executeRequest(request, manifest.config.timeoutMs);
|
|
383
|
+
const durationMs = Date.now() - started;
|
|
384
|
+
const resolvedExpect = resolveRuntimeTokens(
|
|
385
|
+
resolveValue(test.expect, runtimeData, runtimeCapture),
|
|
386
|
+
runtimeData,
|
|
387
|
+
runtimeCapture
|
|
388
|
+
);
|
|
389
|
+
const assertions = evaluateExpectations({ expect: resolvedExpect, response });
|
|
390
|
+
const expectationsPassed = assertions.every((assertion) => assertion.passed);
|
|
391
|
+
const captureAssertions = expectationsPassed
|
|
392
|
+
? applyCapture(test.capture, response, runtimeCapture)
|
|
393
|
+
: [];
|
|
394
|
+
const allAssertions = assertions.concat(captureAssertions);
|
|
395
|
+
const passed = allAssertions.every((assertion) => assertion.passed);
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
id,
|
|
399
|
+
name: test.name,
|
|
400
|
+
enabled: true,
|
|
401
|
+
skipped: false,
|
|
402
|
+
passed,
|
|
403
|
+
type: test.type || null,
|
|
404
|
+
tags: Array.isArray(test.tags) ? test.tags : [],
|
|
405
|
+
rationale: test.rationale || null,
|
|
406
|
+
method: request.method,
|
|
407
|
+
path: request.path,
|
|
408
|
+
url: request.url,
|
|
409
|
+
status: response.status,
|
|
410
|
+
durationMs,
|
|
411
|
+
request: requestForReport(request),
|
|
412
|
+
response: responseForReport(response),
|
|
413
|
+
assertions: allAssertions
|
|
414
|
+
};
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const durationMs = Date.now() - started;
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
id,
|
|
420
|
+
name: test.name,
|
|
421
|
+
enabled: true,
|
|
422
|
+
skipped: false,
|
|
423
|
+
passed: false,
|
|
424
|
+
type: test.type || null,
|
|
425
|
+
tags: Array.isArray(test.tags) ? test.tags : [],
|
|
426
|
+
rationale: test.rationale || null,
|
|
427
|
+
method: request ? request.method : (test.method || null),
|
|
428
|
+
path: request ? request.path : (test.path || null),
|
|
429
|
+
url: request ? request.url : null,
|
|
430
|
+
status: null,
|
|
431
|
+
durationMs,
|
|
432
|
+
request: request ? requestForReport(request) : null,
|
|
433
|
+
response: null,
|
|
434
|
+
assertions: [
|
|
435
|
+
{
|
|
436
|
+
passed: false,
|
|
437
|
+
type: 'request',
|
|
438
|
+
target: 'http',
|
|
439
|
+
expected: 'successful HTTP request',
|
|
440
|
+
actual: error.message,
|
|
441
|
+
message: `Request failed: ${error.message}`
|
|
442
|
+
}
|
|
443
|
+
]
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function validateTest(test, index) {
|
|
449
|
+
const label = test.name || `test at index ${index}`;
|
|
450
|
+
|
|
451
|
+
if (!test.name) throw new Error(`Test at index ${index} requires name.`);
|
|
452
|
+
if (!test.method) throw new Error(`${label} requires method.`);
|
|
453
|
+
if (!test.path) throw new Error(`${label} requires path.`);
|
|
454
|
+
if (!test.expect || typeof test.expect !== 'object') {
|
|
455
|
+
throw new Error(`${label} requires expect object.`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function buildRequest({ manifest, runtimeData, runtimeCapture, test }) {
|
|
460
|
+
const config = manifest.config || {};
|
|
461
|
+
|
|
462
|
+
const method = String(test.method || 'GET').toUpperCase();
|
|
463
|
+
const headers = resolveRuntimeTokens(
|
|
464
|
+
resolveValue(
|
|
465
|
+
{
|
|
466
|
+
...(config.defaultHeaders || {}),
|
|
467
|
+
...(test.headers || {})
|
|
468
|
+
},
|
|
469
|
+
runtimeData,
|
|
470
|
+
runtimeCapture
|
|
471
|
+
),
|
|
472
|
+
runtimeData,
|
|
473
|
+
runtimeCapture
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const query = resolveRuntimeTokens(
|
|
477
|
+
resolveValue(test.query, runtimeData, runtimeCapture),
|
|
478
|
+
runtimeData,
|
|
479
|
+
runtimeCapture
|
|
480
|
+
);
|
|
481
|
+
const requestPath = resolveRuntimeTokens(
|
|
482
|
+
resolveValue(test.path, runtimeData, runtimeCapture),
|
|
483
|
+
runtimeData,
|
|
484
|
+
runtimeCapture
|
|
485
|
+
);
|
|
486
|
+
const url = buildUrl(config.baseUrl, requestPath, query, runtimeData);
|
|
487
|
+
|
|
488
|
+
const request = {
|
|
489
|
+
method,
|
|
490
|
+
path: requestPath,
|
|
491
|
+
url,
|
|
492
|
+
headers,
|
|
493
|
+
body: undefined,
|
|
494
|
+
bodyType: null
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
if (Object.prototype.hasOwnProperty.call(test, 'body')) {
|
|
498
|
+
const bodyType = test.bodyType || 'json';
|
|
499
|
+
const resolvedBody = resolveRuntimeTokens(
|
|
500
|
+
resolveValue(test.body, runtimeData, runtimeCapture),
|
|
501
|
+
runtimeData,
|
|
502
|
+
runtimeCapture
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
request.bodyType = bodyType;
|
|
506
|
+
request.body = encodeBody(resolvedBody, bodyType, request.headers);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return request;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function encodeBody(body, bodyType, headers) {
|
|
513
|
+
switch (bodyType) {
|
|
514
|
+
case 'json':
|
|
515
|
+
if (!hasHeader(headers, 'content-type')) {
|
|
516
|
+
headers['content-type'] = 'application/json';
|
|
517
|
+
}
|
|
518
|
+
return JSON.stringify(body);
|
|
519
|
+
|
|
520
|
+
case 'form':
|
|
521
|
+
if (!hasHeader(headers, 'content-type')) {
|
|
522
|
+
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
523
|
+
}
|
|
524
|
+
return encodeFormBody(body);
|
|
525
|
+
|
|
526
|
+
case 'text':
|
|
527
|
+
if (!hasHeader(headers, 'content-type')) {
|
|
528
|
+
headers['content-type'] = 'text/plain';
|
|
529
|
+
}
|
|
530
|
+
return typeof body === 'string' ? body : String(body);
|
|
531
|
+
|
|
532
|
+
default:
|
|
533
|
+
throw new Error(`Unsupported bodyType: ${bodyType}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function encodeFormBody(body) {
|
|
538
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
539
|
+
throw new Error('bodyType "form" requires body to be an object.');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const params = new URLSearchParams();
|
|
543
|
+
|
|
544
|
+
for (const [key, value] of Object.entries(body)) {
|
|
545
|
+
if (value === undefined || value === null) continue;
|
|
546
|
+
params.set(key, String(value));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return params.toString();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function buildUrl(baseUrl, requestPath, query, runtimeData) {
|
|
553
|
+
const url = new URL(requestPath, ensureTrailingSlash(baseUrl));
|
|
554
|
+
|
|
555
|
+
if (query && typeof query === 'object') {
|
|
556
|
+
for (const [key, value] of Object.entries(query)) {
|
|
557
|
+
if (value === undefined || value === null) continue;
|
|
558
|
+
url.searchParams.set(key, String(resolveRuntimeTokens(value, runtimeData)));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return url.toString();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function ensureTrailingSlash(value) {
|
|
566
|
+
return String(value).endsWith('/') ? value : `${value}/`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function executeRequest(request, timeoutMs = 5000) {
|
|
570
|
+
const controller = new AbortController();
|
|
571
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
const response = await fetch(request.url, {
|
|
575
|
+
method: request.method,
|
|
576
|
+
headers: request.headers,
|
|
577
|
+
body: request.body,
|
|
578
|
+
signal: controller.signal
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const text = await response.text();
|
|
582
|
+
const headers = headersToObject(response.headers);
|
|
583
|
+
const body = parseBody(text, headers);
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
status: response.status,
|
|
587
|
+
headers,
|
|
588
|
+
body,
|
|
589
|
+
rawBody: text
|
|
590
|
+
};
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (error.name === 'AbortError') {
|
|
593
|
+
throw new Error(`Request timed out after ${timeoutMs}ms`);
|
|
594
|
+
}
|
|
595
|
+
throw error;
|
|
596
|
+
} finally {
|
|
597
|
+
clearTimeout(timeout);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function parseBody(text, headers) {
|
|
602
|
+
if (!text) return null;
|
|
603
|
+
|
|
604
|
+
const contentType = getHeaderValue(headers, 'content-type') || '';
|
|
605
|
+
|
|
606
|
+
if (contentType.includes('application/json') || looksLikeJson(text)) {
|
|
607
|
+
try {
|
|
608
|
+
return JSON.parse(text);
|
|
609
|
+
} catch (_error) {
|
|
610
|
+
return text;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return text;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function looksLikeJson(text) {
|
|
618
|
+
const trimmed = text.trim();
|
|
619
|
+
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function headersToObject(headers) {
|
|
623
|
+
const result = {};
|
|
624
|
+
headers.forEach((value, key) => {
|
|
625
|
+
result[key.toLowerCase()] = value;
|
|
626
|
+
});
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function initializeRuntimeData(data) {
|
|
631
|
+
return resolveBuiltInTokens(clone(data));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function resolveValue(value, runtimeData, runtimeCapture = {}) {
|
|
635
|
+
if (typeof value === 'string') {
|
|
636
|
+
if (value.startsWith('$data.')) {
|
|
637
|
+
const key = value.slice('$data.'.length);
|
|
638
|
+
|
|
639
|
+
if (Object.prototype.hasOwnProperty.call(runtimeData, key)) {
|
|
640
|
+
return clone(runtimeData[key]);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const resolved = getByPath(runtimeData, key);
|
|
644
|
+
|
|
645
|
+
if (!resolved.exists) {
|
|
646
|
+
throw new Error(`Data reference not found: ${value}`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return clone(resolved.value);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (value.startsWith('$capture.')) {
|
|
653
|
+
const key = value.slice('$capture.'.length);
|
|
654
|
+
const resolved = getByPath(runtimeCapture, key);
|
|
655
|
+
|
|
656
|
+
if (!resolved.exists) {
|
|
657
|
+
throw new Error(`Capture reference not found: ${value}`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return clone(resolved.value);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return value;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (Array.isArray(value)) {
|
|
667
|
+
return value.map((item) => resolveValue(item, runtimeData, runtimeCapture));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (value && typeof value === 'object') {
|
|
671
|
+
const result = {};
|
|
672
|
+
for (const [key, child] of Object.entries(value)) {
|
|
673
|
+
result[key] = resolveValue(child, runtimeData, runtimeCapture);
|
|
674
|
+
}
|
|
675
|
+
return result;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return clone(value);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function resolveRuntimeTokens(value, runtimeData, runtimeCapture = {}) {
|
|
682
|
+
if (typeof value === 'string') {
|
|
683
|
+
return value.replace(/\$\{([^}]+)\}/g, (_match, tokenName) => {
|
|
684
|
+
if (tokenName.startsWith('data.')) {
|
|
685
|
+
const dataPath = tokenName.slice('data.'.length);
|
|
686
|
+
const resolved = getByPath(runtimeData, dataPath);
|
|
687
|
+
|
|
688
|
+
if (!resolved.exists) {
|
|
689
|
+
throw new Error(`Data reference not found: ${tokenName}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return String(resolved.value);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (tokenName.startsWith('capture.')) {
|
|
696
|
+
const capturePath = tokenName.slice('capture.'.length);
|
|
697
|
+
const resolved = getByPath(runtimeCapture, capturePath);
|
|
698
|
+
|
|
699
|
+
if (!resolved.exists) {
|
|
700
|
+
throw new Error(`Capture reference not found: ${tokenName}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return String(resolved.value);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return generateToken(tokenName);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (Array.isArray(value)) {
|
|
711
|
+
return value.map((item) => resolveRuntimeTokens(item, runtimeData, runtimeCapture));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (value && typeof value === 'object') {
|
|
715
|
+
const result = {};
|
|
716
|
+
for (const [key, child] of Object.entries(value)) {
|
|
717
|
+
result[key] = resolveRuntimeTokens(child, runtimeData, runtimeCapture);
|
|
718
|
+
}
|
|
719
|
+
return result;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return value;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function resolveBuiltInTokens(value) {
|
|
726
|
+
if (typeof value === 'string') {
|
|
727
|
+
return value.replace(/\$\{([^}]+)\}/g, (_match, tokenName) => {
|
|
728
|
+
if (tokenName.startsWith('data.')) {
|
|
729
|
+
return `\${${tokenName}}`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return generateToken(tokenName);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (Array.isArray(value)) {
|
|
737
|
+
return value.map((item) => resolveBuiltInTokens(item));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (value && typeof value === 'object') {
|
|
741
|
+
const result = {};
|
|
742
|
+
for (const [key, child] of Object.entries(value)) {
|
|
743
|
+
result[key] = resolveBuiltInTokens(child);
|
|
744
|
+
}
|
|
745
|
+
return result;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return value;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function getByPath(source, dotPath) {
|
|
752
|
+
if (!dotPath) {
|
|
753
|
+
return { exists: true, value: source };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const parts = String(dotPath).split('.');
|
|
757
|
+
let current = source;
|
|
758
|
+
|
|
759
|
+
for (const part of parts) {
|
|
760
|
+
if (current === null || current === undefined) {
|
|
761
|
+
return { exists: false, value: undefined };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (!Object.prototype.hasOwnProperty.call(Object(current), part)) {
|
|
765
|
+
return { exists: false, value: undefined };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
current = current[part];
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return { exists: true, value: current };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function applyCapture(capture, response, runtimeCapture) {
|
|
775
|
+
if (capture === undefined) return [];
|
|
776
|
+
|
|
777
|
+
const assertions = [];
|
|
778
|
+
|
|
779
|
+
for (const [name, spec] of Object.entries(capture)) {
|
|
780
|
+
const captureSpec = normalizeCaptureSpec(spec);
|
|
781
|
+
const resolved = getCaptureValue(response, captureSpec.from);
|
|
782
|
+
|
|
783
|
+
if (!resolved.exists) {
|
|
784
|
+
const passed = captureSpec.optional === true;
|
|
785
|
+
assertions.push({
|
|
786
|
+
passed,
|
|
787
|
+
type: 'capture',
|
|
788
|
+
target: name,
|
|
789
|
+
expected: captureSpec.from,
|
|
790
|
+
actual: undefined,
|
|
791
|
+
message: passed
|
|
792
|
+
? `Optional capture skipped: ${name} from ${captureSpec.from}`
|
|
793
|
+
: `Capture failed: ${name} not found at ${captureSpec.from}`
|
|
794
|
+
});
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
runtimeCapture[name] = clone(resolved.value);
|
|
799
|
+
assertions.push({
|
|
800
|
+
passed: true,
|
|
801
|
+
type: 'capture',
|
|
802
|
+
target: name,
|
|
803
|
+
expected: captureSpec.from,
|
|
804
|
+
actual: resolved.value,
|
|
805
|
+
message: `Captured ${name} from ${captureSpec.from}`
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return assertions;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function normalizeCaptureSpec(spec) {
|
|
813
|
+
if (typeof spec === 'string') {
|
|
814
|
+
return { from: spec, optional: false };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
from: spec.from,
|
|
819
|
+
optional: spec.optional === true
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function getCaptureValue(response, capturePath) {
|
|
824
|
+
if (capturePath === 'status') {
|
|
825
|
+
return { exists: true, value: response.status };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (capturePath === 'rawBody') {
|
|
829
|
+
return { exists: true, value: response.rawBody };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (capturePath.startsWith('body.')) {
|
|
833
|
+
return getByPath(response.body, capturePath.slice('body.'.length));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (capturePath.startsWith('headers.')) {
|
|
837
|
+
const headerName = capturePath.slice('headers.'.length).toLowerCase();
|
|
838
|
+
const value = getHeaderValue(response.headers, headerName);
|
|
839
|
+
return {
|
|
840
|
+
exists: value !== undefined,
|
|
841
|
+
value
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return { exists: false, value: undefined };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function generateToken(tokenName) {
|
|
849
|
+
switch (tokenName) {
|
|
850
|
+
case 'randomId':
|
|
851
|
+
return Math.random().toString(36).slice(2);
|
|
852
|
+
case 'timestamp':
|
|
853
|
+
return new Date().toISOString();
|
|
854
|
+
case 'uuid':
|
|
855
|
+
return cryptoRandomUuid();
|
|
856
|
+
case 'randomEmail':
|
|
857
|
+
return `test-${Math.random().toString(36).slice(2)}@example.com`;
|
|
858
|
+
default:
|
|
859
|
+
return `\${${tokenName}}`;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function cryptoRandomUuid() {
|
|
864
|
+
if (global.crypto && typeof global.crypto.randomUUID === 'function') {
|
|
865
|
+
return global.crypto.randomUUID();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
|
|
869
|
+
const random = Math.floor(Math.random() * 16);
|
|
870
|
+
const value = char === 'x' ? random : (random & 0x3) | 0x8;
|
|
871
|
+
return value.toString(16);
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function summarize(results) {
|
|
876
|
+
const summary = {
|
|
877
|
+
total: results.length,
|
|
878
|
+
passed: 0,
|
|
879
|
+
failed: 0,
|
|
880
|
+
skipped: 0
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
for (const result of results) {
|
|
884
|
+
if (result.skipped) summary.skipped += 1;
|
|
885
|
+
else if (result.passed) summary.passed += 1;
|
|
886
|
+
else summary.failed += 1;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return summary;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function printConsoleReport(suiteResult, options = {}) {
|
|
893
|
+
const verbose = options.verbose === true;
|
|
894
|
+
|
|
895
|
+
console.log(suiteResult.name);
|
|
896
|
+
console.log('');
|
|
897
|
+
|
|
898
|
+
for (const result of suiteResult.results) {
|
|
899
|
+
if (result.skipped) {
|
|
900
|
+
console.log(`- ${result.name} (skipped)`);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const marker = result.passed ? '✓' : '✗';
|
|
905
|
+
console.log(`${marker} ${result.name}`);
|
|
906
|
+
|
|
907
|
+
if (!result.passed || verbose) {
|
|
908
|
+
console.log(` ${result.method} ${result.path}`);
|
|
909
|
+
if (result.status !== null && result.status !== undefined) {
|
|
910
|
+
console.log(` Status: ${result.status}`);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const assertionsToPrint = verbose
|
|
914
|
+
? result.assertions
|
|
915
|
+
: result.assertions.filter((assertion) => !assertion.passed);
|
|
916
|
+
|
|
917
|
+
for (const assertion of assertionsToPrint) {
|
|
918
|
+
const assertionMarker = assertion.passed ? '✓' : '✗';
|
|
919
|
+
console.log(` ${assertionMarker} ${assertion.message}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
console.log('');
|
|
925
|
+
console.log(
|
|
926
|
+
`Summary: ${suiteResult.summary.passed} passed, ${suiteResult.summary.failed} failed, ${suiteResult.summary.skipped} skipped, ${suiteResult.summary.total} total`
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function writeJsonReport(reportPath, suiteResult) {
|
|
931
|
+
const fullPath = path.resolve(process.cwd(), reportPath);
|
|
932
|
+
fs.writeFileSync(fullPath, `${JSON.stringify(suiteResult, null, 2)}\n`, 'utf8');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
function writeHttpTranscript(transcriptPath, suiteResult) {
|
|
937
|
+
const fullPath = path.resolve(process.cwd(), transcriptPath);
|
|
938
|
+
fs.writeFileSync(fullPath, renderHttpTranscript(suiteResult), 'utf8');
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function renderHttpTranscript(suiteResult) {
|
|
942
|
+
const lines = [];
|
|
943
|
+
|
|
944
|
+
lines.push('TRAM HTTP Transcript');
|
|
945
|
+
lines.push(`Suite: ${suiteResult.name}`);
|
|
946
|
+
lines.push(`Generated: ${new Date().toISOString()}`);
|
|
947
|
+
lines.push('');
|
|
948
|
+
|
|
949
|
+
for (const result of suiteResult.results) {
|
|
950
|
+
if (result.skipped) {
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
lines.push('============================================================');
|
|
955
|
+
lines.push(`${result.name} (${result.id})`);
|
|
956
|
+
lines.push('');
|
|
957
|
+
lines.push(renderTranscriptRequest(result.request));
|
|
958
|
+
lines.push('');
|
|
959
|
+
lines.push(renderTranscriptResponse(result.response));
|
|
960
|
+
|
|
961
|
+
const captureAssertions = (result.assertions || []).filter((assertion) => assertion.type === 'capture');
|
|
962
|
+
if (captureAssertions.length > 0) {
|
|
963
|
+
lines.push('');
|
|
964
|
+
lines.push('--- Captures ------------------------------------------------');
|
|
965
|
+
for (const assertion of captureAssertions) {
|
|
966
|
+
if (assertion.passed) {
|
|
967
|
+
lines.push(`${assertion.target} = ${formatTranscriptValue(assertion.actual)}`);
|
|
968
|
+
} else {
|
|
969
|
+
lines.push(`${assertion.target} = <not captured>`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
lines.push('');
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return `${lines.join('\n')}\n`;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function renderTranscriptRequest(request) {
|
|
981
|
+
if (!request) {
|
|
982
|
+
return '> REQUEST UNAVAILABLE';
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const url = new URL(request.url);
|
|
986
|
+
const lines = [];
|
|
987
|
+
|
|
988
|
+
lines.push(`> ${request.method} ${url.pathname}${url.search} HTTP/1.1`);
|
|
989
|
+
lines.push(`> Host: ${url.host}`);
|
|
990
|
+
|
|
991
|
+
for (const [name, value] of Object.entries(request.headers || {})) {
|
|
992
|
+
lines.push(`> ${name}: ${redactHeaderValue(name, value)}`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (request.body !== null && request.body !== undefined) {
|
|
996
|
+
lines.push('>');
|
|
997
|
+
lines.push(prefixTranscriptBody(formatTranscriptBody(request.body), '> '));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return lines.join('\n');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function renderTranscriptResponse(response) {
|
|
1004
|
+
if (!response) {
|
|
1005
|
+
return '< REQUEST FAILED';
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const lines = [];
|
|
1009
|
+
|
|
1010
|
+
lines.push(`< HTTP/1.1 ${response.status}`);
|
|
1011
|
+
|
|
1012
|
+
for (const [name, value] of Object.entries(response.headers || {})) {
|
|
1013
|
+
lines.push(`< ${name}: ${redactHeaderValue(name, value)}`);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (response.rawBody) {
|
|
1017
|
+
lines.push('<');
|
|
1018
|
+
lines.push(prefixTranscriptBody(formatTranscriptBody(response.body), '< '));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return lines.join('\n');
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function formatTranscriptBody(value) {
|
|
1025
|
+
if (typeof value === 'string') {
|
|
1026
|
+
return value;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return JSON.stringify(value, null, 2);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function prefixTranscriptBody(value, prefix) {
|
|
1033
|
+
return String(value)
|
|
1034
|
+
.split('\n')
|
|
1035
|
+
.map((line) => `${prefix}${line}`)
|
|
1036
|
+
.join('\n');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function formatTranscriptValue(value) {
|
|
1040
|
+
if (typeof value === 'string') {
|
|
1041
|
+
return JSON.stringify(value);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return JSON.stringify(value, null, 2);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function redactHeaderValue(name, value) {
|
|
1048
|
+
const sensitiveHeaders = new Set(['authorization', 'cookie', 'set-cookie', 'x-api-key']);
|
|
1049
|
+
|
|
1050
|
+
if (sensitiveHeaders.has(String(name).toLowerCase())) {
|
|
1051
|
+
return '[redacted]';
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return value;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function redactHeaders(headers) {
|
|
1058
|
+
return Object.fromEntries(
|
|
1059
|
+
Object.entries(headers || {}).map(([name, value]) => [name, redactHeaderValue(name, value)])
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function requestForReport(request) {
|
|
1064
|
+
return {
|
|
1065
|
+
method: request.method,
|
|
1066
|
+
url: request.url,
|
|
1067
|
+
headers: redactHeaders(request.headers),
|
|
1068
|
+
bodyType: request.bodyType,
|
|
1069
|
+
body: request.body ? safeParseRequestBody(request.body, request.bodyType) : null
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function safeParseRequestBody(value, bodyType) {
|
|
1074
|
+
if (bodyType === 'json') {
|
|
1075
|
+
try {
|
|
1076
|
+
return JSON.parse(value);
|
|
1077
|
+
} catch (_error) {
|
|
1078
|
+
return value;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (bodyType === 'form') {
|
|
1083
|
+
return Object.fromEntries(new URLSearchParams(value));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return value;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function responseForReport(response) {
|
|
1090
|
+
return {
|
|
1091
|
+
status: response.status,
|
|
1092
|
+
headers: redactHeaders(response.headers),
|
|
1093
|
+
body: response.body,
|
|
1094
|
+
rawBody: response.rawBody
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function hasHeader(headers, name) {
|
|
1099
|
+
const normalized = name.toLowerCase();
|
|
1100
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === normalized);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function getHeaderValue(headers, name) {
|
|
1104
|
+
const normalized = name.toLowerCase();
|
|
1105
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
1106
|
+
if (key.toLowerCase() === normalized) return value;
|
|
1107
|
+
}
|
|
1108
|
+
return undefined;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function clone(value) {
|
|
1112
|
+
if (value === undefined) return undefined;
|
|
1113
|
+
return JSON.parse(JSON.stringify(value));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
main();
|
|
1117
|
+
|