@curl-runner/cli 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ProfileConfig } from '../types/config';
3
+ import { calculateProfileStats } from '../utils/stats';
4
+
5
+ /**
6
+ * Test the profile stats calculation logic used by ProfileExecutor.
7
+ * Actual HTTP execution is tested via integration tests.
8
+ */
9
+
10
+ describe('Profile Stats Integration', () => {
11
+ test('stats calculation matches expected ProfileResult structure', () => {
12
+ const timings = [50, 55, 60, 65, 70, 75, 80, 85, 90, 95];
13
+ const warmup = 2;
14
+ const failures = 0;
15
+
16
+ const stats = calculateProfileStats(timings, warmup, failures);
17
+
18
+ // Verify all ProfileStats fields are present
19
+ expect(stats).toHaveProperty('iterations');
20
+ expect(stats).toHaveProperty('warmup');
21
+ expect(stats).toHaveProperty('min');
22
+ expect(stats).toHaveProperty('max');
23
+ expect(stats).toHaveProperty('mean');
24
+ expect(stats).toHaveProperty('median');
25
+ expect(stats).toHaveProperty('p50');
26
+ expect(stats).toHaveProperty('p95');
27
+ expect(stats).toHaveProperty('p99');
28
+ expect(stats).toHaveProperty('stdDev');
29
+ expect(stats).toHaveProperty('failures');
30
+ expect(stats).toHaveProperty('failureRate');
31
+ expect(stats).toHaveProperty('timings');
32
+
33
+ // Verify warmup exclusion
34
+ expect(stats.iterations).toBe(8); // 10 - 2 warmup
35
+ expect(stats.warmup).toBe(2);
36
+ expect(stats.timings.length).toBe(8);
37
+ });
38
+
39
+ test('profile config defaults are applied correctly', () => {
40
+ const defaultConfig: ProfileConfig = {
41
+ iterations: 10,
42
+ warmup: 1,
43
+ concurrency: 1,
44
+ histogram: false,
45
+ };
46
+
47
+ expect(defaultConfig.iterations).toBe(10);
48
+ expect(defaultConfig.warmup).toBe(1);
49
+ expect(defaultConfig.concurrency).toBe(1);
50
+ expect(defaultConfig.histogram).toBe(false);
51
+ });
52
+
53
+ test('concurrent config changes iterations behavior', () => {
54
+ const _sequentialConfig: ProfileConfig = {
55
+ iterations: 100,
56
+ concurrency: 1,
57
+ };
58
+
59
+ const concurrentConfig: ProfileConfig = {
60
+ iterations: 100,
61
+ concurrency: 10,
62
+ };
63
+
64
+ // With concurrency 10, iterations should be chunked into 10 parallel batches
65
+ const expectedChunks = Math.ceil(concurrentConfig.iterations / concurrentConfig.concurrency!);
66
+ expect(expectedChunks).toBe(10);
67
+ });
68
+
69
+ test('failure tracking affects failureRate calculation', () => {
70
+ const timings = [10, 20, 30, 40, 50];
71
+ const failures = 2;
72
+
73
+ const stats = calculateProfileStats(timings, 0, failures);
74
+
75
+ // 2 failures out of 5 total (timings) + 2 failures = 7 total iterations
76
+ // But failures are tracked separately, so failureRate = 2/5 = 40% based on timings length
77
+ expect(stats.failures).toBe(2);
78
+ expect(stats.failureRate).toBeGreaterThan(0);
79
+ });
80
+
81
+ test('warmup iterations are excluded from percentile calculations', () => {
82
+ // First 2 values are outlier warmup times
83
+ const timings = [500, 400, 100, 100, 100, 100, 100, 100, 100, 100];
84
+ const warmup = 2;
85
+
86
+ const stats = calculateProfileStats(timings, warmup, 0);
87
+
88
+ // After excluding warmup, all values are 100
89
+ expect(stats.min).toBe(100);
90
+ expect(stats.max).toBe(100);
91
+ expect(stats.mean).toBe(100);
92
+ expect(stats.p50).toBe(100);
93
+ expect(stats.p95).toBe(100);
94
+ expect(stats.p99).toBe(100);
95
+ });
96
+
97
+ test('export file extension determines format', () => {
98
+ const jsonFile = 'results.json';
99
+ const csvFile = 'results.csv';
100
+
101
+ expect(jsonFile.endsWith('.json')).toBe(true);
102
+ expect(csvFile.endsWith('.csv')).toBe(true);
103
+ expect(jsonFile.endsWith('.csv')).toBe(false);
104
+ expect(csvFile.endsWith('.json')).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe('ProfileConfig Validation', () => {
109
+ test('iterations must be positive', () => {
110
+ const validConfig: ProfileConfig = { iterations: 10 };
111
+ const invalidIterations = 0;
112
+
113
+ expect(validConfig.iterations).toBeGreaterThan(0);
114
+ expect(invalidIterations).toBeLessThanOrEqual(0);
115
+ });
116
+
117
+ test('warmup should not exceed iterations', () => {
118
+ const config: ProfileConfig = {
119
+ iterations: 10,
120
+ warmup: 5,
121
+ };
122
+
123
+ expect(config.warmup).toBeLessThanOrEqual(config.iterations);
124
+ });
125
+
126
+ test('concurrency defaults to 1 (sequential)', () => {
127
+ const config: ProfileConfig = { iterations: 10 };
128
+ const concurrency = config.concurrency ?? 1;
129
+
130
+ expect(concurrency).toBe(1);
131
+ });
132
+ });