@curl-runner/cli 1.14.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,266 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import type {
4
+ Baseline,
5
+ BaselineFile,
6
+ DiffConfig,
7
+ ExecutionResult,
8
+ GlobalDiffConfig,
9
+ } from '../types/config';
10
+
11
+ const BASELINE_VERSION = 1;
12
+ const DEFAULT_BASELINE_DIR = '__baselines__';
13
+
14
+ /**
15
+ * Manages baseline files: reading, writing, and listing.
16
+ */
17
+ export class BaselineManager {
18
+ private baselineDir: string;
19
+ private writeLocks: Map<string, Promise<void>> = new Map();
20
+
21
+ constructor(globalConfig: GlobalDiffConfig = {}) {
22
+ this.baselineDir = globalConfig.dir || DEFAULT_BASELINE_DIR;
23
+ }
24
+
25
+ /**
26
+ * Gets the baseline file path for a label.
27
+ */
28
+ getBaselinePath(yamlPath: string, label: string): string {
29
+ const dir = path.dirname(yamlPath);
30
+ const basename = path.basename(yamlPath, path.extname(yamlPath));
31
+ return path.join(dir, this.baselineDir, `${basename}.${label}.baseline.json`);
32
+ }
33
+
34
+ /**
35
+ * Gets the baseline directory for a YAML file.
36
+ */
37
+ getBaselineDir(yamlPath: string): string {
38
+ return path.join(path.dirname(yamlPath), this.baselineDir);
39
+ }
40
+
41
+ /**
42
+ * Loads baseline file for a specific label.
43
+ */
44
+ async load(yamlPath: string, label: string): Promise<BaselineFile | null> {
45
+ const baselinePath = this.getBaselinePath(yamlPath, label);
46
+ try {
47
+ const file = Bun.file(baselinePath);
48
+ if (!(await file.exists())) {
49
+ return null;
50
+ }
51
+ const content = await file.text();
52
+ return JSON.parse(content) as BaselineFile;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Saves baseline file with write queue for parallel safety.
60
+ */
61
+ async save(yamlPath: string, label: string, data: BaselineFile): Promise<void> {
62
+ const baselinePath = this.getBaselinePath(yamlPath, label);
63
+
64
+ const existingLock = this.writeLocks.get(baselinePath);
65
+ const writePromise = (async () => {
66
+ if (existingLock) {
67
+ await existingLock;
68
+ }
69
+
70
+ const dir = path.dirname(baselinePath);
71
+ await fs.mkdir(dir, { recursive: true });
72
+
73
+ const content = JSON.stringify(data, null, 2);
74
+ await Bun.write(baselinePath, content);
75
+ })();
76
+
77
+ this.writeLocks.set(baselinePath, writePromise);
78
+ await writePromise;
79
+ this.writeLocks.delete(baselinePath);
80
+ }
81
+
82
+ /**
83
+ * Gets a single baseline by request name.
84
+ */
85
+ async get(yamlPath: string, label: string, requestName: string): Promise<Baseline | null> {
86
+ const file = await this.load(yamlPath, label);
87
+ return file?.baselines[requestName] || null;
88
+ }
89
+
90
+ /**
91
+ * Lists all available baseline labels for a YAML file.
92
+ */
93
+ async listLabels(yamlPath: string): Promise<string[]> {
94
+ const dir = this.getBaselineDir(yamlPath);
95
+ const basename = path.basename(yamlPath, path.extname(yamlPath));
96
+
97
+ try {
98
+ const files = await fs.readdir(dir);
99
+ const labels: string[] = [];
100
+
101
+ for (const file of files) {
102
+ const match = file.match(new RegExp(`^${basename}\\.(.+)\\.baseline\\.json$`));
103
+ if (match) {
104
+ labels.push(match[1]);
105
+ }
106
+ }
107
+
108
+ return labels.sort();
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Creates a baseline from execution result.
116
+ */
117
+ createBaseline(result: ExecutionResult, config: DiffConfig): Baseline {
118
+ const baseline: Baseline = {
119
+ hash: '',
120
+ capturedAt: new Date().toISOString(),
121
+ };
122
+
123
+ if (result.status !== undefined) {
124
+ baseline.status = result.status;
125
+ }
126
+
127
+ if (result.headers) {
128
+ baseline.headers = this.normalizeHeaders(result.headers);
129
+ }
130
+
131
+ if (result.body !== undefined) {
132
+ baseline.body = result.body;
133
+ }
134
+
135
+ if (config.includeTimings && result.metrics?.duration !== undefined) {
136
+ baseline.timing = result.metrics.duration;
137
+ }
138
+
139
+ baseline.hash = this.hash(baseline);
140
+
141
+ return baseline;
142
+ }
143
+
144
+ /**
145
+ * Normalizes headers for consistent comparison.
146
+ */
147
+ private normalizeHeaders(headers: Record<string, string>): Record<string, string> {
148
+ const normalized: Record<string, string> = {};
149
+ const sortedKeys = Object.keys(headers).sort();
150
+ for (const key of sortedKeys) {
151
+ normalized[key.toLowerCase()] = headers[key];
152
+ }
153
+ return normalized;
154
+ }
155
+
156
+ /**
157
+ * Generates a hash for baseline content.
158
+ */
159
+ hash(content: unknown): string {
160
+ const str = JSON.stringify(content);
161
+ const hasher = new Bun.CryptoHasher('md5');
162
+ hasher.update(str);
163
+ return hasher.digest('hex').slice(0, 8);
164
+ }
165
+
166
+ /**
167
+ * Saves execution results as a baseline.
168
+ */
169
+ async saveBaseline(
170
+ yamlPath: string,
171
+ label: string,
172
+ results: ExecutionResult[],
173
+ config: DiffConfig,
174
+ ): Promise<void> {
175
+ const baselines: Record<string, Baseline> = {};
176
+
177
+ for (const result of results) {
178
+ if (result.skipped || !result.success) {
179
+ continue;
180
+ }
181
+ const name = result.request.name || result.request.url;
182
+ baselines[name] = this.createBaseline(result, config);
183
+ }
184
+
185
+ const file: BaselineFile = {
186
+ version: BASELINE_VERSION,
187
+ label,
188
+ capturedAt: new Date().toISOString(),
189
+ baselines,
190
+ };
191
+
192
+ await this.save(yamlPath, label, file);
193
+ }
194
+
195
+ /**
196
+ * Deletes baselines older than specified days.
197
+ */
198
+ async cleanOldBaselines(yamlPath: string, olderThanDays: number): Promise<number> {
199
+ const dir = this.getBaselineDir(yamlPath);
200
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
201
+ let deleted = 0;
202
+
203
+ try {
204
+ const files = await fs.readdir(dir);
205
+
206
+ for (const file of files) {
207
+ if (!file.endsWith('.baseline.json')) {
208
+ continue;
209
+ }
210
+
211
+ const filePath = path.join(dir, file);
212
+ const stat = await fs.stat(filePath);
213
+
214
+ if (stat.mtimeMs < cutoff) {
215
+ await fs.unlink(filePath);
216
+ deleted++;
217
+ }
218
+ }
219
+ } catch {
220
+ // Directory doesn't exist or other error
221
+ }
222
+
223
+ return deleted;
224
+ }
225
+
226
+ /**
227
+ * Merges request-level config with global config.
228
+ */
229
+ static mergeConfig(
230
+ globalConfig: GlobalDiffConfig | undefined,
231
+ requestConfig: DiffConfig | boolean | undefined,
232
+ ): DiffConfig | null {
233
+ if (!requestConfig && !globalConfig?.enabled) {
234
+ return null;
235
+ }
236
+
237
+ if (requestConfig === true) {
238
+ return {
239
+ enabled: true,
240
+ exclude: globalConfig?.exclude || [],
241
+ match: globalConfig?.match || {},
242
+ includeTimings: globalConfig?.includeTimings || false,
243
+ };
244
+ }
245
+
246
+ if (typeof requestConfig === 'object' && requestConfig.enabled !== false) {
247
+ return {
248
+ enabled: true,
249
+ exclude: [...(globalConfig?.exclude || []), ...(requestConfig.exclude || [])],
250
+ match: { ...(globalConfig?.match || {}), ...(requestConfig.match || {}) },
251
+ includeTimings: requestConfig.includeTimings ?? globalConfig?.includeTimings ?? false,
252
+ };
253
+ }
254
+
255
+ if (globalConfig?.enabled && requestConfig === undefined) {
256
+ return {
257
+ enabled: true,
258
+ exclude: globalConfig.exclude || [],
259
+ match: globalConfig.match || {},
260
+ includeTimings: globalConfig.includeTimings || false,
261
+ };
262
+ }
263
+
264
+ return null;
265
+ }
266
+ }
@@ -0,0 +1,316 @@
1
+ import type { DiffCompareResult, DiffSummary, ResponseDiff } from '../types/config';
2
+
3
+ const COLORS = {
4
+ reset: '\x1b[0m',
5
+ red: '\x1b[31m',
6
+ green: '\x1b[32m',
7
+ yellow: '\x1b[33m',
8
+ cyan: '\x1b[36m',
9
+ dim: '\x1b[2m',
10
+ bright: '\x1b[1m',
11
+ };
12
+
13
+ /**
14
+ * Formats diff comparison results for various outputs.
15
+ */
16
+ export class DiffFormatter {
17
+ private outputFormat: 'terminal' | 'json' | 'markdown';
18
+
19
+ constructor(outputFormat: 'terminal' | 'json' | 'markdown' = 'terminal') {
20
+ this.outputFormat = outputFormat;
21
+ }
22
+
23
+ private color(text: string, color: keyof typeof COLORS): string {
24
+ if (this.outputFormat !== 'terminal') {
25
+ return text;
26
+ }
27
+ return `${COLORS[color]}${text}${COLORS.reset}`;
28
+ }
29
+
30
+ /**
31
+ * Formats the complete diff summary.
32
+ */
33
+ formatSummary(summary: DiffSummary, baselineLabel: string, currentLabel: string): string {
34
+ switch (this.outputFormat) {
35
+ case 'json':
36
+ return this.formatSummaryJson(summary);
37
+ case 'markdown':
38
+ return this.formatSummaryMarkdown(summary, baselineLabel, currentLabel);
39
+ default:
40
+ return this.formatSummaryTerminal(summary, baselineLabel, currentLabel);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Terminal format for summary.
46
+ */
47
+ private formatSummaryTerminal(
48
+ summary: DiffSummary,
49
+ baselineLabel: string,
50
+ currentLabel: string,
51
+ ): string {
52
+ const lines: string[] = [];
53
+
54
+ lines.push('');
55
+ lines.push(
56
+ `${this.color('Response Diff:', 'bright')} ${this.color(baselineLabel, 'cyan')} → ${this.color(currentLabel, 'cyan')}`,
57
+ );
58
+ lines.push('');
59
+
60
+ for (const result of summary.results) {
61
+ lines.push(this.formatResultTerminal(result));
62
+ lines.push('');
63
+ }
64
+
65
+ lines.push(this.formatStatsSummary(summary));
66
+
67
+ return lines.join('\n');
68
+ }
69
+
70
+ /**
71
+ * JSON format for summary.
72
+ */
73
+ private formatSummaryJson(summary: DiffSummary): string {
74
+ return JSON.stringify(
75
+ {
76
+ summary: {
77
+ total: summary.totalRequests,
78
+ unchanged: summary.unchanged,
79
+ changed: summary.changed,
80
+ newBaselines: summary.newBaselines,
81
+ },
82
+ results: summary.results.map((r) => ({
83
+ request: r.requestName,
84
+ status: r.hasDifferences ? 'changed' : r.isNewBaseline ? 'new' : 'unchanged',
85
+ differences: r.differences,
86
+ timingDiff: r.timingDiff,
87
+ })),
88
+ },
89
+ null,
90
+ 2,
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Markdown format for summary.
96
+ */
97
+ private formatSummaryMarkdown(
98
+ summary: DiffSummary,
99
+ baselineLabel: string,
100
+ currentLabel: string,
101
+ ): string {
102
+ const lines: string[] = [];
103
+
104
+ lines.push(`# Response Diff: ${baselineLabel} → ${currentLabel}`);
105
+ lines.push('');
106
+ lines.push(`| Metric | Count |`);
107
+ lines.push(`|--------|-------|`);
108
+ lines.push(`| Total Requests | ${summary.totalRequests} |`);
109
+ lines.push(`| Unchanged | ${summary.unchanged} |`);
110
+ lines.push(`| Changed | ${summary.changed} |`);
111
+ lines.push(`| New Baselines | ${summary.newBaselines} |`);
112
+ lines.push('');
113
+
114
+ if (summary.changed > 0) {
115
+ lines.push('## Changes');
116
+ lines.push('');
117
+
118
+ for (const result of summary.results) {
119
+ if (result.hasDifferences) {
120
+ lines.push(this.formatResultMarkdown(result));
121
+ }
122
+ }
123
+ }
124
+
125
+ if (summary.newBaselines > 0) {
126
+ lines.push('## New Requests');
127
+ lines.push('');
128
+ for (const result of summary.results) {
129
+ if (result.isNewBaseline) {
130
+ lines.push(`- \`${result.requestName}\``);
131
+ }
132
+ }
133
+ lines.push('');
134
+ }
135
+
136
+ return lines.join('\n');
137
+ }
138
+
139
+ /**
140
+ * Formats a single result for terminal.
141
+ */
142
+ formatResultTerminal(result: DiffCompareResult): string {
143
+ const lines: string[] = [];
144
+
145
+ if (result.isNewBaseline) {
146
+ lines.push(` ${this.color('NEW', 'cyan')} ${this.color(result.requestName, 'bright')}`);
147
+ return lines.join('\n');
148
+ }
149
+
150
+ if (!result.hasDifferences) {
151
+ lines.push(
152
+ ` ${this.color('✓', 'green')} ${this.color(result.requestName, 'bright')} ${this.color('(no changes)', 'dim')}`,
153
+ );
154
+ return lines.join('\n');
155
+ }
156
+
157
+ lines.push(` ${this.color('✗', 'red')} ${this.color(result.requestName, 'bright')}`);
158
+
159
+ for (const diff of result.differences) {
160
+ lines.push(this.formatDifferenceTerminal(diff));
161
+ }
162
+
163
+ if (result.timingDiff) {
164
+ const { baseline, current, changePercent } = result.timingDiff;
165
+ const sign = changePercent >= 0 ? '+' : '';
166
+ const color = changePercent > 20 ? 'red' : changePercent < -20 ? 'green' : 'yellow';
167
+ lines.push(
168
+ ` ${this.color('timing:', 'cyan')} ${baseline}ms → ${current}ms ${this.color(`(${sign}${changePercent.toFixed(0)}%)`, color)}`,
169
+ );
170
+ }
171
+
172
+ return lines.join('\n');
173
+ }
174
+
175
+ /**
176
+ * Formats a single difference for terminal.
177
+ */
178
+ private formatDifferenceTerminal(diff: ResponseDiff): string {
179
+ const lines: string[] = [];
180
+ const path = diff.path || '(root)';
181
+
182
+ switch (diff.type) {
183
+ case 'added':
184
+ lines.push(` ${this.color(path, 'cyan')}:`);
185
+ lines.push(` ${this.color(`+ ${this.stringify(diff.current)}`, 'green')}`);
186
+ break;
187
+
188
+ case 'removed':
189
+ lines.push(` ${this.color(path, 'cyan')}:`);
190
+ lines.push(` ${this.color(`- ${this.stringify(diff.baseline)}`, 'red')}`);
191
+ break;
192
+
193
+ case 'changed':
194
+ lines.push(` ${this.color(path, 'cyan')}:`);
195
+ lines.push(` ${this.color(`- ${this.stringify(diff.baseline)}`, 'red')}`);
196
+ lines.push(` ${this.color(`+ ${this.stringify(diff.current)}`, 'green')}`);
197
+ break;
198
+
199
+ case 'type_mismatch':
200
+ lines.push(` ${this.color(path, 'cyan')} (type mismatch):`);
201
+ lines.push(
202
+ ` ${this.color(`- ${this.stringify(diff.baseline)} (${typeof diff.baseline})`, 'red')}`,
203
+ );
204
+ lines.push(
205
+ ` ${this.color(`+ ${this.stringify(diff.current)} (${typeof diff.current})`, 'green')}`,
206
+ );
207
+ break;
208
+ }
209
+
210
+ return lines.join('\n');
211
+ }
212
+
213
+ /**
214
+ * Formats a single result for markdown.
215
+ */
216
+ private formatResultMarkdown(result: DiffCompareResult): string {
217
+ const lines: string[] = [];
218
+
219
+ lines.push(`### \`${result.requestName}\``);
220
+ lines.push('');
221
+ lines.push('```diff');
222
+
223
+ for (const diff of result.differences) {
224
+ lines.push(this.formatDifferenceMarkdown(diff));
225
+ }
226
+
227
+ lines.push('```');
228
+
229
+ if (result.timingDiff) {
230
+ const { baseline, current, changePercent } = result.timingDiff;
231
+ const sign = changePercent >= 0 ? '+' : '';
232
+ lines.push('');
233
+ lines.push(`**Timing:** ${baseline}ms → ${current}ms (${sign}${changePercent.toFixed(0)}%)`);
234
+ }
235
+
236
+ lines.push('');
237
+
238
+ return lines.join('\n');
239
+ }
240
+
241
+ /**
242
+ * Formats a single difference for markdown.
243
+ */
244
+ private formatDifferenceMarkdown(diff: ResponseDiff): string {
245
+ const lines: string[] = [];
246
+ const path = diff.path || '(root)';
247
+
248
+ switch (diff.type) {
249
+ case 'added':
250
+ lines.push(`# ${path}:`);
251
+ lines.push(`+ ${this.stringify(diff.current)}`);
252
+ break;
253
+
254
+ case 'removed':
255
+ lines.push(`# ${path}:`);
256
+ lines.push(`- ${this.stringify(diff.baseline)}`);
257
+ break;
258
+
259
+ case 'changed':
260
+ lines.push(`# ${path}:`);
261
+ lines.push(`- ${this.stringify(diff.baseline)}`);
262
+ lines.push(`+ ${this.stringify(diff.current)}`);
263
+ break;
264
+
265
+ case 'type_mismatch':
266
+ lines.push(`# ${path} (type mismatch):`);
267
+ lines.push(`- ${this.stringify(diff.baseline)} (${typeof diff.baseline})`);
268
+ lines.push(`+ ${this.stringify(diff.current)} (${typeof diff.current})`);
269
+ break;
270
+ }
271
+
272
+ return lines.join('\n');
273
+ }
274
+
275
+ /**
276
+ * Formats stats summary for terminal.
277
+ */
278
+ private formatStatsSummary(summary: DiffSummary): string {
279
+ const parts: string[] = [];
280
+
281
+ if (summary.unchanged > 0) {
282
+ parts.push(this.color(`${summary.unchanged} unchanged`, 'green'));
283
+ }
284
+ if (summary.changed > 0) {
285
+ parts.push(this.color(`${summary.changed} changed`, 'red'));
286
+ }
287
+ if (summary.newBaselines > 0) {
288
+ parts.push(this.color(`${summary.newBaselines} new`, 'cyan'));
289
+ }
290
+
291
+ return `Summary: ${parts.join(', ')} (${summary.totalRequests} total)`;
292
+ }
293
+
294
+ /**
295
+ * Converts value to display string.
296
+ */
297
+ private stringify(value: unknown): string {
298
+ if (value === undefined) {
299
+ return 'undefined';
300
+ }
301
+ if (value === null) {
302
+ return 'null';
303
+ }
304
+ if (typeof value === 'string') {
305
+ return `"${value}"`;
306
+ }
307
+ if (typeof value === 'object') {
308
+ const str = JSON.stringify(value);
309
+ if (str.length > 80) {
310
+ return `${str.slice(0, 77)}...`;
311
+ }
312
+ return str;
313
+ }
314
+ return String(value);
315
+ }
316
+ }
@@ -0,0 +1,3 @@
1
+ export { BaselineManager } from './baseline-manager';
2
+ export { DiffFormatter } from './diff-formatter';
3
+ export { DiffOrchestrator, ResponseDiffer } from './response-differ';