@scenarist/msw-adapter 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -7,6 +7,7 @@ This package is an internal implementation detail of Scenarist and is **NOT publ
7
7
  ## Why is this package private?
8
8
 
9
9
  **You should install a framework adapter instead:**
10
+
10
11
  - ✅ `@scenarist/express-adapter` - For Express applications
11
12
  - ✅ `@scenarist/nextjs-adapter` - For Next.js applications
12
13
  - ✅ `@scenarist/core` - For building custom adapters
@@ -21,6 +22,7 @@ This package is an internal implementation detail of Scenarist and is **NOT publ
21
22
  6. **Cleaner API surface** - Framework adapters ARE the public API, msw-adapter is hidden complexity
22
23
 
23
24
  **Dependency structure:**
25
+
24
26
  ```
25
27
  Your App
26
28
  ├─ @scenarist/express-adapter (public, install this!)
@@ -36,7 +38,7 @@ Your App
36
38
 
37
39
  ## What is Scenarist?
38
40
 
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.
41
+ **Scenarist** enables concurrent tests to run with different backend states by switching mock scenarios at runtime via test IDs. Your real application code executes while external API responses are controlled by scenarios. This package provides the MSW integration layer that makes it all work.
40
42
 
41
43
  **The big picture:**
42
44
 
@@ -70,6 +72,7 @@ The MSW adapter is the bridge between Scenarist's serializable mock definitions
70
72
  This adapter implements all 25 Scenarist capabilities:
71
73
 
72
74
  ### Request Matching (6 capabilities)
75
+
73
76
  - **Body matching** (partial match) - Match on request body fields
74
77
  - **Header matching** (exact, case-insensitive) - Match on header values
75
78
  - **Query matching** (exact) - Match on query parameters
@@ -78,12 +81,14 @@ This adapter implements all 25 Scenarist capabilities:
78
81
  - **Fallback mocks** - Mocks without criteria act as catch-all
79
82
 
80
83
  ### Response Sequences (4 capabilities)
84
+
81
85
  - **Single responses** - Return same response every time
82
86
  - **Response sequences** - Ordered responses for polling scenarios
83
87
  - **Repeat modes** - `last`, `cycle`, `none` behaviors
84
88
  - **Sequence exhaustion** - Skip exhausted sequences to fallback
85
89
 
86
90
  ### Stateful Mocks (6 capabilities)
91
+
87
92
  - **State capture** - Extract values from requests
88
93
  - **State injection** - Inject state into responses via templates
89
94
  - **Array append** - Syntax: `stateKey[]` for arrays
@@ -92,12 +97,14 @@ This adapter implements all 25 Scenarist capabilities:
92
97
  - **State reset** - Fresh state on scenario switch
93
98
 
94
99
  ### URL Patterns (4 capabilities)
100
+
95
101
  - **Exact matches** - `https://api.example.com/users`
96
102
  - **Wildcards** - `*/api/*`, `https://*/users`
97
103
  - **Path parameters** - `/users/:id`, `/posts/:postId/comments/:commentId`
98
104
  - **Native RegExp** - `/\/api\/v\d+\//`, `/\/users\/\d+$/` (weak comparison per MSW behavior)
99
105
 
100
106
  ### MSW Integration (6 capabilities)
107
+
101
108
  - **Dynamic handler generation** - Single handler routes at runtime
102
109
  - **Response building** - Status codes, JSON bodies, headers, delays
103
110
  - **Automatic default fallback** - Collects default + active scenario mocks together
@@ -131,28 +138,35 @@ You typically don't install or use this directly. Instead, use a framework-speci
131
138
  Converts string patterns into MSW-compatible URL matchers:
132
139
 
133
140
  ```typescript
134
- import { matchesUrl } from '@scenarist/msw-adapter';
141
+ import { matchesUrl } from "@scenarist/msw-adapter";
135
142
 
136
- matchesUrl('https://api.github.com/users/octocat', 'GET', 'https://api.github.com/users/:username');
143
+ matchesUrl(
144
+ "https://api.github.com/users/octocat",
145
+ "GET",
146
+ "https://api.github.com/users/:username",
147
+ );
137
148
  // Returns: { matches: true, params: { username: 'octocat' } }
138
149
 
139
- matchesUrl('https://api.stripe.com/v1/charges', 'POST', '*/v1/charges');
150
+ matchesUrl("https://api.stripe.com/v1/charges", "POST", "*/v1/charges");
140
151
  // Returns: { matches: true, params: {} }
141
152
  ```
142
153
 
143
154
  **Supported patterns (three types with different hostname matching):**
144
155
 
145
156
  **1. Pathname-only patterns** (origin-agnostic - match ANY hostname)
157
+
146
158
  - Path params: `/users/:id`, `/posts/:postId/comments/:commentId`
147
159
  - Exact: `/api/users`
148
160
  - Wildcards: `/api/*`, `*/users/*`
149
161
 
150
162
  **2. Full URL patterns** (hostname-specific - match ONLY specified hostname)
