@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.
- package/dist/binary.d.ts +8 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +9 -0
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +60 -29
- package/dist/http-helpers.d.ts +15 -3
- package/dist/http-helpers.d.ts.map +1 -1
- package/dist/http-helpers.js +24 -6
- package/dist/index.d.ts +90 -27
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +71 -20
- package/dist/plugin-pipeline.d.ts +11 -4
- package/dist/plugin-pipeline.d.ts.map +1 -1
- package/dist/plugin-pipeline.js +98 -35
- package/dist/response-parser.d.ts.map +1 -1
- package/dist/response-parser.js +47 -17
- package/package.json +5 -3
- package/src/binary.test.ts +126 -0
- package/src/binary.ts +11 -0
- package/src/builder.ts +82 -35
- package/src/http-helpers.test.ts +39 -0
- package/src/http-helpers.ts +46 -10
- package/src/index.ts +1 -0
- package/src/interceptor.test.ts +197 -0
- package/src/interceptor.ts +215 -128
- package/src/parser.property.test.ts +91 -55
- package/src/plugin-pipeline.ts +150 -45
- package/src/plugin-system.test.ts +95 -0
- package/src/response-parser.ts +58 -19
- package/src/server.test.ts +81 -12
- package/src/steps/async-support.steps.ts +29 -8
- package/src/steps/audit-onerror-tuple.steps.ts +14 -30
- package/src/steps/error-handling.steps.ts +463 -253
- package/src/steps/fetch-interceptor.steps.ts +457 -0
- package/src/steps/http-methods.steps.ts +19 -8
- package/src/steps/lifecycle-events.steps.ts +50 -43
- package/src/steps/performance-reliability.steps.ts +78 -411
- package/src/steps/plugin-integration.steps.ts +54 -0
- package/src/steps/request-history.steps.ts +412 -230
- package/src/steps/standalone-server.steps.ts +7 -13
- package/src/steps/state-concurrency.steps.ts +142 -714
- package/src/steps/interceptor.steps.ts +0 -260
- package/src/steps/stateful-workflows.steps.ts +0 -353
package/dist/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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",
|
|
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
|
-
:
|
|
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":"
|
|
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"}
|
package/dist/plugin-pipeline.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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":"
|
|
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"}
|
package/dist/response-parser.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
33
|
+
status = result.status;
|
|
34
|
+
body = result.body;
|
|
35
|
+
headers = result.headers ?? {};
|
|
36
|
+
tupleFormat = true;
|
|
25
37
|
}
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
65
|
+
let appliedRouteContentType = false;
|
|
66
|
+
if (!hasContentType(headers) && routeConfig.contentType && !tupleFormat) {
|
|
42
67
|
headers["content-type"] = routeConfig.contentType;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
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
|
+
}
|