@curl-runner/cli 1.10.0 → 1.12.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 +1 -1
- package/src/cli.ts +92 -0
- package/src/executor/request-executor.ts +51 -1
- package/src/snapshot/index.ts +3 -0
- package/src/snapshot/snapshot-differ.test.ts +358 -0
- package/src/snapshot/snapshot-differ.ts +296 -0
- package/src/snapshot/snapshot-formatter.ts +170 -0
- package/src/snapshot/snapshot-manager.test.ts +204 -0
- package/src/snapshot/snapshot-manager.ts +342 -0
- package/src/types/config.ts +190 -0
- package/src/utils/condition-evaluator.test.ts +415 -0
- package/src/utils/condition-evaluator.ts +327 -0
- package/src/utils/logger.ts +67 -4
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConditionExpression,
|
|
3
|
+
ConditionOperator,
|
|
4
|
+
ResponseStoreContext,
|
|
5
|
+
WhenCondition,
|
|
6
|
+
} from '../types/config';
|
|
7
|
+
import { getValueByPath, valueToString } from './response-store';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Result of evaluating a condition.
|
|
11
|
+
*/
|
|
12
|
+
export interface ConditionResult {
|
|
13
|
+
shouldRun: boolean;
|
|
14
|
+
reason?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parses a string shorthand condition into a ConditionExpression.
|
|
19
|
+
* Supports formats like:
|
|
20
|
+
* - "store.status == 200"
|
|
21
|
+
* - "store.userId exists"
|
|
22
|
+
* - "store.body.type contains user"
|
|
23
|
+
* - "store.version >= 2"
|
|
24
|
+
*
|
|
25
|
+
* @param condition - String condition to parse
|
|
26
|
+
* @returns Parsed ConditionExpression or null if invalid
|
|
27
|
+
*/
|
|
28
|
+
export function parseStringCondition(condition: string): ConditionExpression | null {
|
|
29
|
+
const trimmed = condition.trim();
|
|
30
|
+
|
|
31
|
+
// Check for exists/not-exists operators first (unary)
|
|
32
|
+
const existsMatch = trimmed.match(/^(.+?)\s+(exists|not-exists)$/i);
|
|
33
|
+
if (existsMatch) {
|
|
34
|
+
return {
|
|
35
|
+
left: existsMatch[1].trim(),
|
|
36
|
+
operator: existsMatch[2].toLowerCase() as ConditionOperator,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Binary operators pattern
|
|
41
|
+
const operatorPattern = /^(.+?)\s*(==|!=|>=|<=|>|<|contains|matches)\s*(.+)$/i;
|
|
42
|
+
const match = trimmed.match(operatorPattern);
|
|
43
|
+
|
|
44
|
+
if (!match) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [, left, operator, right] = match;
|
|
49
|
+
const rightValue = parseRightValue(right.trim());
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
left: left.trim(),
|
|
53
|
+
operator: operator.toLowerCase() as ConditionOperator,
|
|
54
|
+
right: rightValue,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses the right-hand value, converting to appropriate type.
|
|
60
|
+
*/
|
|
61
|
+
function parseRightValue(value: string): string | number | boolean {
|
|
62
|
+
// Boolean
|
|
63
|
+
if (value === 'true') {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (value === 'false') {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Number
|
|
71
|
+
const num = Number(value);
|
|
72
|
+
if (!Number.isNaN(num) && value !== '') {
|
|
73
|
+
return num;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// String - remove quotes if present
|
|
77
|
+
if (
|
|
78
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
79
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
80
|
+
) {
|
|
81
|
+
return value.slice(1, -1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Gets a value from the store context using a path.
|
|
89
|
+
* Handles "store.x" prefix by stripping it.
|
|
90
|
+
*/
|
|
91
|
+
function getStoreValue(path: string, context: ResponseStoreContext): unknown {
|
|
92
|
+
// Strip "store." prefix if present
|
|
93
|
+
const normalizedPath = path.startsWith('store.') ? path.slice(6) : path;
|
|
94
|
+
|
|
95
|
+
// First check if it's a direct key in context
|
|
96
|
+
if (normalizedPath in context) {
|
|
97
|
+
const value = context[normalizedPath];
|
|
98
|
+
// Try to parse JSON if it looks like an object/array
|
|
99
|
+
if (value.startsWith('{') || value.startsWith('[')) {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(value);
|
|
102
|
+
} catch {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Try to parse as number
|
|
107
|
+
const num = Number(value);
|
|
108
|
+
if (!Number.isNaN(num) && value !== '') {
|
|
109
|
+
return num;
|
|
110
|
+
}
|
|
111
|
+
// Try to parse as boolean
|
|
112
|
+
if (value === 'true') {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (value === 'false') {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Handle nested paths like "body.data.id" where context has "body" as JSON string
|
|
122
|
+
const parts = normalizedPath.split('.');
|
|
123
|
+
const rootKey = parts[0];
|
|
124
|
+
|
|
125
|
+
if (rootKey in context) {
|
|
126
|
+
const rootValue = context[rootKey];
|
|
127
|
+
// Try to parse as JSON and navigate
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(rootValue);
|
|
130
|
+
return getValueByPath(parsed, parts.slice(1).join('.'));
|
|
131
|
+
} catch {
|
|
132
|
+
// Not JSON, can't navigate further
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Compares two values based on case sensitivity setting.
|
|
142
|
+
*/
|
|
143
|
+
function compareStrings(a: string, b: string, caseSensitive: boolean): boolean {
|
|
144
|
+
if (caseSensitive) {
|
|
145
|
+
return a === b;
|
|
146
|
+
}
|
|
147
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Evaluates a single condition expression against the store context.
|
|
152
|
+
*/
|
|
153
|
+
export function evaluateExpression(
|
|
154
|
+
expr: ConditionExpression,
|
|
155
|
+
context: ResponseStoreContext,
|
|
156
|
+
): { passed: boolean; description: string } {
|
|
157
|
+
const leftValue = getStoreValue(expr.left, context);
|
|
158
|
+
const caseSensitive = expr.caseSensitive ?? false;
|
|
159
|
+
|
|
160
|
+
const formatValue = (v: unknown): string => {
|
|
161
|
+
if (v === undefined) {
|
|
162
|
+
return 'undefined';
|
|
163
|
+
}
|
|
164
|
+
if (v === null) {
|
|
165
|
+
return 'null';
|
|
166
|
+
}
|
|
167
|
+
if (typeof v === 'string') {
|
|
168
|
+
return `"${v}"`;
|
|
169
|
+
}
|
|
170
|
+
return String(v);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const description = `${expr.left} ${expr.operator}${expr.right !== undefined ? ` ${formatValue(expr.right)}` : ''}`;
|
|
174
|
+
|
|
175
|
+
switch (expr.operator) {
|
|
176
|
+
case 'exists': {
|
|
177
|
+
const passed = leftValue !== undefined && leftValue !== null && leftValue !== '';
|
|
178
|
+
return { passed, description };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'not-exists': {
|
|
182
|
+
const passed = leftValue === undefined || leftValue === null || leftValue === '';
|
|
183
|
+
return { passed, description };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case '==': {
|
|
187
|
+
let passed: boolean;
|
|
188
|
+
if (typeof leftValue === 'string' && typeof expr.right === 'string') {
|
|
189
|
+
passed = compareStrings(leftValue, expr.right, caseSensitive);
|
|
190
|
+
} else {
|
|
191
|
+
// biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for type coercion
|
|
192
|
+
passed = leftValue == expr.right;
|
|
193
|
+
}
|
|
194
|
+
return { passed, description };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case '!=': {
|
|
198
|
+
let passed: boolean;
|
|
199
|
+
if (typeof leftValue === 'string' && typeof expr.right === 'string') {
|
|
200
|
+
passed = !compareStrings(leftValue, expr.right, caseSensitive);
|
|
201
|
+
} else {
|
|
202
|
+
// biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for type coercion
|
|
203
|
+
passed = leftValue != expr.right;
|
|
204
|
+
}
|
|
205
|
+
return { passed, description };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case '>': {
|
|
209
|
+
const passed = Number(leftValue) > Number(expr.right);
|
|
210
|
+
return { passed, description };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case '<': {
|
|
214
|
+
const passed = Number(leftValue) < Number(expr.right);
|
|
215
|
+
return { passed, description };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case '>=': {
|
|
219
|
+
const passed = Number(leftValue) >= Number(expr.right);
|
|
220
|
+
return { passed, description };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case '<=': {
|
|
224
|
+
const passed = Number(leftValue) <= Number(expr.right);
|
|
225
|
+
return { passed, description };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case 'contains': {
|
|
229
|
+
const leftStr = valueToString(leftValue);
|
|
230
|
+
const rightStr = String(expr.right ?? '');
|
|
231
|
+
const passed = caseSensitive
|
|
232
|
+
? leftStr.includes(rightStr)
|
|
233
|
+
: leftStr.toLowerCase().includes(rightStr.toLowerCase());
|
|
234
|
+
return { passed, description };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case 'matches': {
|
|
238
|
+
const leftStr = valueToString(leftValue);
|
|
239
|
+
const pattern = String(expr.right ?? '');
|
|
240
|
+
try {
|
|
241
|
+
const flags = caseSensitive ? '' : 'i';
|
|
242
|
+
const regex = new RegExp(pattern, flags);
|
|
243
|
+
const passed = regex.test(leftStr);
|
|
244
|
+
return { passed, description };
|
|
245
|
+
} catch {
|
|
246
|
+
// Invalid regex pattern
|
|
247
|
+
return { passed: false, description: `${description} (invalid regex)` };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
default:
|
|
252
|
+
return { passed: false, description: `unknown operator: ${expr.operator}` };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Evaluates a WhenCondition against the store context.
|
|
258
|
+
*
|
|
259
|
+
* @param condition - The condition to evaluate
|
|
260
|
+
* @param context - The store context with values from previous requests
|
|
261
|
+
* @returns Result indicating whether the request should run
|
|
262
|
+
*/
|
|
263
|
+
export function evaluateCondition(
|
|
264
|
+
condition: WhenCondition | string,
|
|
265
|
+
context: ResponseStoreContext,
|
|
266
|
+
): ConditionResult {
|
|
267
|
+
// Handle string shorthand
|
|
268
|
+
if (typeof condition === 'string') {
|
|
269
|
+
const parsed = parseStringCondition(condition);
|
|
270
|
+
if (!parsed) {
|
|
271
|
+
return { shouldRun: false, reason: `invalid condition syntax: "${condition}"` };
|
|
272
|
+
}
|
|
273
|
+
const result = evaluateExpression(parsed, context);
|
|
274
|
+
return {
|
|
275
|
+
shouldRun: result.passed,
|
|
276
|
+
reason: result.passed ? undefined : `condition not met: ${result.description}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Handle compound "all" condition (AND logic with short-circuit)
|
|
281
|
+
if (condition.all && condition.all.length > 0) {
|
|
282
|
+
for (const expr of condition.all) {
|
|
283
|
+
const result = evaluateExpression(expr, context);
|
|
284
|
+
if (!result.passed) {
|
|
285
|
+
return {
|
|
286
|
+
shouldRun: false,
|
|
287
|
+
reason: `condition not met: ${result.description}`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { shouldRun: true };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Handle compound "any" condition (OR logic with short-circuit)
|
|
295
|
+
if (condition.any && condition.any.length > 0) {
|
|
296
|
+
const descriptions: string[] = [];
|
|
297
|
+
for (const expr of condition.any) {
|
|
298
|
+
const result = evaluateExpression(expr, context);
|
|
299
|
+
if (result.passed) {
|
|
300
|
+
return { shouldRun: true };
|
|
301
|
+
}
|
|
302
|
+
descriptions.push(result.description);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
shouldRun: false,
|
|
306
|
+
reason: `no conditions met: ${descriptions.join(', ')}`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Handle single condition (inline left/operator/right)
|
|
311
|
+
if (condition.left && condition.operator) {
|
|
312
|
+
const expr: ConditionExpression = {
|
|
313
|
+
left: condition.left,
|
|
314
|
+
operator: condition.operator,
|
|
315
|
+
right: condition.right,
|
|
316
|
+
caseSensitive: condition.caseSensitive,
|
|
317
|
+
};
|
|
318
|
+
const result = evaluateExpression(expr, context);
|
|
319
|
+
return {
|
|
320
|
+
shouldRun: result.passed,
|
|
321
|
+
reason: result.passed ? undefined : `condition not met: ${result.description}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// No valid condition found - default to run
|
|
326
|
+
return { shouldRun: true };
|
|
327
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SnapshotFormatter } from '../snapshot/snapshot-formatter';
|
|
1
2
|
import type {
|
|
2
3
|
ExecutionResult,
|
|
3
4
|
ExecutionSummary,
|
|
@@ -409,6 +410,12 @@ export class Logger {
|
|
|
409
410
|
this.logValidationErrors(result.error);
|
|
410
411
|
}
|
|
411
412
|
|
|
413
|
+
// Show snapshot result
|
|
414
|
+
if (result.snapshotResult) {
|
|
415
|
+
console.log();
|
|
416
|
+
this.logSnapshotResult(result.request.name || 'Request', result.snapshotResult);
|
|
417
|
+
}
|
|
418
|
+
|
|
412
419
|
console.log();
|
|
413
420
|
return;
|
|
414
421
|
}
|
|
@@ -532,9 +539,27 @@ export class Logger {
|
|
|
532
539
|
this.logValidationErrors(result.error);
|
|
533
540
|
}
|
|
534
541
|
|
|
542
|
+
// Show snapshot result
|
|
543
|
+
if (result.snapshotResult) {
|
|
544
|
+
console.log();
|
|
545
|
+
this.logSnapshotResult(result.request.name || 'Request', result.snapshotResult);
|
|
546
|
+
}
|
|
547
|
+
|
|
535
548
|
console.log();
|
|
536
549
|
}
|
|
537
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Logs snapshot comparison result.
|
|
553
|
+
*/
|
|
554
|
+
private logSnapshotResult(requestName: string, result: ExecutionResult['snapshotResult']): void {
|
|
555
|
+
if (!result) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const formatter = new SnapshotFormatter();
|
|
560
|
+
console.log(formatter.formatResult(requestName, result));
|
|
561
|
+
}
|
|
562
|
+
|
|
538
563
|
logSummary(summary: ExecutionSummary, isGlobal: boolean = false): void {
|
|
539
564
|
// For raw format, don't show summary
|
|
540
565
|
if (this.config.format === 'raw') {
|
|
@@ -548,6 +573,7 @@ export class Logger {
|
|
|
548
573
|
total: summary.total,
|
|
549
574
|
successful: summary.successful,
|
|
550
575
|
failed: summary.failed,
|
|
576
|
+
skipped: summary.skipped,
|
|
551
577
|
duration: summary.duration,
|
|
552
578
|
},
|
|
553
579
|
results: summary.results.map((result) => ({
|
|
@@ -583,10 +609,11 @@ export class Logger {
|
|
|
583
609
|
if (level === 'minimal') {
|
|
584
610
|
// Simple one-line summary for minimal, similar to docs example
|
|
585
611
|
const statusColor = summary.failed === 0 ? 'green' : 'red';
|
|
612
|
+
const skippedText = summary.skipped > 0 ? `, ${summary.skipped} skipped` : '';
|
|
586
613
|
const successText =
|
|
587
614
|
summary.failed === 0
|
|
588
|
-
? `${summary.total} request${summary.total === 1 ? '' : 's'} completed successfully`
|
|
589
|
-
: `${summary.successful}/${summary.total} request${summary.total === 1 ? '' : 's'} completed, ${summary.failed} failed`;
|
|
615
|
+
? `${summary.total} request${summary.total === 1 ? '' : 's'} completed successfully${skippedText}`
|
|
616
|
+
: `${summary.successful}/${summary.total} request${summary.total === 1 ? '' : 's'} completed, ${summary.failed} failed${skippedText}`;
|
|
590
617
|
|
|
591
618
|
const summaryPrefix = isGlobal ? '◆ Global Summary' : 'Summary';
|
|
592
619
|
console.log(`${summaryPrefix}: ${this.color(successText, statusColor)}`);
|
|
@@ -596,10 +623,11 @@ export class Logger {
|
|
|
596
623
|
// Compact summary for standard/detailed - much simpler
|
|
597
624
|
const _successRate = ((summary.successful / summary.total) * 100).toFixed(1);
|
|
598
625
|
const statusColor = summary.failed === 0 ? 'green' : 'red';
|
|
626
|
+
const skippedText = summary.skipped > 0 ? `, ${summary.skipped} skipped` : '';
|
|
599
627
|
const successText =
|
|
600
628
|
summary.failed === 0
|
|
601
|
-
? `${summary.total} request${summary.total === 1 ? '' : 's'} completed successfully`
|
|
602
|
-
: `${summary.successful}/${summary.total} request${summary.total === 1 ? '' : 's'} completed, ${summary.failed} failed`;
|
|
629
|
+
? `${summary.total} request${summary.total === 1 ? '' : 's'} completed successfully${skippedText}`
|
|
630
|
+
: `${summary.successful}/${summary.total} request${summary.total === 1 ? '' : 's'} completed, ${summary.failed} failed${skippedText}`;
|
|
603
631
|
|
|
604
632
|
const summaryPrefix = isGlobal ? '◆ Global Summary' : 'Summary';
|
|
605
633
|
console.log();
|
|
@@ -633,6 +661,41 @@ export class Logger {
|
|
|
633
661
|
console.log(this.color(`✓ ${message}`, 'green'));
|
|
634
662
|
}
|
|
635
663
|
|
|
664
|
+
logSkipped(config: RequestConfig, index: number, reason?: string): void {
|
|
665
|
+
if (!this.shouldShowOutput()) {
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const name = config.name || `Request ${index}`;
|
|
670
|
+
|
|
671
|
+
if (this.config.format === 'json') {
|
|
672
|
+
const jsonResult = {
|
|
673
|
+
request: {
|
|
674
|
+
name: config.name,
|
|
675
|
+
url: config.url,
|
|
676
|
+
method: config.method || 'GET',
|
|
677
|
+
},
|
|
678
|
+
skipped: true,
|
|
679
|
+
reason: reason || 'condition not met',
|
|
680
|
+
};
|
|
681
|
+
console.log(JSON.stringify(jsonResult, null, 2));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Pretty format
|
|
686
|
+
console.log(
|
|
687
|
+
`${this.color('⊘', 'yellow')} ${this.color(name, 'bright')} ${this.color('[SKIP]', 'yellow')}`,
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
if (reason) {
|
|
691
|
+
const treeNodes: TreeNode[] = [{ label: 'Reason', value: reason, color: 'yellow' }];
|
|
692
|
+
const renderer = new TreeRenderer(this.colors);
|
|
693
|
+
renderer.render(treeNodes);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
console.log();
|
|
697
|
+
}
|
|
698
|
+
|
|
636
699
|
logFileHeader(fileName: string, requestCount: number): void {
|
|
637
700
|
if (!this.shouldShowOutput() || this.config.format !== 'pretty') {
|
|
638
701
|
return;
|