@scenarist/core 0.2.1 → 0.3.1

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 (55) hide show
  1. package/dist/adapters/console-logger.d.ts +40 -0
  2. package/dist/adapters/console-logger.d.ts.map +1 -0
  3. package/dist/adapters/console-logger.js +193 -0
  4. package/dist/adapters/in-memory-state-manager.d.ts.map +1 -1
  5. package/dist/adapters/in-memory-state-manager.js +3 -0
  6. package/dist/adapters/index.d.ts +2 -0
  7. package/dist/adapters/index.d.ts.map +1 -1
  8. package/dist/adapters/index.js +2 -0
  9. package/dist/adapters/noop-logger.d.ts +26 -0
  10. package/dist/adapters/noop-logger.d.ts.map +1 -0
  11. package/dist/adapters/noop-logger.js +26 -0
  12. package/dist/contracts/framework-adapter.d.ts +18 -0
  13. package/dist/contracts/framework-adapter.d.ts.map +1 -1
  14. package/dist/domain/config-builder.d.ts.map +1 -1
  15. package/dist/domain/config-builder.js +13 -0
  16. package/dist/domain/deep-equals.d.ts.map +1 -1
  17. package/dist/domain/deep-equals.js +2 -0
  18. package/dist/domain/index.d.ts +1 -0
  19. package/dist/domain/index.d.ts.map +1 -1
  20. package/dist/domain/index.js +1 -0
  21. package/dist/domain/log-events.d.ts +71 -0
  22. package/dist/domain/log-events.d.ts.map +1 -0
  23. package/dist/domain/log-events.js +74 -0
  24. package/dist/domain/path-extraction.js +1 -0
  25. package/dist/domain/regex-matching.d.ts.map +1 -1
  26. package/dist/domain/regex-matching.js +1 -0
  27. package/dist/domain/response-selector.d.ts +2 -1
  28. package/dist/domain/response-selector.d.ts.map +1 -1
  29. package/dist/domain/response-selector.js +120 -13
  30. package/dist/domain/scenario-manager.d.ts +4 -2
  31. package/dist/domain/scenario-manager.d.ts.map +1 -1
  32. package/dist/domain/scenario-manager.js +42 -24
  33. package/dist/domain/state-condition-evaluator.js +1 -0
  34. package/dist/domain/template-replacement.d.ts.map +1 -1
  35. package/dist/domain/template-replacement.js +3 -0
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/ports/driven/logger.d.ts +119 -0
  40. package/dist/ports/driven/logger.d.ts.map +1 -0
  41. package/dist/ports/driven/logger.js +1 -0
  42. package/dist/ports/driven/response-selector.d.ts +3 -1
  43. package/dist/ports/driven/response-selector.d.ts.map +1 -1
  44. package/dist/ports/driven/response-selector.js +1 -0
  45. package/dist/ports/index.d.ts +1 -0
  46. package/dist/ports/index.d.ts.map +1 -1
  47. package/dist/types/config.d.ts +29 -0
  48. package/dist/types/config.d.ts.map +1 -1
  49. package/dist/types/errors.d.ts +49 -0
  50. package/dist/types/errors.d.ts.map +1 -0
  51. package/dist/types/errors.js +26 -0
  52. package/dist/types/index.d.ts +3 -1
  53. package/dist/types/index.d.ts.map +1 -1
  54. package/dist/types/index.js +2 -1
  55. package/package.json +3 -3
@@ -1,9 +1,11 @@
1
- import { ResponseSelectionError } from "../ports/driven/response-selector.js";
1
+ import { ScenaristError, ErrorCodes } from "../types/errors.js";
2
2
  import { extractFromPath } from "./path-extraction.js";
3
3
  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 { noOpLogger } from "../adapters/index.js";