163
+
151
164
  - Exact: `https://api.example.com/users`
152
165
  - Path params: `https://api.example.com/users/:id`
153
166
  - Wildcards: `https://*/users`, `https://api.example.com/*`
154
167
 
155
168
  **3. Native RegExp** (origin-agnostic - MSW weak comparison)
169
+
156
170
  - `/\/api\/v\d+\//` (matches /api/v1/, /api/v2/, etc.)
157
171
  - `/\/users\/\d+$/` (matches /users/123 at any origin)
158
172
 
@@ -179,15 +193,15 @@ const mock = findMatchingMock(mocks, 'GET', 'https://api.example.com/users/123')
179
193
  Converts `ScenaristMock` to MSW `HttpResponse`:
180
194
 
181
195
  ```typescript
182
- import { buildResponse } from '@scenarist/msw-adapter';
196
+ import { buildResponse } from "@scenarist/msw-adapter";
183
197
 
184
198
  const mockDef: ScenaristMock = {
185
- method: 'GET',
186
- url: '/api/user',
199
+ method: "GET",
200
+ url: "/api/user",
187
201
  response: {
188
202
  status: 200,
189
- body: { id: '123', name: 'John' },
190
- headers: { 'X-Custom': 'value' },
203
+ body: { id: "123", name: "John" },
204
+ headers: { "X-Custom": "value" },
191
205
  delay: 100,
192
206
  },
193
207
  };
@@ -201,11 +215,11 @@ const response = await buildResponse(mockDef);
201
215
  The core integration - a single MSW handler that routes dynamically:
202
216
 
203
217
  ```typescript
204
- import { createDynamicHandler } from '@scenarist/msw-adapter';
205
- import { setupServer } from 'msw/node';
218
+ import { createDynamicHandler } from "@scenarist/msw-adapter";
219
+ import { setupServer } from "msw/node";
206
220
 
