@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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +755 -28
  3. package/dist/adapters/in-memory-registry.d.ts +18 -0
  4. package/dist/adapters/in-memory-registry.d.ts.map +1 -0
  5. package/dist/adapters/in-memory-registry.js +25 -0
  6. package/dist/adapters/in-memory-sequence-tracker.d.ts +28 -0
  7. package/dist/adapters/in-memory-sequence-tracker.d.ts.map +1 -0
  8. package/dist/adapters/in-memory-sequence-tracker.js +82 -0
  9. package/dist/adapters/in-memory-state-manager.d.ts +24 -0
  10. package/dist/adapters/in-memory-state-manager.d.ts.map +1 -0
  11. package/dist/adapters/in-memory-state-manager.js +81 -0
  12. package/dist/adapters/in-memory-store.d.ts +18 -0
  13. package/dist/adapters/in-memory-store.d.ts.map +1 -0
  14. package/dist/adapters/in-memory-store.js +25 -0
  15. package/dist/adapters/index.d.ts +5 -0
  16. package/dist/adapters/index.d.ts.map +1 -0
  17. package/dist/adapters/index.js +4 -0
  18. package/dist/constants/headers.d.ts +10 -0
  19. package/dist/constants/headers.d.ts.map +1 -0
  20. package/dist/constants/headers.js +9 -0
  21. package/dist/constants/index.d.ts +2 -0
  22. package/dist/constants/index.d.ts.map +1 -0
  23. package/dist/constants/index.js +1 -0
  24. package/dist/contracts/framework-adapter.d.ts +118 -0
  25. package/dist/contracts/framework-adapter.d.ts.map +1 -0
  26. package/dist/contracts/framework-adapter.js +1 -0
  27. package/dist/contracts/index.d.ts +2 -0
  28. package/dist/contracts/index.d.ts.map +1 -0
  29. package/dist/contracts/index.js +1 -0
  30. package/dist/domain/config-builder.d.ts +9 -0
  31. package/dist/domain/config-builder.d.ts.map +1 -0
  32. package/dist/domain/config-builder.js +20 -0
  33. package/dist/domain/index.d.ts +5 -0
  34. package/dist/domain/index.d.ts.map +1 -0
  35. package/dist/domain/index.js +4 -0
  36. package/dist/domain/path-extraction.d.ts +17 -0
  37. package/dist/domain/path-extraction.d.ts.map +1 -0
  38. package/dist/domain/path-extraction.js +60 -0
  39. package/dist/domain/regex-matching.d.ts +20 -0
  40. package/dist/domain/regex-matching.d.ts.map +1 -0
  41. package/dist/domain/regex-matching.js +27 -0
  42. package/dist/domain/response-selector.d.ts +22 -0
  43. package/dist/domain/response-selector.d.ts.map +1 -0
  44. package/dist/domain/response-selector.js +337 -0
  45. package/dist/domain/scenario-manager.d.ts +20 -0
  46. package/dist/domain/scenario-manager.d.ts.map +1 -0
  47. package/dist/domain/scenario-manager.js +90 -0
  48. package/dist/domain/template-replacement.d.ts +11 -0
  49. package/dist/domain/template-replacement.d.ts.map +1 -0
  50. package/dist/domain/template-replacement.js +94 -0
  51. package/dist/index.d.ts +8 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +8 -0
  54. package/dist/ports/driven/request-context.d.ts +43 -0
  55. package/dist/ports/driven/request-context.d.ts.map +1 -0
  56. package/dist/ports/driven/request-context.js +1 -0
  57. package/dist/ports/driven/response-selector.d.ts +34 -0
  58. package/dist/ports/driven/response-selector.d.ts.map +1 -0
  59. package/dist/ports/driven/response-selector.js +9 -0
  60. package/dist/ports/driven/scenario-registry.d.ts +46 -0
  61. package/dist/ports/driven/scenario-registry.d.ts.map +1 -0
  62. package/dist/ports/driven/scenario-registry.js +1 -0
  63. package/dist/ports/driven/scenario-store.d.ts +33 -0
  64. package/dist/ports/driven/scenario-store.d.ts.map +1 -0
  65. package/dist/ports/driven/scenario-store.js +1 -0
  66. package/dist/ports/driven/sequence-tracker.d.ts +49 -0
  67. package/dist/ports/driven/sequence-tracker.d.ts.map +1 -0
  68. package/dist/ports/driven/sequence-tracker.js +1 -0
  69. package/dist/ports/driven/state-manager.d.ts +56 -0
  70. package/dist/ports/driven/state-manager.d.ts.map +1 -0
  71. package/dist/ports/driven/state-manager.js +1 -0
  72. package/dist/ports/driving/scenario-manager.d.ts +99 -0
  73. package/dist/ports/driving/scenario-manager.d.ts.map +1 -0
  74. package/dist/ports/driving/scenario-manager.js +1 -0
  75. package/dist/ports/index.d.ts +8 -0
  76. package/dist/ports/index.d.ts.map +1 -0
  77. package/dist/ports/index.js +1 -0
  78. package/dist/schemas/index.d.ts +18 -0
  79. package/dist/schemas/index.d.ts.map +1 -0
  80. package/dist/schemas/index.js +17 -0
  81. package/dist/schemas/match-criteria.d.ts +27 -0
  82. package/dist/schemas/match-criteria.d.ts.map +1 -0
  83. package/dist/schemas/match-criteria.js +71 -0
  84. package/dist/schemas/scenario-definition.d.ts +276 -0
  85. package/dist/schemas/scenario-definition.d.ts.map +1 -0
  86. package/dist/schemas/scenario-definition.js +78 -0
  87. package/dist/schemas/scenario-requests.d.ts +33 -0
  88. package/dist/schemas/scenario-requests.d.ts.map +1 -0
  89. package/dist/schemas/scenario-requests.js +29 -0
  90. package/dist/schemas/scenarios-object.d.ts +91 -0
  91. package/dist/schemas/scenarios-object.d.ts.map +1 -0
  92. package/dist/schemas/scenarios-object.js +17 -0
  93. package/dist/types/config.d.ts +70 -0
  94. package/dist/types/config.d.ts.map +1 -0
  95. package/dist/types/config.js +1 -0
  96. package/dist/types/index.d.ts +4 -0
  97. package/dist/types/index.d.ts.map +1 -0
  98. package/dist/types/index.js +1 -0
  99. package/dist/types/scenario.d.ts +141 -0
  100. package/dist/types/scenario.d.ts.map +1 -0
  101. package/dist/types/scenario.js +1 -0
  102. 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
+ };
@@ -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';