@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,5 +1,65 @@
1
1
  /// <reference path="../schmock.d.ts" />
2
+ import { isBinaryBody } from "./binary.js";
2
3
  import { isRouteNotFound, toHttpMethod } from "./constants.js";
4
+ const PASSTHROUGH = Symbol("schmock.fetch.passthrough");
5
+ function toFetchBinaryBody(body) {
6
+ if (body instanceof ArrayBuffer)
7
+ return body;
8
+ const bytes = new Uint8Array(body.byteLength);
9
+ bytes.set(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
10
+ return bytes;
11
+ }
12
+ let activeSession;
13
+ function createInterceptorSession() {
14
+ const baselineFetch = globalThis.fetch;
15
+ const interceptors = [];
16
+ const dispatchFetch = async (input, init) => {
17
+ const snapshot = interceptors.slice();
18
+ for (let index = snapshot.length - 1; index >= 0; index -= 1) {
19
+ const result = await snapshot[index].intercept(input, init);
20
+ if (result !== PASSTHROUGH) {
21
+ return result;
22
+ }
23
+ }
24
+ return baselineFetch(input, init);
25
+ };
26
+ return { baselineFetch, dispatchFetch, interceptors };
27
+ }
28
+ function registerInterceptor(intercept) {
29
+ let session = activeSession;
30
+ if (!session || globalThis.fetch !== session.dispatchFetch) {
31
+ session = createInterceptorSession();
32
+ activeSession = session;
33
+ globalThis.fetch = session.dispatchFetch;
34
+ }
35
+ const token = Symbol("schmock.fetch.interceptor");
36
+ session.interceptors.push({ token, intercept });
37
+ let active = true;
38
+ return {
39
+ restore() {
40
+ if (!active)
41
+ return;
42
+ active = false;
43
+ const index = session.interceptors.findIndex((entry) => entry.token === token);
44
+ if (index !== -1) {
45
+ session.interceptors.splice(index, 1);
46
+ }
47
+ if (session.interceptors.length !== 0)
48
+ return;
49
+ if (activeSession === session) {
50
+ activeSession = undefined;
51
+ }
52
+ // A library may have installed its own fetch wrapper after Schmock. Its
53
+ // replacement is now the current owner and must not be overwritten.
54
+ if (globalThis.fetch === session.dispatchFetch) {
55
+ globalThis.fetch = session.baselineFetch;
56
+ }
57
+ },
58
+ get active() {
59
+ return active;
60
+ },
61
+ };
62
+ }
3
63
  /**
4
64
  * Extract pathname from a URL string, handling both absolute and relative URLs.
5
65
  */
@@ -141,9 +201,7 @@ async function extractBody(input, init) {
141
201
  */
142
202
  export function createFetchInterceptor(handle, options = {}) {
143
203
  const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = options;
144
- const originalFetch = globalThis.fetch;
145
- let active = true;
146
- globalThis.fetch = async (input, init) => {
204
+ return registerInterceptor(async (input, init) => {
147
205
  // Resolve the URL string
148
206
  const urlString = input instanceof Request
149
207
  ? input.url
@@ -162,13 +220,13 @@ export function createFetchInterceptor(handle, options = {}) {
162
220
  if (baseOrigin) {
163
221
  const reqOrigin = extractOrigin(urlString);
164
222
  if (reqOrigin !== baseOrigin) {
165
- return originalFetch(input, init);
223
+ return PASSTHROUGH;
166
224
  }
167
225
  }
168
226
  if (basePath) {
169
227
  const isMatch = path === basePath || path.startsWith(`${basePath}/`);
170
228
  if (!isMatch) {
171
- return originalFetch(input, init);
229
+ return PASSTHROUGH;
172
230
  }
173
231
  }
174
232
  }
@@ -200,7 +258,7 @@ export function createFetchInterceptor(handle, options = {}) {
200
258
  // Route not found — passthrough or 404
201
259
  if (isRouteNotFound(schmockResponse)) {
202
260
  if (passthrough) {
203
- return originalFetch(input, init);
261
+ return PASSTHROUGH;
204
262
  }
205
263
  return new Response(JSON.stringify({
206
264
  error: "No matching mock route found",
@@ -223,13 +281,17 @@ export function createFetchInterceptor(handle, options = {}) {
223
281
  if (!responseHeaders.has("content-type") &&
224
282
  response.body !== null &&
225
283
  response.body !== undefined) {
226
- responseHeaders.set("content-type", "application/json");
284
+ responseHeaders.set("content-type", isBinaryBody(response.body)
285
+ ? "application/octet-stream"
286
+ : "application/json");
227
287
  }
228
288
  const responseBody = response.body === null || response.body === undefined
229
289
  ? null
230
290
  : typeof response.body === "string"
231
291
  ? response.body
232
- : JSON.stringify(response.body);
292
+ : isBinaryBody(response.body)
293
+ ? toFetchBinaryBody(response.body)
294
+ : JSON.stringify(response.body);
233
295
  return new Response(responseBody, {
234
296
  status: response.status,
235
297
  headers: responseHeaders,
@@ -245,16 +307,5 @@ export function createFetchInterceptor(handle, options = {}) {
245
307
  }
246
308
  throw error;
247
309
  }
248
- };
249
- return {
250
- restore() {
251
- if (active) {
252
- globalThis.fetch = originalFetch;
253
- active = false;
254
- }
255
- },
256
- get active() {
257
- return active;
258
- },
259
- };
310
+ });
260
311
  }
@@ -2,14 +2,21 @@
2
2
  interface PipelineLogger {
3
3
  log(category: string, message: string, data?: unknown): void;
4
4
  }
5
+ interface PipelineResult {
6
+ context: Schmock.PluginContext;
7
+ response?: unknown;
8
+ recoveredFromError?: boolean;
9
+ requestShortCircuited?: boolean;
10
+ }
11
+ /** Run request guards before route code is allowed to execute. */
12
+ export declare function runPluginBeforeRequest(plugins: Schmock.Plugin[], context: Schmock.PluginContext, logger: PipelineLogger): Promise<PipelineResult>;
13
+ /** Give pipeline error handlers a chance to recover a route-generator error. */
14
+ export declare function recoverGeneratorError(plugins: Schmock.Plugin[], context: Schmock.PluginContext, error: unknown, logger: PipelineLogger): Promise<PipelineResult>;
5
15
  /**
6
16
  * Run all registered plugins in sequence
7
17
  * First plugin to set response becomes generator, subsequent plugins transform
8
18
  * Handles plugin errors via onError hooks
9
19
  */
10
- export declare function runPluginPipeline(plugins: Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger): Promise<{
11
- context: Schmock.PluginContext;
12
- response?: unknown;
13
- }>;
20
+ export declare function runPluginPipeline(plugins: Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger): Promise<PipelineResult>;
14
21
  export {};
15
22
  //# sourceMappingURL=plugin-pipeline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin-pipeline.d.ts","sourceRoot":"","sources":["../src/plugin-pipeline.ts"],"names":[],"mappings":"AAGA,uEAAuE;AACvE,UAAU,cAAc;IACtB,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC9D;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,EACzB,OAAO,EAAE,OAAO,CAAC,aAAa,EAC9B,eAAe,EAAE,OAAO,EACxB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC,aAAa,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAoFjE"}
1
+ {"version":3,"file":"plugin-pipeline.d.ts","sourceRoot":"","sources":["../src/plugin-pipeline.ts"],"names":[],"mappings":"AAEA,uEAAuE;AACvE,UAAU,cAAc;IACtB,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC9D;AAED,UAAU,cAAc;IACtB,OAAO,EAAE,OAAO,CAAC,aAAa,CAAC;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAqDD,kEAAkE;AAClE,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,EACzB,OAAO,EAAE,OAAO,CAAC,aAAa,EAC9B,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,cAAc,CAAC,CAiDzB;AAED,gFAAgF;AAChF,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,EACzB,OAAO,EAAE,OAAO,CAAC,aAAa,EAC9B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,cAAc,CAAC,CAUzB;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,EACzB,OAAO,EAAE,OAAO,CAAC,aAAa,EAC9B,eAAe,EAAE,OAAO,EACxB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,cAAc,CAAC,CA0DzB"}
@@ -1,5 +1,91 @@
1
- import { isStatusTuple } from "./constants.js";
2
1
  import { errorMessage, PluginError } from "./errors.js";
2
+ function isPluginResult(value) {
3
+ return (typeof value === "object" &&
4
+ value !== null &&
5
+ "context" in value &&
6
+ typeof value.context === "object" &&
7
+ value.context !== null);
8
+ }
9
+ async function recoverPluginError(plugins, startIndex, error, context, logger) {
10
+ let currentError = error instanceof Error ? error : new Error(errorMessage(error));
11
+ for (let index = startIndex; index < plugins.length; index += 1) {
12
+ const plugin = plugins[index];
13
+ if (!plugin.onError)
14
+ continue;
15
+ try {
16
+ const errorResult = await plugin.onError(currentError, context);
17
+ if (errorResult instanceof Error) {
18
+ currentError = errorResult;
19
+ continue;
20
+ }
21
+ if (errorResult !== undefined) {
22
+ logger.log("pipeline", `Plugin ${plugin.name} handled error`);
23
+ return { handled: true, response: errorResult };
24
+ }
25
+ }
26
+ catch (hookError) {
27
+ currentError =
28
+ hookError instanceof Error
29
+ ? hookError
30
+ : new Error(errorMessage(hookError));
31
+ logger.log("pipeline", `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`);
32
+ }
33
+ }
34
+ return { handled: false, error: currentError };
35
+ }
36
+ /** Run request guards before route code is allowed to execute. */
37
+ export async function runPluginBeforeRequest(plugins, context, logger) {
38
+ let currentContext = context;
39
+ for (let index = 0; index < plugins.length; index += 1) {
40
+ const plugin = plugins[index];
41
+ if (!plugin.beforeRequest)
42
+ continue;
43
+ logger.log("pipeline", `Running beforeRequest: ${plugin.name}`);
44
+ try {
45
+ const result = await plugin.beforeRequest(currentContext);
46
+ if (result === undefined)
47
+ continue;
48
+ if (!isPluginResult(result)) {
49
+ throw new Error(`Plugin ${plugin.name} didn't return valid result`);
50
+ }
51
+ currentContext = result.context;
52
+ if (result.response !== undefined) {
53
+ logger.log("pipeline", `Plugin ${plugin.name} rejected request`);
54
+ return {
55
+ context: currentContext,
56
+ response: result.response,
57
+ requestShortCircuited: true,
58
+ };
59
+ }
60
+ }
61
+ catch (error) {
62
+ logger.log("pipeline", `Plugin ${plugin.name} beforeRequest failed: ${errorMessage(error)}`);
63
+ const recovery = await recoverPluginError(plugins, index, error, currentContext, logger);
64
+ if (recovery.handled) {
65
+ return {
66
+ context: currentContext,
67
+ response: recovery.response,
68
+ recoveredFromError: true,
69
+ requestShortCircuited: true,
70
+ };
71
+ }
72
+ throw new PluginError(plugin.name, recovery.error);
73
+ }
74
+ }
75
+ return { context: currentContext };
76
+ }
77
+ /** Give pipeline error handlers a chance to recover a route-generator error. */
78
+ export async function recoverGeneratorError(plugins, context, error, logger) {
79
+ const recovery = await recoverPluginError(plugins, 0, error, context, logger);
80
+ if (recovery.handled) {
81
+ return {
82
+ context,
83
+ response: recovery.response,
84
+ recoveredFromError: true,
85
+ };
86
+ }
87
+ throw recovery.error;
88
+ }
3
89
  /**
4
90
  * Run all registered plugins in sequence
5
91
  * First plugin to set response becomes generator, subsequent plugins transform
@@ -9,11 +95,12 @@ export async function runPluginPipeline(plugins, context, initialResponse, logge
9
95
  let currentContext = context;
10
96
  let response = initialResponse;
11
97
  logger.log("pipeline", `Running plugin pipeline for ${plugins.length} plugins`);
12
- for (const plugin of plugins) {
98
+ for (let index = 0; index < plugins.length; index += 1) {
99
+ const plugin = plugins[index];
13
100
  logger.log("pipeline", `Processing plugin: ${plugin.name}`);
14
101
  try {
15
102
  const result = await plugin.process(currentContext, response);
16
- if (!result?.context) {
103
+ if (!isPluginResult(result)) {
17
104
  throw new Error(`Plugin ${plugin.name} didn't return valid result`);
18
105
  }
19
106
  currentContext = result.context;
@@ -30,39 +117,15 @@ export async function runPluginPipeline(plugins, context, initialResponse, logge
30
117
  }
31
118
  catch (error) {
32
119
  logger.log("pipeline", `Plugin ${plugin.name} failed: ${errorMessage(error)}`);
33
- // Try error handling if plugin has onError hook
34
- if (plugin.onError) {
35
- try {
36
- const pluginError = error instanceof Error ? error : new Error(errorMessage(error));
37
- const errorResult = await plugin.onError(pluginError, currentContext);
38
- if (errorResult) {
39
- logger.log("pipeline", `Plugin ${plugin.name} handled error`);
40
- // Error return → transform the thrown error
41
- if (errorResult instanceof Error) {
42
- throw new PluginError(plugin.name, errorResult);
43
- }
44
- // ResponseResult return → recover, stop pipeline
45
- // Accept both object-with-status and status tuple [code, body] / [code, body, headers]
46
- if (isStatusTuple(errorResult) ||
47
- (typeof errorResult === "object" &&
48
- errorResult !== null &&
49
- "status" in errorResult)) {
50
- response = errorResult;
51
- break;
52
- }
53
- }
54
- // void/falsy return → propagate original error below
55
- }
56
- catch (hookError) {
57
- // If the hook itself threw (including our PluginError above), re-throw it
58
- if (hookError instanceof PluginError) {
59
- throw hookError;
60
- }
61
- logger.log("pipeline", `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`);
62
- }
120
+ const recovery = await recoverPluginError(plugins, index, error, currentContext, logger);
121
+ if (recovery.handled) {
122
+ return {
123
+ context: currentContext,
124
+ response: recovery.response,
125
+ recoveredFromError: true,
126
+ };
63
127
  }
64
- const cause = error instanceof Error ? error : new Error(errorMessage(error));
65
- throw new PluginError(plugin.name, cause);
128
+ throw new PluginError(plugin.name, recovery.error);
66
129
  }
67
130
  }
68
131
  return { context: currentContext, response };
@@ -1 +1 @@
1
- {"version":3,"file":"response-parser.d.ts","sourceRoot":"","sources":["../src/response-parser.ts"],"names":[],"mappings":"AAgBA;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,WAAW,EAAE,OAAO,CAAC,WAAW,GAC/B,OAAO,CAAC,QAAQ,CAmDlB"}
1
+ {"version":3,"file":"response-parser.d.ts","sourceRoot":"","sources":["../src/response-parser.ts"],"names":[],"mappings":"AAoCA;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,WAAW,EAAE,OAAO,CAAC,WAAW,GAC/B,OAAO,CAAC,QAAQ,CAsElB"}
@@ -1,10 +1,23 @@
1
+ import { isBinaryBody } from "./binary.js";
1
2
  import { isStatusTuple } from "./constants.js";
3
+ const BINARY_CONTENT_TYPE = "application/octet-stream";
4
+ function hasContentType(headers) {
5
+ return Object.keys(headers).some((header) => header.toLowerCase() === "content-type");
6
+ }
2
7
  function isResponseObject(value) {
3
8
  return (typeof value === "object" &&
4
9
  value !== null &&
5
10
  "status" in value &&
6
11
  typeof value.status === "number" &&
7
- "body" in value);
12
+ "body" in value &&
13
+ (!("headers" in value) ||
14
+ value.headers === undefined ||
15
+ isStringRecord(value.headers)));
16
+ }
17
+ function isStringRecord(value) {
18
+ return (typeof value === "object" &&
19
+ value !== null &&
20
+ Object.values(value).every((entry) => typeof entry === "string"));
8
21
  }
9
22
  /**
10
23
  * Parse and normalize response result into Response object
@@ -17,14 +30,13 @@ export function parseResponse(result, routeConfig) {
17
30
  let tupleFormat = false;
18
31
  // Handle already-formed response objects (from plugin error recovery)
19
32
  if (isResponseObject(result)) {
20
- return {
21
- status: result.status,
22
- body: result.body,
23
- headers: result.headers || {},
24
- };
33
+ status = result.status;
34
+ body = result.body;
35
+ headers = result.headers ?? {};
36
+ tupleFormat = true;
25
37
  }
26
- // Handle tuple response format [status, body, headers?]
27
- if (isStatusTuple(result)) {
38
+ else if (isStatusTuple(result)) {
39
+ // Handle tuple response format [status, body, headers?]
28
40
  [status, body, headers = {}] = result;
29
41
  tupleFormat = true;
30
42
  }
@@ -36,18 +48,36 @@ export function parseResponse(result, routeConfig) {
36
48
  }
37
49
  body = undefined; // Ensure body is undefined for null responses
38
50
  }
51
+ const binaryBody = isBinaryBody(body);
52
+ // Binary response values need a transport-safe MIME type. Tuple headers still
53
+ // take precedence, while a non-JSON route override (for example image/png)
54
+ // remains authoritative for non-tuple responses.
55
+ if (!hasContentType(headers) && binaryBody) {
56
+ headers["content-type"] =
57
+ !tupleFormat &&
58
+ routeConfig.contentType &&
59
+ routeConfig.contentType !== "application/json"
60
+ ? routeConfig.contentType
61
+ : BINARY_CONTENT_TYPE;
62
+ }
39
63
  // Add content-type header from route config if it exists and headers don't already have it
40
64
  // But only if this isn't a tuple response (where headers are explicitly controlled)
41
- if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
65
+ let appliedRouteContentType = false;
66
+ if (!hasContentType(headers) && routeConfig.contentType && !tupleFormat) {
42
67
  headers["content-type"] = routeConfig.contentType;
43
- // Handle special conversion cases when contentType is explicitly set
44
- if (routeConfig.contentType === "text/plain" && body !== undefined) {
45
- if (typeof body === "object" && !Buffer.isBuffer(body)) {
46
- body = JSON.stringify(body);
47
- }
48
- else if (typeof body !== "string") {
49
- body = String(body);
50
- }
68
+ appliedRouteContentType = true;
69
+ }
70
+ // Handle special conversion cases when contentType is explicitly set. A
71
+ // binary body keeps its bytes even when a custom MIME type is configured.
72
+ if (appliedRouteContentType &&
73
+ routeConfig.contentType === "text/plain" &&
74
+ body !== undefined &&
75
+ !binaryBody) {
76
+ if (typeof body === "object") {
77
+ body = JSON.stringify(body);
78
+ }
79
+ else if (typeof body !== "string") {
80
+ body = String(body);
51
81
  }
52
82
  }
53
83
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/core",
3
3
  "description": "Core functionality for Schmock",
4
- "version": "2.2.2",
4
+ "version": "2.3.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -22,7 +22,7 @@
22
22
  "build:lib": "bun build --minify --target node --outdir=dist src/index.ts",
23
23
  "build:types": "rm -f tsconfig.tsbuildinfo && tsc --build && node scripts/embed-ambient-types.js",
24
24
  "pretest": "rm -f src/*.js src/*.d.ts || true",
25
- "test": "vitest",
25
+ "test": "vitest run",
26
26
  "test:watch": "vitest --watch",
27
27
  "pretest:bdd": "rm -f src/*.js src/*.d.ts || true",
28
28
  "test:bdd": "vitest run --config vitest.config.bdd.ts",
@@ -31,9 +31,11 @@
31
31
  "check:publish": "publint && attw --pack --ignore-rules cjs-resolves-to-esm"
32
32
  },
33
33
  "license": "MIT",
34
+ "dependencies": {
35
+ "@types/json-schema": "^7.0.15"
36
+ },
34
37
  "devDependencies": {
35
38
  "@amiceli/vitest-cucumber": "^7.0.0",
36
- "@types/json-schema": "^7.0.15",
37
39
  "@types/node": "^26.1.0",
38
40
  "@vitest/ui": "^4.1.9",
39
41
  "vitest": "^4.1.9"
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isBinaryBody } from "./binary.js";
3
+ import { schmock } from "./index.js";
4
+ import { parseResponse } from "./response-parser.js";
5
+
6
+ function binaryBytes(value: unknown): number[] {
7
+ if (value instanceof ArrayBuffer) {
8
+ return [...new Uint8Array(value)];
9
+ }
10
+ if (ArrayBuffer.isView(value)) {
11
+ return [
12
+ ...new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
13
+ ];
14
+ }
15
+ throw new Error("Expected a binary response body");
16
+ }
17
+
18
+ describe("isBinaryBody", () => {
19
+ it("recognizes browser binary values without a Node Buffer global", () => {
20
+ expect(isBinaryBody(new Uint8Array([1, 2, 3]))).toBe(true);
21
+ expect(isBinaryBody(new ArrayBuffer(3))).toBe(true);
22
+ expect(isBinaryBody(new DataView(new SharedArrayBuffer(3)))).toBe(true);
23
+ expect(isBinaryBody({ 0: 1, length: 1 })).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe("parseResponse binary handling", () => {
28
+ it("stringifies text objects when Buffer is not available", () => {
29
+ const originalBuffer = globalThis.Buffer;
30
+ Reflect.deleteProperty(globalThis, "Buffer");
31
+
32
+ try {
33
+ const response = parseResponse(
34
+ { message: "browser" },
35
+ { contentType: "text/plain" },
36
+ );
37
+
38
+ expect(response.body).toBe('{"message":"browser"}');
39
+ } finally {
40
+ globalThis.Buffer = originalBuffer;
41
+ }
42
+ });
43
+
44
+ it("detects binary returned by a dynamic generator", async () => {
45
+ const mock = schmock();
46
+ mock("GET /dynamic-binary", () => new Uint8Array([1, 2, 3]));
47
+
48
+ const response = await mock.handle("GET", "/dynamic-binary");
49
+
50
+ expect(response.headers["content-type"]).toBe("application/octet-stream");
51
+ expect(binaryBytes(response.body)).toEqual([1, 2, 3]);
52
+ });
53
+
54
+ it("detects binary in a status tuple", async () => {
55
+ const mock = schmock();
56
+ const bytes = new Uint8Array([4, 5, 6]);
57
+ mock(
58
+ "GET /tuple-binary",
59
+ () => [206, new DataView(bytes.buffer)] satisfies [number, unknown],
60
+ );
61
+
62
+ const response = await mock.handle("GET", "/tuple-binary");
63
+
64
+ expect(response.status).toBe(206);
65
+ expect(response.headers["content-type"]).toBe("application/octet-stream");
66
+ expect(binaryBytes(response.body)).toEqual([4, 5, 6]);
67
+ });
68
+
69
+ it("detects binary in a response object", () => {
70
+ const response = parseResponse(
71
+ { status: 206, body: new Uint8Array([7, 8]), headers: {} },
72
+ {},
73
+ );
74
+
75
+ expect(response.status).toBe(206);
76
+ expect(response.headers["content-type"]).toBe("application/octet-stream");
77
+ expect(binaryBytes(response.body)).toEqual([7, 8]);
78
+ });
79
+
80
+ it("preserves an explicit binary content type", async () => {
81
+ const mock = schmock();
82
+ mock("GET /image", () => new Uint8Array([137, 80, 78, 71]), {
83
+ contentType: "image/png",
84
+ });
85
+
86
+ const response = await mock.handle("GET", "/image");
87
+
88
+ expect(response.headers["content-type"]).toBe("image/png");
89
+ expect(binaryBytes(response.body)).toEqual([137, 80, 78, 71]);
90
+ });
91
+
92
+ it("preserves a tuple content-type header without adding a duplicate", () => {
93
+ const response = parseResponse(
94
+ [
95
+ 200,
96
+ new Uint8Array([1]),
97
+ { "Content-Type": "application/custom-binary" },
98
+ ],
99
+ { contentType: "application/json" },
100
+ );
101
+
102
+ expect(response.headers).toEqual({
103
+ "Content-Type": "application/custom-binary",
104
+ });
105
+ expect(binaryBytes(response.body)).toEqual([1]);
106
+ });
107
+ });
108
+
109
+ describe("browser runtime compatibility", () => {
110
+ it("defines and serves object routes when Buffer is not available", async () => {
111
+ const originalBuffer = globalThis.Buffer;
112
+ Reflect.deleteProperty(globalThis, "Buffer");
113
+
114
+ try {
115
+ const mock = schmock();
116
+ mock("GET /browser", { runtime: "browser" });
117
+
118
+ const response = await mock.handle("GET", "/browser");
119
+ expect(response.status).toBe(200);
120
+ expect(response.body).toEqual({ runtime: "browser" });
121
+ expect(response.headers["content-type"]).toBe("application/json");
122
+ } finally {
123
+ globalThis.Buffer = originalBuffer;
124
+ }
125
+ });
126
+ });
package/src/binary.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Browser-safe binary body detection.
3
+ *
4
+ * Every ArrayBuffer view is accepted, including DataView, typed arrays backed
5
+ * by SharedArrayBuffer, and Node.js Buffer without referencing its global.
6
+ */
7
+ export function isBinaryBody(
8
+ value: unknown,
9
+ ): value is ArrayBuffer | ArrayBufferView {
10
+ return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
11
+ }