@curl-runner/cli 1.16.0 → 1.16.2
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 +2 -2
- package/src/ci-exit.test.ts +0 -216
- package/src/cli.ts +0 -1351
- package/src/commands/upgrade.ts +0 -262
- package/src/diff/baseline-manager.test.ts +0 -181
- package/src/diff/baseline-manager.ts +0 -266
- package/src/diff/diff-formatter.ts +0 -316
- package/src/diff/index.ts +0 -3
- package/src/diff/response-differ.test.ts +0 -330
- package/src/diff/response-differ.ts +0 -489
- package/src/executor/max-concurrency.test.ts +0 -139
- package/src/executor/profile-executor.test.ts +0 -132
- package/src/executor/profile-executor.ts +0 -167
- package/src/executor/request-executor.ts +0 -663
- package/src/parser/yaml.test.ts +0 -480
- package/src/parser/yaml.ts +0 -271
- package/src/snapshot/index.ts +0 -3
- package/src/snapshot/snapshot-differ.test.ts +0 -358
- package/src/snapshot/snapshot-differ.ts +0 -296
- package/src/snapshot/snapshot-formatter.ts +0 -170
- package/src/snapshot/snapshot-manager.test.ts +0 -204
- package/src/snapshot/snapshot-manager.ts +0 -342
- package/src/types/bun-yaml.d.ts +0 -11
- package/src/types/config.ts +0 -638
- package/src/utils/colors.ts +0 -30
- package/src/utils/condition-evaluator.test.ts +0 -415
- package/src/utils/condition-evaluator.ts +0 -327
- package/src/utils/curl-builder.test.ts +0 -165
- package/src/utils/curl-builder.ts +0 -209
- package/src/utils/installation-detector.test.ts +0 -52
- package/src/utils/installation-detector.ts +0 -123
- package/src/utils/logger.ts +0 -856
- package/src/utils/response-store.test.ts +0 -213
- package/src/utils/response-store.ts +0 -108
- package/src/utils/stats.test.ts +0 -161
- package/src/utils/stats.ts +0 -151
- package/src/utils/version-checker.ts +0 -158
- package/src/version.ts +0 -43
- package/src/watcher/file-watcher.test.ts +0 -186
- package/src/watcher/file-watcher.ts +0 -140
|
@@ -1,663 +0,0 @@
|
|
|
1
|
-
import { YamlParser } from '../parser/yaml';
|
|
2
|
-
import { SnapshotManager } from '../snapshot/snapshot-manager';
|
|
3
|
-
import type {
|
|
4
|
-
ExecutionResult,
|
|
5
|
-
ExecutionSummary,
|
|
6
|
-
FileAttachment,
|
|
7
|
-
FormFieldValue,
|
|
8
|
-
GlobalConfig,
|
|
9
|
-
JsonValue,
|
|
10
|
-
RequestConfig,
|
|
11
|
-
ResponseStoreContext,
|
|
12
|
-
SnapshotConfig,
|
|
13
|
-
} from '../types/config';
|
|
14
|
-
import { evaluateCondition } from '../utils/condition-evaluator';
|
|
15
|
-
import { CurlBuilder } from '../utils/curl-builder';
|
|
16
|
-
import { Logger } from '../utils/logger';
|
|
17
|
-
import { createStoreContext, extractStoreValues } from '../utils/response-store';
|
|
18
|
-
|
|
19
|
-
export class RequestExecutor {
|
|
20
|
-
private logger: Logger;
|
|
21
|
-
private globalConfig: GlobalConfig;
|
|
22
|
-
private snapshotManager: SnapshotManager;
|
|
23
|
-
|
|
24
|
-
constructor(globalConfig: GlobalConfig = {}) {
|
|
25
|
-
this.globalConfig = globalConfig;
|
|
26
|
-
this.logger = new Logger(globalConfig.output);
|
|
27
|
-
this.snapshotManager = new SnapshotManager(globalConfig.snapshot);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
private mergeOutputConfig(config: RequestConfig): GlobalConfig['output'] {
|
|
31
|
-
// Precedence: Individual YAML file > curl-runner.yaml > CLI options > env vars > defaults
|
|
32
|
-
return {
|
|
33
|
-
...this.globalConfig.output, // CLI options, env vars, and defaults (lowest priority)
|
|
34
|
-
...config.sourceOutputConfig, // Individual file's output config (highest priority)
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Gets the effective snapshot config for a request.
|
|
40
|
-
*/
|
|
41
|
-
private getSnapshotConfig(config: RequestConfig): SnapshotConfig | null {
|
|
42
|
-
return SnapshotManager.mergeConfig(this.globalConfig.snapshot, config.snapshot);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Checks if a form field value is a file attachment.
|
|
47
|
-
*/
|
|
48
|
-
private isFileAttachment(value: FormFieldValue): value is FileAttachment {
|
|
49
|
-
return typeof value === 'object' && value !== null && 'file' in value;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Validates that all file attachments in formData exist.
|
|
54
|
-
* Returns an error message if any file is missing, or undefined if all files exist.
|
|
55
|
-
*/
|
|
56
|
-
private async validateFileAttachments(config: RequestConfig): Promise<string | undefined> {
|
|
57
|
-
if (!config.formData) {
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const missingFiles: string[] = [];
|
|
62
|
-
|
|
63
|
-
for (const [fieldName, fieldValue] of Object.entries(config.formData)) {
|
|
64
|
-
if (this.isFileAttachment(fieldValue)) {
|
|
65
|
-
const filePath = fieldValue.file;
|
|
66
|
-
const file = Bun.file(filePath);
|
|
67
|
-
const exists = await file.exists();
|
|
68
|
-
if (!exists) {
|
|
69
|
-
missingFiles.push(`${fieldName}: ${filePath}`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (missingFiles.length > 0) {
|
|
75
|
-
return `File(s) not found: ${missingFiles.join(', ')}`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async executeRequest(config: RequestConfig, index: number = 0): Promise<ExecutionResult> {
|
|
82
|
-
const startTime = performance.now();
|
|
83
|
-
|
|
84
|
-
// Create per-request logger with merged output configuration
|
|
85
|
-
const outputConfig = this.mergeOutputConfig(config);
|
|
86
|
-
const requestLogger = new Logger(outputConfig);
|
|
87
|
-
|
|
88
|
-
requestLogger.logRequestStart(config, index);
|
|
89
|
-
|
|
90
|
-
// Validate file attachments exist before executing
|
|
91
|
-
const fileError = await this.validateFileAttachments(config);
|
|
92
|
-
if (fileError) {
|
|
93
|
-
const failedResult: ExecutionResult = {
|
|
94
|
-
request: config,
|
|
95
|
-
success: false,
|
|
96
|
-
error: fileError,
|
|
97
|
-
metrics: {
|
|
98
|
-
duration: performance.now() - startTime,
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
requestLogger.logRequestComplete(failedResult);
|
|
102
|
-
return failedResult;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const command = CurlBuilder.buildCommand(config);
|
|
106
|
-
requestLogger.logCommand(command);
|
|
107
|
-
|
|
108
|
-
let attempt = 0;
|
|
109
|
-
let lastError: string | undefined;
|
|
110
|
-
const maxAttempts = (config.retry?.count || 0) + 1;
|
|
111
|
-
|
|
112
|
-
while (attempt < maxAttempts) {
|
|
113
|
-
if (attempt > 0) {
|
|
114
|
-
requestLogger.logRetry(attempt, maxAttempts - 1);
|
|
115
|
-
if (config.retry?.delay) {
|
|
116
|
-
const backoff = config.retry.backoff ?? 1;
|
|
117
|
-
const delay = config.retry.delay * backoff ** (attempt - 1);
|
|
118
|
-
await Bun.sleep(delay);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const result = await CurlBuilder.executeCurl(command);
|
|
123
|
-
|
|
124
|
-
if (result.success) {
|
|
125
|
-
let body = result.body;
|
|
126
|
-
try {
|
|
127
|
-
if (
|
|
128
|
-
result.headers?.['content-type']?.includes('application/json') ||
|
|
129
|
-
(body && (body.trim().startsWith('{') || body.trim().startsWith('[')))
|
|
130
|
-
) {
|
|
131
|
-
body = JSON.parse(body);
|
|
132
|
-
}
|
|
133
|
-
} catch (_e) {}
|
|
134
|
-
|
|
135
|
-
const executionResult: ExecutionResult = {
|
|
136
|
-
request: config,
|
|
137
|
-
success: true,
|
|
138
|
-
status: result.status,
|
|
139
|
-
headers: result.headers,
|
|
140
|
-
body,
|
|
141
|
-
metrics: {
|
|
142
|
-
...result.metrics,
|
|
143
|
-
duration: performance.now() - startTime,
|
|
144
|
-
},
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
if (config.expect) {
|
|
148
|
-
const validationResult = this.validateResponse(executionResult, config.expect);
|
|
149
|
-
if (!validationResult.success) {
|
|
150
|
-
executionResult.success = false;
|
|
151
|
-
executionResult.error = validationResult.error;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Snapshot testing
|
|
156
|
-
const snapshotConfig = this.getSnapshotConfig(config);
|
|
157
|
-
if (snapshotConfig && config.sourceFile) {
|
|
158
|
-
const snapshotResult = await this.snapshotManager.compareAndUpdate(
|
|
159
|
-
config.sourceFile,
|
|
160
|
-
config.name || 'Request',
|
|
161
|
-
executionResult,
|
|
162
|
-
snapshotConfig,
|
|
163
|
-
);
|
|
164
|
-
executionResult.snapshotResult = snapshotResult;
|
|
165
|
-
|
|
166
|
-
if (!snapshotResult.match && !snapshotResult.updated) {
|
|
167
|
-
executionResult.success = false;
|
|
168
|
-
if (!executionResult.error) {
|
|
169
|
-
executionResult.error = 'Snapshot mismatch';
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
requestLogger.logRequestComplete(executionResult);
|
|
175
|
-
return executionResult;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
lastError = result.error;
|
|
179
|
-
attempt++;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const failedResult: ExecutionResult = {
|
|
183
|
-
request: config,
|
|
184
|
-
success: false,
|
|
185
|
-
error: lastError,
|
|
186
|
-
metrics: {
|
|
187
|
-
duration: performance.now() - startTime,
|
|
188
|
-
},
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
requestLogger.logRequestComplete(failedResult);
|
|
192
|
-
return failedResult;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private validateResponse(
|
|
196
|
-
result: ExecutionResult,
|
|
197
|
-
expect: RequestConfig['expect'],
|
|
198
|
-
): { success: boolean; error?: string } {
|
|
199
|
-
if (!expect) {
|
|
200
|
-
return { success: true };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const errors: string[] = [];
|
|
204
|
-
|
|
205
|
-
// Validate status
|
|
206
|
-
if (expect.status !== undefined) {
|
|
207
|
-
const expectedStatuses = Array.isArray(expect.status) ? expect.status : [expect.status];
|
|
208
|
-
if (!expectedStatuses.includes(result.status || 0)) {
|
|
209
|
-
errors.push(`Expected status ${expectedStatuses.join(' or ')}, got ${result.status}`);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Validate headers
|
|
214
|
-
if (expect.headers) {
|
|
215
|
-
for (const [key, value] of Object.entries(expect.headers)) {
|
|
216
|
-
const actualValue = result.headers?.[key] || result.headers?.[key.toLowerCase()];
|
|
217
|
-
if (actualValue !== value) {
|
|
218
|
-
errors.push(`Expected header ${key}="${value}", got "${actualValue}"`);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Validate body
|
|
224
|
-
if (expect.body !== undefined) {
|
|
225
|
-
const bodyErrors = this.validateBodyProperties(result.body, expect.body, '');
|
|
226
|
-
if (bodyErrors.length > 0) {
|
|
227
|
-
errors.push(...bodyErrors);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Validate response time
|
|
232
|
-
if (expect.responseTime !== undefined && result.metrics) {
|
|
233
|
-
const responseTimeMs = result.metrics.duration;
|
|
234
|
-
if (!this.validateRangePattern(responseTimeMs, expect.responseTime)) {
|
|
235
|
-
errors.push(
|
|
236
|
-
`Expected response time to match ${expect.responseTime}ms, got ${responseTimeMs.toFixed(2)}ms`,
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const hasValidationErrors = errors.length > 0;
|
|
242
|
-
|
|
243
|
-
// Handle failure expectation logic
|
|
244
|
-
if (expect.failure === true) {
|
|
245
|
-
// We expect this request to fail (negative testing)
|
|
246
|
-
// Success means: validations pass AND status indicates error (4xx/5xx)
|
|
247
|
-
if (hasValidationErrors) {
|
|
248
|
-
return { success: false, error: errors.join('; ') };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Check if status indicates an error
|
|
252
|
-
const status = result.status || 0;
|
|
253
|
-
if (status >= 400) {
|
|
254
|
-
// Status indicates error and validations passed - SUCCESS for negative testing
|
|
255
|
-
return { success: true };
|
|
256
|
-
} else {
|
|
257
|
-
// Expected failure but got success status - FAILURE
|
|
258
|
-
return {
|
|
259
|
-
success: false,
|
|
260
|
-
error: `Expected request to fail (4xx/5xx) but got status ${status}`,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
} else {
|
|
264
|
-
// Normal case: expect success (validations should pass)
|
|
265
|
-
if (hasValidationErrors) {
|
|
266
|
-
return { success: false, error: errors.join('; ') };
|
|
267
|
-
} else {
|
|
268
|
-
return { success: true };
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private validateBodyProperties(
|
|
274
|
-
actualBody: JsonValue,
|
|
275
|
-
expectedBody: JsonValue,
|
|
276
|
-
path: string,
|
|
277
|
-
): string[] {
|
|
278
|
-
const errors: string[] = [];
|
|
279
|
-
|
|
280
|
-
if (typeof expectedBody !== 'object' || expectedBody === null) {
|
|
281
|
-
// Advanced value validation
|
|
282
|
-
const validationResult = this.validateValue(actualBody, expectedBody, path || 'body');
|
|
283
|
-
if (!validationResult.isValid) {
|
|
284
|
-
errors.push(validationResult.error!);
|
|
285
|
-
}
|
|
286
|
-
return errors;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Array validation
|
|
290
|
-
if (Array.isArray(expectedBody)) {
|
|
291
|
-
const validationResult = this.validateValue(actualBody, expectedBody, path || 'body');
|
|
292
|
-
if (!validationResult.isValid) {
|
|
293
|
-
errors.push(validationResult.error!);
|
|
294
|
-
}
|
|
295
|
-
return errors;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Object property comparison with array selector support
|
|
299
|
-
for (const [key, expectedValue] of Object.entries(expectedBody)) {
|
|
300
|
-
const currentPath = path ? `${path}.${key}` : key;
|
|
301
|
-
let actualValue: JsonValue;
|
|
302
|
-
|
|
303
|
-
// Handle array selectors like [0], [-1], *, slice(0,3)
|
|
304
|
-
if (Array.isArray(actualBody) && this.isArraySelector(key)) {
|
|
305
|
-
actualValue = this.getArrayValue(actualBody, key);
|
|
306
|
-
} else {
|
|
307
|
-
actualValue = actualBody?.[key];
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (
|
|
311
|
-
typeof expectedValue === 'object' &&
|
|
312
|
-
expectedValue !== null &&
|
|
313
|
-
!Array.isArray(expectedValue)
|
|
314
|
-
) {
|
|
315
|
-
// Recursive validation for nested objects
|
|
316
|
-
const nestedErrors = this.validateBodyProperties(actualValue, expectedValue, currentPath);
|
|
317
|
-
errors.push(...nestedErrors);
|
|
318
|
-
} else {
|
|
319
|
-
// Advanced value validation
|
|
320
|
-
const validationResult = this.validateValue(actualValue, expectedValue, currentPath);
|
|
321
|
-
if (!validationResult.isValid) {
|
|
322
|
-
errors.push(validationResult.error!);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return errors;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private validateValue(
|
|
331
|
-
actualValue: JsonValue,
|
|
332
|
-
expectedValue: JsonValue,
|
|
333
|
-
path: string,
|
|
334
|
-
): { isValid: boolean; error?: string } {
|
|
335
|
-
// Wildcard validation - accept any value
|
|
336
|
-
if (expectedValue === '*') {
|
|
337
|
-
return { isValid: true };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Multiple acceptable values (array)
|
|
341
|
-
if (Array.isArray(expectedValue)) {
|
|
342
|
-
const isMatch = expectedValue.some((expected) => {
|
|
343
|
-
if (expected === '*') {
|
|
344
|
-
return true;
|
|
345
|
-
}
|
|
346
|
-
if (typeof expected === 'string' && this.isRegexPattern(expected)) {
|
|
347
|
-
return this.validateRegexPattern(actualValue, expected);
|
|
348
|
-
}
|
|
349
|
-
if (typeof expected === 'string' && this.isRangePattern(expected)) {
|
|
350
|
-
return this.validateRangePattern(actualValue, expected);
|
|
351
|
-
}
|
|
352
|
-
return actualValue === expected;
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
if (!isMatch) {
|
|
356
|
-
return {
|
|
357
|
-
isValid: false,
|
|
358
|
-
error: `Expected ${path} to match one of ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actualValue)}`,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
return { isValid: true };
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Regex pattern validation
|
|
365
|
-
if (typeof expectedValue === 'string' && this.isRegexPattern(expectedValue)) {
|
|
366
|
-
if (!this.validateRegexPattern(actualValue, expectedValue)) {
|
|
367
|
-
return {
|
|
368
|
-
isValid: false,
|
|
369
|
-
error: `Expected ${path} to match pattern ${expectedValue}, got ${JSON.stringify(actualValue)}`,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
return { isValid: true };
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Numeric range validation
|
|
376
|
-
if (typeof expectedValue === 'string' && this.isRangePattern(expectedValue)) {
|
|
377
|
-
if (!this.validateRangePattern(actualValue, expectedValue)) {
|
|
378
|
-
return {
|
|
379
|
-
isValid: false,
|
|
380
|
-
error: `Expected ${path} to match range ${expectedValue}, got ${JSON.stringify(actualValue)}`,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
return { isValid: true };
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Null handling
|
|
387
|
-
if (expectedValue === 'null' || expectedValue === null) {
|
|
388
|
-
if (actualValue !== null) {
|
|
389
|
-
return {
|
|
390
|
-
isValid: false,
|
|
391
|
-
error: `Expected ${path} to be null, got ${JSON.stringify(actualValue)}`,
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
return { isValid: true };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Exact value comparison
|
|
398
|
-
if (actualValue !== expectedValue) {
|
|
399
|
-
return {
|
|
400
|
-
isValid: false,
|
|
401
|
-
error: `Expected ${path} to be ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actualValue)}`,
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return { isValid: true };
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
private isRegexPattern(pattern: string): boolean {
|
|
409
|
-
return (
|
|
410
|
-
pattern.startsWith('^') ||
|
|
411
|
-
pattern.endsWith('$') ||
|
|
412
|
-
pattern.includes('\\d') ||
|
|
413
|
-
pattern.includes('\\w') ||
|
|
414
|
-
pattern.includes('\\s') ||
|
|
415
|
-
pattern.includes('[') ||
|
|
416
|
-
pattern.includes('*') ||
|
|
417
|
-
pattern.includes('+') ||
|
|
418
|
-
pattern.includes('?')
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
private validateRegexPattern(actualValue: JsonValue, pattern: string): boolean {
|
|
423
|
-
// Convert value to string for regex matching
|
|
424
|
-
const stringValue = String(actualValue);
|
|
425
|
-
try {
|
|
426
|
-
const regex = new RegExp(pattern);
|
|
427
|
-
return regex.test(stringValue);
|
|
428
|
-
} catch {
|
|
429
|
-
return false;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
private isRangePattern(pattern: string): boolean {
|
|
434
|
-
// Only match explicit comparison operators, not simple number-dash-number patterns
|
|
435
|
-
// This prevents matching things like zip codes "92998-3874" as ranges
|
|
436
|
-
return /^(>=?|<=?|>|<)\s*[\d.-]+(\s*,\s*(>=?|<=?|>|<)\s*[\d.-]+)*$/.test(pattern);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
private validateRangePattern(actualValue: JsonValue, pattern: string): boolean {
|
|
440
|
-
const numValue = Number(actualValue);
|
|
441
|
-
if (Number.isNaN(numValue)) {
|
|
442
|
-
return false;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// Handle range like "10 - 50" or "10-50"
|
|
446
|
-
const rangeMatch = pattern.match(/^([\d.-]+)\s*-\s*([\d.-]+)$/);
|
|
447
|
-
if (rangeMatch) {
|
|
448
|
-
const min = Number(rangeMatch[1]);
|
|
449
|
-
const max = Number(rangeMatch[2]);
|
|
450
|
-
return numValue >= min && numValue <= max;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Handle comma-separated conditions like ">= 0, <= 100"
|
|
454
|
-
const conditions = pattern.split(',').map((c) => c.trim());
|
|
455
|
-
return conditions.every((condition) => {
|
|
456
|
-
const match = condition.match(/^(>=?|<=?|>|<)\s*([\d.-]+)$/);
|
|
457
|
-
if (!match) {
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const operator = match[1];
|
|
462
|
-
const targetValue = Number(match[2]);
|
|
463
|
-
|
|
464
|
-
switch (operator) {
|
|
465
|
-
case '>':
|
|
466
|
-
return numValue > targetValue;
|
|
467
|
-
case '>=':
|
|
468
|
-
return numValue >= targetValue;
|
|
469
|
-
case '<':
|
|
470
|
-
return numValue < targetValue;
|
|
471
|
-
case '<=':
|
|
472
|
-
return numValue <= targetValue;
|
|
473
|
-
default:
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
private isArraySelector(key: string): boolean {
|
|
480
|
-
return /^\[.*\]$/.test(key) || key === '*' || key.startsWith('slice(');
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
private getArrayValue(array: JsonValue[], selector: string): JsonValue {
|
|
484
|
-
if (selector === '*') {
|
|
485
|
-
return array; // Return entire array for * validation
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (selector.startsWith('[') && selector.endsWith(']')) {
|
|
489
|
-
const index = selector.slice(1, -1);
|
|
490
|
-
if (index === '*') {
|
|
491
|
-
return array;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const numIndex = Number(index);
|
|
495
|
-
if (!Number.isNaN(numIndex)) {
|
|
496
|
-
return numIndex >= 0 ? array[numIndex] : array[array.length + numIndex];
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (selector.startsWith('slice(')) {
|
|
501
|
-
const match = selector.match(/slice\((\d+)(?:,(\d+))?\)/);
|
|
502
|
-
if (match) {
|
|
503
|
-
const start = Number(match[1]);
|
|
504
|
-
const end = match[2] ? Number(match[2]) : undefined;
|
|
505
|
-
return array.slice(start, end);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
return undefined;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
async executeSequential(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
513
|
-
const startTime = performance.now();
|
|
514
|
-
const results: ExecutionResult[] = [];
|
|
515
|
-
const storeContext = createStoreContext();
|
|
516
|
-
|
|
517
|
-
for (let i = 0; i < requests.length; i++) {
|
|
518
|
-
// Interpolate store variables before execution
|
|
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
|
-
|
|
537
|
-
const result = await this.executeRequest(interpolatedRequest, i + 1);
|
|
538
|
-
results.push(result);
|
|
539
|
-
|
|
540
|
-
// Store values from successful responses
|
|
541
|
-
if (result.success && interpolatedRequest.store) {
|
|
542
|
-
const storedValues = extractStoreValues(result, interpolatedRequest.store);
|
|
543
|
-
Object.assign(storeContext, storedValues);
|
|
544
|
-
this.logStoredValues(storedValues);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
if (!result.success && !this.globalConfig.continueOnError) {
|
|
548
|
-
this.logger.logError('Stopping execution due to error');
|
|
549
|
-
break;
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
return this.createSummary(results, performance.now() - startTime);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Interpolates store variables (${store.variableName}) in a request config.
|
|
558
|
-
* This is called at execution time to resolve values from previous responses.
|
|
559
|
-
*/
|
|
560
|
-
private interpolateStoreVariables(
|
|
561
|
-
request: RequestConfig,
|
|
562
|
-
storeContext: ResponseStoreContext,
|
|
563
|
-
): RequestConfig {
|
|
564
|
-
// Only interpolate if there are stored values
|
|
565
|
-
if (Object.keys(storeContext).length === 0) {
|
|
566
|
-
return request;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Re-interpolate the request with store context
|
|
570
|
-
// We pass empty variables since static variables were already resolved
|
|
571
|
-
return YamlParser.interpolateVariables(request, {}, storeContext) as RequestConfig;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
/**
|
|
575
|
-
* Logs stored values for debugging purposes.
|
|
576
|
-
*/
|
|
577
|
-
private logStoredValues(values: ResponseStoreContext): void {
|
|
578
|
-
if (Object.keys(values).length === 0) {
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const entries = Object.entries(values);
|
|
583
|
-
for (const [key, value] of entries) {
|
|
584
|
-
const displayValue = value.length > 50 ? `${value.substring(0, 50)}...` : value;
|
|
585
|
-
this.logger.logInfo(`Stored: ${key} = "${displayValue}"`);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
async executeParallel(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
590
|
-
const startTime = performance.now();
|
|
591
|
-
const maxConcurrency = this.globalConfig.maxConcurrency;
|
|
592
|
-
|
|
593
|
-
// If no concurrency limit, execute all requests simultaneously
|
|
594
|
-
if (!maxConcurrency || maxConcurrency >= requests.length) {
|
|
595
|
-
const promises = requests.map((request, index) => this.executeRequest(request, index + 1));
|
|
596
|
-
const results = await Promise.all(promises);
|
|
597
|
-
return this.createSummary(results, performance.now() - startTime);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Execute in chunks with limited concurrency
|
|
601
|
-
const results: ExecutionResult[] = [];
|
|
602
|
-
for (let i = 0; i < requests.length; i += maxConcurrency) {
|
|
603
|
-
const chunk = requests.slice(i, i + maxConcurrency);
|
|
604
|
-
const chunkPromises = chunk.map((request, chunkIndex) =>
|
|
605
|
-
this.executeRequest(request, i + chunkIndex + 1),
|
|
606
|
-
);
|
|
607
|
-
const chunkResults = await Promise.all(chunkPromises);
|
|
608
|
-
results.push(...chunkResults);
|
|
609
|
-
|
|
610
|
-
// Check if we should stop on error
|
|
611
|
-
const hasError = chunkResults.some((r) => !r.success);
|
|
612
|
-
if (hasError && !this.globalConfig.continueOnError) {
|
|
613
|
-
this.logger.logError('Stopping execution due to error');
|
|
614
|
-
break;
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
return this.createSummary(results, performance.now() - startTime);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
async execute(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
622
|
-
this.logger.logExecutionStart(requests.length, this.globalConfig.execution || 'sequential');
|
|
623
|
-
|
|
624
|
-
const summary =
|
|
625
|
-
this.globalConfig.execution === 'parallel'
|
|
626
|
-
? await this.executeParallel(requests)
|
|
627
|
-
: await this.executeSequential(requests);
|
|
628
|
-
|
|
629
|
-
this.logger.logSummary(summary);
|
|
630
|
-
|
|
631
|
-
if (this.globalConfig.output?.saveToFile) {
|
|
632
|
-
await this.saveSummaryToFile(summary);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
return summary;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
private createSummary(results: ExecutionResult[], duration: number): ExecutionSummary {
|
|
639
|
-
const skipped = results.filter((r) => r.skipped).length;
|
|
640
|
-
const successful = results.filter((r) => r.success && !r.skipped).length;
|
|
641
|
-
const failed = results.filter((r) => !r.success).length;
|
|
642
|
-
|
|
643
|
-
return {
|
|
644
|
-
total: results.length,
|
|
645
|
-
successful,
|
|
646
|
-
failed,
|
|
647
|
-
skipped,
|
|
648
|
-
duration,
|
|
649
|
-
results,
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
private async saveSummaryToFile(summary: ExecutionSummary): Promise<void> {
|
|
654
|
-
const file = this.globalConfig.output?.saveToFile;
|
|
655
|
-
if (!file) {
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
const content = JSON.stringify(summary, null, 2);
|
|
660
|
-
await Bun.write(file, content);
|
|
661
|
-
this.logger.logInfo(`Results saved to ${file}`);
|
|
662
|
-
}
|
|
663
|
-
}
|