@schmock/core 2.2.2 → 2.3.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 (45) hide show
  1. package/dist/binary.d.ts +8 -0
  2. package/dist/binary.d.ts.map +1 -0
  3. package/dist/binary.js +9 -0
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +60 -29
  6. package/dist/http-helpers.d.ts +15 -3
  7. package/dist/http-helpers.d.ts.map +1 -1
  8. package/dist/http-helpers.js +24 -6
  9. package/dist/index.d.ts +90 -27
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/interceptor.d.ts.map +1 -1
  13. package/dist/interceptor.js +71 -20
  14. package/dist/plugin-pipeline.d.ts +11 -4
  15. package/dist/plugin-pipeline.d.ts.map +1 -1
  16. package/dist/plugin-pipeline.js +98 -35
  17. package/dist/response-parser.d.ts.map +1 -1
  18. package/dist/response-parser.js +47 -17
  19. package/package.json +5 -3
  20. package/src/binary.test.ts +126 -0
  21. package/src/binary.ts +11 -0
  22. package/src/builder.ts +82 -35
  23. package/src/http-helpers.test.ts +39 -0
  24. package/src/http-helpers.ts +46 -10
  25. package/src/index.ts +1 -0
  26. package/src/interceptor.test.ts +197 -0
  27. package/src/interceptor.ts +215 -128
  28. package/src/parser.property.test.ts +91 -55
  29. package/src/plugin-pipeline.ts +150 -45
  30. package/src/plugin-system.test.ts +95 -0
  31. package/src/response-parser.ts +58 -19
  32. package/src/server.test.ts +81 -12
  33. package/src/steps/async-support.steps.ts +29 -8
  34. package/src/steps/audit-onerror-tuple.steps.ts +14 -30
  35. package/src/steps/error-handling.steps.ts +463 -253
  36. package/src/steps/fetch-interceptor.steps.ts +457 -0
  37. package/src/steps/http-methods.steps.ts +19 -8
  38. package/src/steps/lifecycle-events.steps.ts +50 -43
  39. package/src/steps/performance-reliability.steps.ts +78 -411
  40. package/src/steps/plugin-integration.steps.ts +54 -0
  41. package/src/steps/request-history.steps.ts +412 -230
  42. package/src/steps/standalone-server.steps.ts +7 -13
  43. package/src/steps/state-concurrency.steps.ts +142 -714
  44. package/src/steps/interceptor.steps.ts +0 -260
  45. package/src/steps/stateful-workflows.steps.ts +0 -353
@@ -1,7 +1,100 @@
1
1
  /// <reference path="../schmock.d.ts" />
2
2
 
3
+ import { isBinaryBody } from "./binary.js";
3
4
  import { isRouteNotFound, toHttpMethod } from "./constants.js";
4
5
 
