@curl-runner/cli 1.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@curl-runner/cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "A powerful CLI tool for HTTP request management using YAML configuration",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -11,6 +11,7 @@ import type {
11
11
  ResponseStoreContext,
12
12
  SnapshotConfig,
13
13
  } from '../types/config';
14
+ import { evaluateCondition } from '../utils/condition-evaluator';
14
15
  import { CurlBuilder } from '../utils/curl-builder';
15
16
  import { Logger } from '../utils/logger';
16
17
  import { createStoreContext, extractStoreValues } from '../utils/response-store';
@@ -516,6 +517,23 @@ export class RequestExecutor {
516
517
  for (let i = 0; i < requests.length; i++) {
517
518
  // Interpolate store variables before execution
518
519
  const interpolatedRequest = this.interpolateStoreVariables(requests[i], storeContext);
520
+
521
+ // Evaluate `when` condition if present
522
+ if (interpolatedRequest.when) {
523
+ const conditionResult = evaluateCondition(interpolatedRequest.when, storeContext);
524
+ if (!conditionResult.shouldRun) {
525
+ const skippedResult: ExecutionResult = {
526
+ request: interpolatedRequest,
527
+ success: true, // Skipped requests are not failures
528
+ skipped: true,
529
+ skipReason: conditionResult.reason,
530
+ };
531
+ results.push(skippedResult);
532
+ this.logger.logSkipped(interpolatedRequest, i + 1, conditionResult.reason);
533
+ continue;
534
+ }
535
+ }
536
+
519
537
  const result = await this.executeRequest(interpolatedRequest, i + 1);
520
538
  results.push(result);
521
539
 
@@ -618,13 +636,15 @@ export class RequestExecutor {
618
636
  }
619
637
 
620
638
  private createSummary(results: ExecutionResult[], duration: number): ExecutionSummary {
621
- const successful = results.filter((r) => r.success).length;
639
+ const skipped = results.filter((r) => r.skipped).length;
640
+ const successful = results.filter((r) => r.success && !r.skipped).length;
622
641
  const failed = results.filter((r) => !r.success).length;
623
642
 
624
643
  return {
625
644
  total: results.length,
626
645
  successful,
627
646
  failed,
647
+ skipped,
628
648
  duration,
629
649
  results,
630
650
  };
@@ -57,6 +57,87 @@ export type FormDataConfig = Record<string, FormFieldValue>;
57
57
  */
58
58
  export type StoreConfig = Record<string, string>;
59
59
 
60
+ /**
61
+ * Operators for conditional expressions.
62
+ */
63
+ export type ConditionOperator =
64
+ | '=='
65
+ | '!='
66
+ | '>'
67
+ | '<'
68
+ | '>='
69
+ | '<='
70
+ | 'contains'
71
+ | 'matches'
72
+ | 'exists'
73
+ | 'not-exists';
74
+
75
+ /**
76
+ * A single condition expression comparing a store value against an expected value.
77
+ *
78
+ * Examples:
79
+ * - `{ left: "store.status", operator: "==", right: 200 }`
80
+ * - `{ left: "store.userId", operator: "exists" }`
81
+ * - `{ left: "store.body.type", operator: "contains", right: "user" }`
82
+ */
83
+ export interface ConditionExpression {
84
+ /** Left operand - typically a store path like "store.status" or "store.body.id" */
85
+ left: string;
86
+ /** Comparison operator */
87
+ operator: ConditionOperator;
88
+ /** Right operand - the value to compare against. Optional for exists/not-exists. */
89
+ right?: string | number | boolean;
90
+ /** Case-sensitive comparison for string operators. Default: false (case-insensitive) */
91
+ caseSensitive?: boolean;
92
+ }
93
+
94
+ /**
95
+ * Configuration for conditional request execution.
96
+ * Supports single conditions, AND (all), and OR (any) compound conditions.
97
+ *
98
+ * Examples:
99
+ * ```yaml
100
+ * # Single condition
101
+ * when:
102
+ * left: store.status
103
+ * operator: "=="
104
+ * right: 200
105
+ *
106
+ * # AND condition (all must be true)
107
+ * when:
108
+ * all:
109
+ * - left: store.status
110
+ * operator: "=="
111
+ * right: 200
112
+ * - left: store.userId
113
+ * operator: exists
114
+ *
115
+ * # OR condition (any must be true)
116
+ * when:
117
+ * any:
118
+ * - left: store.type
119
+ * operator: "=="
120
+ * right: "admin"
121
+ * - left: store.type
122
+ * operator: "=="
123
+ * right: "superuser"
124
+ * ```
125
+ */
126
+ export interface WhenCondition {
127
+ /** All conditions must be true (AND logic) */
128
+ all?: ConditionExpression[];
129
+ /** Any condition must be true (OR logic) */
130
+ any?: ConditionExpression[];
131
+ /** Single condition - left operand */
132
+ left?: string;
133
+ /** Single condition - operator */
134
+ operator?: ConditionOperator;
135
+ /** Single condition - right operand */
136
+ right?: string | number | boolean;
137
+ /** Case-sensitive comparison for string operators. Default: false */
138
+ caseSensitive?: boolean;
139
+ }
140
+
60
141
  /**
61
142
  * SSL/TLS certificate configuration options.
62
143
  *
@@ -140,6 +221,30 @@ export interface RequestConfig {
140
221
  * contentType: headers.content-type
141
222
  */
142
223
  store?: StoreConfig;
224
+ /**
225
+ * Conditional execution - skip/run request based on previous results.
226
+ * Only works in sequential execution mode.
227
+ *
228
+ * @example
229
+ * # Object syntax
230
+ * when:
231
+ * left: store.status
232
+ * operator: "=="
233
+ * right: 200
234
+ *
235
+ * # String shorthand
236
+ * when: "store.status == 200"
237
+ *
238
+ * # Compound conditions
239
+ * when:
240
+ * all:
241
+ * - left: store.userId
242
+ * operator: exists
243
+ * - left: store.status
244
+ * operator: "<"
245
+ * right: 400
246
+ */
247
+ when?: WhenCondition | string;
143
248
  expect?: {
144
249
  failure?: boolean; // If true, expect the request to fail (for negative testing)
145
250
  status?: number | number[];
@@ -264,12 +369,17 @@ export interface ExecutionResult {
264
369
  };
265
370
  /** Snapshot comparison result (if snapshot testing enabled). */
266
371
  snapshotResult?: SnapshotCompareResult;
372
+ /** Whether this request was skipped due to a `when` condition. */
373
+ skipped?: boolean;
374
+ /** Reason the request was skipped (condition that failed). */
375
+ skipReason?: string;
267
376
  }
268
377
 
269
378
  export interface ExecutionSummary {
270
379
  total: number;
271
380
  successful: number;
272
381
  failed: number;
382
+ skipped: number;
273
383
  duration: number;
274
384
  results: ExecutionResult[];
275
385
  }
@@ -0,0 +1,415 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ConditionExpression, ResponseStoreContext, WhenCondition } from '../types/config';
3
+ import { evaluateCondition, evaluateExpression, parseStringCondition } from './condition-evaluator';
4
+
5
+ describe('parseStringCondition', () => {
6
+ test('should parse equality condition', () => {
7
+ const result = parseStringCondition('store.status == 200');
8
+ expect(result).toEqual({
9
+ left: 'store.status',
10
+ operator: '==',
11
+ right: 200,
12
+ });
13
+ });
14
+
15
+ test('should parse inequality condition', () => {
16
+ const result = parseStringCondition('store.type != admin');
17
+ expect(result).toEqual({
18
+ left: 'store.type',
19
+ operator: '!=',
20
+ right: 'admin',
21
+ });
22
+ });
23
+
24
+ test('should parse greater than condition', () => {
25
+ const result = parseStringCondition('store.count > 10');
26
+ expect(result).toEqual({
27
+ left: 'store.count',
28
+ operator: '>',
29
+ right: 10,
30
+ });
31
+ });
32
+
33
+ test('should parse less than condition', () => {
34
+ const result = parseStringCondition('store.price < 100.5');
35
+ expect(result).toEqual({
36
+ left: 'store.price',
37
+ operator: '<',
38
+ right: 100.5,
39
+ });
40
+ });
41
+
42
+ test('should parse greater than or equal condition', () => {
43
+ const result = parseStringCondition('store.version >= 2');
44
+ expect(result).toEqual({
45
+ left: 'store.version',
46
+ operator: '>=',
47
+ right: 2,
48
+ });
49
+ });
50
+
51
+ test('should parse less than or equal condition', () => {
52
+ const result = parseStringCondition('store.age <= 65');
53
+ expect(result).toEqual({
54
+ left: 'store.age',
55
+ operator: '<=',
56
+ right: 65,
57
+ });
58
+ });
59
+
60
+ test('should parse contains condition', () => {
61
+ const result = parseStringCondition('store.message contains error');
62
+ expect(result).toEqual({
63
+ left: 'store.message',
64
+ operator: 'contains',
65
+ right: 'error',
66
+ });
67
+ });
68
+
69
+ test('should parse matches condition', () => {
70
+ const result = parseStringCondition('store.email matches ^[a-z]+@');
71
+ expect(result).toEqual({
72
+ left: 'store.email',
73
+ operator: 'matches',
74
+ right: '^[a-z]+@',
75
+ });
76
+ });
77
+
78
+ test('should parse exists condition', () => {
79
+ const result = parseStringCondition('store.userId exists');
80
+ expect(result).toEqual({
81
+ left: 'store.userId',
82
+ operator: 'exists',
83
+ });
84
+ });
85
+
86
+ test('should parse not-exists condition', () => {
87
+ const result = parseStringCondition('store.error not-exists');
88
+ expect(result).toEqual({
89
+ left: 'store.error',
90
+ operator: 'not-exists',
91
+ });
92
+ });
93
+
94
+ test('should parse boolean true', () => {
95
+ const result = parseStringCondition('store.enabled == true');
96
+ expect(result).toEqual({
97
+ left: 'store.enabled',
98
+ operator: '==',
99
+ right: true,
100
+ });
101
+ });
102
+
103
+ test('should parse boolean false', () => {
104
+ const result = parseStringCondition('store.disabled == false');
105
+ expect(result).toEqual({
106
+ left: 'store.disabled',
107
+ operator: '==',
108
+ right: false,
109
+ });
110
+ });
111
+
112
+ test('should parse quoted strings', () => {
113
+ const result = parseStringCondition('store.name == "John Doe"');
114
+ expect(result).toEqual({
115
+ left: 'store.name',
116
+ operator: '==',
117
+ right: 'John Doe',
118
+ });
119
+ });
120
+
121
+ test('should return null for invalid syntax', () => {
122
+ expect(parseStringCondition('invalid')).toBeNull();
123
+ expect(parseStringCondition('')).toBeNull();
124
+ expect(parseStringCondition('store.x')).toBeNull();
125
+ });
126
+ });
127
+
128
+ describe('evaluateExpression', () => {
129
+ const context: ResponseStoreContext = {
130
+ status: '200',
131
+ userId: '123',
132
+ name: 'John',
133
+ empty: '',
134
+ enabled: 'true',
135
+ count: '42',
136
+ body: '{"type":"user","nested":{"id":1}}',
137
+ };
138
+
139
+ describe('exists operator', () => {
140
+ test('should return true for existing value', () => {
141
+ const expr: ConditionExpression = { left: 'store.userId', operator: 'exists' };
142
+ expect(evaluateExpression(expr, context).passed).toBe(true);
143
+ });
144
+
145
+ test('should return false for empty string', () => {
146
+ const expr: ConditionExpression = { left: 'store.empty', operator: 'exists' };
147
+ expect(evaluateExpression(expr, context).passed).toBe(false);
148
+ });
149
+
150
+ test('should return false for missing value', () => {
151
+ const expr: ConditionExpression = { left: 'store.missing', operator: 'exists' };
152
+ expect(evaluateExpression(expr, context).passed).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('not-exists operator', () => {
157
+ test('should return true for missing value', () => {
158
+ const expr: ConditionExpression = { left: 'store.missing', operator: 'not-exists' };
159
+ expect(evaluateExpression(expr, context).passed).toBe(true);
160
+ });
161
+
162
+ test('should return true for empty string', () => {
163
+ const expr: ConditionExpression = { left: 'store.empty', operator: 'not-exists' };
164
+ expect(evaluateExpression(expr, context).passed).toBe(true);
165
+ });
166
+
167
+ test('should return false for existing value', () => {
168
+ const expr: ConditionExpression = { left: 'store.userId', operator: 'not-exists' };
169
+ expect(evaluateExpression(expr, context).passed).toBe(false);
170
+ });
171
+ });
172
+
173
+ describe('== operator', () => {
174
+ test('should compare numbers', () => {
175
+ const expr: ConditionExpression = { left: 'store.status', operator: '==', right: 200 };
176
+ expect(evaluateExpression(expr, context).passed).toBe(true);
177
+ });
178
+
179
+ test('should compare strings case-insensitively by default', () => {
180
+ const expr: ConditionExpression = { left: 'store.name', operator: '==', right: 'john' };
181
+ expect(evaluateExpression(expr, context).passed).toBe(true);
182
+ });
183
+
184
+ test('should compare strings case-sensitively when specified', () => {
185
+ const expr: ConditionExpression = {
186
+ left: 'store.name',
187
+ operator: '==',
188
+ right: 'john',
189
+ caseSensitive: true,
190
+ };
191
+ expect(evaluateExpression(expr, context).passed).toBe(false);
192
+ });
193
+
194
+ test('should compare booleans', () => {
195
+ const expr: ConditionExpression = { left: 'store.enabled', operator: '==', right: true };
196
+ expect(evaluateExpression(expr, context).passed).toBe(true);
197
+ });
198
+ });
199
+
200
+ describe('!= operator', () => {
201
+ test('should return true for different values', () => {
202
+ const expr: ConditionExpression = { left: 'store.status', operator: '!=', right: 404 };
203
+ expect(evaluateExpression(expr, context).passed).toBe(true);
204
+ });
205
+
206
+ test('should return false for equal values', () => {
207
+ const expr: ConditionExpression = { left: 'store.status', operator: '!=', right: 200 };
208
+ expect(evaluateExpression(expr, context).passed).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe('comparison operators', () => {
213
+ test('should compare with >', () => {
214
+ const expr: ConditionExpression = { left: 'store.count', operator: '>', right: 40 };
215
+ expect(evaluateExpression(expr, context).passed).toBe(true);
216
+ });
217
+
218
+ test('should compare with <', () => {
219
+ const expr: ConditionExpression = { left: 'store.count', operator: '<', right: 50 };
220
+ expect(evaluateExpression(expr, context).passed).toBe(true);
221
+ });
222
+
223
+ test('should compare with >=', () => {
224
+ const expr: ConditionExpression = { left: 'store.count', operator: '>=', right: 42 };
225
+ expect(evaluateExpression(expr, context).passed).toBe(true);
226
+ });
227
+
228
+ test('should compare with <=', () => {
229
+ const expr: ConditionExpression = { left: 'store.count', operator: '<=', right: 42 };
230
+ expect(evaluateExpression(expr, context).passed).toBe(true);
231
+ });
232
+ });
233
+
234
+ describe('contains operator', () => {
235
+ test('should find substring case-insensitively', () => {
236
+ const expr: ConditionExpression = { left: 'store.name', operator: 'contains', right: 'OH' };
237
+ expect(evaluateExpression(expr, context).passed).toBe(true);
238
+ });
239
+
240
+ test('should find substring case-sensitively when specified', () => {
241
+ const expr: ConditionExpression = {
242
+ left: 'store.name',
243
+ operator: 'contains',
244
+ right: 'OH',
245
+ caseSensitive: true,
246
+ };
247
+ expect(evaluateExpression(expr, context).passed).toBe(false);
248
+ });
249
+
250
+ test('should return false when substring not found', () => {
251
+ const expr: ConditionExpression = { left: 'store.name', operator: 'contains', right: 'xyz' };
252
+ expect(evaluateExpression(expr, context).passed).toBe(false);
253
+ });
254
+ });
255
+
256
+ describe('matches operator', () => {
257
+ test('should match regex pattern', () => {
258
+ const expr: ConditionExpression = {
259
+ left: 'store.name',
260
+ operator: 'matches',
261
+ right: '^[A-Z]',
262
+ };
263
+ expect(evaluateExpression(expr, context).passed).toBe(true);
264
+ });
265
+
266
+ test('should be case-insensitive by default', () => {
267
+ const expr: ConditionExpression = {
268
+ left: 'store.name',
269
+ operator: 'matches',
270
+ right: '^john$',
271
+ };
272
+ expect(evaluateExpression(expr, context).passed).toBe(true);
273
+ });
274
+
275
+ test('should respect caseSensitive flag', () => {
276
+ const expr: ConditionExpression = {
277
+ left: 'store.name',
278
+ operator: 'matches',
279
+ right: '^john$',
280
+ caseSensitive: true,
281
+ };
282
+ expect(evaluateExpression(expr, context).passed).toBe(false);
283
+ });
284
+
285
+ test('should handle invalid regex', () => {
286
+ const expr: ConditionExpression = { left: 'store.name', operator: 'matches', right: '[' };
287
+ expect(evaluateExpression(expr, context).passed).toBe(false);
288
+ });
289
+ });
290
+ });
291
+
292
+ describe('evaluateCondition', () => {
293
+ const context: ResponseStoreContext = {
294
+ status: '200',
295
+ userId: '123',
296
+ type: 'admin',
297
+ enabled: 'true',
298
+ };
299
+
300
+ describe('string shorthand', () => {
301
+ test('should evaluate valid string condition', () => {
302
+ const result = evaluateCondition('store.status == 200', context);
303
+ expect(result.shouldRun).toBe(true);
304
+ expect(result.reason).toBeUndefined();
305
+ });
306
+
307
+ test('should return false with reason for failing condition', () => {
308
+ const result = evaluateCondition('store.status == 404', context);
309
+ expect(result.shouldRun).toBe(false);
310
+ expect(result.reason).toContain('condition not met');
311
+ });
312
+
313
+ test('should return false for invalid syntax', () => {
314
+ const result = evaluateCondition('invalid syntax', context);
315
+ expect(result.shouldRun).toBe(false);
316
+ expect(result.reason).toContain('invalid condition syntax');
317
+ });
318
+ });
319
+
320
+ describe('single condition object', () => {
321
+ test('should evaluate single condition', () => {
322
+ const condition: WhenCondition = {
323
+ left: 'store.userId',
324
+ operator: 'exists',
325
+ };
326
+ expect(evaluateCondition(condition, context).shouldRun).toBe(true);
327
+ });
328
+
329
+ test('should fail single condition', () => {
330
+ const condition: WhenCondition = {
331
+ left: 'store.missing',
332
+ operator: 'exists',
333
+ };
334
+ const result = evaluateCondition(condition, context);
335
+ expect(result.shouldRun).toBe(false);
336
+ expect(result.reason).toContain('condition not met');
337
+ });
338
+ });
339
+
340
+ describe('all (AND) conditions', () => {
341
+ test('should pass when all conditions true', () => {
342
+ const condition: WhenCondition = {
343
+ all: [
344
+ { left: 'store.status', operator: '==', right: 200 },
345
+ { left: 'store.userId', operator: 'exists' },
346
+ { left: 'store.type', operator: '==', right: 'admin' },
347
+ ],
348
+ };
349
+ expect(evaluateCondition(condition, context).shouldRun).toBe(true);
350
+ });
351
+
352
+ test('should fail when any condition false (short-circuit)', () => {
353
+ const condition: WhenCondition = {
354
+ all: [
355
+ { left: 'store.status', operator: '==', right: 200 },
356
+ { left: 'store.status', operator: '==', right: 404 }, // fails
357
+ { left: 'store.userId', operator: 'exists' },
358
+ ],
359
+ };
360
+ const result = evaluateCondition(condition, context);
361
+ expect(result.shouldRun).toBe(false);
362
+ expect(result.reason).toContain('store.status == 404');
363
+ });
364
+ });
365
+
366
+ describe('any (OR) conditions', () => {
367
+ test('should pass when any condition true (short-circuit)', () => {
368
+ const condition: WhenCondition = {
369
+ any: [
370
+ { left: 'store.status', operator: '==', right: 404 }, // fails
371
+ { left: 'store.status', operator: '==', right: 200 }, // passes
372
+ { left: 'store.status', operator: '==', right: 500 }, // skipped
373
+ ],
374
+ };
375
+ expect(evaluateCondition(condition, context).shouldRun).toBe(true);
376
+ });
377
+
378
+ test('should fail when all conditions false', () => {
379
+ const condition: WhenCondition = {
380
+ any: [
381
+ { left: 'store.status', operator: '==', right: 404 },
382
+ { left: 'store.status', operator: '==', right: 500 },
383
+ ],
384
+ };
385
+ const result = evaluateCondition(condition, context);
386
+ expect(result.shouldRun).toBe(false);
387
+ expect(result.reason).toContain('no conditions met');
388
+ });
389
+ });
390
+
391
+ describe('edge cases', () => {
392
+ test('should run when no valid condition specified', () => {
393
+ const condition: WhenCondition = {};
394
+ expect(evaluateCondition(condition, context).shouldRun).toBe(true);
395
+ });
396
+
397
+ test('should handle empty all array', () => {
398
+ const condition: WhenCondition = { all: [] };
399
+ expect(evaluateCondition(condition, context).shouldRun).toBe(true);
400
+ });
401
+
402
+ test('should handle empty any array', () => {
403
+ const condition: WhenCondition = { any: [] };
404
+ expect(evaluateCondition(condition, context).shouldRun).toBe(true);
405
+ });
406
+
407
+ test('should handle nested JSON in store', () => {
408
+ const nestedContext: ResponseStoreContext = {
409
+ body: '{"user":{"id":123,"role":"admin"}}',
410
+ };
411
+ const result = evaluateCondition('store.body.user.role == admin', nestedContext);
412
+ expect(result.shouldRun).toBe(true);
413
+ });
414
+ });
415
+ });
@@ -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
+ }
@@ -573,6 +573,7 @@ export class Logger {
573
573
  total: summary.total,
574
574
  successful: summary.successful,
575
575
  failed: summary.failed,
576
+ skipped: summary.skipped,
576
577
  duration: summary.duration,
577
578
  },
578
579
  results: summary.results.map((result) => ({
@@ -608,10 +609,11 @@ export class Logger {
608
609
  if (level === 'minimal') {
609
610
  // Simple one-line summary for minimal, similar to docs example
610
611
  const statusColor = summary.failed === 0 ? 'green' : 'red';
612
+ const skippedText = summary.skipped > 0 ? `, ${summary.skipped} skipped` : '';
611
613
  const successText =
612
614
  summary.failed === 0
613
- ? `${summary.total} request${summary.total === 1 ? '' : 's'} completed successfully`
614
- : `${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}`;
615
617
 
616
618
  const summaryPrefix = isGlobal ? '◆ Global Summary' : 'Summary';
617
619
  console.log(`${summaryPrefix}: ${this.color(successText, statusColor)}`);
@@ -621,10 +623,11 @@ export class Logger {
621
623
  // Compact summary for standard/detailed - much simpler
622
624
  const _successRate = ((summary.successful / summary.total) * 100).toFixed(1);
623
625
  const statusColor = summary.failed === 0 ? 'green' : 'red';
626
+ const skippedText = summary.skipped > 0 ? `, ${summary.skipped} skipped` : '';
624
627
  const successText =
625
628
  summary.failed === 0
626
- ? `${summary.total} request${summary.total === 1 ? '' : 's'} completed successfully`
627
- : `${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}`;
628
631
 
629
632
  const summaryPrefix = isGlobal ? '◆ Global Summary' : 'Summary';
630
633
  console.log();
@@ -658,6 +661,41 @@ export class Logger {
658
661
  console.log(this.color(`✓ ${message}`, 'green'));
659
662
  }
660
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
+
661
699
  logFileHeader(fileName: string, requestCount: number): void {
662
700
  if (!this.shouldShowOutput() || this.config.format !== 'pretty') {
663
701
  return;