@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.
@@ -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
- const result = await this.executeRequest(requests[i], i + 1);
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
+ });
@@ -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
- static interpolateVariables(obj: unknown, variables: Record<string, string>): unknown {
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 dynamicValue = YamlParser.resolveDynamicVariable(varName);
23
- return dynamicValue !== null ? dynamicValue : variables[varName] || obj;
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 dynamicValue = YamlParser.resolveDynamicVariable(varName);
29
- return dynamicValue !== null ? dynamicValue : variables[varName] || match;
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') {
@@ -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
+ });