@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.
@@ -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
+ }
@@ -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;