@curl-runner/cli 1.0.3 → 1.2.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/dist/cli.js +24 -7
- package/package.json +1 -1
- package/src/ci-exit.test.ts +215 -0
- package/src/cli.ts +122 -2
- package/src/executor/request-executor.ts +47 -1
- package/src/parser/yaml.test.ts +176 -0
- package/src/parser/yaml.ts +54 -8
- package/src/types/config.ts +60 -0
- package/src/utils/response-store.test.ts +213 -0
- package/src/utils/response-store.ts +108 -0
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { YamlParser } from '../parser/yaml';
|
|
1
2
|
import type {
|
|
2
3
|
ExecutionResult,
|
|
3
4
|
ExecutionSummary,
|
|
4
5
|
GlobalConfig,
|
|
5
6
|
JsonValue,
|
|
6
7
|
RequestConfig,
|
|
8
|
+
ResponseStoreContext,
|
|
7
9
|
} from '../types/config';
|
|
8
10
|
import { CurlBuilder } from '../utils/curl-builder';
|
|
9
11
|
import { Logger } from '../utils/logger';
|
|
12
|
+
import { createStoreContext, extractStoreValues } from '../utils/response-store';
|
|
10
13
|
|
|
11
14
|
export class RequestExecutor {
|
|
12
15
|
private logger: Logger;
|
|
@@ -423,11 +426,21 @@ export class RequestExecutor {
|
|
|
423
426
|
async executeSequential(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
424
427
|
const startTime = performance.now();
|
|
425
428
|
const results: ExecutionResult[] = [];
|
|
429
|
+
const storeContext = createStoreContext();
|
|
426
430
|
|
|
427
431
|
for (let i = 0; i < requests.length; i++) {
|
|
428
|
-
|
|
432
|
+
// Interpolate store variables before execution
|
|
433
|
+
const interpolatedRequest = this.interpolateStoreVariables(requests[i], storeContext);
|
|
434
|
+
const result = await this.executeRequest(interpolatedRequest, i + 1);
|
|
429
435
|
results.push(result);
|
|
430
436
|
|
|
437
|
+
// Store values from successful responses
|
|
438
|
+
if (result.success && interpolatedRequest.store) {
|
|
439
|
+
const storedValues = extractStoreValues(result, interpolatedRequest.store);
|
|
440
|
+
Object.assign(storeContext, storedValues);
|
|
441
|
+
this.logStoredValues(storedValues);
|
|
442
|
+
}
|
|
443
|
+
|
|
431
444
|
if (!result.success && !this.globalConfig.continueOnError) {
|
|
432
445
|
this.logger.logError('Stopping execution due to error');
|
|
433
446
|
break;
|
|
@@ -437,6 +450,39 @@ export class RequestExecutor {
|
|
|
437
450
|
return this.createSummary(results, performance.now() - startTime);
|
|
438
451
|
}
|
|
439
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Interpolates store variables (${store.variableName}) in a request config.
|
|
455
|
+
* This is called at execution time to resolve values from previous responses.
|
|
456
|
+
*/
|
|
457
|
+
private interpolateStoreVariables(
|
|
458
|
+
request: RequestConfig,
|
|
459
|
+
storeContext: ResponseStoreContext,
|
|
460
|
+
): RequestConfig {
|
|
461
|
+
// Only interpolate if there are stored values
|
|
462
|
+
if (Object.keys(storeContext).length === 0) {
|
|
463
|
+
return request;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Re-interpolate the request with store context
|
|
467
|
+
// We pass empty variables since static variables were already resolved
|
|
468
|
+
return YamlParser.interpolateVariables(request, {}, storeContext) as RequestConfig;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Logs stored values for debugging purposes.
|
|
473
|
+
*/
|
|
474
|
+
private logStoredValues(values: ResponseStoreContext): void {
|
|
475
|
+
if (Object.keys(values).length === 0) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const entries = Object.entries(values);
|
|
480
|
+
for (const [key, value] of entries) {
|
|
481
|
+
const displayValue = value.length > 50 ? `${value.substring(0, 50)}...` : value;
|
|
482
|
+
this.logger.logInfo(`Stored: ${key} = "${displayValue}"`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
440
486
|
async executeParallel(requests: RequestConfig[]): Promise<ExecutionSummary> {
|
|
441
487
|
const startTime = performance.now();
|
|
442
488
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { YamlParser } from './yaml';
|
|
3
|
+
|
|
4
|
+
describe('YamlParser.interpolateVariables with store context', () => {
|
|
5
|
+
test('should resolve store variables', () => {
|
|
6
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
7
|
+
const obj = { url: 'https://api.example.com/users/${store.userId}' };
|
|
8
|
+
const variables = {};
|
|
9
|
+
const storeContext = { userId: '123' };
|
|
10
|
+
|
|
11
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext);
|
|
12
|
+
expect(result).toEqual({ url: 'https://api.example.com/users/123' });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('should resolve multiple store variables in one string', () => {
|
|
16
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
17
|
+
const obj = { url: 'https://api.example.com/users/${store.userId}/posts/${store.postId}' };
|
|
18
|
+
const variables = {};
|
|
19
|
+
const storeContext = { userId: '123', postId: '456' };
|
|
20
|
+
|
|
21
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext);
|
|
22
|
+
expect(result).toEqual({ url: 'https://api.example.com/users/123/posts/456' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('should resolve store variables in nested objects', () => {
|
|
26
|
+
const obj = {
|
|
27
|
+
headers: {
|
|
28
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
29
|
+
Authorization: 'Bearer ${store.token}',
|
|
30
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
31
|
+
'X-User-Id': '${store.userId}',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const variables = {};
|
|
35
|
+
const storeContext = { token: 'jwt-token', userId: '123' };
|
|
36
|
+
|
|
37
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext);
|
|
38
|
+
expect(result).toEqual({
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: 'Bearer jwt-token',
|
|
41
|
+
'X-User-Id': '123',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should resolve store variables in arrays', () => {
|
|
47
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
48
|
+
const obj = { ids: ['${store.id1}', '${store.id2}'] };
|
|
49
|
+
const variables = {};
|
|
50
|
+
const storeContext = { id1: '1', id2: '2' };
|
|
51
|
+
|
|
52
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext);
|
|
53
|
+
expect(result).toEqual({ ids: ['1', '2'] });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('should keep unresolved store variables as-is', () => {
|
|
57
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
58
|
+
const obj = { url: 'https://api.example.com/users/${store.missing}' };
|
|
59
|
+
const variables = {};
|
|
60
|
+
const storeContext = {};
|
|
61
|
+
|
|
62
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext);
|
|
63
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
64
|
+
expect(result).toEqual({ url: 'https://api.example.com/users/${store.missing}' });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should mix store variables with static variables', () => {
|
|
68
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
69
|
+
const obj = { url: '${BASE_URL}/users/${store.userId}' };
|
|
70
|
+
const variables = { BASE_URL: 'https://api.example.com' };
|
|
71
|
+
const storeContext = { userId: '123' };
|
|
72
|
+
|
|
73
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext);
|
|
74
|
+
expect(result).toEqual({ url: 'https://api.example.com/users/123' });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('should work without store context (backwards compatibility)', () => {
|
|
78
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing variable interpolation
|
|
79
|
+
const obj = { url: '${BASE_URL}/users' };
|
|
80
|
+
const variables = { BASE_URL: 'https://api.example.com' };
|
|
81
|
+
|
|
82
|
+
const result = YamlParser.interpolateVariables(obj, variables);
|
|
83
|
+
expect(result).toEqual({ url: 'https://api.example.com/users' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('should resolve single store variable as exact value', () => {
|
|
87
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
88
|
+
const obj = { userId: '${store.userId}' };
|
|
89
|
+
const variables = {};
|
|
90
|
+
const storeContext = { userId: '123' };
|
|
91
|
+
|
|
92
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext);
|
|
93
|
+
expect(result).toEqual({ userId: '123' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('should mix store variables with dynamic variables', () => {
|
|
97
|
+
const obj = {
|
|
98
|
+
headers: {
|
|
99
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing variable interpolation
|
|
100
|
+
'X-Request-ID': '${UUID}',
|
|
101
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
102
|
+
Authorization: 'Bearer ${store.token}',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const variables = {};
|
|
106
|
+
const storeContext = { token: 'my-token' };
|
|
107
|
+
|
|
108
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext) as typeof obj;
|
|
109
|
+
// UUID should be a valid UUID string
|
|
110
|
+
expect(result.headers['X-Request-ID']).toMatch(
|
|
111
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
112
|
+
);
|
|
113
|
+
expect(result.headers.Authorization).toBe('Bearer my-token');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('should resolve store variables in request body', () => {
|
|
117
|
+
const obj = {
|
|
118
|
+
body: {
|
|
119
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
120
|
+
userId: '${store.userId}',
|
|
121
|
+
name: 'Test User',
|
|
122
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: Testing ${store.xxx} interpolation
|
|
123
|
+
parentId: '${store.parentId}',
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const variables = {};
|
|
127
|
+
const storeContext = { userId: '123', parentId: '456' };
|
|
128
|
+
|
|
129
|
+
const result = YamlParser.interpolateVariables(obj, variables, storeContext);
|
|
130
|
+
expect(result).toEqual({
|
|
131
|
+
body: {
|
|
132
|
+
userId: '123',
|
|
133
|
+
name: 'Test User',
|
|
134
|
+
parentId: '456',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('YamlParser.resolveVariable', () => {
|
|
141
|
+
test('should resolve store variable', () => {
|
|
142
|
+
const storeContext = { userId: '123' };
|
|
143
|
+
const result = YamlParser.resolveVariable('store.userId', {}, storeContext);
|
|
144
|
+
expect(result).toBe('123');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('should return null for missing store variable', () => {
|
|
148
|
+
const storeContext = { other: 'value' };
|
|
149
|
+
const result = YamlParser.resolveVariable('store.missing', {}, storeContext);
|
|
150
|
+
expect(result).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('should resolve dynamic variable', () => {
|
|
154
|
+
const result = YamlParser.resolveVariable('UUID', {}, {});
|
|
155
|
+
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('should resolve static variable', () => {
|
|
159
|
+
const variables = { BASE_URL: 'https://api.example.com' };
|
|
160
|
+
const result = YamlParser.resolveVariable('BASE_URL', variables, {});
|
|
161
|
+
expect(result).toBe('https://api.example.com');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('should return null for unknown variable', () => {
|
|
165
|
+
const result = YamlParser.resolveVariable('UNKNOWN', {}, {});
|
|
166
|
+
expect(result).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('should prioritize store context over static variables', () => {
|
|
170
|
+
// This test ensures store.X prefix is properly handled
|
|
171
|
+
const variables = { 'store.userId': 'static-value' };
|
|
172
|
+
const storeContext = { userId: 'store-value' };
|
|
173
|
+
const result = YamlParser.resolveVariable('store.userId', variables, storeContext);
|
|
174
|
+
expect(result).toBe('store-value');
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/parser/yaml.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { YAML } from 'bun';
|
|
2
|
-
import type { RequestConfig, YamlFile } from '../types/config';
|
|
2
|
+
import type { RequestConfig, ResponseStoreContext, YamlFile } from '../types/config';
|
|
3
3
|
|
|
4
4
|
// Using class for organization, but could be refactored to functions
|
|
5
5
|
export class YamlParser {
|
|
@@ -13,31 +13,45 @@ export class YamlParser {
|
|
|
13
13
|
return YAML.parse(content) as YamlFile;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Interpolates variables in an object, supporting:
|
|
18
|
+
* - Static variables: ${VAR_NAME}
|
|
19
|
+
* - Dynamic variables: ${UUID}, ${TIMESTAMP}, ${DATE:format}, ${TIME:format}
|
|
20
|
+
* - Stored response values: ${store.variableName}
|
|
21
|
+
*
|
|
22
|
+
* @param obj - The object to interpolate
|
|
23
|
+
* @param variables - Static variables map
|
|
24
|
+
* @param storeContext - Optional stored response values from previous requests
|
|
25
|
+
*/
|
|
26
|
+
static interpolateVariables(
|
|
27
|
+
obj: unknown,
|
|
28
|
+
variables: Record<string, string>,
|
|
29
|
+
storeContext?: ResponseStoreContext,
|
|
30
|
+
): unknown {
|
|
17
31
|
if (typeof obj === 'string') {
|
|
18
32
|
// Check if it's a single variable like ${VAR} (no other characters)
|
|
19
33
|
const singleVarMatch = obj.match(/^\$\{([^}]+)\}$/);
|
|
20
34
|
if (singleVarMatch) {
|
|
21
35
|
const varName = singleVarMatch[1];
|
|
22
|
-
const
|
|
23
|
-
return
|
|
36
|
+
const resolvedValue = YamlParser.resolveVariable(varName, variables, storeContext);
|
|
37
|
+
return resolvedValue !== null ? resolvedValue : obj;
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
// Handle multiple variables in the string using regex replacement
|
|
27
41
|
return obj.replace(/\$\{([^}]+)\}/g, (match, varName) => {
|
|
28
|
-
const
|
|
29
|
-
return
|
|
42
|
+
const resolvedValue = YamlParser.resolveVariable(varName, variables, storeContext);
|
|
43
|
+
return resolvedValue !== null ? resolvedValue : match;
|
|
30
44
|
});
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
if (Array.isArray(obj)) {
|
|
34
|
-
return obj.map((item) => YamlParser.interpolateVariables(item, variables));
|
|
48
|
+
return obj.map((item) => YamlParser.interpolateVariables(item, variables, storeContext));
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
if (obj && typeof obj === 'object') {
|
|
38
52
|
const result: Record<string, unknown> = {};
|
|
39
53
|
for (const [key, value] of Object.entries(obj)) {
|
|
40
|
-
result[key] = YamlParser.interpolateVariables(value, variables);
|
|
54
|
+
result[key] = YamlParser.interpolateVariables(value, variables, storeContext);
|
|
41
55
|
}
|
|
42
56
|
return result;
|
|
43
57
|
}
|
|
@@ -45,6 +59,38 @@ export class YamlParser {
|
|
|
45
59
|
return obj;
|
|
46
60
|
}
|
|
47
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Resolves a single variable reference.
|
|
64
|
+
* Priority: store context > dynamic variables > static variables
|
|
65
|
+
*/
|
|
66
|
+
static resolveVariable(
|
|
67
|
+
varName: string,
|
|
68
|
+
variables: Record<string, string>,
|
|
69
|
+
storeContext?: ResponseStoreContext,
|
|
70
|
+
): string | null {
|
|
71
|
+
// Check for store variable (${store.variableName})
|
|
72
|
+
if (varName.startsWith('store.') && storeContext) {
|
|
73
|
+
const storeVarName = varName.slice(6); // Remove 'store.' prefix
|
|
74
|
+
if (storeVarName in storeContext) {
|
|
75
|
+
return storeContext[storeVarName];
|
|
76
|
+
}
|
|
77
|
+
return null; // Store variable not found, return null to keep original
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for dynamic variable
|
|
81
|
+
const dynamicValue = YamlParser.resolveDynamicVariable(varName);
|
|
82
|
+
if (dynamicValue !== null) {
|
|
83
|
+
return dynamicValue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check for static variable
|
|
87
|
+
if (varName in variables) {
|
|
88
|
+
return variables[varName];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
48
94
|
static resolveDynamicVariable(varName: string): string | null {
|
|
49
95
|
// UUID generation
|
|
50
96
|
if (varName === 'UUID') {
|
package/src/types/config.ts
CHANGED
|
@@ -4,6 +4,18 @@ export interface JsonObject {
|
|
|
4
4
|
}
|
|
5
5
|
export interface JsonArray extends Array<JsonValue> {}
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for storing response values as variables for subsequent requests.
|
|
9
|
+
* Maps a variable name to a JSON path in the response.
|
|
10
|
+
*
|
|
11
|
+
* Examples:
|
|
12
|
+
* - `{ "userId": "body.id" }` - Store response body's id field as ${store.userId}
|
|
13
|
+
* - `{ "token": "body.data.token" }` - Store nested field
|
|
14
|
+
* - `{ "statusCode": "status" }` - Store HTTP status code
|
|
15
|
+
* - `{ "contentType": "headers.content-type" }` - Store response header
|
|
16
|
+
*/
|
|
17
|
+
export type StoreConfig = Record<string, string>;
|
|
18
|
+
|
|
7
19
|
export interface RequestConfig {
|
|
8
20
|
name?: string;
|
|
9
21
|
url: string;
|
|
@@ -29,6 +41,18 @@ export interface RequestConfig {
|
|
|
29
41
|
delay?: number;
|
|
30
42
|
};
|
|
31
43
|
variables?: Record<string, string>;
|
|
44
|
+
/**
|
|
45
|
+
* Store response values as variables for subsequent requests.
|
|
46
|
+
* Use JSON path syntax to extract values from the response.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* store:
|
|
50
|
+
* userId: body.id
|
|
51
|
+
* token: body.data.accessToken
|
|
52
|
+
* statusCode: status
|
|
53
|
+
* contentType: headers.content-type
|
|
54
|
+
*/
|
|
55
|
+
store?: StoreConfig;
|
|
32
56
|
expect?: {
|
|
33
57
|
failure?: boolean; // If true, expect the request to fail (for negative testing)
|
|
34
58
|
status?: number | number[];
|
|
@@ -55,9 +79,39 @@ export interface CollectionConfig {
|
|
|
55
79
|
requests: RequestConfig[];
|
|
56
80
|
}
|
|
57
81
|
|
|
82
|
+
/**
|
|
83
|
+
* CI exit code configuration options.
|
|
84
|
+
* These options control how curl-runner exits in CI/CD pipelines.
|
|
85
|
+
*/
|
|
86
|
+
export interface CIExitConfig {
|
|
87
|
+
/**
|
|
88
|
+
* When true, exit with code 1 if any validation failures occur,
|
|
89
|
+
* regardless of the continueOnError setting.
|
|
90
|
+
* This is useful for CI/CD pipelines that need strict validation.
|
|
91
|
+
*/
|
|
92
|
+
strictExit?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Maximum number of failures allowed before exiting with code 1.
|
|
95
|
+
* If set to 0, any failure will cause a non-zero exit.
|
|
96
|
+
* If undefined and strictExit is true, any failure causes non-zero exit.
|
|
97
|
+
*/
|
|
98
|
+
failOn?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Maximum percentage of failures allowed before exiting with code 1.
|
|
101
|
+
* Value should be between 0 and 100.
|
|
102
|
+
* If set to 10, up to 10% of requests can fail without causing a non-zero exit.
|
|
103
|
+
*/
|
|
104
|
+
failOnPercentage?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
58
107
|
export interface GlobalConfig {
|
|
59
108
|
execution?: 'sequential' | 'parallel';
|
|
60
109
|
continueOnError?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* CI/CD exit code configuration.
|
|
112
|
+
* Controls when curl-runner should exit with non-zero status codes.
|
|
113
|
+
*/
|
|
114
|
+
ci?: CIExitConfig;
|
|
61
115
|
variables?: Record<string, string>;
|
|
62
116
|
output?: {
|
|
63
117
|
verbose?: boolean;
|
|
@@ -104,3 +158,9 @@ export interface ExecutionSummary {
|
|
|
104
158
|
duration: number;
|
|
105
159
|
results: ExecutionResult[];
|
|
106
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Context for storing response values between sequential requests.
|
|
164
|
+
* Values are stored as strings and can be referenced using ${store.variableName} syntax.
|
|
165
|
+
*/
|
|
166
|
+
export type ResponseStoreContext = Record<string, string>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ExecutionResult } from '../types/config';
|
|
3
|
+
import {
|
|
4
|
+
createStoreContext,
|
|
5
|
+
extractStoreValues,
|
|
6
|
+
getValueByPath,
|
|
7
|
+
mergeStoreContext,
|
|
8
|
+
valueToString,
|
|
9
|
+
} from './response-store';
|
|
10
|
+
|
|
11
|
+
describe('getValueByPath', () => {
|
|
12
|
+
const testObj = {
|
|
13
|
+
status: 200,
|
|
14
|
+
body: {
|
|
15
|
+
id: 123,
|
|
16
|
+
user: {
|
|
17
|
+
name: 'John',
|
|
18
|
+
email: 'john@example.com',
|
|
19
|
+
},
|
|
20
|
+
items: [
|
|
21
|
+
{ id: 1, name: 'Item 1' },
|
|
22
|
+
{ id: 2, name: 'Item 2' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
headers: {
|
|
26
|
+
'content-type': 'application/json',
|
|
27
|
+
'x-request-id': 'abc123',
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test('should get top-level value', () => {
|
|
32
|
+
expect(getValueByPath(testObj, 'status')).toBe(200);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('should get nested value', () => {
|
|
36
|
+
expect(getValueByPath(testObj, 'body.id')).toBe(123);
|
|
37
|
+
expect(getValueByPath(testObj, 'body.user.name')).toBe('John');
|
|
38
|
+
expect(getValueByPath(testObj, 'body.user.email')).toBe('john@example.com');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('should get header value', () => {
|
|
42
|
+
expect(getValueByPath(testObj, 'headers.content-type')).toBe('application/json');
|
|
43
|
+
expect(getValueByPath(testObj, 'headers.x-request-id')).toBe('abc123');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should get array element by index', () => {
|
|
47
|
+
expect(getValueByPath(testObj, 'body.items.0.id')).toBe(1);
|
|
48
|
+
expect(getValueByPath(testObj, 'body.items.1.name')).toBe('Item 2');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('should get array element using bracket notation', () => {
|
|
52
|
+
expect(getValueByPath(testObj, 'body.items[0].id')).toBe(1);
|
|
53
|
+
expect(getValueByPath(testObj, 'body.items[1].name')).toBe('Item 2');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('should return undefined for non-existent path', () => {
|
|
57
|
+
expect(getValueByPath(testObj, 'body.nonexistent')).toBeUndefined();
|
|
58
|
+
expect(getValueByPath(testObj, 'body.user.age')).toBeUndefined();
|
|
59
|
+
expect(getValueByPath(testObj, 'nonexistent.path')).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('should return undefined for null or undefined object', () => {
|
|
63
|
+
expect(getValueByPath(null, 'any.path')).toBeUndefined();
|
|
64
|
+
expect(getValueByPath(undefined, 'any.path')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should handle primitive values correctly', () => {
|
|
68
|
+
expect(getValueByPath('string', 'length')).toBeUndefined();
|
|
69
|
+
expect(getValueByPath(123, 'toString')).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('valueToString', () => {
|
|
74
|
+
test('should convert string to string', () => {
|
|
75
|
+
expect(valueToString('hello')).toBe('hello');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('should convert number to string', () => {
|
|
79
|
+
expect(valueToString(123)).toBe('123');
|
|
80
|
+
expect(valueToString(45.67)).toBe('45.67');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('should convert boolean to string', () => {
|
|
84
|
+
expect(valueToString(true)).toBe('true');
|
|
85
|
+
expect(valueToString(false)).toBe('false');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('should convert null and undefined to empty string', () => {
|
|
89
|
+
expect(valueToString(null)).toBe('');
|
|
90
|
+
expect(valueToString(undefined)).toBe('');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('should JSON stringify objects', () => {
|
|
94
|
+
expect(valueToString({ a: 1 })).toBe('{"a":1}');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('should JSON stringify arrays', () => {
|
|
98
|
+
expect(valueToString([1, 2, 3])).toBe('[1,2,3]');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('extractStoreValues', () => {
|
|
103
|
+
const mockResult: ExecutionResult = {
|
|
104
|
+
request: {
|
|
105
|
+
url: 'https://api.example.com/users',
|
|
106
|
+
method: 'POST',
|
|
107
|
+
},
|
|
108
|
+
success: true,
|
|
109
|
+
status: 201,
|
|
110
|
+
headers: {
|
|
111
|
+
'content-type': 'application/json',
|
|
112
|
+
'x-request-id': 'req-12345',
|
|
113
|
+
},
|
|
114
|
+
body: {
|
|
115
|
+
id: 456,
|
|
116
|
+
data: {
|
|
117
|
+
token: 'jwt-token-here',
|
|
118
|
+
user: {
|
|
119
|
+
id: 789,
|
|
120
|
+
name: 'Test User',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
metrics: {
|
|
125
|
+
duration: 150,
|
|
126
|
+
size: 1024,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
test('should extract status', () => {
|
|
131
|
+
const result = extractStoreValues(mockResult, {
|
|
132
|
+
statusCode: 'status',
|
|
133
|
+
});
|
|
134
|
+
expect(result.statusCode).toBe('201');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('should extract body fields', () => {
|
|
138
|
+
const result = extractStoreValues(mockResult, {
|
|
139
|
+
userId: 'body.id',
|
|
140
|
+
token: 'body.data.token',
|
|
141
|
+
userName: 'body.data.user.name',
|
|
142
|
+
});
|
|
143
|
+
expect(result.userId).toBe('456');
|
|
144
|
+
expect(result.token).toBe('jwt-token-here');
|
|
145
|
+
expect(result.userName).toBe('Test User');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('should extract header values', () => {
|
|
149
|
+
const result = extractStoreValues(mockResult, {
|
|
150
|
+
contentType: 'headers.content-type',
|
|
151
|
+
requestId: 'headers.x-request-id',
|
|
152
|
+
});
|
|
153
|
+
expect(result.contentType).toBe('application/json');
|
|
154
|
+
expect(result.requestId).toBe('req-12345');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('should extract metrics', () => {
|
|
158
|
+
const result = extractStoreValues(mockResult, {
|
|
159
|
+
duration: 'metrics.duration',
|
|
160
|
+
});
|
|
161
|
+
expect(result.duration).toBe('150');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('should handle non-existent paths', () => {
|
|
165
|
+
const result = extractStoreValues(mockResult, {
|
|
166
|
+
missing: 'body.nonexistent',
|
|
167
|
+
});
|
|
168
|
+
expect(result.missing).toBe('');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('should extract multiple values', () => {
|
|
172
|
+
const result = extractStoreValues(mockResult, {
|
|
173
|
+
id: 'body.id',
|
|
174
|
+
status: 'status',
|
|
175
|
+
contentType: 'headers.content-type',
|
|
176
|
+
});
|
|
177
|
+
expect(result.id).toBe('456');
|
|
178
|
+
expect(result.status).toBe('201');
|
|
179
|
+
expect(result.contentType).toBe('application/json');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('createStoreContext', () => {
|
|
184
|
+
test('should create empty context', () => {
|
|
185
|
+
const context = createStoreContext();
|
|
186
|
+
expect(context).toEqual({});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('mergeStoreContext', () => {
|
|
191
|
+
test('should merge contexts', () => {
|
|
192
|
+
const existing = { a: '1', b: '2' };
|
|
193
|
+
const newValues = { c: '3', d: '4' };
|
|
194
|
+
const merged = mergeStoreContext(existing, newValues);
|
|
195
|
+
expect(merged).toEqual({ a: '1', b: '2', c: '3', d: '4' });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('should override existing values', () => {
|
|
199
|
+
const existing = { a: '1', b: '2' };
|
|
200
|
+
const newValues = { b: 'new', c: '3' };
|
|
201
|
+
const merged = mergeStoreContext(existing, newValues);
|
|
202
|
+
expect(merged).toEqual({ a: '1', b: 'new', c: '3' });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('should not mutate original contexts', () => {
|
|
206
|
+
const existing = { a: '1' };
|
|
207
|
+
const newValues = { b: '2' };
|
|
208
|
+
const merged = mergeStoreContext(existing, newValues);
|
|
209
|
+
expect(existing).toEqual({ a: '1' });
|
|
210
|
+
expect(newValues).toEqual({ b: '2' });
|
|
211
|
+
expect(merged).toEqual({ a: '1', b: '2' });
|
|
212
|
+
});
|
|
213
|
+
});
|