@curl-runner/cli 1.14.0 → 1.16.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/package.json +2 -1
- package/src/ci-exit.test.ts +1 -0
- package/src/cli.ts +308 -14
- package/src/commands/upgrade.ts +262 -0
- package/src/diff/baseline-manager.test.ts +181 -0
- package/src/diff/baseline-manager.ts +266 -0
- package/src/diff/diff-formatter.ts +316 -0
- package/src/diff/index.ts +3 -0
- package/src/diff/response-differ.test.ts +330 -0
- package/src/diff/response-differ.ts +489 -0
- package/src/parser/yaml.ts +2 -3
- package/src/types/bun-yaml.d.ts +11 -0
- package/src/types/config.ts +102 -0
- package/src/utils/curl-builder.ts +9 -1
- package/src/utils/installation-detector.test.ts +52 -0
- package/src/utils/installation-detector.ts +123 -0
- package/src/utils/logger.ts +1 -1
- package/src/utils/version-checker.ts +10 -17
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Baseline,
|
|
3
|
+
DiffCompareResult,
|
|
4
|
+
DiffConfig,
|
|
5
|
+
DiffSummary,
|
|
6
|
+
ExecutionResult,
|
|
7
|
+
GlobalDiffConfig,
|
|
8
|
+
JsonValue,
|
|
9
|
+
ResponseDiff,
|
|
10
|
+
} from '../types/config';
|
|
11
|
+
import { BaselineManager } from './baseline-manager';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compares responses against baselines with support for exclusions and match rules.
|
|
15
|
+
*/
|
|
16
|
+
export class ResponseDiffer {
|
|
17
|
+
private excludePaths: Set<string>;
|
|
18
|
+
private matchRules: Map<string, string>;
|
|
19
|
+
private includeTimings: boolean;
|
|
20
|
+
|
|
21
|
+
constructor(config: DiffConfig) {
|
|
22
|
+
this.excludePaths = new Set(config.exclude || []);
|
|
23
|
+
this.matchRules = new Map(Object.entries(config.match || {}));
|
|
24
|
+
this.includeTimings = config.includeTimings || false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compares current response against baseline.
|
|
29
|
+
*/
|
|
30
|
+
compare(
|
|
31
|
+
baseline: Baseline,
|
|
32
|
+
current: Baseline,
|
|
33
|
+
baselineLabel: string,
|
|
34
|
+
currentLabel: string,
|
|
35
|
+
requestName: string,
|
|
36
|
+
): DiffCompareResult {
|
|
37
|
+
const differences: ResponseDiff[] = [];
|
|
38
|
+
|
|
39
|
+
// Compare status
|
|
40
|
+
if (baseline.status !== undefined || current.status !== undefined) {
|
|
41
|
+
if (baseline.status !== current.status && !this.isExcluded('status')) {
|
|
42
|
+
differences.push({
|
|
43
|
+
path: 'status',
|
|
44
|
+
baseline: baseline.status,
|
|
45
|
+
current: current.status,
|
|
46
|
+
type: 'changed',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Compare headers
|
|
52
|
+
if (baseline.headers || current.headers) {
|
|
53
|
+
const headerDiffs = this.compareObjects(
|
|
54
|
+
baseline.headers || {},
|
|
55
|
+
current.headers || {},
|
|
56
|
+
'headers',
|
|
57
|
+
);
|
|
58
|
+
differences.push(...headerDiffs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Compare body
|
|
62
|
+
if (baseline.body !== undefined || current.body !== undefined) {
|
|
63
|
+
const bodyDiffs = this.deepCompare(baseline.body, current.body, 'body');
|
|
64
|
+
differences.push(...bodyDiffs);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result: DiffCompareResult = {
|
|
68
|
+
requestName,
|
|
69
|
+
hasDifferences: differences.length > 0,
|
|
70
|
+
isNewBaseline: false,
|
|
71
|
+
baselineLabel,
|
|
72
|
+
currentLabel,
|
|
73
|
+
differences,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Add timing diff if enabled
|
|
77
|
+
if (this.includeTimings && baseline.timing !== undefined && current.timing !== undefined) {
|
|
78
|
+
const changePercent = ((current.timing - baseline.timing) / baseline.timing) * 100;
|
|
79
|
+
result.timingDiff = {
|
|
80
|
+
baseline: baseline.timing,
|
|
81
|
+
current: current.timing,
|
|
82
|
+
changePercent,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Deep comparison of two values with path tracking.
|
|
91
|
+
*/
|
|
92
|
+
deepCompare(baseline: unknown, current: unknown, path: string): ResponseDiff[] {
|
|
93
|
+
if (this.isExcluded(path)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (this.matchesRule(path, current)) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (baseline === null && current === null) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
if (baseline === undefined && current === undefined) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const baselineType = this.getType(baseline);
|
|
109
|
+
const currentType = this.getType(current);
|
|
110
|
+
|
|
111
|
+
if (baselineType !== currentType) {
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
path,
|
|
115
|
+
baseline,
|
|
116
|
+
current,
|
|
117
|
+
type: 'type_mismatch',
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (baselineType !== 'object' && baselineType !== 'array') {
|
|
123
|
+
if (baseline !== current) {
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
path,
|
|
127
|
+
baseline,
|
|
128
|
+
current,
|
|
129
|
+
type: 'changed',
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (baselineType === 'array') {
|
|
137
|
+
return this.compareArrays(baseline as JsonValue[], current as JsonValue[], path);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return this.compareObjects(
|
|
141
|
+
baseline as Record<string, unknown>,
|
|
142
|
+
current as Record<string, unknown>,
|
|
143
|
+
path,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compares two arrays.
|
|
149
|
+
*/
|
|
150
|
+
private compareArrays(baseline: JsonValue[], current: JsonValue[], path: string): ResponseDiff[] {
|
|
151
|
+
const differences: ResponseDiff[] = [];
|
|
152
|
+
const maxLen = Math.max(baseline.length, current.length);
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < maxLen; i++) {
|
|
155
|
+
const itemPath = `${path}[${i}]`;
|
|
156
|
+
|
|
157
|
+
if (i >= baseline.length) {
|
|
158
|
+
if (!this.isExcluded(itemPath)) {
|
|
159
|
+
differences.push({
|
|
160
|
+
path: itemPath,
|
|
161
|
+
baseline: undefined,
|
|
162
|
+
current: current[i],
|
|
163
|
+
type: 'added',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
} else if (i >= current.length) {
|
|
167
|
+
if (!this.isExcluded(itemPath)) {
|
|
168
|
+
differences.push({
|
|
169
|
+
path: itemPath,
|
|
170
|
+
baseline: baseline[i],
|
|
171
|
+
current: undefined,
|
|
172
|
+
type: 'removed',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
const itemDiffs = this.deepCompare(baseline[i], current[i], itemPath);
|
|
177
|
+
differences.push(...itemDiffs);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return differences;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Compares two objects.
|
|
186
|
+
*/
|
|
187
|
+
private compareObjects(
|
|
188
|
+
baseline: Record<string, unknown>,
|
|
189
|
+
current: Record<string, unknown>,
|
|
190
|
+
path: string,
|
|
191
|
+
): ResponseDiff[] {
|
|
192
|
+
const differences: ResponseDiff[] = [];
|
|
193
|
+
const allKeys = new Set([...Object.keys(baseline), ...Object.keys(current)]);
|
|
194
|
+
|
|
195
|
+
for (const key of allKeys) {
|
|
196
|
+
const keyPath = path ? `${path}.${key}` : key;
|
|
197
|
+
const hasBaseline = key in baseline;
|
|
198
|
+
const hasCurrent = key in current;
|
|
199
|
+
|
|
200
|
+
if (!hasBaseline && hasCurrent) {
|
|
201
|
+
if (!this.isExcluded(keyPath)) {
|
|
202
|
+
differences.push({
|
|
203
|
+
path: keyPath,
|
|
204
|
+
baseline: undefined,
|
|
205
|
+
current: current[key],
|
|
206
|
+
type: 'added',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
} else if (hasBaseline && !hasCurrent) {
|
|
210
|
+
if (!this.isExcluded(keyPath)) {
|
|
211
|
+
differences.push({
|
|
212
|
+
path: keyPath,
|
|
213
|
+
baseline: baseline[key],
|
|
214
|
+
current: undefined,
|
|
215
|
+
type: 'removed',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
const keyDiffs = this.deepCompare(baseline[key], current[key], keyPath);
|
|
220
|
+
differences.push(...keyDiffs);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return differences;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Checks if a path should be excluded from comparison.
|
|
229
|
+
*/
|
|
230
|
+
isExcluded(path: string): boolean {
|
|
231
|
+
if (this.excludePaths.has(path)) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const pattern of this.excludePaths) {
|
|
236
|
+
if (pattern.startsWith('*.')) {
|
|
237
|
+
const suffix = pattern.slice(2);
|
|
238
|
+
if (path.endsWith(`.${suffix}`)) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
const lastPart = path.split('.').pop();
|
|
242
|
+
if (lastPart === suffix) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (pattern.includes('[*]')) {
|
|
248
|
+
const regex = new RegExp(
|
|
249
|
+
`^${pattern.replace(/\[\*\]/g, '\\[\\d+\\]').replace(/\./g, '\\.')}$`,
|
|
250
|
+
);
|
|
251
|
+
if (regex.test(path)) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Checks if a value matches a custom rule for its path.
|
|
262
|
+
*/
|
|
263
|
+
matchesRule(path: string, value: unknown): boolean {
|
|
264
|
+
const rule = this.matchRules.get(path);
|
|
265
|
+
if (!rule) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (rule === '*') {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (rule.startsWith('regex:')) {
|
|
274
|
+
const pattern = rule.slice(6);
|
|
275
|
+
try {
|
|
276
|
+
const regex = new RegExp(pattern);
|
|
277
|
+
return regex.test(String(value));
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Gets the type of a value for comparison.
|
|
288
|
+
*/
|
|
289
|
+
private getType(value: unknown): string {
|
|
290
|
+
if (value === null) {
|
|
291
|
+
return 'null';
|
|
292
|
+
}
|
|
293
|
+
if (value === undefined) {
|
|
294
|
+
return 'undefined';
|
|
295
|
+
}
|
|
296
|
+
if (Array.isArray(value)) {
|
|
297
|
+
return 'array';
|
|
298
|
+
}
|
|
299
|
+
return typeof value;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Orchestrates response diffing between runs.
|
|
305
|
+
*/
|
|
306
|
+
export class DiffOrchestrator {
|
|
307
|
+
private manager: BaselineManager;
|
|
308
|
+
|
|
309
|
+
constructor(globalConfig: GlobalDiffConfig = {}) {
|
|
310
|
+
this.manager = new BaselineManager(globalConfig);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Compares execution results against a baseline.
|
|
315
|
+
*/
|
|
316
|
+
async compareWithBaseline(
|
|
317
|
+
yamlPath: string,
|
|
318
|
+
results: ExecutionResult[],
|
|
319
|
+
currentLabel: string,
|
|
320
|
+
baselineLabel: string,
|
|
321
|
+
config: DiffConfig,
|
|
322
|
+
): Promise<DiffSummary> {
|
|
323
|
+
const baselineFile = await this.manager.load(yamlPath, baselineLabel);
|
|
324
|
+
const diffResults: DiffCompareResult[] = [];
|
|
325
|
+
|
|
326
|
+
for (const result of results) {
|
|
327
|
+
if (result.skipped || !result.success) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const requestName = result.request.name || result.request.url;
|
|
332
|
+
const currentBaseline = this.manager.createBaseline(result, config);
|
|
333
|
+
|
|
334
|
+
if (!baselineFile) {
|
|
335
|
+
// No baseline exists - mark as new
|
|
336
|
+
diffResults.push({
|
|
337
|
+
requestName,
|
|
338
|
+
hasDifferences: false,
|
|
339
|
+
isNewBaseline: true,
|
|
340
|
+
baselineLabel,
|
|
341
|
+
currentLabel,
|
|
342
|
+
differences: [],
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const storedBaseline = baselineFile.baselines[requestName];
|
|
348
|
+
|
|
349
|
+
if (!storedBaseline) {
|
|
350
|
+
// Request not in baseline - mark as new
|
|
351
|
+
diffResults.push({
|
|
352
|
+
requestName,
|
|
353
|
+
hasDifferences: false,
|
|
354
|
+
isNewBaseline: true,
|
|
355
|
+
baselineLabel,
|
|
356
|
+
currentLabel,
|
|
357
|
+
differences: [],
|
|
358
|
+
});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const differ = new ResponseDiffer(config);
|
|
363
|
+
const compareResult = differ.compare(
|
|
364
|
+
storedBaseline,
|
|
365
|
+
currentBaseline,
|
|
366
|
+
baselineLabel,
|
|
367
|
+
currentLabel,
|
|
368
|
+
requestName,
|
|
369
|
+
);
|
|
370
|
+
diffResults.push(compareResult);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
totalRequests: diffResults.length,
|
|
375
|
+
unchanged: diffResults.filter((r) => !r.hasDifferences && !r.isNewBaseline).length,
|
|
376
|
+
changed: diffResults.filter((r) => r.hasDifferences).length,
|
|
377
|
+
newBaselines: diffResults.filter((r) => r.isNewBaseline).length,
|
|
378
|
+
results: diffResults,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Compares two stored baselines (offline comparison).
|
|
384
|
+
*/
|
|
385
|
+
async compareTwoBaselines(
|
|
386
|
+
yamlPath: string,
|
|
387
|
+
label1: string,
|
|
388
|
+
label2: string,
|
|
389
|
+
config: DiffConfig,
|
|
390
|
+
): Promise<DiffSummary> {
|
|
391
|
+
const file1 = await this.manager.load(yamlPath, label1);
|
|
392
|
+
const file2 = await this.manager.load(yamlPath, label2);
|
|
393
|
+
|
|
394
|
+
if (!file1) {
|
|
395
|
+
throw new Error(`Baseline '${label1}' not found`);
|
|
396
|
+
}
|
|
397
|
+
if (!file2) {
|
|
398
|
+
throw new Error(`Baseline '${label2}' not found`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const allRequestNames = new Set([
|
|
402
|
+
...Object.keys(file1.baselines),
|
|
403
|
+
...Object.keys(file2.baselines),
|
|
404
|
+
]);
|
|
405
|
+
|
|
406
|
+
const diffResults: DiffCompareResult[] = [];
|
|
407
|
+
const differ = new ResponseDiffer(config);
|
|
408
|
+
|
|
409
|
+
for (const requestName of allRequestNames) {
|
|
410
|
+
const baseline1 = file1.baselines[requestName];
|
|
411
|
+
const baseline2 = file2.baselines[requestName];
|
|
412
|
+
|
|
413
|
+
if (!baseline1) {
|
|
414
|
+
diffResults.push({
|
|
415
|
+
requestName,
|
|
416
|
+
hasDifferences: true,
|
|
417
|
+
isNewBaseline: false,
|
|
418
|
+
baselineLabel: label1,
|
|
419
|
+
currentLabel: label2,
|
|
420
|
+
differences: [
|
|
421
|
+
{
|
|
422
|
+
path: '',
|
|
423
|
+
baseline: undefined,
|
|
424
|
+
current: 'exists',
|
|
425
|
+
type: 'added',
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
});
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!baseline2) {
|
|
433
|
+
diffResults.push({
|
|
434
|
+
requestName,
|
|
435
|
+
hasDifferences: true,
|
|
436
|
+
isNewBaseline: false,
|
|
437
|
+
baselineLabel: label1,
|
|
438
|
+
currentLabel: label2,
|
|
439
|
+
differences: [
|
|
440
|
+
{
|
|
441
|
+
path: '',
|
|
442
|
+
baseline: 'exists',
|
|
443
|
+
current: undefined,
|
|
444
|
+
type: 'removed',
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const compareResult = differ.compare(baseline1, baseline2, label1, label2, requestName);
|
|
452
|
+
diffResults.push(compareResult);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
totalRequests: diffResults.length,
|
|
457
|
+
unchanged: diffResults.filter((r) => !r.hasDifferences).length,
|
|
458
|
+
changed: diffResults.filter((r) => r.hasDifferences).length,
|
|
459
|
+
newBaselines: 0,
|
|
460
|
+
results: diffResults,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Saves current results as baseline.
|
|
466
|
+
*/
|
|
467
|
+
async saveBaseline(
|
|
468
|
+
yamlPath: string,
|
|
469
|
+
label: string,
|
|
470
|
+
results: ExecutionResult[],
|
|
471
|
+
config: DiffConfig,
|
|
472
|
+
): Promise<void> {
|
|
473
|
+
await this.manager.saveBaseline(yamlPath, label, results, config);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Lists available baseline labels.
|
|
478
|
+
*/
|
|
479
|
+
async listLabels(yamlPath: string): Promise<string[]> {
|
|
480
|
+
return this.manager.listLabels(yamlPath);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Gets the baseline manager instance.
|
|
485
|
+
*/
|
|
486
|
+
getManager(): BaselineManager {
|
|
487
|
+
return this.manager;
|
|
488
|
+
}
|
|
489
|
+
}
|
package/src/parser/yaml.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { YAML } from 'bun';
|
|
2
1
|
import type { RequestConfig, ResponseStoreContext, YamlFile } from '../types/config';
|
|
3
2
|
|
|
4
3
|
// Using class for organization, but could be refactored to functions
|
|
@@ -6,11 +5,11 @@ export class YamlParser {
|
|
|
6
5
|
static async parseFile(filepath: string): Promise<YamlFile> {
|
|
7
6
|
const file = Bun.file(filepath);
|
|
8
7
|
const content = await file.text();
|
|
9
|
-
return YAML.parse(content) as YamlFile;
|
|
8
|
+
return Bun.YAML.parse(content) as YamlFile;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
11
|
static parse(content: string): YamlFile {
|
|
13
|
-
return YAML.parse(content) as YamlFile;
|
|
12
|
+
return Bun.YAML.parse(content) as YamlFile;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
/**
|
package/src/types/config.ts
CHANGED
|
@@ -257,6 +257,11 @@ export interface RequestConfig {
|
|
|
257
257
|
* Use `true` to enable with defaults, or provide detailed config.
|
|
258
258
|
*/
|
|
259
259
|
snapshot?: SnapshotConfig | boolean;
|
|
260
|
+
/**
|
|
261
|
+
* Response diffing configuration for this request.
|
|
262
|
+
* Use `true` to enable with defaults, or provide detailed config.
|
|
263
|
+
*/
|
|
264
|
+
diff?: DiffConfig | boolean;
|
|
260
265
|
sourceOutputConfig?: {
|
|
261
266
|
verbose?: boolean;
|
|
262
267
|
showHeaders?: boolean;
|
|
@@ -335,6 +340,11 @@ export interface GlobalConfig {
|
|
|
335
340
|
* Saves response snapshots and compares future runs against them.
|
|
336
341
|
*/
|
|
337
342
|
snapshot?: GlobalSnapshotConfig;
|
|
343
|
+
/**
|
|
344
|
+
* Response diffing configuration.
|
|
345
|
+
* Compare responses between environments or runs to detect API drift.
|
|
346
|
+
*/
|
|
347
|
+
diff?: GlobalDiffConfig;
|
|
338
348
|
variables?: Record<string, string>;
|
|
339
349
|
output?: {
|
|
340
350
|
verbose?: boolean;
|
|
@@ -374,6 +384,8 @@ export interface ExecutionResult {
|
|
|
374
384
|
};
|
|
375
385
|
/** Snapshot comparison result (if snapshot testing enabled). */
|
|
376
386
|
snapshotResult?: SnapshotCompareResult;
|
|
387
|
+
/** Diff comparison result (if response diffing enabled). */
|
|
388
|
+
diffResult?: DiffCompareResult;
|
|
377
389
|
/** Whether this request was skipped due to a `when` condition. */
|
|
378
390
|
skipped?: boolean;
|
|
379
391
|
/** Reason the request was skipped (condition that failed). */
|
|
@@ -534,3 +546,93 @@ export interface SnapshotCompareResult {
|
|
|
534
546
|
updated: boolean;
|
|
535
547
|
differences: SnapshotDiff[];
|
|
536
548
|
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Configuration for response diffing at request level.
|
|
552
|
+
*/
|
|
553
|
+
export interface DiffConfig {
|
|
554
|
+
/** Enable diffing for this request. */
|
|
555
|
+
enabled?: boolean;
|
|
556
|
+
/** Paths to exclude from comparison (e.g., 'body.timestamp'). */
|
|
557
|
+
exclude?: string[];
|
|
558
|
+
/** Match rules for dynamic values (path -> '*' or 'regex:pattern'). */
|
|
559
|
+
match?: Record<string, string>;
|
|
560
|
+
/** Include timing differences in comparison. Default: false */
|
|
561
|
+
includeTimings?: boolean;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Global configuration for response diffing.
|
|
566
|
+
*/
|
|
567
|
+
export interface GlobalDiffConfig extends DiffConfig {
|
|
568
|
+
/** Directory for baseline files. Default: '__baselines__' */
|
|
569
|
+
dir?: string;
|
|
570
|
+
/** Label for current run (e.g., 'staging', 'production'). */
|
|
571
|
+
label?: string;
|
|
572
|
+
/** Label to compare against. */
|
|
573
|
+
compareWith?: string;
|
|
574
|
+
/** Save current run as baseline. */
|
|
575
|
+
save?: boolean;
|
|
576
|
+
/** Output format for diff results. Default: 'terminal' */
|
|
577
|
+
outputFormat?: 'terminal' | 'json' | 'markdown';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Stored baseline data for a single request.
|
|
582
|
+
*/
|
|
583
|
+
export interface Baseline {
|
|
584
|
+
status?: number;
|
|
585
|
+
headers?: Record<string, string>;
|
|
586
|
+
body?: JsonValue;
|
|
587
|
+
timing?: number;
|
|
588
|
+
hash: string;
|
|
589
|
+
capturedAt: string;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Baseline file format.
|
|
594
|
+
*/
|
|
595
|
+
export interface BaselineFile {
|
|
596
|
+
version: number;
|
|
597
|
+
label: string;
|
|
598
|
+
capturedAt: string;
|
|
599
|
+
baselines: Record<string, Baseline>;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Single difference in response comparison.
|
|
604
|
+
*/
|
|
605
|
+
export interface ResponseDiff {
|
|
606
|
+
path: string;
|
|
607
|
+
baseline: unknown;
|
|
608
|
+
current: unknown;
|
|
609
|
+
type: 'added' | 'removed' | 'changed' | 'type_mismatch';
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Result of comparing a response against a baseline.
|
|
614
|
+
*/
|
|
615
|
+
export interface DiffCompareResult {
|
|
616
|
+
requestName: string;
|
|
617
|
+
hasDifferences: boolean;
|
|
618
|
+
isNewBaseline: boolean;
|
|
619
|
+
baselineLabel: string;
|
|
620
|
+
currentLabel: string;
|
|
621
|
+
differences: ResponseDiff[];
|
|
622
|
+
timingDiff?: {
|
|
623
|
+
baseline: number;
|
|
624
|
+
current: number;
|
|
625
|
+
changePercent: number;
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Summary of diff comparison across all requests.
|
|
631
|
+
*/
|
|
632
|
+
export interface DiffSummary {
|
|
633
|
+
totalRequests: number;
|
|
634
|
+
unchanged: number;
|
|
635
|
+
changed: number;
|
|
636
|
+
newBaselines: number;
|
|
637
|
+
results: DiffCompareResult[];
|
|
638
|
+
}
|
|
@@ -133,7 +133,15 @@ export class CurlBuilder {
|
|
|
133
133
|
status?: number;
|
|
134
134
|
headers?: Record<string, string>;
|
|
135
135
|
body?: string;
|
|
136
|
-
metrics?:
|
|
136
|
+
metrics?: {
|
|
137
|
+
duration: number;
|
|
138
|
+
size?: number;
|
|
139
|
+
dnsLookup?: number;
|
|
140
|
+
tcpConnection?: number;
|
|
141
|
+
tlsHandshake?: number;
|
|
142
|
+
firstByte?: number;
|
|
143
|
+
download?: number;
|
|
144
|
+
};
|
|
137
145
|
error?: string;
|
|
138
146
|
}> {
|
|
139
147
|
try {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { getUpgradeCommand, getUpgradeCommandWindows, isWindows } from './installation-detector';
|
|
3
|
+
|
|
4
|
+
describe('getUpgradeCommand', () => {
|
|
5
|
+
test('returns bun command for bun source', () => {
|
|
6
|
+
expect(getUpgradeCommand('bun')).toBe('bun install -g @curl-runner/cli@latest');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('returns npm command for npm source', () => {
|
|
10
|
+
expect(getUpgradeCommand('npm')).toBe('npm install -g @curl-runner/cli@latest');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('returns curl command for curl source', () => {
|
|
14
|
+
expect(getUpgradeCommand('curl')).toBe(
|
|
15
|
+
'curl -fsSL https://www.curl-runner.com/install.sh | bash',
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('returns curl command for standalone source', () => {
|
|
20
|
+
expect(getUpgradeCommand('standalone')).toBe(
|
|
21
|
+
'curl -fsSL https://www.curl-runner.com/install.sh | bash',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('getUpgradeCommandWindows', () => {
|
|
27
|
+
test('returns bun command for bun source', () => {
|
|
28
|
+
expect(getUpgradeCommandWindows('bun')).toBe('bun install -g @curl-runner/cli@latest');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('returns npm command for npm source', () => {
|
|
32
|
+
expect(getUpgradeCommandWindows('npm')).toBe('npm install -g @curl-runner/cli@latest');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('returns PowerShell command for curl source', () => {
|
|
36
|
+
expect(getUpgradeCommandWindows('curl')).toBe(
|
|
37
|
+
'irm https://www.curl-runner.com/install.ps1 | iex',
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('returns PowerShell command for standalone source', () => {
|
|
42
|
+
expect(getUpgradeCommandWindows('standalone')).toBe(
|
|
43
|
+
'irm https://www.curl-runner.com/install.ps1 | iex',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('isWindows', () => {
|
|
49
|
+
test('returns boolean based on platform', () => {
|
|
50
|
+
expect(typeof isWindows()).toBe('boolean');
|
|
51
|
+
});
|
|
52
|
+
});
|