@scenarist/core 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +755 -28
- package/dist/adapters/in-memory-registry.d.ts +18 -0
- package/dist/adapters/in-memory-registry.d.ts.map +1 -0
- package/dist/adapters/in-memory-registry.js +25 -0
- package/dist/adapters/in-memory-sequence-tracker.d.ts +28 -0
- package/dist/adapters/in-memory-sequence-tracker.d.ts.map +1 -0
- package/dist/adapters/in-memory-sequence-tracker.js +82 -0
- package/dist/adapters/in-memory-state-manager.d.ts +24 -0
- package/dist/adapters/in-memory-state-manager.d.ts.map +1 -0
- package/dist/adapters/in-memory-state-manager.js +81 -0
- package/dist/adapters/in-memory-store.d.ts +18 -0
- package/dist/adapters/in-memory-store.d.ts.map +1 -0
- package/dist/adapters/in-memory-store.js +25 -0
- package/dist/adapters/index.d.ts +5 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +4 -0
- package/dist/constants/headers.d.ts +10 -0
- package/dist/constants/headers.d.ts.map +1 -0
- package/dist/constants/headers.js +9 -0
- package/dist/constants/index.d.ts +2 -0
- package/dist/constants/index.d.ts.map +1 -0
- package/dist/constants/index.js +1 -0
- package/dist/contracts/framework-adapter.d.ts +118 -0
- package/dist/contracts/framework-adapter.d.ts.map +1 -0
- package/dist/contracts/framework-adapter.js +1 -0
- package/dist/contracts/index.d.ts +2 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/domain/config-builder.d.ts +9 -0
- package/dist/domain/config-builder.d.ts.map +1 -0
- package/dist/domain/config-builder.js +20 -0
- package/dist/domain/index.d.ts +5 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +4 -0
- package/dist/domain/path-extraction.d.ts +17 -0
- package/dist/domain/path-extraction.d.ts.map +1 -0
- package/dist/domain/path-extraction.js +60 -0
- package/dist/domain/regex-matching.d.ts +20 -0
- package/dist/domain/regex-matching.d.ts.map +1 -0
- package/dist/domain/regex-matching.js +27 -0
- package/dist/domain/response-selector.d.ts +22 -0
- package/dist/domain/response-selector.d.ts.map +1 -0
- package/dist/domain/response-selector.js +337 -0
- package/dist/domain/scenario-manager.d.ts +20 -0
- package/dist/domain/scenario-manager.d.ts.map +1 -0
- package/dist/domain/scenario-manager.js +90 -0
- package/dist/domain/template-replacement.d.ts +11 -0
- package/dist/domain/template-replacement.d.ts.map +1 -0
- package/dist/domain/template-replacement.js +94 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/ports/driven/request-context.d.ts +43 -0
- package/dist/ports/driven/request-context.d.ts.map +1 -0
- package/dist/ports/driven/request-context.js +1 -0
- package/dist/ports/driven/response-selector.d.ts +34 -0
- package/dist/ports/driven/response-selector.d.ts.map +1 -0
- package/dist/ports/driven/response-selector.js +9 -0
- package/dist/ports/driven/scenario-registry.d.ts +46 -0
- package/dist/ports/driven/scenario-registry.d.ts.map +1 -0
- package/dist/ports/driven/scenario-registry.js +1 -0
- package/dist/ports/driven/scenario-store.d.ts +33 -0
- package/dist/ports/driven/scenario-store.d.ts.map +1 -0
- package/dist/ports/driven/scenario-store.js +1 -0
- package/dist/ports/driven/sequence-tracker.d.ts +49 -0
- package/dist/ports/driven/sequence-tracker.d.ts.map +1 -0
- package/dist/ports/driven/sequence-tracker.js +1 -0
- package/dist/ports/driven/state-manager.d.ts +56 -0
- package/dist/ports/driven/state-manager.d.ts.map +1 -0
- package/dist/ports/driven/state-manager.js +1 -0
- package/dist/ports/driving/scenario-manager.d.ts +99 -0
- package/dist/ports/driving/scenario-manager.d.ts.map +1 -0
- package/dist/ports/driving/scenario-manager.js +1 -0
- package/dist/ports/index.d.ts +8 -0
- package/dist/ports/index.d.ts.map +1 -0
- package/dist/ports/index.js +1 -0
- package/dist/schemas/index.d.ts +18 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +17 -0
- package/dist/schemas/match-criteria.d.ts +27 -0
- package/dist/schemas/match-criteria.d.ts.map +1 -0
- package/dist/schemas/match-criteria.js +71 -0
- package/dist/schemas/scenario-definition.d.ts +276 -0
- package/dist/schemas/scenario-definition.d.ts.map +1 -0
- package/dist/schemas/scenario-definition.js +78 -0
- package/dist/schemas/scenario-requests.d.ts +33 -0
- package/dist/schemas/scenario-requests.d.ts.map +1 -0
- package/dist/schemas/scenario-requests.js +29 -0
- package/dist/schemas/scenarios-object.d.ts +91 -0
- package/dist/schemas/scenarios-object.d.ts.map +1 -0
- package/dist/schemas/scenarios-object.js +17 -0
- package/dist/types/config.d.ts +70 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/scenario.d.ts +141 -0
- package/dist/types/scenario.d.ts.map +1 -0
- package/dist/types/scenario.js +1 -0
- package/package.json +67 -7
package/README.md
CHANGED
|
@@ -1,45 +1,772 @@
|
|
|
1
1
|
# @scenarist/core
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
10
|
+
The core domain logic for Scenarist - a framework-agnostic library for managing MSW mock scenarios in E2E testing environments.
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
## What is Scenarist?
|
|
8
13
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
**Scenarist's solution:**
|
|
19
25
|
|
|
20
|
-
|
|
26
|
+
Define scenarios once, switch at runtime via HTTP calls, run tests in parallel with complete isolation.
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
```typescript
|
|
29
|
+
// Define once
|
|
30
|
+
const errorScenario = { id: 'error', mocks: [{ method: 'GET', url: '*/api/*', response: { status: 500 } }] };
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
36
|
+
// Tests run concurrently with different backend states
|
|
37
|
+
```
|
|
30
38
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
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
|
-
**
|
|
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
|