@scenarist/core 0.4.8 → 0.4.10

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 +1 @@
1
- {"version":3,"file":"in-memory-state-manager.d.ts","sourceRoot":"","sources":["../../src/adapters/in-memory-state-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAcrE;;;;;;GAMG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IAEtE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO;IAUzC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAyBtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI/C,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgB7D,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,cAAc;IA6CtB,OAAO,CAAC,cAAc;CA8BvB;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAO,YAE7C,CAAC"}
1
+ {"version":3,"file":"in-memory-state-manager.d.ts","sourceRoot":"","sources":["../../src/adapters/in-memory-state-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAGrE;;;;;;GAMG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IAEtE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO;IAUzC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAyBtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI/C,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgB7D,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,cAAc;IA6CtB,OAAO,CAAC,cAAc;CA8BvB;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAO,YAE7C,CAAC"}
@@ -1,12 +1,4 @@
1
- const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
2
- const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
3
- /**
4
- * Type guard to check if a value is a plain object (Record).
5
- * Used to properly narrow types after typeof checks.
6
- */
7
- const isRecord = (value) => {
8
- return typeof value === "object" && value !== null && !Array.isArray(value);
9
- };
1
+ import { isRecord, isDangerousKey } from "../domain/type-guards.js";
10
2
  /**
11
3
  * In-memory implementation of StateManager port.
12
4
  * Fast, single-process state storage for stateful mocks.
@@ -1 +1 @@
1
- {"version":3,"file":"deep-equals.d.ts","sourceRoot":"","sources":["../../src/domain/deep-equals.ts"],"names":[],"mappings":"AAQA;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OA+DnD,CAAC"}
1
+ {"version":3,"file":"deep-equals.d.ts","sourceRoot":"","sources":["../../src/domain/deep-equals.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OA+DnD,CAAC"}
@@ -1,10 +1,4 @@
1
- /**
2
- * Type guard to check if a value is a plain object (Record).
3
- * Used to properly narrow types after typeof checks.
4
- */
5
- const isRecord = (value) => {
6
- return typeof value === "object" && value !== null && !Array.isArray(value);
7
- };
1
+ import { isRecord } from "./type-guards.js";
8
2
  /**
9
3
  * Deep equality comparison for values.
10
4
  *
@@ -1 +1 @@
1
- {"version":3,"file":"path-extraction.d.ts","sourceRoot":"","sources":["../../src/domain/path-extraction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAc/D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,kBAAkB,EAC3B,MAAM,MAAM,KACX,OAyBF,CAAC"}
1
+ {"version":3,"file":"path-extraction.d.ts","sourceRoot":"","sources":["../../src/domain/path-extraction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAG/D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,kBAAkB,EAC3B,MAAM,MAAM,KACX,OAyBF,CAAC"}
@@ -1,12 +1,4 @@
1
- const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
2
- const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
3
- /**
4
- * Type guard to check if a value is a plain object (Record).
5
- * Used to properly narrow types after typeof checks.
6
- */
7
- const isRecord = (value) => {
8
- return typeof value === "object" && value !== null && !Array.isArray(value);
9
- };
1
+ import { isRecord, isDangerousKey } from "./type-guards.js";
10
2
  /**
11
3
  * Extracts a value from HttpRequestContext based on a path expression.
12
4
  *
@@ -1 +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,OASF,CAAC"}
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"}
@@ -21,8 +21,7 @@ export const matchesRegex = (value, pattern) => {
21
21
  const regex = new RegExp(pattern.source, pattern.flags);
22
22
  return regex.test(value);
23
23
  }
24
- catch (error) {
25
- console.error("Regex matching error:", error);
24
+ catch {
26
25
  return false;
27
26
  }
28
27
  };
@@ -1,7 +1,4 @@
1
1
  import type { ResponseSelector, SequenceTracker, StateManager, Logger } from "../ports/index.js";
2
- /**
3
- * Options for creating a response selector.
4
- */
5
2
  type CreateResponseSelectorOptions = {
6
3
  sequenceTracker?: SequenceTracker;
7
4
  stateManager?: StateManager;
@@ -1 +1 @@
1
- {"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAuC3B;;GAEG;AACH,KAAK,6BAA6B,GAAG;IACnC,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GACjC,UAAS,6BAAkC,KAC1C,gBAgRF,CAAC"}
1
+ {"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AA2C3B,KAAK,6BAA6B,GAAG;IACnC,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAsEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GACjC,UAAS,6BAAkC,KAC1C,gBAwSF,CAAC"}
@@ -4,20 +4,49 @@ import { applyTemplates } from "./template-replacement.js";
4
4
  import { matchesRegex } from "./regex-matching.js";
5
5
  import { createStateResponseResolver } from "./state-response-resolver.js";
6
6
  import { deepEquals } from "./deep-equals.js";
7
+ import { isRecord } from "./type-guards.js";
7
8
  import { noOpLogger } from "../adapters/index.js";
8
9
  import { LogCategories, LogEvents } from "./log-events.js";
9
- /**
10
- * Type guard to check if a value is a plain object (Record).
11
- * Used to properly narrow types after typeof checks.
12
- */
13
- const isRecord = (value) => {
14
- return typeof value === "object" && value !== null && !Array.isArray(value);
15
- };
16
10
  const SPECIFICITY_RANGES = {
17
11
  MATCH_CRITERIA_BASE: 100,
18
12
  SEQUENCE_FALLBACK: 1,
19
13
  SIMPLE_FALLBACK: 0,
20
14
  };
15
+ const createSequenceExhaustedError = ({ testId, scenarioId, context, }) => new ScenaristError(`Sequence exhausted for ${context.method} ${context.url}. All responses have been consumed and repeat mode is 'none'.`, {
16
+ code: ErrorCodes.SEQUENCE_EXHAUSTED,
17
+ context: {
18
+ testId,
19
+ scenarioId,
20
+ requestInfo: {
21
+ method: context.method,
22
+ url: context.url,
23
+ },
24
+ hint: "Add a fallback mock to handle requests after sequence exhaustion, or use repeat: 'last' or 'cycle' instead of 'none'.",
25
+ },
26
+ });
27
+ const createNoMockFoundError = ({ testId, scenarioId, context, }) => new ScenaristError(`No mock matched for ${context.method} ${context.url}`, {
28
+ code: ErrorCodes.NO_MOCK_FOUND,
29
+ context: {
30
+ testId,
31
+ scenarioId,
32
+ requestInfo: {
33
+ method: context.method,
34
+ url: context.url,
35
+ },
36
+ hint: "Add a fallback mock (without match criteria) to handle unmatched requests, or add a mock with matching criteria.",
37
+ },
38
+ });
39
+ const createNoResponseTypeError = ({ testId, scenarioId, mockIndex, }) => new ScenaristError(`Mock has neither response nor sequence field`, {
40
+ code: ErrorCodes.VALIDATION_ERROR,
41
+ context: {
42
+ testId,
43
+ scenarioId,
44
+ mockInfo: {
45
+ index: mockIndex,
46
+ },
47
+ hint: "Each mock must have a 'response', 'sequence', or 'stateResponse' field.",
48
+ },
49
+ });
21
50
  /**
22
51
  * Creates a response selector domain service.
23
52
  *
@@ -31,159 +60,154 @@ const SPECIFICITY_RANGES = {
31
60
  */
32
61
  export const createResponseSelector = (options = {}) => {
33
62
  const { sequenceTracker, stateManager, logger = noOpLogger } = options;
34
- return {
35
- selectResponse(testId, scenarioId, context, mocks) {
36
- const logContext = { testId, scenarioId };
37
- // Log the number of candidate mocks
38
- logger.debug(LogCategories.MATCHING, LogEvents.MOCK_CANDIDATES_FOUND, logContext, {
39
- count: mocks.length,
40
- });
41
- let bestMatch = null;
42
- // Track if we skipped any exhausted sequences (for better error messages)
43
- let skippedExhaustedSequences = false;
44
- // Find all matching mocks and score them by specificity
45
- for (let mockIndex = 0; mockIndex < mocks.length; mockIndex++) {
46
- // eslint-disable-next-line security/detect-object-injection -- Index bounded by loop (0 <= i < length)
47
- const mockWithParams = mocks[mockIndex];
48
- const mock = mockWithParams.mock;
49
- // Skip exhausted sequences (repeat: 'none' that have been exhausted)
50
- if (mock.sequence && sequenceTracker) {
51
- const { exhausted } = sequenceTracker.getPosition(testId, scenarioId, mockIndex);
52
- if (exhausted) {
53
- skippedExhaustedSequences = true;
54
- continue; // Skip to next mock, allowing fallback to be selected
55
- }
56
- }
57
- // Check if this mock has match criteria
58
- if (mock.match) {
59
- // If match criteria exists, check if it matches the request
60
- const matched = matchesCriteria(context, mock.match, testId, stateManager);
61
- // Log the evaluation result
62
- logger.debug(LogCategories.MATCHING, LogEvents.MOCK_MATCH_EVALUATED, logContext, {
63
- mockIndex,
64
- matched,
65
- hasCriteria: true,
66
- });
67
- if (matched) {
68
- // Match criteria always have higher priority than fallbacks
69
- // Base specificity ensures even 1 field beats any fallback
70
- const specificity = SPECIFICITY_RANGES.MATCH_CRITERIA_BASE +
71
- calculateSpecificity(mock.match);
72
- // Keep this mock if it's more specific than current best
73
- // (or if no best match yet)
74
- if (!bestMatch || specificity > bestMatch.specificity) {
75
- bestMatch = { mockWithParams, mockIndex, specificity };
76
- }
77
- }
78
- // If match criteria exists but doesn't match, skip to next mock
79
- continue;
80
- }
81
- // No match criteria = fallback mock (always matches)
82
- // Log fallback evaluation with response type info for debugging Issue #328
83
- logger.debug(LogCategories.MATCHING, LogEvents.MOCK_MATCH_EVALUATED, logContext, {
63
+ const isExhaustedSequence = ({ testId, scenarioId, mockIndex, mock, }) => {
64
+ if (!mock.sequence || !sequenceTracker) {
65
+ return false;
66
+ }
67
+ return sequenceTracker.getPosition(testId, scenarioId, mockIndex).exhausted;
68
+ };
69
+ const scoreCriteriaMatch = ({ context, criteria, mockIndex, logContext, }) => {
70
+ const matched = matchesCriteria({
71
+ context,
72
+ criteria,
73
+ testId: logContext.testId,
74
+ stateManager,
75
+ });
76
+ logger.debug(LogCategories.MATCHING, LogEvents.MOCK_MATCH_EVALUATED, logContext, { mockIndex, matched, hasCriteria: true });
77
+ if (!matched) {
78
+ return null;
79
+ }
80
+ return (SPECIFICITY_RANGES.MATCH_CRITERIA_BASE + calculateSpecificity(criteria));
81
+ };
82
+ const scoreFallback = ({ mock, mockIndex, logContext, }) => {
83
+ logger.debug(LogCategories.MATCHING, LogEvents.MOCK_MATCH_EVALUATED, logContext, {
84
+ mockIndex,
85
+ matched: true,
86
+ hasCriteria: false,
87
+ hasSequence: !!mock.sequence,
88
+ hasStateResponse: !!mock.stateResponse,
89
+ hasResponse: !!mock.response,
90
+ });
91
+ return mock.sequence || mock.stateResponse
92
+ ? SPECIFICITY_RANGES.SEQUENCE_FALLBACK
93
+ : SPECIFICITY_RANGES.SIMPLE_FALLBACK;
94
+ };
95
+ const findBestMatch = ({ testId, scenarioId, context, mocks, }) => {
96
+ const logContext = { testId, scenarioId };
97
+ return mocks.reduce((acc, mockWithParams, mockIndex) => {
98
+ const mock = mockWithParams.mock;
99
+ if (isExhaustedSequence({ testId, scenarioId, mockIndex, mock })) {
100
+ return { ...acc, skippedExhaustedSequences: true };
101
+ }
102
+ if (mock.match) {
103
+ const specificity = scoreCriteriaMatch({
104
+ context,
105
+ criteria: mock.match,
84
106
  mockIndex,
85
- matched: true,
86
- hasCriteria: false,
87
- hasSequence: !!mock.sequence,
88
- hasStateResponse: !!mock.stateResponse,
89
- hasResponse: !!mock.response,
107
+ logContext,
90
108
  });
91
- // Dynamic response types (sequence, stateResponse) get higher priority than simple responses
92
- // This ensures they are selected over simple fallback responses
93
- // Both sequence and stateResponse get the same specificity (Issue #316 fix)
94
- const fallbackSpecificity = mock.sequence || mock.stateResponse
95
- ? SPECIFICITY_RANGES.SEQUENCE_FALLBACK
96
- : SPECIFICITY_RANGES.SIMPLE_FALLBACK;
97
- if (!bestMatch || fallbackSpecificity >= bestMatch.specificity) {
98
- // For equal specificity fallbacks, last wins
99
- // This allows active scenario mocks to override default mocks
100
- // Applies to both simple fallbacks (0) and sequence fallbacks (1)
101
- bestMatch = {
102
- mockWithParams,
103
- mockIndex,
104
- specificity: fallbackSpecificity,
109
+ if (specificity !== null &&
110
+ (!acc.bestMatch || specificity > acc.bestMatch.specificity)) {
111
+ return {
112
+ ...acc,
113
+ bestMatch: { mockWithParams, mockIndex, specificity },
105
114
  };
106
115
  }
116
+ return acc;
117
+ }
118
+ const specificity = scoreFallback({ mock, mockIndex, logContext });
119
+ if (!acc.bestMatch || specificity >= acc.bestMatch.specificity) {
120
+ return {
121
+ ...acc,
122
+ bestMatch: { mockWithParams, mockIndex, specificity },
123
+ };
107
124
  }
108
- // Return the best matching mock
125
+ return acc;
126
+ }, { bestMatch: null, skippedExhaustedSequences: false });
127
+ };
128
+ const applyResponseTemplates = ({ testId, response, params, }) => {
129
+ if (!stateManager && !params) {
130
+ return response;
131
+ }
132
+ const templateData = {
133
+ state: stateManager ? stateManager.getAll(testId) : {},
134
+ params: params || {},
135
+ };
136
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- applyTemplates preserves structure; input ScenaristResponse → output ScenaristResponse
137
+ return applyTemplates(response, templateData);
138
+ };
139
+ const applyAfterResponseState = ({ testId, mock, responseResult, logContext, }) => {
140
+ const effectiveAfterResponse = resolveEffectiveAfterResponse(mock.afterResponse, responseResult.matchedCondition);
141
+ if (effectiveAfterResponse?.setState && stateManager) {
142
+ stateManager.merge(testId, effectiveAfterResponse.setState);
143
+ logger.debug(LogCategories.STATE, LogEvents.STATE_SET, logContext, {
144
+ setState: effectiveAfterResponse.setState,
145
+ source: responseResult.matchedCondition ? "condition" : "mock-level",
146
+ });
147
+ }
148
+ };
149
+ const processMatchedMock = ({ testId, scenarioId, context, match, }) => {
150
+ const logContext = { testId, scenarioId };
151
+ const { mockWithParams, mockIndex, specificity } = match;
152
+ const mock = mockWithParams.mock;
153
+ logger.info(LogCategories.MATCHING, LogEvents.MOCK_SELECTED, logContext, {
154
+ mockIndex,
155
+ specificity,
156
+ });
157
+ const responseResult = selectResponseFromMock({
158
+ testId,
159
+ scenarioId,
160
+ mockIndex,
161
+ mock,
162
+ sequenceTracker,
163
+ stateManager,
164
+ logger,
165
+ });
166
+ if (!responseResult) {
167
+ return {
168
+ success: false,
169
+ error: createNoResponseTypeError({ testId, scenarioId, mockIndex }),
170
+ };
171
+ }
172
+ if (mock.captureState && stateManager) {
173
+ captureState({
174
+ testId,
175
+ context,
176
+ captureConfig: mock.captureState,
177
+ stateManager,
178
+ });
179
+ }
180
+ const finalResponse = applyResponseTemplates({
181
+ testId,
182
+ response: responseResult.response,
183
+ params: mockWithParams.params,
184
+ });
185
+ applyAfterResponseState({ testId, mock, responseResult, logContext });
186
+ return { success: true, data: finalResponse };
187
+ };
188
+ return {
189
+ selectResponse(testId, scenarioId, context, mocks) {
190
+ const logContext = { testId, scenarioId };
191
+ logger.debug(LogCategories.MATCHING, LogEvents.MOCK_CANDIDATES_FOUND, logContext, { count: mocks.length });
192
+ const { bestMatch, skippedExhaustedSequences } = findBestMatch({
193
+ testId,
194
+ scenarioId,
195
+ context,
196
+ mocks,
197
+ });
109
198
  if (bestMatch) {
110
- const { mockWithParams, mockIndex, specificity } = bestMatch;
111
- const mock = mockWithParams.mock;
112
- // Log successful selection
113
- logger.info(LogCategories.MATCHING, LogEvents.MOCK_SELECTED, logContext, {
114
- mockIndex,
115
- specificity,
199
+ return processMatchedMock({
200
+ testId,
201
+ scenarioId,
202
+ context,
203
+ match: bestMatch,
116
204
  });
117
- // Select response (single, sequence, or stateResponse)
118
- const responseResult = selectResponseFromMock(testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger);
119
- if (!responseResult) {
120
- return {
121
- success: false,
122
- error: new ScenaristError(`Mock has neither response nor sequence field`, {
123
- code: ErrorCodes.VALIDATION_ERROR,
124
- context: {
125
- testId,
126
- scenarioId,
127
- mockInfo: {
128
- index: mockIndex,
129
- },
130
- hint: "Each mock must have a 'response', 'sequence', or 'stateResponse' field.",
131
- },
132
- }),
133
- };
134
- }
135
- // Phase 3: Capture state from request if configured
136
- if (mock.captureState && stateManager) {
137
- captureState(testId, context, mock.captureState, stateManager);
138
- }
139
- // Apply templates to response (both state AND params)
140
- let finalResponse = responseResult.response;
141
- if (stateManager || mockWithParams.params) {
142
- const currentState = stateManager ? stateManager.getAll(testId) : {};
143
- // Merge state and params for template replacement
144
- // params take precedence over state for the same key
145
- const templateData = {
146
- state: currentState,
147
- params: mockWithParams.params || {},
148
- };
149
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- applyTemplates preserves structure; input ScenaristResponse → output ScenaristResponse
150
- finalResponse = applyTemplates(responseResult.response, templateData);
151
- }
152
- // Apply afterResponse.setState to mutate state for subsequent requests
153
- // Uses condition-level afterResponse if available (#338)
154
- const effectiveAfterResponse = resolveEffectiveAfterResponse(mock.afterResponse, responseResult.matchedCondition);
155
- if (effectiveAfterResponse?.setState && stateManager) {
156
- stateManager.merge(testId, effectiveAfterResponse.setState);
157
- logger.debug(LogCategories.STATE, LogEvents.STATE_SET, logContext, {
158
- setState: effectiveAfterResponse.setState,
159
- source: responseResult.matchedCondition
160
- ? "condition"
161
- : "mock-level",
162
- });
163
- }
164
- return { success: true, data: finalResponse };
165
205
  }
166
- // No mock matched - determine specific error type
167
206
  if (skippedExhaustedSequences) {
168
- // All matching mocks were exhausted sequences
169
- logger.warn(LogCategories.SEQUENCE, LogEvents.SEQUENCE_EXHAUSTED, logContext, {
170
- url: context.url,
171
- method: context.method,
172
- });
207
+ logger.warn(LogCategories.SEQUENCE, LogEvents.SEQUENCE_EXHAUSTED, logContext, { url: context.url, method: context.method });
173
208
  return {
174
209
  success: false,
175
- error: new ScenaristError(`Sequence exhausted for ${context.method} ${context.url}. All responses have been consumed and repeat mode is 'none'.`, {
176
- code: ErrorCodes.SEQUENCE_EXHAUSTED,
177
- context: {
178
- testId,
179
- scenarioId,
180
- requestInfo: {
181
- method: context.method,
182
- url: context.url,
183
- },
184
- hint: "Add a fallback mock to handle requests after sequence exhaustion, or use repeat: 'last' or 'cycle' instead of 'none'.",
185
- },
186
- }),
210
+ error: createSequenceExhaustedError({ testId, scenarioId, context }),
187
211
  };
188
212
  }
189
213
  logger.warn(LogCategories.MATCHING, LogEvents.MOCK_NO_MATCH, logContext, {
@@ -193,18 +217,7 @@ export const createResponseSelector = (options = {}) => {
193
217
  });
194
218
  return {
195
219
  success: false,
196
- error: new ScenaristError(`No mock matched for ${context.method} ${context.url}`, {
197
- code: ErrorCodes.NO_MOCK_FOUND,
198
- context: {
199
- testId,
200
- scenarioId,
201
- requestInfo: {
202
- method: context.method,
203
- url: context.url,
204
- },
205
- hint: "Add a fallback mock (without match criteria) to handle unmatched requests, or add a mock with matching criteria.",
206
- },
207
- }),
220
+ error: createNoMockFoundError({ testId, scenarioId, context }),
208
221
  };
209
222
  },
210
223
  };
@@ -240,40 +253,34 @@ const resolveEffectiveAfterResponse = (mockAfterResponse, matchedCondition) => {
240
253
  * @param logger - Logger for debugging
241
254
  * @returns MockResponseResult or null if mock has no response type
242
255
  */
243
- const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger) => {
256
+ const selectResponseFromMock = ({ testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger, }) => {
244
257
  const logContext = { testId, scenarioId };
245
- // Phase 2: If mock has a sequence, use sequence tracker
246
258
  if (mock.sequence) {
247
259
  if (!sequenceTracker) {
248
- // Sequence defined but no tracker provided - return first response
249
- // Note: Schema validation ensures responses array has at least 1 element,
250
- // but defensive check handles malformed data that bypasses validation
251
260
  const firstResponse = mock.sequence.responses[0];
252
261
  return firstResponse
253
262
  ? { response: firstResponse, matchedCondition: null }
254
263
  : null;
255
264
  }
256
- // Get current position from tracker
257
265
  const { position } = sequenceTracker.getPosition(testId, scenarioId, mockIndex);
258
- // Get response at current position
259
- // Note: Exhausted sequences are skipped during matching phase,
260
- // so position should always be valid here
261
266
  // eslint-disable-next-line security/detect-object-injection -- Position bounded by sequence tracker
262
267
  const response = mock.sequence.responses[position];
263
- // Advance position for next call
264
268
  const repeatMode = mock.sequence.repeat || "last";
265
269
  sequenceTracker.advance(testId, scenarioId, mockIndex, mock.sequence.responses.length, repeatMode);
266
270
  return { response, matchedCondition: null };
267
271
  }
268
- // State-aware response: evaluate conditions against current state
269
272
  if (mock.stateResponse) {
270
- return resolveStateResponse(testId, mock.stateResponse, stateManager, logger, logContext);
273
+ return resolveStateResponse({
274
+ testId,
275
+ stateResponse: mock.stateResponse,
276
+ stateManager,
277
+ logger,
278
+ logContext,
279
+ });
271
280
  }
272
- // Phase 1: Single response
273
281
  if (mock.response) {
274
282
  return { response: mock.response, matchedCondition: null };
275
283
  }
276
- // No response type defined
277
284
  return null;
278
285
  };
279
286
  /**
@@ -289,8 +296,7 @@ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTra
289
296
  * @param logContext - Context for log messages
290
297
  * @returns MockResponseResult with response and matched condition
291
298
  */
292
- const resolveStateResponse = (testId, stateResponse, stateManager, logger, logContext) => {
293
- // Without stateManager, always return default
299
+ const resolveStateResponse = ({ testId, stateResponse, stateManager, logger, logContext, }) => {
294
300
  if (!stateManager) {
295
301
  logger.debug(LogCategories.STATE, LogEvents.STATE_RESPONSE_RESOLVED, logContext, {
296
302
  result: "default",
@@ -356,38 +362,23 @@ const calculateSpecificity = (criteria) => {
356
362
  * @param testId - Test ID for state isolation
357
363
  * @param stateManager - Optional state manager for state-based matching
358
364
  */
359
- const matchesCriteria = (context, criteria, testId, stateManager) => {
360
- // Check URL match (exact match or pattern)
361
- if (criteria.url) {
362
- if (!matchesValue(context.url, criteria.url)) {
363
- return false;
364
- }
365
+ const matchesCriteria = ({ context, criteria, testId, stateManager, }) => {
366
+ if (criteria.url && !matchesValue(context.url, criteria.url)) {
367
+ return false;
365
368
  }
366
- // Check body match (partial match)
367
- if (criteria.body) {
368
- if (!matchesBody(context.body, criteria.body)) {
369
- return false;
370
- }
369
+ if (criteria.body && !matchesBody(context.body, criteria.body)) {
370
+ return false;
371
371
  }
372
- // Check headers match (exact match on specified headers)
373
- if (criteria.headers) {
374
- if (!matchesHeaders(context.headers, criteria.headers)) {
375
- return false;
376
- }
372
+ if (criteria.headers && !matchesHeaders(context.headers, criteria.headers)) {
373
+ return false;
377
374
  }
378
- // Check query match (exact match on specified query params)
379
- if (criteria.query) {
380
- if (!matchesQuery(context.query, criteria.query)) {
381
- return false;
382
- }
375
+ if (criteria.query && !matchesQuery(context.query, criteria.query)) {
376
+ return false;
383
377
  }
384
- // Check state match (partial match on current test state)
385
- if (criteria.state) {
386
- if (!matchesState(criteria.state, testId, stateManager)) {
387
- return false;
388
- }
378
+ if (criteria.state &&
379
+ !matchesState({ stateCriteria: criteria.state, testId, stateManager })) {
380
+ return false;
389
381
  }
390
- // All criteria matched
391
382
  return true;
392
383
  };
393
384
  /**
@@ -399,18 +390,15 @@ const matchesCriteria = (context, criteria, testId, stateManager) => {
399
390
  * @param stateManager - State manager to retrieve current state
400
391
  * @returns true if all criteria keys match, false otherwise
401
392
  */
402
- const matchesState = (stateCriteria, testId, stateManager) => {
403
- // Without stateManager, state matching always fails
393
+ const matchesState = ({ stateCriteria, testId, stateManager, }) => {
404
394
  if (!stateManager) {
405
395
  return false;
406
396
  }
407
397
  const currentState = stateManager.getAll(testId);
408
- // All keys in criteria must exist in state with equal values
409
398
  for (const [key, expectedValue] of Object.entries(stateCriteria)) {
410
399
  if (!(key in currentState)) {
411
400
  return false;
412
401
  }
413
- // Deep equality check for values (handles primitives, null, objects)
414
402
  // eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
415
403
  if (!deepEquals(currentState[key], expectedValue)) {
416
404
  return false;
@@ -554,10 +542,9 @@ const matchesQuery = (requestQuery, criteriaQuery) => {
554
542
  * @param captureConfig - Capture configuration (state key -> path expression)
555
543
  * @param stateManager - State manager to store captured values
556
544
  */
557
- const captureState = (testId, context, captureConfig, stateManager) => {
545
+ const captureState = ({ testId, context, captureConfig, stateManager, }) => {
558
546
  for (const [stateKey, pathExpression] of Object.entries(captureConfig)) {
559
547
  const value = extractFromPath(context, pathExpression);
560
- // Guard: Only capture if value exists
561
548
  if (value === undefined) {
562
549
  continue;
563
550
  }
@@ -1 +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,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAwC3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAI,6DAMnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,KAAG,eAmIH,CAAC"}
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,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAwD3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAI,6DAMnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,KAAG,eAyHH,CAAC"}
@@ -20,6 +20,14 @@ const createScenarioValidationError = (scenarioId, validationErrors) => {
20
20
  },
21
21
  });
22
22
  };
23
+ const createScenarioNotFoundError = (scenarioId, testId) => new ScenaristError(`Scenario '${scenarioId}' not found. Did you forget to register it?`, {
24
+ code: ErrorCodes.SCENARIO_NOT_FOUND,
25
+ context: {
26
+ testId,
27
+ scenarioId,
28
+ hint: "Make sure to register the scenario before switching to it. Use manager.registerScenario(definition) first.",
29
+ },
30
+ });
23
31
  /**
24
32
  * Factory function to create a ScenarioManager implementation.
25
33
  *
@@ -72,14 +80,7 @@ export const createScenarioManager = ({ registry, store, stateManager, sequenceT
72
80
  });
73
81
  return {
74
82
  success: false,
75
- error: new ScenaristError(`Scenario '${scenarioId}' not found. Did you forget to register it?`, {
76
- code: ErrorCodes.SCENARIO_NOT_FOUND,
77
- context: {
78
- testId,
79
- scenarioId,
80
- hint: "Make sure to register the scenario before switching to it. Use manager.registerScenario(definition) first.",
81
- },
82
- }),
83
+ error: createScenarioNotFoundError(scenarioId, testId),
83
84
  };
84
85
  }
85
86
  const activeScenario = {
@@ -1,15 +1,2 @@
1
- /**
2
- * Applies templates to a value.
3
- * Replaces {{state.key}} and {{params.key}} patterns with actual values.
4
- *
5
- * Note: This function preserves the structure of the input value.
6
- * Callers passing typed objects (like ScenaristResponse) can safely
7
- * cast the return value back to the input type.
8
- *
9
- * @param value - Value to apply templates to (string, object, array, or primitive)
10
- * @param templateData - Object containing state and params for template replacement.
11
- * Can be flat object (backward compatible) or { state: {...}, params: {...} }
12
- * @returns Value with templates replaced
13
- */
14
1
  export declare const applyTemplates: (value: unknown, templateData: Record<string, unknown>) => unknown;
15
2
  //# sourceMappingURL=template-replacement.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OA+DF,CAAC"}
1
+ {"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAqDA,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OAqBF,CAAC"}
@@ -1,17 +1,4 @@
1
- /**
2
- * Type guard to check if a value is a plain object (Record).
3
- * Used to properly narrow types after typeof checks.
4
- */
5
- const isRecord = (value) => {
6
- return typeof value === "object" && value !== null && !Array.isArray(value);
7
- };
8
- /**
9
- * Security: Check if a property key could cause prototype pollution.
10
- * @see https://github.com/citypaul/scenarist/security/code-scanning
11
- */
12
- const isDangerousKey = (key) => {
13
- return key === "__proto__" || key === "constructor" || key === "prototype";
14
- };
1
+ import { isRecord, isDangerousKey } from "./type-guards.js";
15
2
  /**
16
3
  * Applies templates to a value.
17
4
  * Replaces {{state.key}} and {{params.key}} patterns with actual values.
@@ -25,45 +12,35 @@ const isDangerousKey = (key) => {
25
12
  * Can be flat object (backward compatible) or { state: {...}, params: {...} }
26
13
  * @returns Value with templates replaced
27
14
  */
15
+ const PURE_TEMPLATE_PATTERN = /^\{\{(state|params)\.([^}]{1,256})\}\}$/;
16
+ const MIXED_TEMPLATE_PATTERN = /\{\{(state|params)\.([^}]{1,256})\}\}/g;
17
+ const applyTemplatesToString = (value, templateData) => {
18
+ const pureTemplateMatch = PURE_TEMPLATE_PATTERN.exec(value);
19
+ if (pureTemplateMatch) {
20
+ const prefix = pureTemplateMatch[1];
21
+ const path = pureTemplateMatch[2];
22
+ const resolvedValue = resolveTemplatePath(templateData, prefix, path);
23
+ return resolvedValue !== undefined ? resolvedValue : null;
24
+ }
25
+ return value.replace(MIXED_TEMPLATE_PATTERN, (match, prefix, path) => {
26
+ const resolvedValue = resolveTemplatePath(templateData, prefix, path);
27
+ if (resolvedValue === undefined) {
28
+ return match;
29
+ }
30
+ return String(resolvedValue);
31
+ });
32
+ };
33
+ const normalizeTemplateData = (templateData) => templateData.state !== undefined || templateData.params !== undefined
34
+ ? templateData
35
+ : { state: templateData, params: {} };
28
36
  export const applyTemplates = (value, templateData) => {
29
- // Backward compatibility: If templateData doesn't have 'state' or 'params' keys,
30
- // treat it as a flat state object and wrap it
31
- const normalizedData = templateData.state !== undefined || templateData.params !== undefined
32
- ? templateData
33
- : { state: templateData, params: {} };
34
- // Guard: Handle strings (base case)
37
+ const normalizedData = normalizeTemplateData(templateData);
35
38
  if (typeof value === "string") {
36
- // Check if entire string is a single pure template (no surrounding text)
37
- // Supports both {{state.key}} and {{params.key}}
38
- // Using {1,256} limit to prevent ReDoS attacks with malicious input
39
- const pureTemplateMatch = /^\{\{(state|params)\.([^}]{1,256})\}\}$/.exec(value);
40
- if (pureTemplateMatch) {
41
- // Pure template: return raw value (preserves type - arrays, numbers, objects)
42
- const prefix = pureTemplateMatch[1]; // 'state' or 'params'
43
- const path = pureTemplateMatch[2]; // Guaranteed to exist by regex capture group
44
- const resolvedValue = resolveTemplatePath(normalizedData, prefix, path);
45
- // Return raw value if found, otherwise return null (JSON-safe)
46
- // null is used instead of undefined to ensure JSON serialization preserves the field
47
- return resolvedValue !== undefined ? resolvedValue : null;
48
- }
49
- // Mixed template (has surrounding text): use string replacement
50
- // Supports both {{state.key}} and {{params.key}}
51
- // Using {1,256} limit to prevent ReDoS attacks with malicious input
52
- return value.replace(/\{\{(state|params)\.([^}]{1,256})\}\}/g, (match, prefix, path) => {
53
- const resolvedValue = resolveTemplatePath(normalizedData, prefix, path);
54
- // Guard: Missing keys remain as template
55
- if (resolvedValue === undefined) {
56
- return match;
57
- }
58
- // Convert to string for concatenation with surrounding text
59
- return String(resolvedValue);
60
- });
39
+ return applyTemplatesToString(value, normalizedData);
61
40
  }
62
- // Guard: Handle arrays recursively
63
41
  if (Array.isArray(value)) {
64
42
  return value.map((item) => applyTemplates(item, normalizedData));
65
43
  }
66
- // Guard: Handle objects recursively
67
44
  if (typeof value === "object" && value !== null) {
68
45
  const result = {};
69
46
  for (const [key, val] of Object.entries(value)) {
@@ -72,7 +49,6 @@ export const applyTemplates = (value, templateData) => {
72
49
  }
73
50
  return result;
74
51
  }
75
- // Primitives (number, boolean, null) returned unchanged
76
52
  return value;
77
53
  };
78
54
  /**
@@ -0,0 +1,3 @@
1
+ export declare const isDangerousKey: (key: string) => boolean;
2
+ export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
3
+ //# sourceMappingURL=type-guards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"type-guards.d.ts","sourceRoot":"","sources":["../../src/domain/type-guards.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,OAAkC,CAAC;AAEhF,eAAO,MAAM,QAAQ,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CACH,CAAC"}
@@ -0,0 +1,3 @@
1
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
2
+ export const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
3
+ export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenarist/core",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "Internal: Hexagonal architecture core for scenario-based testing with MSW",
5
5
  "author": "Paul Hammond (citypaul) <paul@packsoftware.co.uk>",
6
6
  "license": "MIT",
@@ -46,13 +46,13 @@
46
46
  "LICENSE"
47
47
  ],
48
48
  "dependencies": {
49
- "redos-detector": "^6.1.2",
49
+ "redos-detector": "^6.1.4",
50
50
  "zod": "^4.3.6"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@vitest/coverage-v8": "^4.0.18",
54
54
  "@vitest/ui": "^4.0.18",
55
- "eslint": "^9.39.2",
55
+ "eslint": "^9.39.3",
56
56
  "fast-check": "^4.5.3",
57
57
  "typescript": "^5.9.3",
58
58
  "vitest": "^4.0.18",