@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
package/README.md CHANGED
@@ -1,45 +1,772 @@
1
1
  # @scenarist/core
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ > **Note:** This is an internal package. Users should install framework-specific adapters instead:
4
+ > - **Express:** `@scenarist/express-adapter`
5
+ > - **Next.js:** `@scenarist/nextjs-adapter`
6
+ > - **Playwright:** `@scenarist/playwright-helpers`
7
+ >
8
+ > All types are re-exported from adapters for convenience. See the [full documentation](https://scenarist.io) for usage guides.
4
9
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
10
+ The core domain logic for Scenarist - a framework-agnostic library for managing MSW mock scenarios in E2E testing environments.
6
11
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
12
+ ## What is Scenarist?
8
13
 
9
- ## Purpose
14
+ **Scenarist** enables concurrent E2E tests to run with different backend states by switching mock scenarios at runtime via test IDs. No application restarts needed, no complex per-test mocking, just simple scenario switching.
10
15
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@scenarist/core`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
16
+ **Problem it solves:**
15
17
 
16
- ## What is OIDC Trusted Publishing?
18
+ Testing multiple backend states (success, errors, loading, edge cases) traditionally requires:
19
+ - Restarting your app for each scenario
20
+ - Complex per-test MSW handler setup
21
+ - Serial test execution to avoid conflicts
22
+ - Brittle mocks duplicated across test files
17
23
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
24
+ **Scenarist's solution:**
19
25
 
20
- ## Setup Instructions
26
+ Define scenarios once, switch at runtime via HTTP calls, run tests in parallel with complete isolation.
21
27
 
22
- To properly configure OIDC trusted publishing for this package:
28
+ ```typescript
29
+ // Define once
30
+ const errorScenario = { id: 'error', mocks: [{ method: 'GET', url: '*/api/*', response: { status: 500 } }] };
23
31
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
32
+ // Switch instantly
33
+ await switchScenario('test-1', 'error'); // Test 1 sees errors
34
+ await switchScenario('test-2', 'success'); // Test 2 sees success (parallel!)
28
35
 
29
- ## DO NOT USE THIS PACKAGE
36
+ // Tests run concurrently with different backend states
37
+ ```
30
38
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
39
+ ## Why Use Scenarist?
36
40
 
37
- ## More Information
41
+ **Runtime Scenario Switching**
42
+ - Change entire backend state with one API call
43
+ - No server restarts between tests
44
+ - Instant feedback during development
38
45
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
46
+ **True Parallel Testing**
47
+ - 100+ tests run concurrently with different scenarios
48
+ - Each test ID has isolated scenario state
49
+ - No conflicts between tests
42
50
 
43
- ---
51
+ **Reusable Scenarios**
52
+ - Define scenarios once, use across all tests
53
+ - Version control your mock scenarios
54
+ - Share scenarios across teams
44
55
 
45
- **Maintained for OIDC setup purposes only**
56
+ **Framework-Agnostic Core**
57
+ - Zero framework dependencies
58
+ - Works with Express, Next.js, and any Node.js framework
59
+ - Hexagonal architecture enables custom adapters
60
+
61
+ **Type-Safe & Tested**
62
+ - TypeScript strict mode throughout
63
+ - 100% test coverage
64
+ - Immutable, declarative data structures
65
+
66
+ ## Architecture
67
+
68
+ This package contains the **hexagon** - pure TypeScript domain logic with zero framework dependencies (except MSW types).
69
+
70
+ ### Package Structure
71
+
72
+ ```
73
+ src/
74
+ ├── types/ # Data structures (use `type` with `readonly`)
75
+ ├── ports/ # Interfaces (use `interface` for behavior contracts)
76
+ ├── domain/ # Business logic implementations
77
+ └── index.ts # Public API exports
78
+ ```
79
+
80
+ ### Hexagonal Architecture Principles
81
+
82
+ **Types (Data Structures):**
83
+ - Defined with `type` keyword
84
+ - All properties are `readonly` for immutability
85
+ - Use declarative patterns (no imperative functions, closures, or hidden logic)
86
+ - Native RegExp allowed for pattern matching (ADR-0016)
87
+ - Examples: `ScenaristScenario`, `ScenaristConfig`, `ActiveScenario`, `ScenaristMock`
88
+
89
+ **Ports (Behavior Contracts):**
90
+ - Defined with `interface` keyword (domain ports) or `type` keyword (adapter contracts)
91
+ - Contracts that adapters must implement
92
+ - Domain ports: `ScenarioManager`, `ScenarioStore`, `RequestContext`
93
+ - Adapter contract: `ScenaristAdapter<TMiddleware>`, `BaseAdapterOptions`
94
+
95
+ **Domain (Implementations):**
96
+ - Pure TypeScript functions and factory patterns
97
+ - No framework dependencies
98
+ - Implements the core business logic
99
+
100
+ ## Core Capabilities
101
+
102
+ Scenarist provides 20+ powerful features for E2E testing. All capabilities are framework-agnostic and available via any adapter (Express, Next.js, etc.).
103
+
104
+ ### Request Matching (11 capabilities)
105
+
106
+ **1. Body matching (partial match)**
107
+ - Match requests based on request body fields
108
+ - Additional fields in request are ignored
109
+ - Perfect for testing different payload scenarios
110
+
111
+ **2. Header matching (case-insensitive)**
112
+ - Match requests based on header values
113
+ - Header names are case-insensitive
114
+ - Ideal for user tier testing (`x-user-tier: premium`)
115
+
116
+ **3. Query parameter matching**
117
+ - Match requests based on query string parameters
118
+ - Enables different responses for filtered requests
119
+
120
+ **4. String matching (6 modes)**
121
+ - **Plain string** (`"value"`): Exact match (backward compatible)
122
+ - **Equals** (`{ equals: "value" }`): Explicit exact match
123
+ - **Contains** (`{ contains: "substring" }`): Substring matching
124
+ - **Starts with** (`{ startsWith: "prefix" }`): Prefix matching
125
+ - **Ends with** (`{ endsWith: "suffix" }`): Suffix matching
126
+ - **Regex** (`{ regex: { source: "pattern", flags: "i" } }`): Pattern matching with ReDoS protection
127
+
128
+ **5. ReDoS protection for regex matching**
129
+ - Validates regex patterns before execution using `redos-detector`
130
+ - Prevents catastrophic backtracking attacks
131
+ - Rejects unsafe patterns at scenario registration
132
+
133
+ **6. Combined matching (all criteria together)**
134
+ - Combine body + headers + query parameters
135
+ - ALL criteria must pass for mock to apply
136
+
137
+ **7. Specificity-based selection**
138
+ - Most specific mock wins regardless of position
139
+ - Calculated score: body fields + headers + query params
140
+ - No need to carefully order your mocks
141
+ - **Tie-breaking:** For mocks with same specificity:
142
+ - Specificity > 0 (with match criteria): First match wins
143
+ - Specificity = 0 (fallback mocks): Last match wins
144
+ - Last fallback wins enables active scenario fallbacks to override default fallbacks
145
+
146
+ **8. Fallback mocks**
147
+ - Mocks without match criteria act as catch-all
148
+ - Specific mocks always take precedence
149
+ - Perfect for default responses
150
+ - When multiple fallbacks exist, last one wins (enables override pattern)
151
+
152
+ **9. Number and boolean matching**
153
+ - Automatically stringifies number/boolean criteria values
154
+ - Enables matching against numeric query params and body fields
155
+
156
+ **10. Null matching**
157
+ - `null` criteria matches empty string (`""`)
158
+ - Useful for optional fields
159
+
160
+ **11. Type coercion**
161
+ - All match values converted to strings before comparison
162
+ - Consistent behavior across headers, query params, and body
163
+
164
+ ### Case-Insensitive Header Matching (RFC 2616 Compliant)
165
+
166
+ Per [RFC 2616 Section 4.2](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2), HTTP header names are case-insensitive. Scenarist implements this standard correctly:
167
+
168
+ **Header Keys:** Case-insensitive (normalized to lowercase for matching)
169
+ **Header Values:** Case-sensitive (preserved as-is)
170
+
171
+ ```typescript
172
+ // All of these match the same mock:
173
+ {
174
+ match: {
175
+ headers: { 'x-user-tier': 'premium' }
176
+ }
177
+ }
178
+
179
+ // Request with 'X-User-Tier: premium' → ✅ Matches
180
+ // Request with 'x-user-tier: premium' → ✅ Matches
181
+ // Request with 'X-USER-TIER: premium' → ✅ Matches
182
+
183
+ // But header VALUES are case-sensitive:
184
+ // Request with 'x-user-tier: Premium' → ❌ Does NOT match (value casing differs)
185
+ ```
186
+
187
+ **Implementation Details:**
188
+ - Core's `ResponseSelector` normalizes both request headers AND criteria headers to lowercase
189
+ - Adapters pass headers as-is (no normalization required)
190
+ - Works regardless of framework (Express, Next.js, etc.)
191
+
192
+ **Why This Matters:**
193
+ - Browser and client libraries may send headers with any casing
194
+ - Tests remain portable across different HTTP clients
195
+ - Standards-compliant behavior prevents matching bugs
196
+
197
+ ### Response Sequences (4 capabilities)
198
+
199
+ **7. Single responses**
200
+ - Return same response every time
201
+ - Simplest mock definition
202
+
203
+ **8. Response sequences (ordered)**
204
+ - Return different response on each call
205
+ - Perfect for polling APIs (pending → processing → complete)
206
+
207
+ **9. Repeat modes (last, cycle, none)**
208
+ - `last`: Stay at final response forever
209
+ - `cycle`: Loop back to first response
210
+ - `none`: Mark as exhausted after last response
211
+
212
+ **10. Sequence exhaustion with fallback**
213
+ - Exhausted sequences (`repeat: none`) skip to next mock
214
+ - Enables rate limiting scenarios
215
+
216
+ ### Stateful Mocks (6 capabilities)
217
+
218
+ **11. State capture from requests**
219
+ - Extract values from request body, headers, or query
220
+ - Store in per-test-ID state
221
+
222
+ **12. State injection via templates**
223
+ - Inject captured state into responses using `{{state.X}}`
224
+ - Dynamic responses based on earlier requests
225
+
226
+ **13. Array append support**
227
+ - Syntax: `stateKey[]` appends to array
228
+ - Perfect for shopping cart scenarios
229
+
230
+ **14. Nested state paths**
231
+ - Support dot notation: `user.profile.name`
232
+ - Both capture and injection support nesting
233
+
234
+ **15. State isolation per test ID**
235
+ - Each test ID has isolated state
236
+ - Parallel tests don't interfere
237
+
238
+ **16. State reset on scenario switch**
239
+ - State cleared when switching scenarios
240
+ - Fresh state for each scenario
241
+
242
+ ### Core Features (4 capabilities)
243
+
244
+ **17. Multiple API mocking**
245
+ - Mock any number of external APIs
246
+ - Combine APIs in single scenario
247
+
248
+ **18. Automatic default scenario fallback**
249
+ - Active scenarios automatically inherit mocks from default scenario
250
+ - Default + active scenario mocks collected together
251
+ - Specificity-based selection chooses best match
252
+ - Only define what changes in active scenario - rest falls back to default
253
+ - No explicit fallback mocks needed in specialized scenarios
254
+
255
+ **19. Test ID isolation (parallel tests)**
256
+ - Run 100+ tests concurrently
257
+ - Each test ID has isolated scenario/state
258
+
259
+ **20. Scenario switching at runtime**
260
+ - Change backend state with one HTTP call
261
+ - No application restart needed
262
+
263
+ ### Additional Features
264
+
265
+ **21. Path parameters** (`/users/:id`)
266
+ **22. Wildcard URLs** (`*/api/*`)
267
+ **23. Response delays** (simulate slow networks)
268
+ **24. Custom headers** in responses
269
+ **25. Strict mode** (fail on unmocked requests)
270
+
271
+ ## Current Status
272
+
273
+ **All Core Features Implemented** ✅
274
+
275
+ - ✅ **Phase 1: Request Content Matching** - Body/headers/query matching with specificity-based selection
276
+ - ✅ **Phase 2: Response Sequences** - Ordered sequences with repeat modes (last/cycle/none)
277
+ - ✅ **Phase 3: Stateful Mocks** - State capture, injection, reset on scenario switch
278
+ - ✅ **Integration**: Used by Express, Next.js, MSW adapters
279
+ - ✅ **Total: 281 tests passing** across all packages with 100% coverage
280
+
281
+ ## Installation
282
+
283
+ This package is part of the Scenarist monorepo. Install dependencies from the root:
284
+
285
+ ```bash
286
+ pnpm install
287
+ ```
288
+
289
+ ## Development
290
+
291
+ ```bash
292
+ # Build the package
293
+ pnpm build
294
+
295
+ # Run tests
296
+ pnpm test
297
+
298
+ # Run tests in watch mode
299
+ pnpm test:watch
300
+
301
+ # Type check
302
+ pnpm typecheck
303
+ ```
304
+
305
+ ## Usage
306
+
307
+ ### Basic Setup
308
+
309
+ ```typescript
310
+ import {
311
+ createScenarioManager,
312
+ InMemoryScenarioRegistry,
313
+ InMemoryScenarioStore,
314
+ buildConfig,
315
+ type ScenaristScenario,
316
+ } from '@scenarist/core';
317
+
318
+ // 1. Define scenarios (declarative patterns)
319
+ const defaultScenario: ScenaristScenario = {
320
+ id: 'default',
321
+ name: 'Default Scenario',
322
+ description: 'Baseline mocks for all APIs',
323
+ mocks: [
324
+ {
325
+ method: 'GET',
326
+ url: 'https://api.example.com/users',
327
+ response: {
328
+ status: 200,
329
+ body: { users: [] },
330
+ },
331
+ },
332
+ ],
333
+ };
334
+
335
+ const happyPathScenario: ScenaristScenario = {
336
+ id: 'happy-path',
337
+ name: 'Happy Path',
338
+ description: 'All API calls succeed',
339
+ devToolEnabled: true,
340
+ mocks: [
341
+ {
342
+ method: 'GET',
343
+ url: 'https://api.example.com/users',
344
+ response: {
345
+ status: 200,
346
+ body: { users: [{ id: 1, name: 'Test User' }] },
347
+ },
348
+ },
349
+ ],
350
+ };
351
+
352
+ // 2. Build configuration (declarative plain data)
353
+ const config = buildConfig({
354
+ enabled: process.env.NODE_ENV !== 'production', // Evaluated first!
355
+ defaultScenario: defaultScenario, // REQUIRED - fallback for unmocked requests
356
+ strictMode: false, // true = error on unmocked requests, false = passthrough
357
+ headers: {
358
+ testId: 'x-scenarist-test-id',
359
+ },
360
+ });
361
+
362
+ // 3. Create adapters (dependency injection)
363
+ const registry = new InMemoryScenarioRegistry();
364
+ const store = new InMemoryScenarioStore();
365
+
366
+ // 4. Create scenario manager
367
+ const manager = createScenarioManager({ registry, store });
368
+
369
+ // 5. Register scenarios
370
+ manager.registerScenario(defaultScenario); // Must register default
371
+ manager.registerScenario(happyPathScenario);
372
+
373
+ // 6. Switch to a scenario
374
+ const result = manager.switchScenario('test-123', 'happy-path');
375
+
376
+ if (result.success) {
377
+ console.log('Scenario activated!');
378
+ }
379
+ ```
380
+
381
+ ### String Matching Strategies
382
+
383
+ Scenarist supports 5 matching strategies for headers, query params, and body fields:
384
+
385
+ ```typescript
386
+ const scenario: ScenaristScenario = {
387
+ id: 'string-matching-examples',
388
+ mocks: [
389
+ // 1. Exact match (default)
390
+ {
391
+ method: 'GET',
392
+ url: '/api/products',
393
+ match: {
394
+ headers: {
395
+ 'x-user-tier': 'premium', // Must match exactly
396
+ },
397
+ },
398
+ response: { status: 200, body: { products: [] } },
399
+ },
400
+
401
+ // 2. Explicit exact match (same as above)
402
+ {
403
+ method: 'GET',
404
+ url: '/api/products',
405
+ match: {
406
+ headers: {
407
+ 'x-user-tier': { equals: 'premium' },
408
+ },
409
+ },
410
+ response: { status: 200, body: { products: [] } },
411
+ },
412
+
413
+ // 3. Contains (substring match)
414
+ {
415
+ method: 'GET',
416
+ url: '/api/products',
417
+ match: {
418
+ headers: {
419
+ 'x-campaign': { contains: 'summer' }, // Matches 'summer-sale', 'mega-summer-event', etc.
420
+ },
421
+ },
422
+ response: { status: 200, body: { campaign: 'summer' } },
423
+ },
424
+
425
+ // 4. Starts with (prefix match)
426
+ {
427
+ method: 'GET',
428
+ url: '/api/keys',
429
+ match: {
430
+ headers: {
431
+ 'x-api-key': { startsWith: 'sk_' }, // Matches 'sk_test_123', 'sk_live_456', etc.
432
+ },
433
+ },
434
+ response: { status: 200, body: { valid: true } },
435
+ },
436
+
437
+ // 5. Ends with (suffix match)
438
+ {
439
+ method: 'GET',
440
+ url: '/api/users',
441
+ match: {
442
+ query: {
443
+ email: { endsWith: '@company.com' }, // Matches 'john@company.com', 'admin@company.com', etc.
444
+ },
445
+ },
446
+ response: { status: 200, body: { users: [] } },
447
+ },
448
+
449
+ // 6. Regex (pattern match with ReDoS protection)
450
+ {
451
+ method: 'GET',
452
+ url: '/api/products',
453
+ match: {
454
+ headers: {
455
+ referer: {
456
+ regex: {
457
+ source: '/premium|/vip', // Matches any referer containing '/premium' or '/vip'
458
+ flags: 'i', // Case-insensitive
459
+ },
460
+ },
461
+ },
462
+ },
463
+ response: { status: 200, body: { tier: 'premium' } },
464
+ },
465
+ ],
466
+ };
467
+ ```
468
+
469
+ ### Native RegExp Support
470
+
471
+ You can use native JavaScript RegExp objects directly instead of the serialized form:
472
+
473
+ ```typescript
474
+ const scenario: ScenaristScenario = {
475
+ id: 'native-regex-examples',
476
+ mocks: [
477
+ // Native RegExp in URL matching
478
+ {
479
+ method: 'GET',
480
+ url: /\/api\/v\d+\/products/, // Matches /api/v1/products, /api/v2/products, etc.
481
+ response: { status: 200, body: { products: [] } },
482
+ },
483
+
484
+ // Native RegExp in header matching
485
+ {
486
+ method: 'GET',
487
+ url: '/api/products',
488
+ match: {
489
+ headers: {
490
+ referer: /\/premium|\/vip/i, // Case-insensitive pattern
491
+ },
492
+ },
493
+ response: { status: 200, body: { tier: 'premium' } },
494
+ },
495
+
496
+ // Both forms are equivalent and have same ReDoS protection
497
+ {
498
+ method: 'GET',
499
+ url: '/api/data',
500
+ match: {
501
+ query: {
502
+ filter: /^\w+$/, // Native RegExp
503
+ // Same as: { regex: { source: '^\\w+$', flags: '' } }
504
+ },
505
+ },
506
+ response: { status: 200, body: { data: [] } },
507
+ },
508
+ ],
509
+ };
510
+ ```
511
+
512
+ **Both serialized and native RegExp patterns receive the same security validation.**
513
+
514
+ ### URL Pattern Matching Rules
515
+
516
+ Scenarist supports three pattern types with different hostname matching behaviors:
517
+
518
+ **1. Pathname-only patterns** (origin-agnostic)
519
+ ```typescript
520
+ { url: '/api/users/:id' }
521
+ ```
522
+ - **Matches ANY hostname** - environment-agnostic
523
+ - Best for mocks that should work across localhost, staging, production
524
+ - Example: `/api/users/123` matches requests to:
525
+ - `http://localhost:3000/api/users/123` ✅
526
+ - `https://staging.example.com/api/users/123` ✅
527
+ - `https://api.production.com/api/users/123` ✅
528
+
529
+ **2. Full URL patterns** (hostname-specific)
530
+ ```typescript
531
+ { url: 'http://api.example.com/users/:id' }
532
+ ```
533
+ - **Matches ONLY the specified hostname** - environment-specific
534
+ - Best for mocks that should only apply to specific domains
535
+ - Example: `http://api.example.com/users/:id` matches:
536
+ - `http://api.example.com/users/123` ✅
537
+ - `http://localhost:3000/users/123` ❌ (different hostname)
538
+ - `https://api.example.com/users/123` ❌ (different protocol)
539
+
540
+ **3. Native RegExp patterns** (origin-agnostic)
541
+ ```typescript
542
+ { url: /\/users\/\d+/ }
543
+ ```
544
+ - **Matches ANY hostname** - substring matching (MSW weak comparison)
545
+ - Best for flexible pattern matching across environments
546
+ - Example: `/\/users\/\d+/` matches:
547
+ - `http://localhost:3000/users/123` ✅
548
+ - `https://api.example.com/api/v1/users/456` ✅
549
+ - Any URL containing `/users/` followed by digits ✅
550
+
551
+ **Choosing the right pattern type:**
552
+
553
+ ```typescript
554
+ // ✅ Use pathname patterns for environment-agnostic mocks
555
+ const defaultScenario = {
556
+ mocks: [
557
+ {
558
+ url: '/api/products', // Works in dev, staging, prod
559
+ response: { status: 200, body: { products: [] } }
560
+ }
561
+ ]
562
+ };
563
+
564
+ // ✅ Use full URL patterns when hostname matters
565
+ const productionOnlyScenario = {
566
+ mocks: [
567
+ {
568
+ url: 'https://api.production.com/admin', // Only matches production
569
+ response: { status: 403, body: { error: 'Admin disabled in prod' } }
570
+ }
571
+ ]
572
+ };
573
+
574
+ // ✅ Use RegExp for flexible pattern matching
575
+ const versionAgnosticScenario = {
576
+ mocks: [
577
+ {
578
+ url: /\/api\/v\d+\/users/, // Matches /api/v1/users, /api/v2/users, etc.
579
+ response: { status: 200, body: { users: [] } }
580
+ }
581
+ ]
582
+ };
583
+ ```
584
+
585
+ **IMPORTANT:** If you specify a hostname explicitly, it WILL be matched. Choose pathname patterns for flexibility, full URL patterns for control.
586
+
587
+ ### Common URL Pattern Examples
588
+
589
+ Here are helpful regex patterns for common use cases:
590
+
591
+ ```typescript
592
+ // API versioning - match any version number
593
+ { url: /\/api\/v\d+\// }
594
+ // Matches: /api/v1/, /api/v2/, /api/v10/, etc.
595
+
596
+ // Numeric IDs only (reject non-numeric)
597
+ { url: /\/users\/\d+$/ }
598
+ // Matches: /users/123 ✅
599
+ // Rejects: /users/abc ❌
600
+
601
+ // File extensions
602
+ { url: /\.json$/ }
603
+ // Matches: /data.json, /api/users.json ✅
604
+ // Rejects: /data.xml, /users ❌
605
+
606
+ // Optional trailing slash
607
+ { url: /\/products\/?$/ }
608
+ // Matches: /products ✅ and /products/ ✅
609
+
610
+ // Multiple extensions
611
+ { url: /\.(jpg|png|gif)$/i }
612
+ // Matches: image.jpg, photo.PNG, avatar.gif ✅
613
+
614
+ // UUID format (simplified)
615
+ { url: /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i }
616
+ // Matches: /550e8400-e29b-41d4-a716-446655440000 ✅
617
+
618
+ // Subdomain matching
619
+ {
620
+ match: {
621
+ headers: {
622
+ host: /^(api|cdn)\.example\.com$/
623
+ }
624
+ }
625
+ }
626
+ // Matches: api.example.com, cdn.example.com ✅
627
+ ```
628
+
629
+ ### Security: ReDoS Protection
630
+
631
+ ⚠️ **IMPORTANT**: Both serialized and native RegExp patterns are validated using `redos-detector` to prevent ReDoS (Regular Expression Denial of Service) attacks.
632
+
633
+ **Unsafe patterns are automatically rejected at scenario registration:**
634
+
635
+ ```typescript
636
+ // ❌ REJECTED - Catastrophic backtracking
637
+ { url: /(a+)+b/ }
638
+ // Error: Unsafe regex pattern detected
639
+
640
+ // ❌ REJECTED - Exponential time complexity
641
+ { match: { headers: { referer: { regex: { source: '(x+x+)+y' } } } } }
642
+ // Error: Unsafe regex pattern detected
643
+
644
+ // ✅ SAFE - Linear time complexity
645
+ { url: /\/api\/[^/]+\/users/ }
646
+ // Matches safely with bounded backtracking
647
+ ```
648
+
649
+ Scenarist validates patterns before execution to protect your tests from denial-of-service attacks caused by malicious or poorly designed regex patterns.
650
+
651
+ ### Key Principles
652
+
653
+ - **Declarative Patterns**: All types use explicit patterns, no imperative functions (ADR-0016)
654
+ - **Dependency Injection**: Ports are injected, never created internally
655
+ - **Immutability**: All data structures use `readonly`
656
+ - **Factory Pattern**: Use `createScenarioManager()`, not classes
657
+ - **Side Benefit**: Most scenarios CAN be JSON-serializable (when not using native RegExp)
658
+
659
+ ## Adapter Contract
660
+
661
+ The core package defines a **universal adapter contract** that all framework adapters (Express, Next.js, etc.) must implement. This ensures consistent API across all frameworks.
662
+
663
+ ### BaseAdapterOptions
664
+
665
+ All adapters must accept these base options:
666
+
667
+ ```typescript
668
+ type BaseAdapterOptions<T extends ScenaristScenarios> = {
669
+ readonly enabled: boolean;
670
+ readonly scenarios: T; // REQUIRED - scenarios object (must have 'default' key)
671
+ readonly strictMode?: boolean;
672
+ readonly headers?: {
673
+ readonly testId?: string;
674
+ };
675
+ readonly endpoints?: {
676
+ readonly setScenario?: string;
677
+ readonly getScenario?: string;
678
+ };
679
+ readonly defaultTestId?: string;
680
+ readonly registry?: ScenarioRegistry;
681
+ readonly store?: ScenarioStore;
682
+ readonly stateManager?: StateManager;
683
+ readonly sequenceTracker?: SequenceTracker;
684
+ };
685
+ ```
686
+
687
+ **IMPORTANT:** The `scenarios` object must include a `'default'` key. This is enforced at runtime via Zod validation in `buildConfig()`. The 'default' scenario serves as the baseline when no specific scenario is active.
688
+
689
+ Adapters can extend this with framework-specific options:
690
+
691
+ ```typescript
692
+ // Express adapter
693
+ type ExpressAdapterOptions<T extends ScenaristScenarios> = BaseAdapterOptions<T> & {
694
+ // Add Express-specific options if needed
695
+ };
696
+
697
+ // Next.js adapter
698
+ type NextJSAdapterOptions<T extends ScenaristScenarios> = BaseAdapterOptions<T> & {
699
+ // Add Next.js-specific options if needed
700
+ };
701
+ ```
702
+
703
+ ### ScenaristAdapter<T, TMiddleware>
704
+
705
+ All adapters must return an object matching this contract:
706
+
707
+ ```typescript
708
+ type ScenaristAdapter<T extends ScenaristScenarios, TMiddleware = unknown> = {
709
+ readonly config: ScenaristConfig; // Resolved configuration
710
+ readonly middleware?: TMiddleware; // Framework-specific middleware (optional - Next.js doesn't have global middleware)
711
+ readonly switchScenario: (testId: string, scenarioId: ScenarioIds<T>, variant?: string) => ScenaristResult<void, Error>;
712
+ readonly getActiveScenario: (testId: string) => ActiveScenario | undefined;
713
+ readonly getScenarioById: (scenarioId: ScenarioIds<T>) => ScenaristScenario | undefined;
714
+ readonly listScenarios: () => ReadonlyArray<ScenaristScenario>;
715
+ readonly clearScenario: (testId: string) => void;
716
+ readonly start: () => void;
717
+ readonly stop: () => Promise<void>;
718
+ };
719
+ ```
720
+
721
+ The generic parameters:
722
+ - `T extends ScenaristScenarios`: The scenarios object type for type-safe scenario IDs
723
+ - `TMiddleware`: Framework-specific middleware type
724
+
725
+ Examples:
726
+ - Express: `ScenaristAdapter<typeof scenarios, Router>`
727
+ - Next.js: `ScenaristAdapter<typeof scenarios, never>` (no global middleware)
728
+
729
+ ### Implementation Example
730
+
731
+ ```typescript
732
+ // Express adapter implementation
733
+ export const createScenarist = <T extends ScenaristScenarios>(
734
+ options: ExpressAdapterOptions<T>
735
+ ): ScenaristAdapter<T, Router> => {
736
+ // Implementation automatically wires:
737
+ // - MSW server with dynamic handler
738
+ // - Test ID middleware
739
+ // - Scenario endpoints
740
+ // - Scenario manager
741
+ // - Registers all scenarios from scenarios object
742
+
743
+ // Register all scenarios upfront
744
+ Object.values(options.scenarios).forEach((scenario) => {
745
+ manager.registerScenario(scenario);
746
+ });
747
+
748
+ return {
749
+ config: resolvedConfig,
750
+ middleware: router, // Express Router
751
+ switchScenario: (id, scenario, variant) => manager.switchScenario(id, scenario, variant),
752
+ // ... all other required methods
753
+ start: () => server.listen(),
754
+ stop: async () => server.close(),
755
+ };
756
+ };
757
+ ```
758
+
759
+ ### Benefits
760
+
761
+ 1. **TypeScript Enforcement**: Adapters must implement the full contract
762
+ 2. **Consistent API**: All adapters work the same way from user perspective
763
+ 3. **Framework Flexibility**: Each adapter can add framework-specific features
764
+ 4. **Future-Proof**: New frameworks can be added with guaranteed API consistency
765
+
766
+ ## Contributing
767
+
768
+ See [CONTRIBUTING.md](../../CONTRIBUTING.md) for development setup and guidelines.
769
+
770
+ ## License
771
+
772
+ MIT