8
+ import { LogCategories, LogEvents } from "./log-events.js";
7
9
  const SPECIFICITY_RANGES = {
8
10
  MATCH_CRITERIA_BASE: 100,
9
11
  SEQUENCE_FALLBACK: 1,
@@ -21,26 +23,41 @@ const SPECIFICITY_RANGES = {
21
23
  * @param options.stateManager - Optional state manager for capture/injection (Phase 3)
22
24
  */
23
25
  export const createResponseSelector = (options = {}) => {
24
- const { sequenceTracker, stateManager } = options;
26
+ const { sequenceTracker, stateManager, logger = noOpLogger } = options;
25
27
  return {
26
28
  selectResponse(testId, scenarioId, context, mocks) {
29
+ const logContext = { testId, scenarioId };
30
+ // Log the number of candidate mocks
31
+ logger.debug(LogCategories.MATCHING, LogEvents.MOCK_CANDIDATES_FOUND, logContext, {
32
+ count: mocks.length,
33
+ });
27
34
  let bestMatch = null;
35
+ // Track if we skipped any exhausted sequences (for better error messages)
36
+ let skippedExhaustedSequences = false;
28
37
  // Find all matching mocks and score them by specificity
29
38
  for (let mockIndex = 0; mockIndex < mocks.length; mockIndex++) {
30
- // Index is guaranteed in bounds by loop condition (0 <= mockIndex < length)
39
+ // eslint-disable-next-line security/detect-object-injection -- Index bounded by loop (0 <= i < length)
31
40
  const mockWithParams = mocks[mockIndex];
32
41
  const mock = mockWithParams.mock;
33
42
  // Skip exhausted sequences (repeat: 'none' that have been exhausted)
34
43
  if (mock.sequence && sequenceTracker) {
35
44
  const { exhausted } = sequenceTracker.getPosition(testId, scenarioId, mockIndex);
36
45
  if (exhausted) {
46
+ skippedExhaustedSequences = true;
37
47
  continue; // Skip to next mock, allowing fallback to be selected
38
48
  }
39
49
  }
40
50
  // Check if this mock has match criteria
41
51
  if (mock.match) {
42
52
  // If match criteria exists, check if it matches the request
43
- if (matchesCriteria(context, mock.match, testId, stateManager)) {
53
+ const matched = matchesCriteria(context, mock.match, testId, stateManager);
54
+ // Log the evaluation result
55
+ logger.debug(LogCategories.MATCHING, LogEvents.MOCK_MATCH_EVALUATED, logContext, {
56
+ mockIndex,
57
+ matched,
58
+ hasCriteria: true,
59
+ });
60
+ if (matched) {
44
61
  // Match criteria always have higher priority than fallbacks
45
62
  // Base specificity ensures even 1 field beats any fallback
46
63
  const specificity = SPECIFICITY_RANGES.MATCH_CRITERIA_BASE +
@@ -55,6 +72,15 @@ export const createResponseSelector = (options = {}) => {
55
72
  continue;
56
73
  }
57
74
  // No match criteria = fallback mock (always matches)
75
+ // Log fallback evaluation with response type info for debugging Issue #328
76
+ logger.debug(LogCategories.MATCHING, LogEvents.MOCK_MATCH_EVALUATED, logContext, {
77
+ mockIndex,
78
+ matched: true,
79
+ hasCriteria: false,
80
+ hasSequence: !!mock.sequence,
81
+ hasStateResponse: !!mock.stateResponse,
82
+ hasResponse: !!mock.response,
83
+ });
58
84
  // Dynamic response types (sequence, stateResponse) get higher priority than simple responses
59
85
  // This ensures they are selected over simple fallback responses
60
86
  // Both sequence and stateResponse get the same specificity (Issue #316 fix)
@@ -74,14 +100,29 @@ export const createResponseSelector = (options = {}) => {
74
100
  }
75
101
  // Return the best matching mock
76
102
  if (bestMatch) {
77
- const { mockWithParams, mockIndex } = bestMatch;
103
+ const { mockWithParams, mockIndex, specificity } = bestMatch;
78
104
  const mock = mockWithParams.mock;
105
+ // Log successful selection
106
+ logger.info(LogCategories.MATCHING, LogEvents.MOCK_SELECTED, logContext, {
107
+ mockIndex,
108
+ specificity,
109
+ });
79
110
  // Select response (single, sequence, or stateResponse)
80
- const response = selectResponseFromMock(testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager);
111
+ const response = selectResponseFromMock(testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger);
81
112
  if (!response) {
82
113
  return {
83
114
  success: false,
84
- error: new ResponseSelectionError(`Mock has neither response nor sequence field`),
115
+ error: new ScenaristError(`Mock has neither response nor sequence field`, {
116
+ code: ErrorCodes.VALIDATION_ERROR,
117
+ context: {
118
+ testId,
119
+ scenarioId,
120
+ mockInfo: {
121
+ index: mockIndex,
122
+ },
123
+ hint: "Each mock must have a 'response', 'sequence', or 'stateResponse' field.",
124
+ },
125
+ }),
85
126
  };
86
127
  }
87
128
  // Phase 3: Capture state from request if configured
@@ -103,13 +144,54 @@ export const createResponseSelector = (options = {}) => {
103
144
  // Apply afterResponse.setState to mutate state for subsequent requests
104
145
  if (mock.afterResponse?.setState && stateManager) {
105
146
  stateManager.merge(testId, mock.afterResponse.setState);
147
+ logger.debug(LogCategories.STATE, LogEvents.STATE_SET, logContext, {
148
+ setState: mock.afterResponse.setState,
149
+ });
106
150
  }
107
151
  return { success: true, data: finalResponse };
108
152
  }
109
- // No mock matched
153
+ // No mock matched - determine specific error type
154
+ if (skippedExhaustedSequences) {
155
+ // All matching mocks were exhausted sequences
156
+ logger.warn(LogCategories.SEQUENCE, LogEvents.SEQUENCE_EXHAUSTED, logContext, {
157
+ url: context.url,
158
+ method: context.method,
159
+ });
160
+ return {
161
+ success: false,
162
+ error: new ScenaristError(`Sequence exhausted for ${context.method} ${context.url}. All responses have been consumed and repeat mode is 'none'.`, {
163
+ code: ErrorCodes.SEQUENCE_EXHAUSTED,
164
+ context: {
165
+ testId,
166
+ scenarioId,
167
+ requestInfo: {
168
+ method: context.method,
169
+ url: context.url,
170
+ },
171
+ hint: "Add a fallback mock to handle requests after sequence exhaustion, or use repeat: 'last' or 'cycle' instead of 'none'.",
172
+ },
173
+ }),
174
+ };
175
+ }
176
+ logger.warn(LogCategories.MATCHING, LogEvents.MOCK_NO_MATCH, logContext, {
177
+ url: context.url,
178
+ method: context.method,
179
+ candidateCount: 0,
180
+ });
110
181
  return {
111
182
  success: false,
112
- error: new ResponseSelectionError(`No mock matched for ${context.method} ${context.url}`),
183
+ error: new ScenaristError(`No mock matched for ${context.method} ${context.url}`, {
184
+ code: ErrorCodes.NO_MOCK_FOUND,
185
+ context: {
186
+ testId,
187
+ scenarioId,
188
+ requestInfo: {
189
+ method: context.method,
190
+ url: context.url,
191
+ },
192
+ hint: "Add a fallback mock (without match criteria) to handle unmatched requests, or add a mock with matching criteria.",
193
+ },
194
+ }),
113
195
  };
114
196
  },
115
197
  };
@@ -123,9 +205,11 @@ export const createResponseSelector = (options = {}) => {
123
205
  * @param mock - The mock definition
124
206
  * @param sequenceTracker - Optional sequence tracker for Phase 2
125
207
  * @param stateManager - Optional state manager for stateResponse
208
+ * @param logger - Logger for debugging
126
209
  * @returns ScenaristResponse or null if mock has no response type
127
210
  */
128
- const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager) => {
211
+ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger) => {
212
+ const logContext = { testId, scenarioId };
129
213
  // Phase 2: If mock has a sequence, use sequence tracker
130
214
  if (mock.sequence) {
131
215
  if (!sequenceTracker) {
@@ -137,6 +221,7 @@ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTra
137
221
  // Get response at current position
138
222
  // Note: Exhausted sequences are skipped during matching phase,
139
223
  // so position should always be valid here
224
+ // eslint-disable-next-line security/detect-object-injection -- Position bounded by sequence tracker
140
225
  const response = mock.sequence.responses[position];
141
226
  // Advance position for next call
142
227
  const repeatMode = mock.sequence.repeat || "last";
@@ -145,7 +230,7 @@ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTra
145
230
  }
146
231
  // State-aware response: evaluate conditions against current state
147
232
  if (mock.stateResponse) {
148
- return resolveStateResponse(testId, mock.stateResponse, stateManager);
233
+ return resolveStateResponse(testId, mock.stateResponse, stateManager, logger, logContext);
149
234
  }
150
235
  // Phase 1: Single response
151
236
  if (mock.response) {
@@ -163,18 +248,36 @@ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTra
163
248
  * @param testId - Test ID for state isolation
164
249
  * @param stateResponse - The stateResponse configuration
165
250
  * @param stateManager - Optional state manager for state lookup
251
+ * @param logger - Logger for debugging
252
+ * @param logContext - Context for log messages
166
253
  * @returns The resolved response (matching condition or default)
167
254
  */
168
- const resolveStateResponse = (testId, stateResponse, stateManager) => {
255
+ const resolveStateResponse = (testId, stateResponse, stateManager, logger, logContext) => {
169
256
  // Without stateManager, always return default
170
257
  if (!stateManager) {
258
+ logger.debug(LogCategories.STATE, LogEvents.STATE_RESPONSE_RESOLVED, logContext, {
259
+ result: "default",
260
+ reason: "no_state_manager",
261
+ });
171
262
  return stateResponse.default;
172
263
  }
173
264
  // Get current state for this test
174
265
  const currentState = stateManager.getAll(testId);
175
266
  // Create resolver and evaluate conditions
176
267
  const resolver = createStateResponseResolver();
177
- return resolver.resolveResponse(stateResponse, currentState);
268
+ const response = resolver.resolveResponse(stateResponse, currentState);
269
+ // Log which response was selected and why
270
+ const isDefault = response === stateResponse.default;
271
+ const matchedCondition = isDefault
272
+ ? null
273
+ : stateResponse.conditions.find((c) => resolver.resolveResponse({ default: stateResponse.default, conditions: [c] }, currentState) !== stateResponse.default);
274
+ logger.debug(LogCategories.STATE, LogEvents.STATE_RESPONSE_RESOLVED, logContext, {
275
+ result: isDefault ? "default" : "condition",
276
+ currentState,
277
+ conditionsCount: stateResponse.conditions.length,
278
+ matchedWhen: matchedCondition?.when ?? null,
279
+ });
280
+ return response;
178
281
  };
179
282
  /**
180
283
  * Calculate specificity score for match criteria.
@@ -276,6 +379,7 @@ const matchesState = (stateCriteria, testId, stateManager) => {
276
379
  return false;
277
380
  }
278
381
  // Deep equality check for values (handles primitives, null, objects)
382
+ // eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
279
383
  if (!deepEquals(currentState[key], expectedValue)) {
280
384
  return false;
281
385
  }
@@ -296,6 +400,7 @@ const matchesBody = (requestBody, criteriaBody) => {
296
400
  const body = requestBody;
297
401
  // Check all required fields exist in request body with matching values
298
402
  for (const [key, criteriaValue] of Object.entries(criteriaBody)) {
403
+ // eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
299
404
  const requestValue = body[key];
300
405
  // Convert to string for matching (type coercion like headers/query)
301
406
  const stringValue = requestValue == null ? "" : String(requestValue);
@@ -378,6 +483,7 @@ const matchesHeaders = (requestHeaders, criteriaHeaders) => {
378
483
  const normalizedRequest = createNormalizedHeaderMap(requestHeaders);
379
484
  for (const [key, value] of Object.entries(criteriaHeaders)) {
380
485
  const normalizedKey = normalizeHeaderName(key);
486
+ // eslint-disable-next-line security/detect-object-injection -- Key normalized from Object.entries iteration
381
487
  const requestValue = normalizedRequest[normalizedKey];
382
488
  if (!requestValue || !matchesValue(requestValue, value)) {
383
489
  return false;
@@ -392,6 +498,7 @@ const matchesHeaders = (requestHeaders, criteriaHeaders) => {
392
498
  const matchesQuery = (requestQuery, criteriaQuery) => {
393
499
  // Check all required query params exist with exact matching values
394
500
  for (const [key, value] of Object.entries(criteriaQuery)) {
501
+ // eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
395
502
  const requestValue = requestQuery[key];
396
503
  if (!requestValue || !matchesValue(requestValue, value)) {
397
504
  return false;
@@ -1,4 +1,4 @@
1
- import type { ScenarioManager, ScenarioRegistry, ScenarioStore, SequenceTracker, StateManager } from "../ports/index.js";
1
+ import type { ScenarioManager, ScenarioRegistry, ScenarioStore, SequenceTracker, StateManager, Logger } from "../ports/index.js";
2
2
  /**
3
3
  * Factory function to create a ScenarioManager implementation.
4
4
  *
@@ -8,13 +8,15 @@ import type { ScenarioManager, ScenarioRegistry, ScenarioStore, SequenceTracker,
8
8
  * - Any registry implementation (in-memory, Redis, files, remote)
9
9
  * - Any store implementation (in-memory, Redis, database)
10
10
  * - Any state manager implementation (in-memory, Redis, database)
11
+ * - Any logger implementation (console, file, remote)
11
12
  * - Proper testing with mock dependencies
12
13
  * - True hexagonal architecture
13
14
  */
14
- export declare const createScenarioManager: ({ registry, store, stateManager, sequenceTracker, }: {
15
+ export declare const createScenarioManager: ({ registry, store, stateManager, sequenceTracker, logger, }: {
15
16
  registry: ScenarioRegistry;
16
17
  store: ScenarioStore;
17
18
  stateManager?: StateManager;
18
19
  sequenceTracker?: SequenceTracker;
20
+ logger?: Logger;
19
21
  }) => ScenarioManager;
20
22
  //# sourceMappingURL=scenario-manager.d.ts.map
@@ -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,EACb,MAAM,mBAAmB,CAAC;AAkC3B;;;;;;;;;;;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"}
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,eAwHH,CAAC"}
@@ -1,24 +1,25 @@
1
+ import { noOpLogger } from "../adapters/index.js";
1
2
  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
- }
3
+ import { ScenaristError, ErrorCodes } from "../types/errors.js";
4
+ import { LogCategories, LogEvents } from "./log-events.js";
5
+ const createDuplicateScenarioError = (scenarioId) => {
6
+ return new ScenaristError(`Scenario '${scenarioId}' is already registered. Each scenario must have a unique ID.`, {
7
+ code: ErrorCodes.DUPLICATE_SCENARIO,
8
+ context: {
9
+ scenarioId,
10
+ hint: "Use a different scenario ID, or remove the existing scenario before registering a new one.",
11
+ },
12
+ });
13
+ };
14
+ const createScenarioValidationError = (scenarioId, validationErrors) => {
15
+ return new ScenaristError(`Invalid scenario definition for '${scenarioId}': ${validationErrors.join(", ")}`, {
16
+ code: ErrorCodes.VALIDATION_ERROR,
17
+ context: {
18
+ scenarioId,
19
+ hint: `Check your scenario definition. Validation errors: ${validationErrors.join("; ")}`,
20
+ },
21
+ });
22
+ };
22
23
  /**
23
24
  * Factory function to create a ScenarioManager implementation.
24
25
  *
@@ -28,10 +29,11 @@ class ScenarioValidationError extends Error {
28
29
  * - Any registry implementation (in-memory, Redis, files, remote)
29
30
  * - Any store implementation (in-memory, Redis, database)
30
31
  * - Any state manager implementation (in-memory, Redis, database)
32
+ * - Any logger implementation (console, file, remote)
31
33
  * - Proper testing with mock dependencies
32
34
  * - True hexagonal architecture
33
35
  */
34
- export const createScenarioManager = ({ registry, store, stateManager, sequenceTracker, }) => {
36
+ export const createScenarioManager = ({ registry, store, stateManager, sequenceTracker, logger = noOpLogger, }) => {
35
37
  return {
36
38
  registerScenario(definition) {
37
39
  // Validate scenario definition at trust boundary
@@ -39,7 +41,7 @@ export const createScenarioManager = ({ registry, store, stateManager, sequenceT
39
41
  if (!validationResult.success) {
40
42
  const errorMessages = validationResult.error.issues.map((err) => `${err.path.join(".")}: ${err.message}`);
41
43
  const scenarioId = definition?.id || "<unknown>";
42
- throw new ScenarioValidationError(`Invalid scenario definition for '${scenarioId}': ${errorMessages.join(", ")}`, errorMessages);
44
+ throw createScenarioValidationError(scenarioId, errorMessages);
43
45
  }
44
46
  const existing = registry.get(definition.id);
45
47
  // Allow re-registering the exact same scenario object (idempotent)
@@ -48,16 +50,30 @@ export const createScenarioManager = ({ registry, store, stateManager, sequenceT
48
50
  }
49
51
  // Prevent registering a different scenario with the same ID
50
52
  if (existing) {
51
- throw new DuplicateScenarioError(definition.id);
53
+ throw createDuplicateScenarioError(definition.id);
52
54
  }
53
55
  registry.register(definition);
56
+ logger.debug(LogCategories.SCENARIO, LogEvents.SCENARIO_REGISTERED, {}, {
57
+ scenarioId: definition.id,
58
+ mockCount: definition.mocks.length, // mocks is required by ScenaristScenarioSchema
59
+ });
54
60
  },
55
61
  switchScenario(testId, scenarioId) {
56
62
  const definition = registry.get(scenarioId);
57
63
  if (!definition) {
64
+ logger.error(LogCategories.SCENARIO, LogEvents.SCENARIO_NOT_FOUND, { testId }, {
65
+ requestedScenarioId: scenarioId,
66
+ });
58
67
  return {
59
68
  success: false,
60
- error: new ScenarioNotFoundError(scenarioId),
69
+ error: new ScenaristError(`Scenario '${scenarioId}' not found. Did you forget to register it?`, {
70
+ code: ErrorCodes.SCENARIO_NOT_FOUND,
71
+ context: {
72
+ testId,
73
+ scenarioId,
74
+ hint: "Make sure to register the scenario before switching to it. Use manager.registerScenario(definition) first.",
75
+ },
76
+ }),
61
77
  };
62
78
  }
63
79
  const activeScenario = {
@@ -72,6 +88,7 @@ export const createScenarioManager = ({ registry, store, stateManager, sequenceT
72
88
  if (stateManager) {
73
89
  stateManager.reset(testId);
74
90
  }
91
+ logger.info(LogCategories.SCENARIO, LogEvents.SCENARIO_SWITCHED, { testId, scenarioId }, {});
75
92
  return { success: true, data: undefined };
76
93
  },
77
94
  getActiveScenario(testId) {
@@ -82,6 +99,7 @@ export const createScenarioManager = ({ registry, store, stateManager, sequenceT
82
99
  },
83
100
  clearScenario(testId) {
84
101
  store.delete(testId);
102
+ logger.debug(LogCategories.SCENARIO, LogEvents.SCENARIO_CLEARED, { testId }, {});
85
103
  },
86
104
  getScenarioById(id) {
87
105
  return registry.get(id);
@@ -32,6 +32,7 @@ const stateMatchesCondition = (state, when) => {
32
32
  if (!(key in state)) {
33
33
  return false;
34
34
  }
35
+ // eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
35
36
  const actualValue = state[key];
36
37
  if (!deepEquals(actualValue, expectedValue)) {
37
38
  return false;
@@ -1 +1 @@
1
- {"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OA8DF,CAAC"}
1
+ {"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OA+DF,CAAC"}
@@ -49,6 +49,7 @@ export const applyTemplates = (value, templateData) => {
49
49
  if (typeof value === "object" && value !== null) {
50
50
  const result = {};
51
51
  for (const [key, val] of Object.entries(value)) {
52
+ // eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
52
53
  result[key] = applyTemplates(val, normalizedData);
53
54
  }
54
55
  return result;
@@ -67,6 +68,7 @@ export const applyTemplates = (value, templateData) => {
67
68
  */
68
69
  const resolveTemplatePath = (templateData, prefix, path) => {
69
70
  // Get the root object (state or params)
71
+ // eslint-disable-next-line security/detect-object-injection -- Prefix validated as 'state' or 'params' by regex
70
72
  const root = templateData[prefix];
71
73
  // Guard: Prefix doesn't exist (e.g., no params provided)
72
74
  if (root === undefined || typeof root !== "object" || root === null) {
@@ -86,6 +88,7 @@ const resolveTemplatePath = (templateData, prefix, path) => {
86
88
  }
87
89
  // Traverse object
88
90
  const record = current;
91
+ // eslint-disable-next-line security/detect-object-injection -- Segment from split() iteration
89
92
  current = record[segment];
90
93
  // Guard: Return undefined if property doesn't exist
91
94
  if (current === undefined) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type * from "./types/index.js";
2
+ export { ScenaristError, ErrorCodes } from "./types/errors.js";
2
3
  export * from "./schemas/index.js";
3
4
  export * from "./constants/index.js";
4
5
  export type * from "./ports/index.js";
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,kBAAkB,CAAC;AAGtC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAG/D,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 CHANGED
@@ -1,3 +1,5 @@
1
+ // Error classes and constants (runtime values, not just types)
2
+ export { ScenaristError, ErrorCodes } from "./types/errors.js";
1
3
  // Schemas (runtime validation)
2
4
  export * from "./schemas/index.js";
3
5
  // Constants
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Log levels ordered by verbosity (ascending).
3
+ * Each level includes all less verbose levels.
4
+ *
5
+ * - silent: No output (production default)
6
+ * - error: Critical failures preventing operation
7
+ * - warn: Potential issues that may cause problems
8
+ * - info: Operation flow and key events
9
+ * - debug: Detailed decision logic
10
+ * - trace: Request/response bodies, verbose details
11
+ */
12
+ export type LogLevel = "silent" | "error" | "warn" | "info" | "debug" | "trace";
13
+ /**
14
+ * Log categories for filtering specific areas of concern.
15
+ * Users can enable/disable categories independently.
16
+ */
17
+ export type LogCategory = "lifecycle" | "scenario" | "matching" | "sequence" | "state" | "template" | "request";
18
+ /**
19
+ * Base context included in all log events.
20
+ * Enables filtering and correlation by test ID.
21
+ */
22
+ export type LogContext = {
23
+ readonly testId?: string;
24
+ readonly scenarioId?: string;
25
+ readonly requestUrl?: string;
26
+ readonly requestMethod?: string;
27
+ };
28
+ /**
29
+ * Structured log entry for capture and serialization.
30
+ */
31
+ export type LogEntry = {
32
+ readonly level: Exclude<LogLevel, "silent">;
33
+ readonly category: LogCategory;
34
+ readonly message: string;
35
+ readonly context: LogContext;
36
+ readonly data?: Record<string, unknown>;
37
+ readonly timestamp: number;
38
+ };
39
+ /**
40
+ * Logger port for structured logging.
41
+ *
42
+ * This is a driven (secondary) port - domain logic calls out to it,
43
+ * implementations are injected via dependency injection.
44
+ *
45
+ * Implementations must handle:
46
+ * - Level filtering (only emit logs at or above configured level)
47
+ * - Category filtering (optionally filter by category)
48
+ * - Output formatting (console, JSON, custom)
49
+ *
50
+ * This port enables:
51
+ * - NoOpLogger: Zero overhead when disabled (production, silent mode)
52
+ * - ConsoleLogger: Human-readable or JSON output (development)
53
+ * - TestLogger: Capture logs for assertion in tests
54
+ * - Custom loggers: User-provided implementations (Winston, Pino, etc.)
55
+ *
56
+ * All methods return void - logging is fire-and-forget.
57
+ * Implementations should never throw.
58
+ */
59
+ export interface Logger {
60
+ /**
61
+ * Log at error level - critical failures preventing operation.
62
+ *
63
+ * @param category - Area of concern for filtering
64
+ * @param message - Human-readable event description
65
+ * @param context - Request context for correlation (testId, scenarioId, etc.)
66
+ * @param data - Optional structured data for the event
67
+ */
68
+ error(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
69
+ /**
70
+ * Log at warn level - potential issues that may cause problems.
71
+ *
72
+ * @param category - Area of concern for filtering
73
+ * @param message - Human-readable event description
74
+ * @param context - Request context for correlation
75
+ * @param data - Optional structured data for the event
76
+ */
77
+ warn(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
78
+ /**
79
+ * Log at info level - operation flow and key events.
80
+ *
81
+ * @param category - Area of concern for filtering
82
+ * @param message - Human-readable event description
83
+ * @param context - Request context for correlation
84
+ * @param data - Optional structured data for the event
85
+ */
86
+ info(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
87
+ /**
88
+ * Log at debug level - detailed decision logic.
89
+ *
90
+ * @param category - Area of concern for filtering
91
+ * @param message - Human-readable event description
92
+ * @param context - Request context for correlation
93
+ * @param data - Optional structured data for the event
94
+ */
95
+ debug(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
96
+ /**
97
+ * Log at trace level - request/response bodies, verbose details.
98
+ *
99
+ * @param category - Area of concern for filtering
100
+ * @param message - Human-readable event description
101
+ * @param context - Request context for correlation
102
+ * @param data - Optional structured data for the event
103
+ */
104
+ trace(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
105
+ /**
106
+ * Check if a specific level would be logged.
107
+ * Enables conditional expensive operations before logging.
108
+ *
109
+ * @example
110
+ * if (logger.isEnabled('trace')) {
111
+ * logger.trace('request', 'body', ctx, { body: JSON.stringify(large) });
112
+ * }
113
+ *
114
+ * @param level - The log level to check
115
+ * @returns true if logging at this level is enabled
116
+ */
117
+ isEnabled(level: Exclude<LogLevel, "silent">): boolean;
118
+ }
119
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../../src/ports/driven/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;AAEhF;;;GAGG;AACH,MAAM,MAAM,WAAW,GACnB,WAAW,GACX,UAAU,GACV,UAAU,GACV,UAAU,GACV,OAAO,GACP,UAAU,GACV,SAAS,CAAC;AAEd;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG;IACrB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC5C,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC;IAC7B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,MAAM;IACrB;;;;;;;OAOG;IACH,KAAK,CACH,QAAQ,EAAE,WAAW,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,UAAU,EACnB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,IAAI,CAAC;IAER;;;;;;;OAOG;IACH,IAAI,CACF,QAAQ,EAAE,WAAW,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,UAAU,EACnB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,IAAI,CAAC;IAER;;;;;;;OAOG;IACH,IAAI,CACF,QAAQ,EAAE,WAAW,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,UAAU,EACnB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,IAAI,CAAC;IAER;;;;;;;OAOG;IACH,KAAK,CACH,QAAQ,EAAE,WAAW,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,UAAU,EACnB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,IAAI,CAAC;IAER;;;;;;;OAOG;IACH,KAAK,CACH,QAAQ,EAAE,WAAW,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,UAAU,EACnB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,IAAI,CAAC;IAER;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,EAAE,QAAQ,CAAC,GAAG,OAAO,CAAC;CACxD"}
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,8 @@
1
1
  import type { ScenaristMockWithParams, ScenaristResponse, HttpRequestContext, ScenaristResult } from "../../types/index.js";
2
+ import { ScenaristError } from "../../types/errors.js";
2
3
  /**
3
4
  * Error type for response selection failures.
5
+ * @deprecated Use ScenaristError with ErrorCodes.NO_MOCK_FOUND instead
4
6
  */
5
7
  export declare class ResponseSelectionError extends Error {
6
8
  constructor(message: string);
@@ -29,6 +31,6 @@ export interface ResponseSelector {
29
31
  * @param mocks - Candidate mocks with extracted params (already filtered by URL/method)
30
32
  * @returns ScenaristResult with selected ScenaristResponse or error if no match found
31
33
  */
32
- selectResponse(testId: string, scenarioId: string, context: HttpRequestContext, mocks: ReadonlyArray<ScenaristMockWithParams>): ScenaristResult<ScenaristResponse, ResponseSelectionError>;
34
+ selectResponse(testId: string, scenarioId: string, context: HttpRequestContext, mocks: ReadonlyArray<ScenaristMockWithParams>): ScenaristResult<ScenaristResponse, ScenaristError>;
33
35
  }
34
36
  //# sourceMappingURL=response-selector.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../../src/ports/driven/response-selector.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,uBAAuB,EACvB,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EAChB,MAAM,sBAAsB,CAAC;AAE9B;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;OAQG;IACH,cAAc,CACZ,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,kBAAkB,EAC3B,KAAK,EAAE,aAAa,CAAC,uBAAuB,CAAC,GAC5C,eAAe,CAAC,iBAAiB,EAAE,sBAAsB,CAAC,CAAC;CAC/D"}
1
+ {"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../../src/ports/driven/response-selector.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,uBAAuB,EACvB,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EAChB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;OAQG;IACH,cAAc,CACZ,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,kBAAkB,EAC3B,KAAK,EAAE,aAAa,CAAC,uBAAuB,CAAC,GAC5C,eAAe,CAAC,iBAAiB,EAAE,cAAc,CAAC,CAAC;CACvD"}
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Error type for response selection failures.
3
+ * @deprecated Use ScenaristError with ErrorCodes.NO_MOCK_FOUND instead
3
4
  */
4
5
  export class ResponseSelectionError extends Error {
5
6
  constructor(message) {
@@ -5,4 +5,5 @@ export type { RequestContext } from "./driven/request-context.js";
5
5
  export type { ResponseSelector } from "./driven/response-selector.js";
6
6
  export type { SequenceTracker, SequencePosition, } from "./driven/sequence-tracker.js";
7
7
  export type { StateManager } from "./driven/state-manager.js";
8
+ export type { Logger, LogLevel, LogCategory, LogContext, LogEntry, } from "./driven/logger.js";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ports/index.ts"],"names":[],"mappings":"AACA,YAAY,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAGrE,YAAY,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACtE,YAAY,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,YAAY,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAClE,YAAY,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACtE,YAAY,EACV,eAAe,EACf,gBAAgB,GACjB,MAAM,8BAA8B,CAAC;AACtC,YAAY,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ports/index.ts"],"names":[],"mappings":"AACA,YAAY,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAGrE,YAAY,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACtE,YAAY,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,YAAY,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAClE,YAAY,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACtE,YAAY,EACV,eAAe,EACf,gBAAgB,GACjB,MAAM,8BAA8B,CAAC;AACtC,YAAY,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAC9D,YAAY,EACV,MAAM,EACN,QAAQ,EACR,WAAW,EACX,UAAU,EACV,QAAQ,GACT,MAAM,oBAAoB,CAAC"}