6
+ const PASSTHROUGH = Symbol("schmock.fetch.passthrough");
7
+
8
+ type InterceptorResult = Response | typeof PASSTHROUGH;
9
+
10
+ function toFetchBinaryBody(
11
+ body: ArrayBuffer | ArrayBufferView,
12
+ ): ArrayBuffer | Uint8Array<ArrayBuffer> {
13
+ if (body instanceof ArrayBuffer) return body;
14
+
15
+ const bytes = new Uint8Array(body.byteLength);
16
+ bytes.set(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
17
+ return bytes;
18
+ }
19
+
20
+ interface RegisteredInterceptor {
21
+ token: symbol;
22
+ intercept: (
23
+ input: RequestInfo | URL,
24
+ init?: RequestInit,
25
+ ) => Promise<InterceptorResult>;
26
+ }
27
+
28
+ interface InterceptorSession {
29
+ baselineFetch: typeof globalThis.fetch;
30
+ dispatchFetch: typeof globalThis.fetch;
31
+ interceptors: RegisteredInterceptor[];
32
+ }
33
+
34
+ let activeSession: InterceptorSession | undefined;
35
+
36
+ function createInterceptorSession(): InterceptorSession {
37
+ const baselineFetch = globalThis.fetch;
38
+ const interceptors: RegisteredInterceptor[] = [];
39
+ const dispatchFetch: typeof globalThis.fetch = async (input, init) => {
40
+ const snapshot = interceptors.slice();
41
+
42
+ for (let index = snapshot.length - 1; index >= 0; index -= 1) {
43
+ const result = await snapshot[index].intercept(input, init);
44
+ if (result !== PASSTHROUGH) {
45
+ return result;
46
+ }
47
+ }
48
+
49
+ return baselineFetch(input, init);
50
+ };
51
+
52
+ return { baselineFetch, dispatchFetch, interceptors };
53
+ }
54
+
55
+ function registerInterceptor(
56
+ intercept: RegisteredInterceptor["intercept"],
57
+ ): Schmock.InterceptHandle {
58
+ let session = activeSession;
59
+ if (!session || globalThis.fetch !== session.dispatchFetch) {
60
+ session = createInterceptorSession();
61
+ activeSession = session;
62
+ globalThis.fetch = session.dispatchFetch;
63
+ }
64
+
65
+ const token = Symbol("schmock.fetch.interceptor");
66
+ session.interceptors.push({ token, intercept });
67
+ let active = true;
68
+
69
+ return {
70
+ restore() {
71
+ if (!active) return;
72
+
73
+ active = false;
74
+ const index = session.interceptors.findIndex(
75
+ (entry) => entry.token === token,
76
+ );
77
+ if (index !== -1) {
78
+ session.interceptors.splice(index, 1);
79
+ }
80
+
81
+ if (session.interceptors.length !== 0) return;
82
+ if (activeSession === session) {
83
+ activeSession = undefined;
84
+ }
85
+
86
+ // A library may have installed its own fetch wrapper after Schmock. Its
87
+ // replacement is now the current owner and must not be overwritten.
88
+ if (globalThis.fetch === session.dispatchFetch) {
89
+ globalThis.fetch = session.baselineFetch;
90
+ }
91
+ },
92
+ get active() {
93
+ return active;
94
+ },
95
+ };
96
+ }
97
+
5
98
  /**
6
99
  * Extract pathname from a URL string, handling both absolute and relative URLs.
7
100
  */
@@ -172,149 +265,143 @@ export function createFetchInterceptor(
172
265
  errorFormatter,
173
266
  } = options;
174
267
 
175
- const originalFetch = globalThis.fetch;
176
- let active = true;
177
-
178
- globalThis.fetch = async (
179
- input: RequestInfo | URL,
180
- init?: RequestInit,
181
- ): Promise<Response> => {
182
- // Resolve the URL string
183
- const urlString =
184
- input instanceof Request
185
- ? input.url
186
- : input instanceof URL
187
- ? input.href
188
- : input;
189
-
190
- const path = extractPathname(urlString);
191
-
192
- // BaseUrl filter — non-matching requests go straight to real fetch.
193
- // Two modes:
194
- // - origin form ("https://api.example.com/v1"): require matching
195
- // origin AND matching path prefix.
196
- // - path form ("/api"): match pathname prefix only.
197
- // Both enforce a segment boundary so "/api" doesn't match "/apiv2".
198
- if (baseUrl) {
199
- const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
200
- if (baseOrigin) {
201
- const reqOrigin = extractOrigin(urlString);
202
- if (reqOrigin !== baseOrigin) {
203
- return originalFetch(input, init);
268
+ return registerInterceptor(
269
+ async (
270
+ input: RequestInfo | URL,
271
+ init?: RequestInit,
272
+ ): Promise<InterceptorResult> => {
273
+ // Resolve the URL string
274
+ const urlString =
275
+ input instanceof Request
276
+ ? input.url
277
+ : input instanceof URL
278
+ ? input.href
279
+ : input;
280
+
281
+ const path = extractPathname(urlString);
282
+
283
+ // BaseUrl filter — non-matching requests go straight to real fetch.
284
+ // Two modes:
285
+ // - origin form ("https://api.example.com/v1"): require matching
286
+ // origin AND matching path prefix.
287
+ // - path form ("/api"): match pathname prefix only.
288
+ // Both enforce a segment boundary so "/api" doesn't match "/apiv2".
289
+ if (baseUrl) {
290
+ const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
291
+ if (baseOrigin) {
292
+ const reqOrigin = extractOrigin(urlString);
293
+ if (reqOrigin !== baseOrigin) {
294
+ return PASSTHROUGH;
295
+ }
204
296
  }
205
- }
206
- if (basePath) {
207
- const isMatch = path === basePath || path.startsWith(`${basePath}/`);
208
- if (!isMatch) {
209
- return originalFetch(input, init);
297
+ if (basePath) {
298
+ const isMatch = path === basePath || path.startsWith(`${basePath}/`);
299
+ if (!isMatch) {
300
+ return PASSTHROUGH;
301
+ }
210
302
  }
211
303
  }
212
- }
213
304
 
214
- // Build adapter request
215
- const method =
216
- input instanceof Request ? input.method : (init?.method ?? "GET");
217
- const headers = extractHeaders(input, init);
218
- const query = extractQuery(urlString);
219
- const body = await extractBody(input, init);
220
-
221
- let adapterRequest: Schmock.AdapterRequest = {
222
- method,
223
- path,
224
- headers,
225
- body,
226
- query,
227
- };
305
+ // Build adapter request
306
+ const method =
307
+ input instanceof Request ? input.method : (init?.method ?? "GET");
308
+ const headers = extractHeaders(input, init);
309
+ const query = extractQuery(urlString);
310
+ const body = await extractBody(input, init);
311
+
312
+ let adapterRequest: Schmock.AdapterRequest = {
313
+ method,
314
+ path,
315
+ headers,
316
+ body,
317
+ query,
318
+ };
228
319
 
229
- try {
230
- // Apply beforeRequest hook
231
- if (beforeRequest) {
232
- const modified = await beforeRequest(adapterRequest);
233
- if (modified) {
234
- adapterRequest = modified;
320
+ try {
321
+ // Apply beforeRequest hook
322
+ if (beforeRequest) {
323
+ const modified = await beforeRequest(adapterRequest);
324
+ if (modified) {
325
+ adapterRequest = modified;
326
+ }
235
327
  }
236
- }
237
328
 
238
- const schmockResponse = await handle(
239
- toHttpMethod(adapterRequest.method),
240
- adapterRequest.path,
241
- {
242
- headers: adapterRequest.headers,
243
- body: adapterRequest.body,
244
- query: adapterRequest.query,
245
- },
246
- );
247
-
248
- // Route not found — passthrough or 404
249
- if (isRouteNotFound(schmockResponse)) {
250
- if (passthrough) {
251
- return originalFetch(input, init);
252
- }
253
- return new Response(
254
- JSON.stringify({
255
- error: "No matching mock route found",
256
- code: "ROUTE_NOT_FOUND",
257
- }),
329
+ const schmockResponse = await handle(
330
+ toHttpMethod(adapterRequest.method),
331
+ adapterRequest.path,
258
332
  {
259
- status: 404,
260
- headers: { "content-type": "application/json" },
333
+ headers: adapterRequest.headers,
334
+ body: adapterRequest.body,
335
+ query: adapterRequest.query,
261
336
  },
262
337
  );
263
- }
264
338
 
265
- // Apply beforeResponse hook
266
- let response: Schmock.AdapterResponse = schmockResponse;
267
- if (beforeResponse) {
268
- const modified = await beforeResponse(response, adapterRequest);
269
- if (modified) {
270
- response = modified;
339
+ // Route not found — passthrough or 404
340
+ if (isRouteNotFound(schmockResponse)) {
341
+ if (passthrough) {
342
+ return PASSTHROUGH;
343
+ }
344
+ return new Response(
345
+ JSON.stringify({
346
+ error: "No matching mock route found",
347
+ code: "ROUTE_NOT_FOUND",
348
+ }),
349
+ {
350
+ status: 404,
351
+ headers: { "content-type": "application/json" },
352
+ },
353
+ );
271
354
  }
272
- }
273
355
 
274
- // Build fetch Response
275
- const responseHeaders = new Headers(response.headers);
276
- if (
277
- !responseHeaders.has("content-type") &&
278
- response.body !== null &&
279
- response.body !== undefined
280
- ) {
281
- responseHeaders.set("content-type", "application/json");
282
- }
356
+ // Apply beforeResponse hook
357
+ let response: Schmock.AdapterResponse = schmockResponse;
358
+ if (beforeResponse) {
359
+ const modified = await beforeResponse(response, adapterRequest);
360
+ if (modified) {
361
+ response = modified;
362
+ }
363
+ }
283
364
 
284
- const responseBody =
285
- response.body === null || response.body === undefined
286
- ? null
287
- : typeof response.body === "string"
288
- ? response.body
289
- : JSON.stringify(response.body);
290
-
291
- return new Response(responseBody, {
292
- status: response.status,
293
- headers: responseHeaders,
294
- });
295
- } catch (error) {
296
- if (errorFormatter) {
297
- const formatted = errorFormatter(
298
- error instanceof Error ? error : new Error(String(error)),
299
- );
300
- return new Response(JSON.stringify(formatted), {
301
- status: 500,
302
- headers: { "content-type": "application/json" },
303
- });
304
- }
305
- throw error;
306
- }
307
- };
365
+ // Build fetch Response
366
+ const responseHeaders = new Headers(response.headers);
367
+ if (
368
+ !responseHeaders.has("content-type") &&
369
+ response.body !== null &&
370
+ response.body !== undefined
371
+ ) {
372
+ responseHeaders.set(
373
+ "content-type",
374
+ isBinaryBody(response.body)
375
+ ? "application/octet-stream"
376
+ : "application/json",
377
+ );
378
+ }
308
379
 
309
- return {
310
- restore() {
311
- if (active) {
312
- globalThis.fetch = originalFetch;
313
- active = false;
380
+ const responseBody =
381
+ response.body === null || response.body === undefined
382
+ ? null
383
+ : typeof response.body === "string"
384
+ ? response.body
385
+ : isBinaryBody(response.body)
386
+ ? toFetchBinaryBody(response.body)
387
+ : JSON.stringify(response.body);
388
+
389
+ return new Response(responseBody, {
390
+ status: response.status,
391
+ headers: responseHeaders,
392
+ });
393
+ } catch (error) {
394
+ if (errorFormatter) {
395
+ const formatted = errorFormatter(
396
+ error instanceof Error ? error : new Error(String(error)),
397
+ );
398
+ return new Response(JSON.stringify(formatted), {
399
+ status: 500,
400
+ headers: { "content-type": "application/json" },
401
+ });
402
+ }
403
+ throw error;
314
404
  }
315
405
  },
316
- get active() {
317
- return active;
318
- },
319
- };
406
+ );
320
407
  }
@@ -83,8 +83,6 @@ const doubleSlashPath = fc
83
83
  .array(safeSeg, { minLength: 2, maxLength: 4 })
84
84
  .map((segs) => `/${segs.join("//")}`);
85
85
 
86
- const trailingSlashPath = safePath.map((p) => `${p}/`);
87
-
88
86
  const deepPath = fc
89
87
  .array(safeSeg, { minLength: 10, maxLength: 30 })
90
88
  .map((segs) => `/${segs.join("/")}`);
@@ -106,14 +104,55 @@ const paramRouteKey = fc
106
104
  return `${m} /${parts.join("/")}`;
107
105
  });
108
106
 
109
- // Route keys with hostile characters in the path portion
110
- const hostileRouteKey = fc
111
- .tuple(method, hostilePath)
112
- .map(([m, p]) => `${m} ${p}`);
107
+ const parameterizedRouteCase = fc
108
+ .tuple(
109
+ method,
110
+ fc.uniqueArray(
111
+ fc.record({
112
+ literal: safeSeg,
113
+ parameter: safeSeg,
114
+ value: safeSeg,
115
+ }),
116
+ {
117
+ minLength: 1,
118
+ maxLength: 4,
119
+ selector: (part) => part.parameter,
120
+ },
121
+ ),
122
+ )
123
+ .map(([httpMethod, parts]) => ({
124
+ key: `${httpMethod} /${parts
125
+ .map((part) => `${part.literal}/:${part.parameter}`)
126
+ .join("/")}`,
127
+ concretePath: `/${parts
128
+ .map((part) => `${part.literal}/${part.value}`)
129
+ .join("/")}`,
130
+ parameterNames: parts.map((part) => part.parameter),
131
+ parameterValues: parts.map((part) => part.value),
132
+ }));
113
133
 
114
134
  // --- Arbitrary inputs ---
115
135
 
116
- const arbitraryString = fc.string({ minLength: 0, maxLength: 200 });
136
+ const asciiString = fc
137
+ .array(fc.integer({ min: 0, max: 0x7f }), {
138
+ minLength: 0,
139
+ maxLength: 200,
140
+ })
141
+ .map((codePoints) => String.fromCharCode(...codePoints));
142
+ const unicodeString = fc.string({ minLength: 0, maxLength: 200 });
143
+
144
+ function expectParseResultOrRouteError(input: string): void {
145
+ let result: ReturnType<typeof parseRouteKey>;
146
+ try {
147
+ result = parseRouteKey(input);
148
+ } catch (error) {
149
+ expect(error).toBeInstanceOf(RouteParseError);
150
+ return;
151
+ }
152
+
153
+ expect(`${result.method} ${result.path}`).toBe(input);
154
+ expect(result.pattern.test(result.path)).toBe(true);
155
+ }
117
156
 
118
157
  // --- Namespace generators ---
119
158
 
@@ -133,42 +172,35 @@ const namespace = fc.oneof(
133
172
 
134
173
  describe("parseRouteKey — fuzz", () => {
135
174
  it("never throws an unhandled error on arbitrary ASCII input", () => {
136
- fc.assert(
137
- fc.property(arbitraryString, (input) => {
138
- try {
139
- parseRouteKey(input);
140
- } catch (e) {
141
- expect(e).toBeInstanceOf(RouteParseError);
142
- }
143
- }),
144
- { numRuns: 1000 },
145
- );
175
+ fc.assert(fc.property(asciiString, expectParseResultOrRouteError), {
176
+ numRuns: 1000,
177
+ });
146
178
  });
147
179
 
148
180
  it("never throws an unhandled error on arbitrary unicode input", () => {
149
- fc.assert(
150
- fc.property(arbitraryString, (input) => {
151
- try {
152
- parseRouteKey(input);
153
- } catch (e) {
154
- expect(e).toBeInstanceOf(RouteParseError);
155
- }
156
- }),
157
- { numRuns: 1000 },
158
- );
181
+ fc.assert(fc.property(unicodeString, expectParseResultOrRouteError), {
182
+ numRuns: 1000,
183
+ });
159
184
  });
160
185
 
161
- it("rejects inputs that look like methods but have hostile paths", () => {
186
+ it("escapes regex metacharacters in literal path segments", () => {
162
187
  fc.assert(
163
- fc.property(hostileRouteKey, (key) => {
164
- try {
165
- const result = parseRouteKey(key);
166
- // If it parses, the regex must be valid
167
- expect(result.pattern).toBeInstanceOf(RegExp);
168
- } catch (e) {
169
- expect(e).toBeInstanceOf(RouteParseError);
170
- }
171
- }),
188
+ fc.property(
189
+ method,
190
+ safeSeg,
191
+ regexMetaSeg,
192
+ safeSeg,
193
+ (httpMethod, prefix, literal, suffix) => {
194
+ const result = parseRouteKey(
195
+ `${httpMethod} /${prefix}/${literal}/${suffix}`,
196
+ );
197
+
198
+ expect(result.pattern.test(result.path)).toBe(true);
199
+ expect(result.pattern.test(`/${prefix}/literal/${suffix}`)).toBe(
200
+ false,
201
+ );
202
+ },
203
+ ),
172
204
  { numRuns: 500 },
173
205
  );
174
206
  });
@@ -195,36 +227,40 @@ describe("parseRouteKey — fuzz", () => {
195
227
  );
196
228
  });
197
229
 
198
- it("produced regex is safe against hostile test paths", () => {
230
+ it("matches concrete parameterized paths and captures every value", () => {
199
231
  fc.assert(
200
- fc.property(routeKey, hostilePath, (key, reqPath) => {
201
- const result = parseRouteKey(key);
202
- const matched = result.pattern.test(reqPath);
203
- expect(typeof matched).toBe("boolean");
232
+ fc.property(parameterizedRouteCase, (routeCase) => {
233
+ const result = parseRouteKey(routeCase.key);
234
+ const match = result.pattern.exec(routeCase.concretePath);
235
+
236
+ expect(match).not.toBeNull();
237
+ expect(match?.slice(1)).toEqual(routeCase.parameterValues);
238
+ expect(result.params).toEqual(routeCase.parameterNames);
204
239
  }),
205
240
  { numRuns: 1000 },
206
241
  );
207
242
  });
208
243
 
209
- it("produced regex handles double-slash paths without crashing", () => {
244
+ it("rejects slash-delimited near misses for parameterized paths", () => {
210
245
  fc.assert(
211
- fc.property(routeKey, doubleSlashPath, (key, reqPath) => {
212
- const result = parseRouteKey(key);
213
- const matched = result.pattern.test(reqPath);
214
- expect(typeof matched).toBe("boolean");
246
+ fc.property(parameterizedRouteCase, safeSeg, (routeCase, extra) => {
247
+ const result = parseRouteKey(routeCase.key);
248
+ const nearMiss = `${routeCase.concretePath}/${extra}`;
249
+
250
+ expect(result.pattern.test(nearMiss)).toBe(false);
215
251
  }),
216
- { numRuns: 300 },
252
+ { numRuns: 500 },
217
253
  );
218
254
  });
219
255
 
220
- it("produced regex handles trailing-slash paths without crashing", () => {
256
+ it("does not normalize malformed separators while matching", () => {
221
257
  fc.assert(
222
- fc.property(routeKey, trailingSlashPath, (key, reqPath) => {
258
+ fc.property(routeKey, (key) => {
223
259
  const result = parseRouteKey(key);
224
- const matched = result.pattern.test(reqPath);
225
- expect(typeof matched).toBe("boolean");
260
+ const doubledLeadingSlash = `/${result.path}`;
261
+ expect(result.pattern.test(doubledLeadingSlash)).toBe(false);
226
262
  }),
227
- { numRuns: 300 },
263
+ { numRuns: 500 },
228
264
  );
229
265
  });
230
266
 
@@ -287,7 +323,7 @@ describe("handle() — fuzz", () => {
287
323
  mock("DELETE /items/:id/comments/:cid", () => ({ deleted: true }));
288
324
 
289
325
  fc.assert(
290
- fc.asyncProperty(method, arbitraryString, async (m, reqPath) => {
326
+ fc.asyncProperty(method, asciiString, async (m, reqPath) => {
291
327
  const response = await mock.handle(m, reqPath);
292
328
  expect(response).toBeDefined();
293
329
  expect(typeof response.status).toBe("number");
@@ -302,7 +338,7 @@ describe("handle() — fuzz", () => {
302
338
  mock("GET /data/:key", () => ({ ok: true }));
303
339
 
304
340
  fc.assert(
305
- fc.asyncProperty(arbitraryString, async (reqPath) => {
341
+ fc.asyncProperty(unicodeString, async (reqPath) => {
306
342
  const response = await mock.handle("GET", reqPath);
307
343
  expect(response).toBeDefined();
308
344
  expect(typeof response.status).toBe("number");