@scenarist/core 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +755 -28
- package/dist/adapters/in-memory-registry.d.ts +18 -0
- package/dist/adapters/in-memory-registry.d.ts.map +1 -0
- package/dist/adapters/in-memory-registry.js +25 -0
- package/dist/adapters/in-memory-sequence-tracker.d.ts +28 -0
- package/dist/adapters/in-memory-sequence-tracker.d.ts.map +1 -0
- package/dist/adapters/in-memory-sequence-tracker.js +82 -0
- package/dist/adapters/in-memory-state-manager.d.ts +24 -0
- package/dist/adapters/in-memory-state-manager.d.ts.map +1 -0
- package/dist/adapters/in-memory-state-manager.js +81 -0
- package/dist/adapters/in-memory-store.d.ts +18 -0
- package/dist/adapters/in-memory-store.d.ts.map +1 -0
- package/dist/adapters/in-memory-store.js +25 -0
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +4 -0
- package/dist/constants/headers.d.ts +10 -0
- package/dist/constants/headers.d.ts.map +1 -0
- package/dist/constants/headers.js +9 -0
- package/dist/constants/index.d.ts +2 -0
- package/dist/constants/index.d.ts.map +1 -0
- package/dist/constants/index.js +1 -0
- package/dist/contracts/framework-adapter.d.ts +118 -0
- package/dist/contracts/framework-adapter.d.ts.map +1 -0
- package/dist/contracts/framework-adapter.js +1 -0
- package/dist/contracts/index.d.ts +2 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/domain/config-builder.d.ts +9 -0
- package/dist/domain/config-builder.d.ts.map +1 -0
- package/dist/domain/config-builder.js +20 -0
- package/dist/domain/index.d.ts +5 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +4 -0
- package/dist/domain/path-extraction.d.ts +17 -0
- package/dist/domain/path-extraction.d.ts.map +1 -0
- package/dist/domain/path-extraction.js +60 -0
- package/dist/domain/regex-matching.d.ts +20 -0
- package/dist/domain/regex-matching.d.ts.map +1 -0
- package/dist/domain/regex-matching.js +27 -0
- package/dist/domain/response-selector.d.ts +22 -0
- package/dist/domain/response-selector.d.ts.map +1 -0
- package/dist/domain/response-selector.js +337 -0
- package/dist/domain/scenario-manager.d.ts +20 -0
- package/dist/domain/scenario-manager.d.ts.map +1 -0
- package/dist/domain/scenario-manager.js +90 -0
- package/dist/domain/template-replacement.d.ts +11 -0
- package/dist/domain/template-replacement.d.ts.map +1 -0
- package/dist/domain/template-replacement.js +94 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/ports/driven/request-context.d.ts +43 -0
- package/dist/ports/driven/request-context.d.ts.map +1 -0
- package/dist/ports/driven/request-context.js +1 -0
- package/dist/ports/driven/response-selector.d.ts +34 -0
- package/dist/ports/driven/response-selector.d.ts.map +1 -0
- package/dist/ports/driven/response-selector.js +9 -0
- package/dist/ports/driven/scenario-registry.d.ts +46 -0
- package/dist/ports/driven/scenario-registry.d.ts.map +1 -0
- package/dist/ports/driven/scenario-registry.js +1 -0
- package/dist/ports/driven/scenario-store.d.ts +33 -0
- package/dist/ports/driven/scenario-store.d.ts.map +1 -0
- package/dist/ports/driven/scenario-store.js +1 -0
- package/dist/ports/driven/sequence-tracker.d.ts +49 -0
- package/dist/ports/driven/sequence-tracker.d.ts.map +1 -0
- package/dist/ports/driven/sequence-tracker.js +1 -0
- package/dist/ports/driven/state-manager.d.ts +56 -0
- package/dist/ports/driven/state-manager.d.ts.map +1 -0
- package/dist/ports/driven/state-manager.js +1 -0
- package/dist/ports/driving/scenario-manager.d.ts +99 -0
- package/dist/ports/driving/scenario-manager.d.ts.map +1 -0
- package/dist/ports/driving/scenario-manager.js +1 -0
- package/dist/ports/index.d.ts +8 -0
- package/dist/ports/index.d.ts.map +1 -0
- package/dist/ports/index.js +1 -0
- package/dist/schemas/index.d.ts +18 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +17 -0
- package/dist/schemas/match-criteria.d.ts +27 -0
- package/dist/schemas/match-criteria.d.ts.map +1 -0
- package/dist/schemas/match-criteria.js +71 -0
- package/dist/schemas/scenario-definition.d.ts +276 -0
- package/dist/schemas/scenario-definition.d.ts.map +1 -0
- package/dist/schemas/scenario-definition.js +78 -0
- package/dist/schemas/scenario-requests.d.ts +33 -0
- package/dist/schemas/scenario-requests.d.ts.map +1 -0
- package/dist/schemas/scenario-requests.js +29 -0
- package/dist/schemas/scenarios-object.d.ts +91 -0
- package/dist/schemas/scenarios-object.d.ts.map +1 -0
- package/dist/schemas/scenarios-object.js +17 -0
- package/dist/types/config.d.ts +70 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/scenario.d.ts +141 -0
- package/dist/types/scenario.d.ts.map +1 -0
- package/dist/types/scenario.js +1 -0
- package/package.json +67 -7
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SerializedRegex } from '../schemas/match-criteria.js';
|
|
2
|
+
/**
|
|
3
|
+
* Match a value against a regex pattern.
|
|
4
|
+
*
|
|
5
|
+
* **Security:**
|
|
6
|
+
* - ReDoS protection via schema validation (redos-detector) at trust boundary
|
|
7
|
+
* - Returns false on error (invalid regex syntax)
|
|
8
|
+
*
|
|
9
|
+
* **Usage:**
|
|
10
|
+
* ```typescript
|
|
11
|
+
* matchesRegex('summer-premium-sale', { source: 'premium|vip', flags: 'i' }) // true
|
|
12
|
+
* matchesRegex('summer-sale', { source: 'premium|vip', flags: 'i' }) // false
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* @param value - String to test against pattern
|
|
16
|
+
* @param pattern - Serialized regex with source and optional flags
|
|
17
|
+
* @returns true if pattern matches, false otherwise
|
|
18
|
+
*/
|
|
19
|
+
export declare const matchesRegex: (value: string, pattern: SerializedRegex) => boolean;
|
|
20
|
+
//# sourceMappingURL=regex-matching.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"regex-matching.d.ts","sourceRoot":"","sources":["../../src/domain/regex-matching.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AAEpE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GACvB,OAAO,MAAM,EACb,SAAS,eAAe,KACvB,OAQF,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match a value against a regex pattern.
|
|
3
|
+
*
|
|
4
|
+
* **Security:**
|
|
5
|
+
* - ReDoS protection via schema validation (redos-detector) at trust boundary
|
|
6
|
+
* - Returns false on error (invalid regex syntax)
|
|
7
|
+
*
|
|
8
|
+
* **Usage:**
|
|
9
|
+
* ```typescript
|
|
10
|
+
* matchesRegex('summer-premium-sale', { source: 'premium|vip', flags: 'i' }) // true
|
|
11
|
+
* matchesRegex('summer-sale', { source: 'premium|vip', flags: 'i' }) // false
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* @param value - String to test against pattern
|
|
15
|
+
* @param pattern - Serialized regex with source and optional flags
|
|
16
|
+
* @returns true if pattern matches, false otherwise
|
|
17
|
+
*/
|
|
18
|
+
export const matchesRegex = (value, pattern) => {
|
|
19
|
+
try {
|
|
20
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
21
|
+
return regex.test(value);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.error('Regex matching error:', error);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ResponseSelector, SequenceTracker, StateManager } from "../ports/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Options for creating a response selector.
|
|
4
|
+
*/
|
|
5
|
+
type CreateResponseSelectorOptions = {
|
|
6
|
+
sequenceTracker?: SequenceTracker;
|
|
7
|
+
stateManager?: StateManager;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Creates a response selector domain service.
|
|
11
|
+
*
|
|
12
|
+
* Phase 1: Request content matching (body/headers/query)
|
|
13
|
+
* Phase 2: Response sequences with repeat modes
|
|
14
|
+
* Phase 3: Stateful mocks with capture/injection
|
|
15
|
+
*
|
|
16
|
+
* @param options - Configuration options
|
|
17
|
+
* @param options.sequenceTracker - Optional sequence position tracker (Phase 2)
|
|
18
|
+
* @param options.stateManager - Optional state manager for capture/injection (Phase 3)
|
|
19
|
+
*/
|
|
20
|
+
export declare const createResponseSelector: (options?: CreateResponseSelectorOptions) => ResponseSelector;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=response-selector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAazF;;GAEG;AACH,KAAK,6BAA6B,GAAG;IACnC,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,YAAY,CAAC,EAAE,YAAY,CAAC;CAC7B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GACjC,UAAS,6BAAkC,KAC1C,gBAwHF,CAAC"}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { ResponseSelectionError } from "../ports/driven/response-selector.js";
|
|
2
|
+
import { extractFromPath } from "./path-extraction.js";
|
|
3
|
+
import { applyTemplates } from "./template-replacement.js";
|
|
4
|
+
import { matchesRegex } from "./regex-matching.js";
|
|
5
|
+
const SPECIFICITY_RANGES = {
|
|
6
|
+
MATCH_CRITERIA_BASE: 100,
|
|
7
|
+
SEQUENCE_FALLBACK: 1,
|
|
8
|
+
SIMPLE_FALLBACK: 0,
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Creates a response selector domain service.
|
|
12
|
+
*
|
|
13
|
+
* Phase 1: Request content matching (body/headers/query)
|
|
14
|
+
* Phase 2: Response sequences with repeat modes
|
|
15
|
+
* Phase 3: Stateful mocks with capture/injection
|
|
16
|
+
*
|
|
17
|
+
* @param options - Configuration options
|
|
18
|
+
* @param options.sequenceTracker - Optional sequence position tracker (Phase 2)
|
|
19
|
+
* @param options.stateManager - Optional state manager for capture/injection (Phase 3)
|
|
20
|
+
*/
|
|
21
|
+
export const createResponseSelector = (options = {}) => {
|
|
22
|
+
const { sequenceTracker, stateManager } = options;
|
|
23
|
+
return {
|
|
24
|
+
selectResponse(testId, scenarioId, context, mocks) {
|
|
25
|
+
let bestMatch = null;
|
|
26
|
+
// Find all matching mocks and score them by specificity
|
|
27
|
+
for (let mockIndex = 0; mockIndex < mocks.length; mockIndex++) {
|
|
28
|
+
// Index is guaranteed in bounds by loop condition (0 <= mockIndex < length)
|
|
29
|
+
const mockWithParams = mocks[mockIndex];
|
|
30
|
+
const mock = mockWithParams.mock;
|
|
31
|
+
// Skip exhausted sequences (repeat: 'none' that have been exhausted)
|
|
32
|
+
if (mock.sequence && sequenceTracker) {
|
|
33
|
+
const { exhausted } = sequenceTracker.getPosition(testId, scenarioId, mockIndex);
|
|
34
|
+
if (exhausted) {
|
|
35
|
+
continue; // Skip to next mock, allowing fallback to be selected
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Check if this mock has match criteria
|
|
39
|
+
if (mock.match) {
|
|
40
|
+
// If match criteria exists, check if it matches the request
|
|
41
|
+
if (matchesCriteria(context, mock.match)) {
|
|
42
|
+
// Match criteria always have higher priority than fallbacks
|
|
43
|
+
// Base specificity ensures even 1 field beats any fallback
|
|
44
|
+
const specificity = SPECIFICITY_RANGES.MATCH_CRITERIA_BASE + calculateSpecificity(mock.match);
|
|
45
|
+
// Keep this mock if it's more specific than current best
|
|
46
|
+
// (or if no best match yet)
|
|
47
|
+
if (!bestMatch || specificity > bestMatch.specificity) {
|
|
48
|
+
bestMatch = { mockWithParams, mockIndex, specificity };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// If match criteria exists but doesn't match, skip to next mock
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// No match criteria = fallback mock (always matches)
|
|
55
|
+
// Sequences get higher priority than simple responses
|
|
56
|
+
// This ensures sequences are selected over simple fallback responses
|
|
57
|
+
const fallbackSpecificity = mock.sequence
|
|
58
|
+
? SPECIFICITY_RANGES.SEQUENCE_FALLBACK
|
|
59
|
+
: SPECIFICITY_RANGES.SIMPLE_FALLBACK;
|
|
60
|
+
if (!bestMatch || fallbackSpecificity >= bestMatch.specificity) {
|
|
61
|
+
// For equal specificity fallbacks, last wins
|
|
62
|
+
// This allows active scenario mocks to override default mocks
|
|
63
|
+
// Applies to both simple fallbacks (0) and sequence fallbacks (1)
|
|
64
|
+
bestMatch = { mockWithParams, mockIndex, specificity: fallbackSpecificity };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Return the best matching mock
|
|
68
|
+
if (bestMatch) {
|
|
69
|
+
const { mockWithParams, mockIndex } = bestMatch;
|
|
70
|
+
const mock = mockWithParams.mock;
|
|
71
|
+
// Select response (either single or from sequence)
|
|
72
|
+
const response = selectResponseFromMock(testId, scenarioId, mockIndex, mock, sequenceTracker);
|
|
73
|
+
if (!response) {
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
error: new ResponseSelectionError(`Mock has neither response nor sequence field`),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Phase 3: Capture state from request if configured
|
|
80
|
+
if (mock.captureState && stateManager) {
|
|
81
|
+
captureState(testId, context, mock.captureState, stateManager);
|
|
82
|
+
}
|
|
83
|
+
// Apply templates to response (both state AND params)
|
|
84
|
+
let finalResponse = response;
|
|
85
|
+
if (stateManager || mockWithParams.params) {
|
|
86
|
+
const currentState = stateManager ? stateManager.getAll(testId) : {};
|
|
87
|
+
// Merge state and params for template replacement
|
|
88
|
+
// params take precedence over state for the same key
|
|
89
|
+
const templateData = {
|
|
90
|
+
state: currentState,
|
|
91
|
+
params: mockWithParams.params || {},
|
|
92
|
+
};
|
|
93
|
+
finalResponse = applyTemplates(response, templateData);
|
|
94
|
+
}
|
|
95
|
+
return { success: true, data: finalResponse };
|
|
96
|
+
}
|
|
97
|
+
// No mock matched
|
|
98
|
+
return {
|
|
99
|
+
success: false,
|
|
100
|
+
error: new ResponseSelectionError(`No mock matched for ${context.method} ${context.url}`),
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Select a response from a mock (either single response or from sequence).
|
|
107
|
+
*
|
|
108
|
+
* @param testId - Test ID for sequence tracking
|
|
109
|
+
* @param scenarioId - Scenario ID for sequence tracking
|
|
110
|
+
* @param mockIndex - Index of the mock in the mocks array
|
|
111
|
+
* @param mock - The mock definition
|
|
112
|
+
* @param sequenceTracker - Optional sequence tracker for Phase 2
|
|
113
|
+
* @returns ScenaristResponse or null if mock has neither response nor sequence
|
|
114
|
+
*/
|
|
115
|
+
const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTracker) => {
|
|
116
|
+
// Phase 2: If mock has a sequence, use sequence tracker
|
|
117
|
+
if (mock.sequence) {
|
|
118
|
+
if (!sequenceTracker) {
|
|
119
|
+
// Sequence defined but no tracker provided - return first response
|
|
120
|
+
return mock.sequence.responses[0] || null;
|
|
121
|
+
}
|
|
122
|
+
// Get current position from tracker
|
|
123
|
+
const { position } = sequenceTracker.getPosition(testId, scenarioId, mockIndex);
|
|
124
|
+
// Get response at current position
|
|
125
|
+
// Note: Exhausted sequences are skipped during matching phase,
|
|
126
|
+
// so position should always be valid here
|
|
127
|
+
const response = mock.sequence.responses[position];
|
|
128
|
+
// Advance position for next call
|
|
129
|
+
const repeatMode = mock.sequence.repeat || 'last';
|
|
130
|
+
sequenceTracker.advance(testId, scenarioId, mockIndex, mock.sequence.responses.length, repeatMode);
|
|
131
|
+
return response;
|
|
132
|
+
}
|
|
133
|
+
// Phase 1: Single response
|
|
134
|
+
if (mock.response) {
|
|
135
|
+
return mock.response;
|
|
136
|
+
}
|
|
137
|
+
// Neither response nor sequence defined
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Calculate specificity score for match criteria.
|
|
142
|
+
* Higher score = more specific match.
|
|
143
|
+
*
|
|
144
|
+
* Scoring:
|
|
145
|
+
* - URL match = +1 point
|
|
146
|
+
* - Each body field = +1 point
|
|
147
|
+
* - Each header = +1 point
|
|
148
|
+
* - Each query param = +1 point
|
|
149
|
+
*
|
|
150
|
+
* Example:
|
|
151
|
+
* { body: { itemType: 'premium' } } = 1 point
|
|
152
|
+
* { body: { itemType: 'premium', quantity: 5 }, headers: { 'x-tier': 'gold' } } = 3 points
|
|
153
|
+
* { url: '/api/products', body: { itemType: 'premium' } } = 2 points
|
|
154
|
+
*/
|
|
155
|
+
const calculateSpecificity = (criteria) => {
|
|
156
|
+
let score = 0;
|
|
157
|
+
if (criteria.url) {
|
|
158
|
+
score += 1;
|
|
159
|
+
}
|
|
160
|
+
if (criteria.body) {
|
|
161
|
+
score += Object.keys(criteria.body).length;
|
|
162
|
+
}
|
|
163
|
+
if (criteria.headers) {
|
|
164
|
+
score += Object.keys(criteria.headers).length;
|
|
165
|
+
}
|
|
166
|
+
if (criteria.query) {
|
|
167
|
+
score += Object.keys(criteria.query).length;
|
|
168
|
+
}
|
|
169
|
+
return score;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Check if request context matches the specified criteria.
|
|
173
|
+
* All specified criteria must match for the overall match to succeed.
|
|
174
|
+
*/
|
|
175
|
+
const matchesCriteria = (context, criteria) => {
|
|
176
|
+
// Check URL match (exact match or pattern)
|
|
177
|
+
if (criteria.url) {
|
|
178
|
+
if (!matchesValue(context.url, criteria.url)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Check body match (partial match)
|
|
183
|
+
if (criteria.body) {
|
|
184
|
+
if (!matchesBody(context.body, criteria.body)) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Check headers match (exact match on specified headers)
|
|
189
|
+
if (criteria.headers) {
|
|
190
|
+
if (!matchesHeaders(context.headers, criteria.headers)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Check query match (exact match on specified query params)
|
|
195
|
+
if (criteria.query) {
|
|
196
|
+
if (!matchesQuery(context.query, criteria.query)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// All criteria matched
|
|
201
|
+
return true;
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Check if request body contains all required fields (partial match).
|
|
205
|
+
* Request can have additional fields beyond what's specified in criteria.
|
|
206
|
+
* Supports all matching strategies via MatchValue type.
|
|
207
|
+
* Non-string values are converted to strings before matching.
|
|
208
|
+
*/
|
|
209
|
+
const matchesBody = (requestBody, criteriaBody) => {
|
|
210
|
+
// If request has no body, can't match
|
|
211
|
+
if (!requestBody || typeof requestBody !== "object") {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const body = requestBody;
|
|
215
|
+
// Check all required fields exist in request body with matching values
|
|
216
|
+
for (const [key, criteriaValue] of Object.entries(criteriaBody)) {
|
|
217
|
+
const requestValue = body[key];
|
|
218
|
+
// Convert to string for matching (type coercion like headers/query)
|
|
219
|
+
const stringValue = requestValue == null ? '' : String(requestValue);
|
|
220
|
+
if (!matchesValue(stringValue, criteriaValue)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
};
|
|
226
|
+
// Header matching follows RFC 2616 (case-insensitive names, case-sensitive values)
|
|
227
|
+
const normalizeHeaderName = (name) => name.toLowerCase();
|
|
228
|
+
const createNormalizedHeaderMap = (headers) => {
|
|
229
|
+
const normalized = {};
|
|
230
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
231
|
+
normalized[normalizeHeaderName(key)] = value;
|
|
232
|
+
}
|
|
233
|
+
return normalized;
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Match a request value against a match criteria value.
|
|
237
|
+
*
|
|
238
|
+
* Supports 7 matching modes:
|
|
239
|
+
* 1. Plain string: exact match (backward compatible)
|
|
240
|
+
* 2. Native RegExp: pattern match (e.g., /\/users\/\d+/)
|
|
241
|
+
* 3. { equals: 'value' }: explicit exact match
|
|
242
|
+
* 4. { contains: 'substring' }: substring match
|
|
243
|
+
* 5. { startsWith: 'prefix' }: prefix match
|
|
244
|
+
* 6. { endsWith: 'suffix' }: suffix match
|
|
245
|
+
* 7. { regex: {...} }: pattern match (serialized form)
|
|
246
|
+
*
|
|
247
|
+
* Type Coercion Behavior:
|
|
248
|
+
* - Non-string criterion values (number, boolean) are converted to strings before matching
|
|
249
|
+
* - null/undefined criterion values are converted to empty string ''
|
|
250
|
+
* - Request value is always expected to be a string
|
|
251
|
+
* - Strategy values within objects are converted to strings (e.g., contains: 123 → '123')
|
|
252
|
+
*
|
|
253
|
+
* This allows backward compatibility with scenarios using non-string values
|
|
254
|
+
* in body matching (e.g., { quantity: 5 } matches body.quantity = 5 or "5")
|
|
255
|
+
*
|
|
256
|
+
* @param requestValue - The actual value from the request (string)
|
|
257
|
+
* @param criteriaValue - The expected value (string, RegExp, number, boolean, null, or strategy object)
|
|
258
|
+
* @returns true if values match, false otherwise
|
|
259
|
+
*/
|
|
260
|
+
const matchesValue = (requestValue, criteriaValue) => {
|
|
261
|
+
if (typeof criteriaValue === "string") {
|
|
262
|
+
return requestValue === criteriaValue;
|
|
263
|
+
}
|
|
264
|
+
// Native RegExp support (ADR-0016)
|
|
265
|
+
if (criteriaValue instanceof RegExp) {
|
|
266
|
+
return matchesRegex(requestValue, {
|
|
267
|
+
source: criteriaValue.source,
|
|
268
|
+
flags: criteriaValue.flags,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (typeof criteriaValue === "number" || typeof criteriaValue === "boolean") {
|
|
272
|
+
return requestValue === String(criteriaValue);
|
|
273
|
+
}
|
|
274
|
+
if (criteriaValue == null) {
|
|
275
|
+
return requestValue === '';
|
|
276
|
+
}
|
|
277
|
+
const strategyValue = criteriaValue;
|
|
278
|
+
if (strategyValue.equals !== undefined) {
|
|
279
|
+
return requestValue === String(strategyValue.equals);
|
|
280
|
+
}
|
|
281
|
+
if (strategyValue.contains !== undefined) {
|
|
282
|
+
return requestValue.includes(String(strategyValue.contains));
|
|
283
|
+
}
|
|
284
|
+
if (strategyValue.startsWith !== undefined) {
|
|
285
|
+
return requestValue.startsWith(String(strategyValue.startsWith));
|
|
286
|
+
}
|
|
287
|
+
if (strategyValue.endsWith !== undefined) {
|
|
288
|
+
return requestValue.endsWith(String(strategyValue.endsWith));
|
|
289
|
+
}
|
|
290
|
+
if (strategyValue.regex !== undefined) {
|
|
291
|
+
return matchesRegex(requestValue, strategyValue.regex);
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
};
|
|
295
|
+
const matchesHeaders = (requestHeaders, criteriaHeaders) => {
|
|
296
|
+
const normalizedRequest = createNormalizedHeaderMap(requestHeaders);
|
|
297
|
+
for (const [key, value] of Object.entries(criteriaHeaders)) {
|
|
298
|
+
const normalizedKey = normalizeHeaderName(key);
|
|
299
|
+
const requestValue = normalizedRequest[normalizedKey];
|
|
300
|
+
if (!requestValue || !matchesValue(requestValue, value)) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return true;
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* Check if request query params contain all specified params with exact values.
|
|
308
|
+
* Request can have additional query params beyond what's specified in criteria.
|
|
309
|
+
*/
|
|
310
|
+
const matchesQuery = (requestQuery, criteriaQuery) => {
|
|
311
|
+
// Check all required query params exist with exact matching values
|
|
312
|
+
for (const [key, value] of Object.entries(criteriaQuery)) {
|
|
313
|
+
const requestValue = requestQuery[key];
|
|
314
|
+
if (!requestValue || !matchesValue(requestValue, value)) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return true;
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* Captures state from request based on CaptureState configuration.
|
|
322
|
+
*
|
|
323
|
+
* @param testId - Test ID for state isolation
|
|
324
|
+
* @param context - HTTP request context
|
|
325
|
+
* @param captureConfig - Capture configuration (state key -> path expression)
|
|
326
|
+
* @param stateManager - State manager to store captured values
|
|
327
|
+
*/
|
|
328
|
+
const captureState = (testId, context, captureConfig, stateManager) => {
|
|
329
|
+
for (const [stateKey, pathExpression] of Object.entries(captureConfig)) {
|
|
330
|
+
const value = extractFromPath(context, pathExpression);
|
|
331
|
+
// Guard: Only capture if value exists
|
|
332
|
+
if (value === undefined) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
stateManager.set(testId, stateKey, value);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ScenarioManager, ScenarioRegistry, ScenarioStore, SequenceTracker, StateManager } from "../ports/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Factory function to create a ScenarioManager implementation.
|
|
4
|
+
*
|
|
5
|
+
* Follows dependency injection principle - all ports are injected, never created internally.
|
|
6
|
+
*
|
|
7
|
+
* This enables:
|
|
8
|
+
* - Any registry implementation (in-memory, Redis, files, remote)
|
|
9
|
+
* - Any store implementation (in-memory, Redis, database)
|
|
10
|
+
* - Any state manager implementation (in-memory, Redis, database)
|
|
11
|
+
* - Proper testing with mock dependencies
|
|
12
|
+
* - True hexagonal architecture
|
|
13
|
+
*/
|
|
14
|
+
export declare const createScenarioManager: ({ registry, store, stateManager, sequenceTracker, }: {
|
|
15
|
+
registry: ScenarioRegistry;
|
|
16
|
+
store: ScenarioStore;
|
|
17
|
+
stateManager?: StateManager;
|
|
18
|
+
sequenceTracker?: SequenceTracker;
|
|
19
|
+
}) => ScenarioManager;
|
|
20
|
+
//# sourceMappingURL=scenario-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scenario-manager.d.ts","sourceRoot":"","sources":["../../src/domain/scenario-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,YAAY,EACb,MAAM,mBAAmB,CAAC;AA6B3B;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GAAI,qDAKnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;CACnC,KAAG,eAgFH,CAAC"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ScenaristScenarioSchema } from "../schemas/index.js";
|
|
2
|
+
class ScenarioNotFoundError extends Error {
|
|
3
|
+
constructor(scenarioId) {
|
|
4
|
+
super(`Scenario '${scenarioId}' not found. Did you forget to register it?`);
|
|
5
|
+
this.name = "ScenarioNotFoundError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
class DuplicateScenarioError extends Error {
|
|
9
|
+
constructor(scenarioId) {
|
|
10
|
+
super(`Scenario '${scenarioId}' is already registered. Each scenario must have a unique ID.`);
|
|
11
|
+
this.name = "DuplicateScenarioError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
class ScenarioValidationError extends Error {
|
|
15
|
+
validationErrors;
|
|
16
|
+
constructor(message, validationErrors) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.validationErrors = validationErrors;
|
|
19
|
+
this.name = "ScenarioValidationError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Factory function to create a ScenarioManager implementation.
|
|
24
|
+
*
|
|
25
|
+
* Follows dependency injection principle - all ports are injected, never created internally.
|
|
26
|
+
*
|
|
27
|
+
* This enables:
|
|
28
|
+
* - Any registry implementation (in-memory, Redis, files, remote)
|
|
29
|
+
* - Any store implementation (in-memory, Redis, database)
|
|
30
|
+
* - Any state manager implementation (in-memory, Redis, database)
|
|
31
|
+
* - Proper testing with mock dependencies
|
|
32
|
+
* - True hexagonal architecture
|
|
33
|
+
*/
|
|
34
|
+
export const createScenarioManager = ({ registry, store, stateManager, sequenceTracker, }) => {
|
|
35
|
+
return {
|
|
36
|
+
registerScenario(definition) {
|
|
37
|
+
// Validate scenario definition at trust boundary
|
|
38
|
+
const validationResult = ScenaristScenarioSchema.safeParse(definition);
|
|
39
|
+
if (!validationResult.success) {
|
|
40
|
+
const errorMessages = validationResult.error.issues.map((err) => `${err.path.join('.')}: ${err.message}`);
|
|
41
|
+
const scenarioId = definition?.id || '<unknown>';
|
|
42
|
+
throw new ScenarioValidationError(`Invalid scenario definition for '${scenarioId}': ${errorMessages.join(', ')}`, errorMessages);
|
|
43
|
+
}
|
|
44
|
+
const existing = registry.get(definition.id);
|
|
45
|
+
// Allow re-registering the exact same scenario object (idempotent)
|
|
46
|
+
if (existing === definition) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Prevent registering a different scenario with the same ID
|
|
50
|
+
if (existing) {
|
|
51
|
+
throw new DuplicateScenarioError(definition.id);
|
|
52
|
+
}
|
|
53
|
+
registry.register(definition);
|
|
54
|
+
},
|
|
55
|
+
switchScenario(testId, scenarioId) {
|
|
56
|
+
const definition = registry.get(scenarioId);
|
|
57
|
+
if (!definition) {
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
error: new ScenarioNotFoundError(scenarioId),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const activeScenario = {
|
|
64
|
+
scenarioId,
|
|
65
|
+
};
|
|
66
|
+
store.set(testId, activeScenario);
|
|
67
|
+
// Phase 2: Reset sequence positions on scenario switch (clean slate)
|
|
68
|
+
if (sequenceTracker) {
|
|
69
|
+
sequenceTracker.reset(testId);
|
|
70
|
+
}
|
|
71
|
+
// Phase 3: Reset state on scenario switch (clean slate)
|
|
72
|
+
if (stateManager) {
|
|
73
|
+
stateManager.reset(testId);
|
|
74
|
+
}
|
|
75
|
+
return { success: true, data: undefined };
|
|
76
|
+
},
|
|
77
|
+
getActiveScenario(testId) {
|
|
78
|
+
return store.get(testId);
|
|
79
|
+
},
|
|
80
|
+
listScenarios() {
|
|
81
|
+
return registry.list();
|
|
82
|
+
},
|
|
83
|
+
clearScenario(testId) {
|
|
84
|
+
store.delete(testId);
|
|
85
|
+
},
|
|
86
|
+
getScenarioById(id) {
|
|
87
|
+
return registry.get(id);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies templates to a value.
|
|
3
|
+
* Replaces {{state.key}} and {{params.key}} patterns with actual values.
|
|
4
|
+
*
|
|
5
|
+
* @param value - Value to apply templates to (string, object, array, or primitive)
|
|
6
|
+
* @param templateData - Object containing state and params for template replacement.
|
|
7
|
+
* Can be flat object (backward compatible) or { state: {...}, params: {...} }
|
|
8
|
+
* @returns Value with templates replaced
|
|
9
|
+
*/
|
|
10
|
+
export declare const applyTemplates: (value: unknown, templateData: Record<string, unknown>) => unknown;
|
|
11
|
+
//# sourceMappingURL=template-replacement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,OAAO,EAAE,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,OAsDtF,CAAC"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies templates to a value.
|
|
3
|
+
* Replaces {{state.key}} and {{params.key}} patterns with actual values.
|
|
4
|
+
*
|
|
5
|
+
* @param value - Value to apply templates to (string, object, array, or primitive)
|
|
6
|
+
* @param templateData - Object containing state and params for template replacement.
|
|
7
|
+
* Can be flat object (backward compatible) or { state: {...}, params: {...} }
|
|
8
|
+
* @returns Value with templates replaced
|
|
9
|
+
*/
|
|
10
|
+
export const applyTemplates = (value, templateData) => {
|
|
11
|
+
// Backward compatibility: If templateData doesn't have 'state' or 'params' keys,
|
|
12
|
+
// treat it as a flat state object and wrap it
|
|
13
|
+
const normalizedData = (templateData.state !== undefined || templateData.params !== undefined)
|
|
14
|
+
? templateData
|
|
15
|
+
: { state: templateData, params: {} };
|
|
16
|
+
// Guard: Handle strings (base case)
|
|
17
|
+
if (typeof value === 'string') {
|
|
18
|
+
// Check if entire string is a single pure template (no surrounding text)
|
|
19
|
+
// Supports both {{state.key}} and {{params.key}}
|
|
20
|
+
const pureTemplateMatch = /^\{\{(state|params)\.([^}]+)\}\}$/.exec(value);
|
|
21
|
+
if (pureTemplateMatch) {
|
|
22
|
+
// Pure template: return raw value (preserves type - arrays, numbers, objects)
|
|
23
|
+
const prefix = pureTemplateMatch[1]; // 'state' or 'params'
|
|
24
|
+
const path = pureTemplateMatch[2]; // Guaranteed to exist by regex capture group
|
|
25
|
+
const resolvedValue = resolveTemplatePath(normalizedData, prefix, path);
|
|
26
|
+
// Return raw value if found, otherwise return null (JSON-safe)
|
|
27
|
+
// null is used instead of undefined to ensure JSON serialization preserves the field
|
|
28
|
+
return resolvedValue !== undefined ? resolvedValue : null;
|
|
29
|
+
}
|
|
30
|
+
// Mixed template (has surrounding text): use string replacement
|
|
31
|
+
// Supports both {{state.key}} and {{params.key}}
|
|
32
|
+
return value.replace(/\{\{(state|params)\.([^}]+)\}\}/g, (match, prefix, path) => {
|
|
33
|
+
const resolvedValue = resolveTemplatePath(normalizedData, prefix, path);
|
|
34
|
+
// Guard: Missing keys remain as template
|
|
35
|
+
if (resolvedValue === undefined) {
|
|
36
|
+
return match;
|
|
37
|
+
}
|
|
38
|
+
// Convert to string for concatenation with surrounding text
|
|
39
|
+
return String(resolvedValue);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// Guard: Handle arrays recursively
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return value.map((item) => applyTemplates(item, normalizedData));
|
|
45
|
+
}
|
|
46
|
+
// Guard: Handle objects recursively
|
|
47
|
+
if (typeof value === 'object' && value !== null) {
|
|
48
|
+
const result = {};
|
|
49
|
+
for (const [key, val] of Object.entries(value)) {
|
|
50
|
+
result[key] = applyTemplates(val, normalizedData);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
// Primitives (number, boolean, null) returned unchanged
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Resolves a template path like 'state.key' or 'params.userId'.
|
|
59
|
+
* Supports nested paths like 'state.user.profile.name' and 'params.path.length'.
|
|
60
|
+
*
|
|
61
|
+
* @param templateData - Template data object containing state and params
|
|
62
|
+
* @param prefix - Template prefix ('state' or 'params')
|
|
63
|
+
* @param path - Path to resolve after prefix (e.g., 'user.name' or 'userId')
|
|
64
|
+
* @returns Value at path, or undefined if not found
|
|
65
|
+
*/
|
|
66
|
+
const resolveTemplatePath = (templateData, prefix, path) => {
|
|
67
|
+
// Get the root object (state or params)
|
|
68
|
+
const root = templateData[prefix];
|
|
69
|
+
// Guard: Prefix doesn't exist (e.g., no params provided)
|
|
70
|
+
if (root === undefined || typeof root !== 'object' || root === null) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
// Resolve nested path within root object
|
|
74
|
+
const segments = path.split('.');
|
|
75
|
+
let current = root;
|
|
76
|
+
for (const segment of segments) {
|
|
77
|
+
// Guard: Can't traverse non-objects
|
|
78
|
+
if (typeof current !== 'object' || current === null) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
// Handle arrays with .length property
|
|
82
|
+
if (Array.isArray(current) && segment === 'length') {
|
|
83
|
+
return current.length;
|
|
84
|
+
}
|
|
85
|
+
// Traverse object
|
|
86
|
+
const record = current;
|
|
87
|
+
current = record[segment];
|
|
88
|
+
// Guard: Return undefined if property doesn't exist
|
|
89
|
+
if (current === undefined) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return current;
|
|
94
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type * from './types/index.js';
|
|
2
|
+
export * from './schemas/index.js';
|
|
3
|
+
export * from './constants/index.js';
|
|
4
|
+
export type * from './ports/index.js';
|
|
5
|
+
export type * from './contracts/index.js';
|
|
6
|
+
export * from './domain/index.js';
|
|
7
|
+
export * from './adapters/index.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,kBAAkB,CAAC;AAGtC,cAAc,oBAAoB,CAAC;AAGnC,cAAc,sBAAsB,CAAC;AAGrC,mBAAmB,kBAAkB,CAAC;AAGtC,mBAAmB,sBAAsB,CAAC;AAG1C,cAAc,mBAAmB,CAAC;AAGlC,cAAc,qBAAqB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Schemas (runtime validation)
|
|
2
|
+
export * from './schemas/index.js';
|
|
3
|
+
// Constants
|
|
4
|
+
export * from './constants/index.js';
|
|
5
|
+
// Domain (implementations)
|
|
6
|
+
export * from './domain/index.js';
|
|
7
|
+
// Adapters (default implementations)
|
|
8
|
+
export * from './adapters/index.js';
|