@scenarist/msw-adapter 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Paul Hammond (citypaul)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,45 +1,320 @@
1
1
  # @scenarist/msw-adapter
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ **⚠️ INTERNAL PACKAGE - DO NOT INSTALL DIRECTLY**
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ This package is an internal implementation detail of Scenarist and is **NOT published to npm**. It exists as a workspace dependency used by framework adapters.
6
6
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
7
+ ## Why is this package private?
8
8
 
9
- ## Purpose
9
+ **You should install a framework adapter instead:**
10
+ - ✅ `@scenarist/express-adapter` - For Express applications
11
+ - ✅ `@scenarist/nextjs-adapter` - For Next.js applications
12
+ - ✅ `@scenarist/core` - For building custom adapters
10
13
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@scenarist/msw-adapter`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
14
+ **This package is private because:**
15
15
 
16
- ## What is OIDC Trusted Publishing?
16
+ 1. **Not user-facing** - Users never import from `@scenarist/msw-adapter` directly
17
+ 2. **Implementation detail** - Provides MSW integration layer used internally by framework adapters
18
+ 3. **Prevents confusion** - Publishing would mislead users into thinking they should install it
19
+ 4. **Version coupling** - Framework adapters control which msw-adapter version they use
20
+ 5. **Simpler maintenance** - Internal packages don't need semver/breaking change coordination
21
+ 6. **Cleaner API surface** - Framework adapters ARE the public API, msw-adapter is hidden complexity
17
22
 
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.
23
+ **Dependency structure:**
24
+ ```
25
+ Your App
26
+ ├─ @scenarist/express-adapter (public, install this!)
27
+ │ └─ @scenarist/msw-adapter (private, workspace:*)
28
+ │ └─ @scenarist/core (public)
29
+
30
+ └─ @scenarist/nextjs-adapter (public, install this!)
31
+ └─ @scenarist/msw-adapter (private, workspace:*)
32
+ └─ @scenarist/core (public)
33
+ ```
19
34
 
20
- ## Setup Instructions
35
+ ---
36
+
37
+ ## What is Scenarist?
21
38
 
22
- To properly configure OIDC trusted publishing for this package:
39
+ **Scenarist** enables concurrent E2E tests to run with different backend states by switching mock scenarios at runtime via test IDs. This package provides the MSW integration layer that makes it all work.
23
40
 
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
41
+ **The big picture:**
28
42
 
29
- ## DO NOT USE THIS PACKAGE
43
+ ```
44
+ Your App → Scenarist Adapter (Express/Next.js) → MSW Adapter → MSW → Intercepted HTTP
45
+ ```
30
46
 
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
47
+ **What this package does:**
36
48
 
37
- ## More Information
49
+ Converts Scenarist's serializable `ScenaristMock` data into MSW `HttpHandler` instances at runtime, enabling Scenarist to work with any Node.js framework.
38
50
 
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)
51
+ **Status:** Stable - Used internally by all Scenarist framework adapters
42
52
 
43
53
  ---
44
54
 
45
- **Maintained for OIDC setup purposes only**
55
+ ## For Framework Adapter Developers
56
+
57
+ If you're building a custom framework adapter for Scenarist, this package provides the MSW integration you need. Otherwise, you should use one of the existing public adapters listed above.
58
+
59
+ ## What does this package provide?
60
+
61
+ The MSW adapter is the bridge between Scenarist's serializable mock definitions and MSW's HTTP interception. It provides:
62
+
63
+ 1. **URL Matching** - Converts URL patterns (with wildcards and path params) into MSW-compatible matchers
64
+ 2. **Mock Matching** - Finds the right mock definition for incoming requests (with request content matching)
65
+ 3. **Response Building** - Transforms `ScenaristMock` into MSW `HttpResponse`
66
+ 4. **Dynamic Handler** - Creates a single MSW handler that routes based on active scenarios
67
+
68
+ ## Core Capabilities
69
+
70
+ This adapter implements all 25 Scenarist capabilities:
71
+
72
+ ### Request Matching (6 capabilities)
73
+ - **Body matching** (partial match) - Match on request body fields
74
+ - **Header matching** (exact, case-insensitive) - Match on header values
75
+ - **Query matching** (exact) - Match on query parameters
76
+ - **Combined matching** - Combine body + headers + query
77
+ - **Specificity-based selection** - Most specific mock wins
78
+ - **Fallback mocks** - Mocks without criteria act as catch-all
79
+
80
+ ### Response Sequences (4 capabilities)
81
+ - **Single responses** - Return same response every time
82
+ - **Response sequences** - Ordered responses for polling scenarios
83
+ - **Repeat modes** - `last`, `cycle`, `none` behaviors
84
+ - **Sequence exhaustion** - Skip exhausted sequences to fallback
85
+
86
+ ### Stateful Mocks (6 capabilities)
87
+ - **State capture** - Extract values from requests
88
+ - **State injection** - Inject state into responses via templates
89
+ - **Array append** - Syntax: `stateKey[]` for arrays
90
+ - **Nested paths** - Dot notation: `user.profile.name`
91
+ - **State isolation** - Per test ID isolation
92
+ - **State reset** - Fresh state on scenario switch
93
+
94
+ ### URL Patterns (4 capabilities)
95
+ - **Exact matches** - `https://api.example.com/users`
96
+ - **Wildcards** - `*/api/*`, `https://*/users`
97
+ - **Path parameters** - `/users/:id`, `/posts/:postId/comments/:commentId`
98
+ - **Native RegExp** - `/\/api\/v\d+\//`, `/\/users\/\d+$/` (weak comparison per MSW behavior)
99
+
100
+ ### MSW Integration (6 capabilities)
101
+ - **Dynamic handler generation** - Single handler routes at runtime
102
+ - **Response building** - Status codes, JSON bodies, headers, delays
103
+ - **Automatic default fallback** - Collects default + active scenario mocks together
104
+ - **Strict mode** - Fail on unmocked requests
105
+ - **Test ID isolation** - Separate state per test ID
106
+ - **Framework-agnostic** - Works with any Node.js framework
107
+
108
+ ## Features
109
+
110
+ - ✅ **URL pattern matching** - Exact matches, glob patterns (`*/api/*`), path parameters (`/users/:id`)
111
+ - ✅ **Request content matching** - Body, headers, query with specificity-based selection
112
+ - ✅ **Dynamic MSW handler generation** - Single handler routes to correct scenario at runtime
113
+ - ✅ **Response building** - Status codes, JSON bodies, headers, delays, state injection
114
+ - ✅ **Sequences and state** - Polling scenarios and stateful mocks fully supported
115
+ - ✅ **Automatic default fallback** - Collects default + active scenario mocks, uses specificity to select best match
116
+ - ✅ **Framework-agnostic** - Works with Express, Next.js, and any Node.js framework via HTTP-level interception
117
+
118
+ ## Internal Package
119
+
120
+ ⚠️ **This is an internal package used by framework adapters.**
121
+
122
+ You typically don't install or use this directly. Instead, use a framework-specific adapter:
123
+
124
+ - **[@scenarist/express-adapter](../../packages/express-adapter)** - For Express applications
125
+ - **[@scenarist/nextjs-adapter](../../packages/nextjs-adapter)** - For Next.js applications (App Router + Pages Router)
126
+
127
+ ## How It Works
128
+
129
+ ### 1. URL Matching
130
+
131
+ Converts string patterns into MSW-compatible URL matchers:
132
+
133
+ ```typescript
134
+ import { matchesUrl } from '@scenarist/msw-adapter';
135
+
136
+ matchesUrl('https://api.github.com/users/octocat', 'GET', 'https://api.github.com/users/:username');
137
+ // Returns: { matches: true, params: { username: 'octocat' } }
138
+
139
+ matchesUrl('https://api.stripe.com/v1/charges', 'POST', '*/v1/charges');
140
+ // Returns: { matches: true, params: {} }
141
+ ```
142
+
143
+ **Supported patterns (three types with different hostname matching):**
144
+
145
+ **1. Pathname-only patterns** (origin-agnostic - match ANY hostname)
146
+ - Path params: `/users/:id`, `/posts/:postId/comments/:commentId`
147
+ - Exact: `/api/users`
148
+ - Wildcards: `/api/*`, `*/users/*`
149
+
150
+ **2. Full URL patterns** (hostname-specific - match ONLY specified hostname)
151
+ - Exact: `https://api.example.com/users`
152
+ - Path params: `https://api.example.com/users/:id`
153
+ - Wildcards: `https://*/users`, `https://api.example.com/*`
154
+
155
+ **3. Native RegExp** (origin-agnostic - MSW weak comparison)
156
+ - `/\/api\/v\d+\//` (matches /api/v1/, /api/v2/, etc.)
157
+ - `/\/users\/\d+$/` (matches /users/123 at any origin)
158
+
159
+ **IMPORTANT:** If you specify a hostname in a full URL pattern, it WILL be matched. Choose pathname patterns for environment-agnostic mocks, full URL patterns when hostname matters.
160
+
161
+ ### 2. Mock Matching
162
+
163
+ Finds the right mock for a request:
164
+
165
+ ```typescript
166
+ import { findMatchingMock } from '@scenarist/msw-adapter';
167
+
168
+ const mocks: ScenaristMock[] = [
169
+ { method: 'GET', url: '/users/:id', response: { status: 200, body: {...} } },
170
+ { method: 'POST', url: '/users', response: { status: 201, body: {...} } },
171
+ ];
172
+
173
+ const mock = findMatchingMock(mocks, 'GET', 'https://api.example.com/users/123');
174
+ // Returns the first mock (matches /users/:id)
175
+ ```
176
+
177
+ ### 3. Response Building
178
+
179
+ Converts `ScenaristMock` to MSW `HttpResponse`:
180
+
181
+ ```typescript
182
+ import { buildResponse } from '@scenarist/msw-adapter';
183
+
184
+ const mockDef: ScenaristMock = {
185
+ method: 'GET',
186
+ url: '/api/user',
187
+ response: {
188
+ status: 200,
189
+ body: { id: '123', name: 'John' },
190
+ headers: { 'X-Custom': 'value' },
191
+ delay: 100,
192
+ },
193
+ };
194
+
195
+ const response = await buildResponse(mockDef);
196
+ // Returns MSW HttpResponse with status, body, headers, and delay
197
+ ```
198
+
199
+ ### 4. Dynamic Handler
200
+
201
+ The core integration - a single MSW handler that routes dynamically:
202
+
203
+ ```typescript
204
+ import { createDynamicHandler } from '@scenarist/msw-adapter';
205
+ import { setupServer } from 'msw/node';
206
+
207
+ const handler = createDynamicHandler({
208
+ getTestId: () => testIdStorage.getStore() ?? 'default-test',
209
+ getActiveScenario: (testId) => manager.getActiveScenario(testId),
210
+ getScenarioDefinition: (scenarioId) => manager.getScenarioById(scenarioId),
211
+ strictMode: false,
212
+ });
213
+
214
+ const server = setupServer(handler);
215
+ server.listen();
216
+ ```
217
+
218
+ **How it works:**
219
+ 1. Gets test ID from AsyncLocalStorage (or other context)
220
+ 2. Looks up active scenario for that test ID
221
+ 3. **Collects mocks from BOTH default AND active scenario** for matching URL + method
222
+ 4. Uses specificity-based selection to choose best match
223
+ 5. Active scenario mocks (with match criteria) override default mocks (no criteria)
224
+ 6. Returns mocked response or passthrough (if no match found)
225
+
226
+ **Key insight:** Default scenario mocks are ALWAYS collected first, then active scenario mocks are added. The specificity algorithm (from `ResponseSelector`) chooses the most specific match. This means specialized scenarios only need to define what changes - everything else automatically falls back to default.
227
+
228
+ ## Architecture
229
+
230
+ This package is designed to be framework-agnostic. Framework adapters (Express, Next.js, etc.) handle:
231
+
232
+ - Test ID extraction (from headers, context, etc.)
233
+ - Scenario management (switching, retrieving)
234
+ - Middleware integration
235
+
236
+ The MSW adapter handles:
237
+
238
+ - URL matching and pattern conversion
239
+ - Mock finding and selection
240
+ - Response building
241
+ - MSW handler creation
242
+
243
+ ## API Reference
244
+
245
+ ### URL Matching
246
+
247
+ ```typescript
248
+ export const matchesUrl = (
249
+ requestUrl: string,
250
+ method: string,
251
+ pattern: string
252
+ ): { matches: boolean; params: Record<string, string> };
253
+ ```
254
+
255
+ ### Mock Matching
256
+
257
+ ```typescript
258
+ export const findMatchingMock = (
259
+ mocks: ReadonlyArray<ScenaristMock>,
260
+ method: string,
261
+ url: string
262
+ ): ScenaristMock | undefined;
263
+ ```
264
+
265
+ ### Response Building
266
+
267
+ ```typescript
268
+ export const buildResponse = (
269
+ mockDef: ScenaristMock
270
+ ): Promise<HttpResponse>;
271
+ ```
272
+
273
+ ### Dynamic Handler
274
+
275
+ ```typescript
276
+ export type DynamicHandlerOptions = {
277
+ readonly getTestId: () => string;
278
+ readonly getActiveScenario: (testId: string) => ActiveScenario | undefined;
279
+ readonly getScenarioDefinition: (scenarioId: string) => ScenaristScenario | undefined;
280
+ readonly strictMode: boolean;
281
+ };
282
+
283
+ export const createDynamicHandler = (
284
+ options: DynamicHandlerOptions
285
+ ): HttpHandler;
286
+ ```
287
+
288
+ ## TypeScript
289
+
290
+ Full TypeScript support with strict mode enabled.
291
+
292
+ **Exported types:**
293
+ ```typescript
294
+ import type {
295
+ DynamicHandlerOptions,
296
+ } from '@scenarist/msw-adapter';
297
+ ```
298
+
299
+ ## Testing
300
+
301
+ This package has comprehensive test coverage:
302
+
303
+ - ✅ URL matching (exact, glob, path params)
304
+ - ✅ Mock matching (method, URL, precedence)
305
+ - ✅ Response building (status, body, headers, delays)
306
+ - ✅ Dynamic handler (scenarios, fallback, strict mode)
307
+ - ✅ **31 tests passing**
308
+
309
+ ## Contributing
310
+
311
+ See [CONTRIBUTING.md](../../CONTRIBUTING.md) for development guidelines.
312
+
313
+ ## License
314
+
315
+ MIT
316
+
317
+ ## Related Packages
318
+
319
+ - **[@scenarist/core](../core)** - Core scenario management
320
+ - **[@scenarist/express-adapter](../express-adapter)** - Express integration (uses this package)
@@ -0,0 +1,3 @@
1
+ import type { ScenaristResponse } from '@scenarist/core';
2
+ export declare const buildResponse: (response: ScenaristResponse) => Promise<Response>;
3
+ //# sourceMappingURL=response-builder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response-builder.d.ts","sourceRoot":"","sources":["../../src/conversion/response-builder.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,eAAO,MAAM,aAAa,GACxB,UAAU,iBAAiB,KAC1B,OAAO,CAAC,QAAQ,CAclB,CAAC"}
@@ -0,0 +1,15 @@
1
+ import { HttpResponse, delay } from 'msw';
2
+ export const buildResponse = async (response) => {
3
+ if (response.delay) {
4
+ await delay(response.delay);
5
+ }
6
+ // Type assertion required: MSW's HttpResponse.json() expects JsonBodyType,
7
+ // but ScenaristResponse.body is typed as 'unknown' to maintain
8
+ // framework-agnostic serialization in core package. The body is guaranteed
9
+ // to be JSON-serializable by ScenaristResponse design contract. We do not
10
+ // perform runtime validation for performance reasons (garbage in, garbage out).
11
+ return HttpResponse.json(response.body, {
12
+ status: response.status,
13
+ headers: response.headers,
14
+ });
15
+ };
@@ -0,0 +1,11 @@
1
+ import type { HttpHandler } from 'msw';
2
+ import type { ActiveScenario, ScenaristScenario, ResponseSelector } from '@scenarist/core';
3
+ export type DynamicHandlerOptions = {
4
+ readonly getTestId: (request: Request) => string;
5
+ readonly getActiveScenario: (testId: string) => ActiveScenario | undefined;
6
+ readonly getScenarioDefinition: (scenarioId: string) => ScenaristScenario | undefined;
7
+ readonly strictMode: boolean;
8
+ readonly responseSelector: ResponseSelector;
9
+ };
10
+ export declare const createDynamicHandler: (options: DynamicHandlerOptions) => HttpHandler;
11
+ //# sourceMappingURL=dynamic-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dynamic-handler.d.ts","sourceRoot":"","sources":["../../src/handlers/dynamic-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,KAAK,EACV,cAAc,EACd,iBAAiB,EAIjB,gBAAgB,EACjB,MAAM,iBAAiB,CAAC;AAIzB,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,CAAC;IACjD,QAAQ,CAAC,iBAAiB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,cAAc,GAAG,SAAS,CAAC;IAC3E,QAAQ,CAAC,qBAAqB,EAAE,CAC9B,UAAU,EAAE,MAAM,KACf,iBAAiB,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,gBAAgB,CAAC;CAC7C,CAAC;AAgGF,eAAO,MAAM,oBAAoB,GAC/B,SAAS,qBAAqB,KAC7B,WA8BF,CAAC"}
@@ -0,0 +1,103 @@
1
+ import { http, passthrough } from 'msw';
2
+ import { buildResponse } from '../conversion/response-builder.js';
3
+ import { matchesUrl } from '../matching/url-matcher.js';
4
+ /**
5
+ * Extract HttpRequestContext from MSW Request object.
6
+ * Converts MSW request to the format expected by ResponseSelector.
7
+ */
8
+ const extractHttpRequestContext = async (request) => {
9
+ // Parse request body if present
10
+ let body = undefined;
11
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
12
+ try {
13
+ const clonedRequest = request.clone();
14
+ body = await clonedRequest.json();
15
+ }
16
+ catch {
17
+ // Body is not JSON or doesn't exist
18
+ body = undefined;
19
+ }
20
+ }
21
+ // Extract headers as Record<string, string>
22
+ // Headers are passed through as-is; normalization is core's responsibility.
23
+ // The Fetch API Headers object already normalizes keys to lowercase,
24
+ // but even if it didn't, core would handle normalization.
25
+ const headers = {};
26
+ request.headers.forEach((value, key) => {
27
+ headers[key] = value;
28
+ });
29
+ // Extract query parameters from URL
30
+ const url = new URL(request.url);
31
+ const query = {};
32
+ url.searchParams.forEach((value, key) => {
33
+ query[key] = value;
34
+ });
35
+ return {
36
+ method: request.method,
37
+ url: request.url,
38
+ body,
39
+ headers,
40
+ query,
41
+ };
42
+ };
43
+ /**
44
+ * Get mocks from active scenario, with default scenario mocks as fallback.
45
+ * Returns URL-matching mocks with their extracted params for ResponseSelector to evaluate.
46
+ *
47
+ * Default mocks are ALWAYS included (if they match URL+method).
48
+ * Active scenario mocks are added after defaults, allowing them to override
49
+ * based on specificity (mocks with match criteria have higher specificity).
50
+ *
51
+ * Each mock is paired with params extracted from its URL pattern.
52
+ * After ResponseSelector chooses a mock, we use THAT mock's params.
53
+ */
54
+ const getMocksFromScenarios = (activeScenario, getScenarioDefinition, method, url) => {
55
+ const mocksWithParams = [];
56
+ // Step 1: ALWAYS include default scenario mocks first
57
+ // These act as fallback when active scenario mocks don't match
58
+ const defaultScenario = getScenarioDefinition('default');
59
+ if (defaultScenario) {
60
+ defaultScenario.mocks.forEach((mock) => {
61
+ const methodMatches = mock.method.toUpperCase() === method.toUpperCase();
62
+ const urlMatch = matchesUrl(mock.url, url);
63
+ if (methodMatches && urlMatch.matches) {
64
+ mocksWithParams.push({ mock, params: urlMatch.params });
65
+ }
66
+ });
67
+ }
68
+ // Step 2: Add active scenario mocks (if any)
69
+ // These override defaults based on specificity (via ResponseSelector)
70
+ if (activeScenario) {
71
+ const scenarioDefinition = getScenarioDefinition(activeScenario.scenarioId);
72
+ if (scenarioDefinition) {
73
+ scenarioDefinition.mocks.forEach((mock) => {
74
+ const methodMatches = mock.method.toUpperCase() === method.toUpperCase();
75
+ const urlMatch = matchesUrl(mock.url, url);
76
+ if (methodMatches && urlMatch.matches) {
77
+ mocksWithParams.push({ mock, params: urlMatch.params });
78
+ }
79
+ });
80
+ }
81
+ }
82
+ return mocksWithParams;
83
+ };
84
+ export const createDynamicHandler = (options) => {
85
+ return http.all('*', async ({ request }) => {
86
+ const testId = options.getTestId(request);
87
+ const activeScenario = options.getActiveScenario(testId);
88
+ const scenarioId = activeScenario?.scenarioId ?? 'default';
89
+ // Extract request context for matching
90
+ const context = await extractHttpRequestContext(request);
91
+ // Get candidate mocks from active or default scenario
92
+ const mocks = getMocksFromScenarios(activeScenario, options.getScenarioDefinition, request.method, request.url);
93
+ // Use injected ResponseSelector to find matching mock
94
+ const result = options.responseSelector.selectResponse(testId, scenarioId, context, mocks);
95
+ if (result.success) {
96
+ return buildResponse(result.data);
97
+ }
98
+ if (options.strictMode) {
99
+ return new Response(null, { status: 501 });
100
+ }
101
+ return passthrough();
102
+ });
103
+ };
@@ -0,0 +1,7 @@
1
+ export { matchesUrl } from './matching/url-matcher.js';
2
+ export type { UrlMatchResult } from './matching/url-matcher.js';
3
+ export { findMatchingMock } from './matching/mock-matcher.js';
4
+ export { buildResponse } from './conversion/response-builder.js';
5
+ export { createDynamicHandler } from './handlers/dynamic-handler.js';
6
+ export type { DynamicHandlerOptions } from './handlers/dynamic-handler.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAEhE,OAAO,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAE9D,OAAO,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAC;AAEjE,OAAO,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AACrE,YAAY,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // Public API
2
+ // These exports are primarily intended for use by Scenarist adapters
3
+ // (e.g., express-adapter, fastify-adapter). End users should use the
4
+ // higher-level adapter packages rather than these low-level utilities directly.
5
+ export { matchesUrl } from './matching/url-matcher.js';
6
+ export { findMatchingMock } from './matching/mock-matcher.js';
7
+ export { buildResponse } from './conversion/response-builder.js';
8
+ export { createDynamicHandler } from './handlers/dynamic-handler.js';
@@ -0,0 +1,3 @@
1
+ import type { ScenaristMock } from '@scenarist/core';
2
+ export declare const findMatchingMock: (mocks: ReadonlyArray<ScenaristMock>, method: string, url: string) => ScenaristMock | undefined;
3
+ //# sourceMappingURL=mock-matcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock-matcher.d.ts","sourceRoot":"","sources":["../../src/matching/mock-matcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGrD,eAAO,MAAM,gBAAgB,GAC3B,OAAO,aAAa,CAAC,aAAa,CAAC,EACnC,QAAQ,MAAM,EACd,KAAK,MAAM,KACV,aAAa,GAAG,SAMlB,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { matchesUrl } from './url-matcher.js';
2
+ export const findMatchingMock = (mocks, method, url) => {
3
+ return mocks.find((mock) => {
4
+ const methodMatches = mock.method.toUpperCase() === method.toUpperCase();
5
+ const urlMatch = matchesUrl(mock.url, url);
6
+ return methodMatches && urlMatch.matches;
7
+ });
8
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Result of URL matching with path parameter extraction.
3
+ *
4
+ * Matches MSW's documented param types:
5
+ * - string for simple params (:id)
6
+ * - string[] for repeating params (:path+)
7
+ */
8
+ export type UrlMatchResult = {
9
+ readonly matches: boolean;
10
+ readonly params?: Readonly<Record<string, string | ReadonlyArray<string>>>;
11
+ };
12
+ /**
13
+ * Match URL patterns using MSW-compatible matching logic.
14
+ *
15
+ * Delegates to path-to-regexp v6 (same as MSW 2.x) for all string patterns.
16
+ * This ensures automatic MSW parity for path parameter extraction.
17
+ *
18
+ * Matching strategies:
19
+ * 1. RegExp patterns: MSW weak comparison (substring matching, origin-agnostic)
20
+ * 2. Pathname-only patterns: Origin-agnostic (match any hostname)
21
+ * 3. Full URL patterns: Hostname-specific (must match exactly)
22
+ *
23
+ * Pattern type examples:
24
+ * - Pathname: '/users/:id' matches 'http://localhost/users/123' AND 'https://api.com/users/123'
25
+ * - Full URL: 'http://api.com/users/:id' matches 'http://api.com/users/123' ONLY
26
+ *
27
+ * Path parameter examples (all handled by path-to-regexp):
28
+ * - Exact: '/users/123' matches '/users/123'
29
+ * - Simple params: '/users/:id' matches '/users/123' → {id: '123'}
30
+ * - Multiple params: '/users/:userId/posts/:postId' → {userId: 'alice', postId: '42'}
31
+ * - Optional params: '/files/:name?' matches '/files' or '/files/doc.txt'
32
+ * - Repeating params: '/files/:path+' matches '/files/a/b/c' → {path: ['a','b','c']}
33
+ * - Custom regex: '/orders/:id(\\d+)' matches '/orders/123' but not '/orders/abc'
34
+ *
35
+ * @see ADR-0016 for MSW weak comparison semantics
36
+ */
37
+ export declare const matchesUrl: (pattern: string | RegExp, requestUrl: string) => UrlMatchResult;
38
+ //# sourceMappingURL=url-matcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-matcher.d.ts","sourceRoot":"","sources":["../../src/matching/url-matcher.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;CAC5E,CAAC;AA8DF;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,UAAU,GACrB,SAAS,MAAM,GAAG,MAAM,EACxB,YAAY,MAAM,KACjB,cAuEF,CAAC"}
@@ -0,0 +1,142 @@
1
+ import { match } from 'path-to-regexp';
2
+ /**
3
+ * Extract pathname from URL string, or return as-is if not a valid URL.
4
+ *
5
+ * CRITICAL: Handles path-to-regexp syntax (`:param`, `?`, `+`, `(regex)`)
6
+ * The URL constructor treats `?` as query string delimiter, which breaks optional params.
7
+ *
8
+ * Examples:
9
+ * - 'http://localhost:3001/api/files/:filename?' → '/api/files/:filename?' (preserves ?)
10
+ * - 'http://localhost:3001/api/files/:path+' → '/api/files/:path+' (preserves +)
11
+ * - '/api/users/:id' → '/api/users/:id' (already pathname)
12
+ */
13
+ const extractPathnameOrReturnAsIs = (url) => {
14
+ // Match protocol://host pattern to manually extract pathname
15
+ // This preserves path-to-regexp syntax that URL constructor would corrupt
16
+ const urlPattern = /^https?:\/\/[^/]+(\/.*)?$/;
17
+ const match = urlPattern.exec(url);
18
+ if (match) {
19
+ // Return everything after the host (group 1), or '/' if no path
20
+ return match[1] || '/';
21
+ }
22
+ // Not a full URL, return as-is (already a pathname)
23
+ return url;
24
+ };
25
+ /**
26
+ * Extract params from path-to-regexp match result, filtering unnamed groups.
27
+ *
28
+ * MSW behavior (via path-to-regexp v6):
29
+ * - Named params (:id, :name) are included
30
+ * - Array params (:path+, :path*) are included as arrays
31
+ * - Unnamed groups like (user|u) create numeric keys ('0', '1', etc.) which are FILTERED OUT
32
+ *
33
+ * Returns Record<string, string | string[]> matching MSW's documented types.
34
+ */
35
+ const extractParams = (params) => {
36
+ return Object.fromEntries(Object.entries(params).filter(([key, value]) => {
37
+ // Filter out unnamed groups (numeric keys like '0', '1', '2', etc.)
38
+ if (/^\d+$/.test(key)) {
39
+ return false;
40
+ }
41
+ // Keep strings and arrays (MSW documented: string | string[])
42
+ return typeof value === 'string' || Array.isArray(value);
43
+ }));
44
+ };
45
+ /**
46
+ * Extract hostname from URL, or return undefined if not a full URL.
47
+ */
48
+ const extractHostname = (url) => {
49
+ const urlPattern = /^https?:\/\/([^/]+)/;
50
+ const match = urlPattern.exec(url);
51
+ return match ? match[1] : undefined;
52
+ };
53
+ /**
54
+ * Match URL patterns using MSW-compatible matching logic.
55
+ *
56
+ * Delegates to path-to-regexp v6 (same as MSW 2.x) for all string patterns.
57
+ * This ensures automatic MSW parity for path parameter extraction.
58
+ *
59
+ * Matching strategies:
60
+ * 1. RegExp patterns: MSW weak comparison (substring matching, origin-agnostic)
61
+ * 2. Pathname-only patterns: Origin-agnostic (match any hostname)
62
+ * 3. Full URL patterns: Hostname-specific (must match exactly)
63
+ *
64
+ * Pattern type examples:
65
+ * - Pathname: '/users/:id' matches 'http://localhost/users/123' AND 'https://api.com/users/123'
66
+ * - Full URL: 'http://api.com/users/:id' matches 'http://api.com/users/123' ONLY
67
+ *
68
+ * Path parameter examples (all handled by path-to-regexp):
69
+ * - Exact: '/users/123' matches '/users/123'
70
+ * - Simple params: '/users/:id' matches '/users/123' → {id: '123'}
71
+ * - Multiple params: '/users/:userId/posts/:postId' → {userId: 'alice', postId: '42'}
72
+ * - Optional params: '/files/:name?' matches '/files' or '/files/doc.txt'
73
+ * - Repeating params: '/files/:path+' matches '/files/a/b/c' → {path: ['a','b','c']}
74
+ * - Custom regex: '/orders/:id(\\d+)' matches '/orders/123' but not '/orders/abc'
75
+ *
76
+ * @see ADR-0016 for MSW weak comparison semantics
77
+ */
78
+ export const matchesUrl = (pattern, requestUrl) => {
79
+ /**
80
+ * RegExp patterns: MSW Weak Comparison (ADR-0016)
81
+ *
82
+ * RegExp.test() performs substring matching (origin-agnostic).
83
+ * This matches MSW's documented behavior for regular expressions.
84
+ *
85
+ * Example: /\/posts\// matches:
86
+ * - 'http://localhost:8080/posts/' ✅
87
+ * - 'https://backend.dev/user/posts/' ✅
88
+ * - 'https://api.example.com/posts/123' ✅
89
+ */
90
+ if (pattern instanceof RegExp) {
91
+ return { matches: pattern.test(requestUrl) };
92
+ }
93
+ /**
94
+ * String patterns: Hostname-aware matching
95
+ *
96
+ * If pattern is a full URL (has hostname), hostname must match.
97
+ * If pattern is pathname-only, any hostname matches (origin-agnostic).
98
+ *
99
+ * This gives users choice:
100
+ * - Use pathname patterns for environment-agnostic mocks
101
+ * - Use full URL patterns when hostname matters
102
+ */
103
+ const patternHostname = extractHostname(pattern);
104
+ const requestHostname = extractHostname(requestUrl);
105
+ // If pattern has hostname, it must match request hostname
106
+ if (patternHostname !== undefined && requestHostname !== undefined) {
107
+ if (patternHostname !== requestHostname) {
108
+ return { matches: false };
109
+ }
110
+ }
111
+ /**
112
+ * Pathname matching using path-to-regexp v6 (same as MSW 2.x)
113
+ *
114
+ * path-to-regexp handles:
115
+ * - Exact string matches
116
+ * - Path parameters (:id, :name, etc.)
117
+ * - Optional parameters (:id?)
118
+ * - Repeating parameters (:path+, :path*)
119
+ * - Custom regex parameters (:id(\\d+))
120
+ * - Unnamed groups filtering
121
+ *
122
+ * By using the same library as MSW, we automatically get MSW-compatible behavior.
123
+ *
124
+ * CRITICAL: Strip query parameters from request URL before matching.
125
+ * path-to-regexp matches against pathname only, not query string.
126
+ * Example: '/users/123?role=admin' should match pattern '/users/:id'
127
+ */
128
+ const patternPath = extractPathnameOrReturnAsIs(pattern);
129
+ let requestPath = extractPathnameOrReturnAsIs(requestUrl);
130
+ // Strip query parameters from request path
131
+ const queryIndex = requestPath.indexOf('?');
132
+ if (queryIndex !== -1) {
133
+ requestPath = requestPath.substring(0, queryIndex);
134
+ }
135
+ const matcher = match(patternPath, { decode: decodeURIComponent });
136
+ const result = matcher(requestPath);
137
+ if (result) {
138
+ const params = extractParams(result.params);
139
+ return { matches: true, params };
140
+ }
141
+ return { matches: false };
142
+ };
package/package.json CHANGED
@@ -1,10 +1,69 @@
1
1
  {
2
2
  "name": "@scenarist/msw-adapter",
3
- "version": "0.0.1",
4
- "description": "OIDC trusted publishing setup package for @scenarist/msw-adapter",
3
+ "version": "0.1.0",
4
+ "description": "Internal: MSW integration layer for Scenarist framework adapters",
5
+ "author": "Paul Hammond (citypaul) <paul@packsoftware.co.uk>",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/citypaul/scenarist#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/citypaul/scenarist.git",
11
+ "directory": "internal/msw-adapter"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/citypaul/scenarist/issues"
15
+ },
5
16
  "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
10
- }
17
+ "msw",
18
+ "mock-service-worker",
19
+ "testing",
20
+ "scenarist",
21
+ "internal"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18.0.0"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "type": "module",
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "dependencies": {
44
+ "path-to-regexp": "^6.3.0",
45
+ "@scenarist/core": "0.1.0"
46
+ },
47
+ "peerDependencies": {
48
+ "msw": "^2.0.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^24.10.1",
52
+ "@vitest/coverage-v8": "^4.0.8",
53
+ "eslint": "^9.39.1",
54
+ "msw": "^2.12.1",
55
+ "typescript": "^5.9.3",
56
+ "vitest": "^4.0.8",
57
+ "@scenarist/eslint-config": "^0.0.0",
58
+ "@scenarist/typescript-config": "0.0.0"
59
+ },
60
+ "scripts": {
61
+ "build": "tsc",
62
+ "dev": "tsc --watch",
63
+ "test": "vitest run",
64
+ "test:watch": "vitest",
65
+ "typecheck": "tsc --project tsconfig.typecheck.json --noEmit",
66
+ "lint": "eslint .",
67
+ "clean": "rm -rf dist"
68
+ }
69
+ }