207
221
  const handler = createDynamicHandler({
208
- getTestId: () => testIdStorage.getStore() ?? 'default-test',
222
+ getTestId: () => testIdStorage.getStore() ?? "default-test",
209
223
  getActiveScenario: (testId) => manager.getActiveScenario(testId),
210
224
  getScenarioDefinition: (scenarioId) => manager.getScenarioById(scenarioId),
211
225
  strictMode: false,
@@ -216,6 +230,7 @@ server.listen();
216
230
  ```
217
231
 
218
232
  **How it works:**
233
+
219
234
  1. Gets test ID from AsyncLocalStorage (or other context)
220
235
  2. Looks up active scenario for that test ID
221
236
  3. **Collects mocks from BOTH default AND active scenario** for matching URL + method
@@ -290,10 +305,9 @@ export const createDynamicHandler = (
290
305
  Full TypeScript support with strict mode enabled.
291
306
 
292
307
  **Exported types:**
308
+
293
309
  ```typescript
294
- import type {
295
- DynamicHandlerOptions,
296
- } from '@scenarist/msw-adapter';
310
+ import type { DynamicHandlerOptions } from "@scenarist/msw-adapter";
297
311
  ```
298
312
 
299
313
  ## Testing
@@ -1,3 +1,3 @@
1
- import type { ScenaristResponse } from '@scenarist/core';
1
+ import type { ScenaristResponse } from "@scenarist/core";
2
2
  export declare const buildResponse: (response: ScenaristResponse) => Promise<Response>;
3
3
  //# sourceMappingURL=response-builder.d.ts.map
@@ -1,4 +1,4 @@
1
- import { HttpResponse, delay } from 'msw';
1
+ import { HttpResponse, delay } from "msw";
2
2
  export const buildResponse = async (response) => {
3
3
  if (response.delay) {
4
4
  await delay(response.delay);
@@ -1,5 +1,5 @@
1
- import type { HttpHandler } from 'msw';
2
- import type { ActiveScenario, ScenaristScenario, ResponseSelector } from '@scenarist/core';
1
+ import type { HttpHandler } from "msw";
2
+ import type { ActiveScenario, ScenaristScenario, ResponseSelector } from "@scenarist/core";
3
3
  export type DynamicHandlerOptions = {
4
4
  readonly getTestId: (request: Request) => string;
5
5
  readonly getActiveScenario: (testId: string) => ActiveScenario | undefined;
@@ -1 +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"}
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;AAiGF,eAAO,MAAM,oBAAoB,GAC/B,SAAS,qBAAqB,KAC7B,WAmCF,CAAC"}
@@ -1,6 +1,6 @@
1
- import { http, passthrough } from 'msw';
2
- import { buildResponse } from '../conversion/response-builder.js';
3
- import { matchesUrl } from '../matching/url-matcher.js';
1
+ import { http, passthrough } from "msw";
2
+ import { buildResponse } from "../conversion/response-builder.js";
3
+ import { matchesUrl } from "../matching/url-matcher.js";
4
4
  /**
5
5
  * Extract HttpRequestContext from MSW Request object.
6
6
  * Converts MSW request to the format expected by ResponseSelector.
@@ -8,7 +8,7 @@ import { matchesUrl } from '../matching/url-matcher.js';
8
8
  const extractHttpRequestContext = async (request) => {
9
9
  // Parse request body if present
10
10
  let body = undefined;
11
- if (request.method !== 'GET' && request.method !== 'HEAD') {
11
+ if (request.method !== "GET" && request.method !== "HEAD") {
12
12
  try {
13
13
  const clonedRequest = request.clone();
14
14
  body = await clonedRequest.json();
@@ -55,7 +55,7 @@ const getMocksFromScenarios = (activeScenario, getScenarioDefinition, method, ur
55
55
  const mocksWithParams = [];
56
56
  // Step 1: ALWAYS include default scenario mocks first
57
57
  // These act as fallback when active scenario mocks don't match
58
- const defaultScenario = getScenarioDefinition('default');
58
+ const defaultScenario = getScenarioDefinition("default");
59
59
  if (defaultScenario) {
60
60
  defaultScenario.mocks.forEach((mock) => {
61
61
  const methodMatches = mock.method.toUpperCase() === method.toUpperCase();
@@ -82,10 +82,10 @@ const getMocksFromScenarios = (activeScenario, getScenarioDefinition, method, ur
82
82
  return mocksWithParams;
83
83
  };
84
84
  export const createDynamicHandler = (options) => {
85
- return http.all('*', async ({ request }) => {
85
+ return http.all("*", async ({ request }) => {
86
86
  const testId = options.getTestId(request);
87
87
  const activeScenario = options.getActiveScenario(testId);
88
- const scenarioId = activeScenario?.scenarioId ?? 'default';
88
+ const scenarioId = activeScenario?.scenarioId ?? "default";
89
89
  // Extract request context for matching
90
90
  const context = await extractHttpRequestContext(request);
91
91
  // Get candidate mocks from active or default scenario
package/dist/index.d.ts CHANGED
@@ -1,7 +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';
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
7
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // These exports are primarily intended for use by Scenarist adapters
3
3
  // (e.g., express-adapter, fastify-adapter). End users should use the
4
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';
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";
@@ -1,3 +1,3 @@
1
- import type { ScenaristMock } from '@scenarist/core';
1
+ import type { ScenaristMock } from "@scenarist/core";
2
2
  export declare const findMatchingMock: (mocks: ReadonlyArray<ScenaristMock>, method: string, url: string) => ScenaristMock | undefined;
3
3
  //# sourceMappingURL=mock-matcher.d.ts.map
@@ -1,4 +1,4 @@
1
- import { matchesUrl } from './url-matcher.js';
1
+ import { matchesUrl } from "./url-matcher.js";
2
2
  export const findMatchingMock = (mocks, method, url) => {
3
3
  return mocks.find((mock) => {
4
4
  const methodMatches = mock.method.toUpperCase() === method.toUpperCase();
@@ -1,4 +1,4 @@
1
- import { match } from 'path-to-regexp';
1
+ import { match } from "path-to-regexp";
2
2
  /**
3
3
  * Extract pathname from URL string, or return as-is if not a valid URL.
4
4
  *
@@ -17,7 +17,7 @@ const extractPathnameOrReturnAsIs = (url) => {
17
17
  const match = urlPattern.exec(url);
18
18
  if (match) {
19
19
  // Return everything after the host (group 1), or '/' if no path
20
- return match[1] || '/';
20
+ return match[1] || "/";
21
21
  }
22
22
  // Not a full URL, return as-is (already a pathname)
23
23
  return url;
@@ -39,7 +39,7 @@ const extractParams = (params) => {
39
39
  return false;
40
40
  }
41
41
  // Keep strings and arrays (MSW documented: string | string[])
42
- return typeof value === 'string' || Array.isArray(value);
42
+ return typeof value === "string" || Array.isArray(value);
43
43
  }));
44
44
  };
45
45
  /**
@@ -128,7 +128,7 @@ export const matchesUrl = (pattern, requestUrl) => {
128
128
  const patternPath = extractPathnameOrReturnAsIs(pattern);
129
129
  let requestPath = extractPathnameOrReturnAsIs(requestUrl);
130
130
  // Strip query parameters from request path
131
- const queryIndex = requestPath.indexOf('?');
131
+ const queryIndex = requestPath.indexOf("?");
132
132
  if (queryIndex !== -1) {
133
133
  requestPath = requestPath.substring(0, queryIndex);
134
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenarist/msw-adapter",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Internal: MSW integration layer for Scenarist framework adapters",
5
5
  "author": "Paul Hammond (citypaul) <paul@packsoftware.co.uk>",
6
6
  "license": "MIT",
@@ -42,7 +42,7 @@
42
42
  ],
43
43
  "dependencies": {
44
44
  "path-to-regexp": "^6.3.0",
45
- "@scenarist/core": "0.1.2"
45
+ "@scenarist/core": "0.1.3"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "msw": "^2.0.0"