@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/src/plugin-pipeline.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { isStatusTuple } from "./constants.js";
|
|
2
1
|
import { errorMessage, PluginError } from "./errors.js";
|
|
3
2
|
|
|
4
3
|
/** Structural typing — DebugLogger satisfies this without an import */
|
|
@@ -6,6 +5,138 @@ interface PipelineLogger {
|
|
|
6
5
|
log(category: string, message: string, data?: unknown): void;
|
|
7
6
|
}
|
|
8
7
|
|
|
8
|
+
interface PipelineResult {
|
|
9
|
+
context: Schmock.PluginContext;
|
|
10
|
+
response?: unknown;
|
|
11
|
+
recoveredFromError?: boolean;
|
|
12
|
+
requestShortCircuited?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isPluginResult(value: unknown): value is Schmock.PluginResult {
|
|
16
|
+
return (
|
|
17
|
+
typeof value === "object" &&
|
|
18
|
+
value !== null &&
|
|
19
|
+
"context" in value &&
|
|
20
|
+
typeof value.context === "object" &&
|
|
21
|
+
value.context !== null
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function recoverPluginError(
|
|
26
|
+
plugins: Schmock.Plugin[],
|
|
27
|
+
startIndex: number,
|
|
28
|
+
error: unknown,
|
|
29
|
+
context: Schmock.PluginContext,
|
|
30
|
+
logger: PipelineLogger,
|
|
31
|
+
): Promise<
|
|
32
|
+
{ handled: true; response: unknown } | { handled: false; error: Error }
|
|
33
|
+
> {
|
|
34
|
+
let currentError =
|
|
35
|
+
error instanceof Error ? error : new Error(errorMessage(error));
|
|
36
|
+
|
|
37
|
+
for (let index = startIndex; index < plugins.length; index += 1) {
|
|
38
|
+
const plugin = plugins[index];
|
|
39
|
+
if (!plugin.onError) continue;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const errorResult = await plugin.onError(currentError, context);
|
|
43
|
+
if (errorResult instanceof Error) {
|
|
44
|
+
currentError = errorResult;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (errorResult !== undefined) {
|
|
48
|
+
logger.log("pipeline", `Plugin ${plugin.name} handled error`);
|
|
49
|
+
return { handled: true, response: errorResult };
|
|
50
|
+
}
|
|
51
|
+
} catch (hookError) {
|
|
52
|
+
currentError =
|
|
53
|
+
hookError instanceof Error
|
|
54
|
+
? hookError
|
|
55
|
+
: new Error(errorMessage(hookError));
|
|
56
|
+
logger.log(
|
|
57
|
+
"pipeline",
|
|
58
|
+
`Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { handled: false, error: currentError };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Run request guards before route code is allowed to execute. */
|
|
67
|
+
export async function runPluginBeforeRequest(
|
|
68
|
+
plugins: Schmock.Plugin[],
|
|
69
|
+
context: Schmock.PluginContext,
|
|
70
|
+
logger: PipelineLogger,
|
|
71
|
+
): Promise<PipelineResult> {
|
|
72
|
+
let currentContext = context;
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index < plugins.length; index += 1) {
|
|
75
|
+
const plugin = plugins[index];
|
|
76
|
+
if (!plugin.beforeRequest) continue;
|
|
77
|
+
|
|
78
|
+
logger.log("pipeline", `Running beforeRequest: ${plugin.name}`);
|
|
79
|
+
try {
|
|
80
|
+
const result = await plugin.beforeRequest(currentContext);
|
|
81
|
+
if (result === undefined) continue;
|
|
82
|
+
if (!isPluginResult(result)) {
|
|
83
|
+
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
currentContext = result.context;
|
|
87
|
+
if (result.response !== undefined) {
|
|
88
|
+
logger.log("pipeline", `Plugin ${plugin.name} rejected request`);
|
|
89
|
+
return {
|
|
90
|
+
context: currentContext,
|
|
91
|
+
response: result.response,
|
|
92
|
+
requestShortCircuited: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.log(
|
|
97
|
+
"pipeline",
|
|
98
|
+
`Plugin ${plugin.name} beforeRequest failed: ${errorMessage(error)}`,
|
|
99
|
+
);
|
|
100
|
+
const recovery = await recoverPluginError(
|
|
101
|
+
plugins,
|
|
102
|
+
index,
|
|
103
|
+
error,
|
|
104
|
+
currentContext,
|
|
105
|
+
logger,
|
|
106
|
+
);
|
|
107
|
+
if (recovery.handled) {
|
|
108
|
+
return {
|
|
109
|
+
context: currentContext,
|
|
110
|
+
response: recovery.response,
|
|
111
|
+
recoveredFromError: true,
|
|
112
|
+
requestShortCircuited: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
throw new PluginError(plugin.name, recovery.error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { context: currentContext };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Give pipeline error handlers a chance to recover a route-generator error. */
|
|
123
|
+
export async function recoverGeneratorError(
|
|
124
|
+
plugins: Schmock.Plugin[],
|
|
125
|
+
context: Schmock.PluginContext,
|
|
126
|
+
error: unknown,
|
|
127
|
+
logger: PipelineLogger,
|
|
128
|
+
): Promise<PipelineResult> {
|
|
129
|
+
const recovery = await recoverPluginError(plugins, 0, error, context, logger);
|
|
130
|
+
if (recovery.handled) {
|
|
131
|
+
return {
|
|
132
|
+
context,
|
|
133
|
+
response: recovery.response,
|
|
134
|
+
recoveredFromError: true,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
throw recovery.error;
|
|
138
|
+
}
|
|
139
|
+
|
|
9
140
|
/**
|
|
10
141
|
* Run all registered plugins in sequence
|
|
11
142
|
* First plugin to set response becomes generator, subsequent plugins transform
|
|
@@ -16,7 +147,7 @@ export async function runPluginPipeline(
|
|
|
16
147
|
context: Schmock.PluginContext,
|
|
17
148
|
initialResponse: unknown,
|
|
18
149
|
logger: PipelineLogger,
|
|
19
|
-
): Promise<
|
|
150
|
+
): Promise<PipelineResult> {
|
|
20
151
|
let currentContext = context;
|
|
21
152
|
let response: unknown = initialResponse;
|
|
22
153
|
|
|
@@ -25,13 +156,14 @@ export async function runPluginPipeline(
|
|
|
25
156
|
`Running plugin pipeline for ${plugins.length} plugins`,
|
|
26
157
|
);
|
|
27
158
|
|
|
28
|
-
for (
|
|
159
|
+
for (let index = 0; index < plugins.length; index += 1) {
|
|
160
|
+
const plugin = plugins[index];
|
|
29
161
|
logger.log("pipeline", `Processing plugin: ${plugin.name}`);
|
|
30
162
|
|
|
31
163
|
try {
|
|
32
164
|
const result = await plugin.process(currentContext, response);
|
|
33
165
|
|
|
34
|
-
if (!result
|
|
166
|
+
if (!isPluginResult(result)) {
|
|
35
167
|
throw new Error(`Plugin ${plugin.name} didn't return valid result`);
|
|
36
168
|
}
|
|
37
169
|
|
|
@@ -54,48 +186,21 @@ export async function runPluginPipeline(
|
|
|
54
186
|
`Plugin ${plugin.name} failed: ${errorMessage(error)}`,
|
|
55
187
|
);
|
|
56
188
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// ResponseResult return → recover, stop pipeline
|
|
72
|
-
// Accept both object-with-status and status tuple [code, body] / [code, body, headers]
|
|
73
|
-
if (
|
|
74
|
-
isStatusTuple(errorResult) ||
|
|
75
|
-
(typeof errorResult === "object" &&
|
|
76
|
-
errorResult !== null &&
|
|
77
|
-
"status" in errorResult)
|
|
78
|
-
) {
|
|
79
|
-
response = errorResult;
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// void/falsy return → propagate original error below
|
|
84
|
-
} catch (hookError) {
|
|
85
|
-
// If the hook itself threw (including our PluginError above), re-throw it
|
|
86
|
-
if (hookError instanceof PluginError) {
|
|
87
|
-
throw hookError;
|
|
88
|
-
}
|
|
89
|
-
logger.log(
|
|
90
|
-
"pipeline",
|
|
91
|
-
`Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
189
|
+
const recovery = await recoverPluginError(
|
|
190
|
+
plugins,
|
|
191
|
+
index,
|
|
192
|
+
error,
|
|
193
|
+
currentContext,
|
|
194
|
+
logger,
|
|
195
|
+
);
|
|
196
|
+
if (recovery.handled) {
|
|
197
|
+
return {
|
|
198
|
+
context: currentContext,
|
|
199
|
+
response: recovery.response,
|
|
200
|
+
recoveredFromError: true,
|
|
201
|
+
};
|
|
94
202
|
}
|
|
95
|
-
|
|
96
|
-
const cause =
|
|
97
|
-
error instanceof Error ? error : new Error(errorMessage(error));
|
|
98
|
-
throw new PluginError(plugin.name, cause);
|
|
203
|
+
throw new PluginError(plugin.name, recovery.error);
|
|
99
204
|
}
|
|
100
205
|
}
|
|
101
206
|
|
|
@@ -86,6 +86,57 @@ describe("plugin system", () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
describe("plugin execution pipeline", () => {
|
|
89
|
+
it("runs request guards before the route generator", async () => {
|
|
90
|
+
const mock = schmock();
|
|
91
|
+
const generator = vi.fn(() => ({ created: true }));
|
|
92
|
+
const guard: Schmock.Plugin = {
|
|
93
|
+
name: "guard",
|
|
94
|
+
beforeRequest(context) {
|
|
95
|
+
return {
|
|
96
|
+
context,
|
|
97
|
+
response: [401, { code: "AUTH_REQUIRED" }],
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
process: (context, currentResponse) => ({
|
|
101
|
+
context,
|
|
102
|
+
response: currentResponse,
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
mock("POST /guarded", generator).pipe(guard);
|
|
107
|
+
|
|
108
|
+
const response = await mock.handle("POST", "/guarded");
|
|
109
|
+
expect(response.status).toBe(401);
|
|
110
|
+
expect(response.body).toEqual({ code: "AUTH_REQUIRED" });
|
|
111
|
+
expect(generator).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("passes pre-request context changes into the generator", async () => {
|
|
115
|
+
const mock = schmock();
|
|
116
|
+
const plugin: Schmock.Plugin = {
|
|
117
|
+
name: "request-transformer",
|
|
118
|
+
beforeRequest(context) {
|
|
119
|
+
return {
|
|
120
|
+
context: {
|
|
121
|
+
...context,
|
|
122
|
+
headers: { ...context.headers, "x-added": "yes" },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
process: (context, currentResponse) => ({
|
|
127
|
+
context,
|
|
128
|
+
response: currentResponse,
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
mock("GET /context", ({ headers }) => ({
|
|
133
|
+
added: headers["x-added"],
|
|
134
|
+
})).pipe(plugin);
|
|
135
|
+
|
|
136
|
+
const response = await mock.handle("GET", "/context");
|
|
137
|
+
expect(response.body).toEqual({ added: "yes" });
|
|
138
|
+
});
|
|
139
|
+
|
|
89
140
|
it("passes context between plugins", async () => {
|
|
90
141
|
const mock = schmock();
|
|
91
142
|
let receivedContext: any;
|
|
@@ -251,6 +302,50 @@ describe("plugin system", () => {
|
|
|
251
302
|
});
|
|
252
303
|
|
|
253
304
|
describe("plugin error handling", () => {
|
|
305
|
+
it("allows a downstream error handler to recover an earlier failure", async () => {
|
|
306
|
+
const mock = schmock();
|
|
307
|
+
const failingPlugin: Schmock.Plugin = {
|
|
308
|
+
name: "failing",
|
|
309
|
+
process() {
|
|
310
|
+
throw new Error("failed");
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
const recoveryPlugin: Schmock.Plugin = {
|
|
314
|
+
name: "recovery",
|
|
315
|
+
process: (context, currentResponse) => ({
|
|
316
|
+
context,
|
|
317
|
+
response: currentResponse,
|
|
318
|
+
}),
|
|
319
|
+
onError: () => [503, { code: "RECOVERED" }],
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
mock("GET /recover", "original").pipe(failingPlugin).pipe(recoveryPlugin);
|
|
323
|
+
|
|
324
|
+
const response = await mock.handle("GET", "/recover");
|
|
325
|
+
expect(response.status).toBe(503);
|
|
326
|
+
expect(response.body).toEqual({ code: "RECOVERED" });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("allows a plugin error handler to recover a generator failure", async () => {
|
|
330
|
+
const mock = schmock();
|
|
331
|
+
const recoveryPlugin: Schmock.Plugin = {
|
|
332
|
+
name: "generator-recovery",
|
|
333
|
+
process: (context, currentResponse) => ({
|
|
334
|
+
context,
|
|
335
|
+
response: currentResponse,
|
|
336
|
+
}),
|
|
337
|
+
onError: () => [502, { code: "GENERATOR_RECOVERED" }],
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
mock("GET /generator-failure", () => {
|
|
341
|
+
throw new Error("generator failed");
|
|
342
|
+
}).pipe(recoveryPlugin);
|
|
343
|
+
|
|
344
|
+
const response = await mock.handle("GET", "/generator-failure");
|
|
345
|
+
expect(response.status).toBe(502);
|
|
346
|
+
expect(response.body).toEqual({ code: "GENERATOR_RECOVERED" });
|
|
347
|
+
});
|
|
348
|
+
|
|
254
349
|
it("throws PluginError when plugin process fails", async () => {
|
|
255
350
|
const mock = schmock();
|
|
256
351
|
|
package/src/response-parser.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { isBinaryBody } from "./binary.js";
|
|
1
2
|
import { isStatusTuple } from "./constants.js";
|
|
2
3
|
|
|
4
|
+
const BINARY_CONTENT_TYPE = "application/octet-stream";
|
|
5
|
+
|
|
6
|
+
function hasContentType(headers: Record<string, string>): boolean {
|
|
7
|
+
return Object.keys(headers).some(
|
|
8
|
+
(header) => header.toLowerCase() === "content-type",
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
function isResponseObject(value: unknown): value is {
|
|
4
13
|
status: number;
|
|
5
14
|
body: unknown;
|
|
@@ -9,8 +18,19 @@ function isResponseObject(value: unknown): value is {
|
|
|
9
18
|
typeof value === "object" &&
|
|
10
19
|
value !== null &&
|
|
11
20
|
"status" in value &&
|
|
12
|
-
typeof
|
|
13
|
-
"body" in value
|
|
21
|
+
typeof value.status === "number" &&
|
|
22
|
+
"body" in value &&
|
|
23
|
+
(!("headers" in value) ||
|
|
24
|
+
value.headers === undefined ||
|
|
25
|
+
isStringRecord(value.headers))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
30
|
+
return (
|
|
31
|
+
typeof value === "object" &&
|
|
32
|
+
value !== null &&
|
|
33
|
+
Object.values(value).every((entry) => typeof entry === "string")
|
|
14
34
|
);
|
|
15
35
|
}
|
|
16
36
|
|
|
@@ -30,15 +50,12 @@ export function parseResponse(
|
|
|
30
50
|
|
|
31
51
|
// Handle already-formed response objects (from plugin error recovery)
|
|
32
52
|
if (isResponseObject(result)) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// Handle tuple response format [status, body, headers?]
|
|
41
|
-
if (isStatusTuple(result)) {
|
|
53
|
+
status = result.status;
|
|
54
|
+
body = result.body;
|
|
55
|
+
headers = result.headers ?? {};
|
|
56
|
+
tupleFormat = true;
|
|
57
|
+
} else if (isStatusTuple(result)) {
|
|
58
|
+
// Handle tuple response format [status, body, headers?]
|
|
42
59
|
[status, body, headers = {}] = result;
|
|
43
60
|
tupleFormat = true;
|
|
44
61
|
}
|
|
@@ -52,18 +69,40 @@ export function parseResponse(
|
|
|
52
69
|
body = undefined; // Ensure body is undefined for null responses
|
|
53
70
|
}
|
|
54
71
|
|
|
72
|
+
const binaryBody = isBinaryBody(body);
|
|
73
|
+
|
|
74
|
+
// Binary response values need a transport-safe MIME type. Tuple headers still
|
|
75
|
+
// take precedence, while a non-JSON route override (for example image/png)
|
|
76
|
+
// remains authoritative for non-tuple responses.
|
|
77
|
+
if (!hasContentType(headers) && binaryBody) {
|
|
78
|
+
headers["content-type"] =
|
|
79
|
+
!tupleFormat &&
|
|
80
|
+
routeConfig.contentType &&
|
|
81
|
+
routeConfig.contentType !== "application/json"
|
|
82
|
+
? routeConfig.contentType
|
|
83
|
+
: BINARY_CONTENT_TYPE;
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
// Add content-type header from route config if it exists and headers don't already have it
|
|
56
87
|
// But only if this isn't a tuple response (where headers are explicitly controlled)
|
|
57
|
-
|
|
88
|
+
let appliedRouteContentType = false;
|
|
89
|
+
if (!hasContentType(headers) && routeConfig.contentType && !tupleFormat) {
|
|
58
90
|
headers["content-type"] = routeConfig.contentType;
|
|
91
|
+
appliedRouteContentType = true;
|
|
92
|
+
}
|
|
59
93
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
// Handle special conversion cases when contentType is explicitly set. A
|
|
95
|
+
// binary body keeps its bytes even when a custom MIME type is configured.
|
|
96
|
+
if (
|
|
97
|
+
appliedRouteContentType &&
|
|
98
|
+
routeConfig.contentType === "text/plain" &&
|
|
99
|
+
body !== undefined &&
|
|
100
|
+
!binaryBody
|
|
101
|
+
) {
|
|
102
|
+
if (typeof body === "object") {
|
|
103
|
+
body = JSON.stringify(body);
|
|
104
|
+
} else if (typeof body !== "string") {
|
|
105
|
+
body = String(body);
|
|
67
106
|
}
|
|
68
107
|
}
|
|
69
108
|
|
package/src/server.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
1
2
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
3
|
import { schmock } from "./index";
|
|
3
4
|
|
|
@@ -71,6 +72,53 @@ describe("Standalone Server", () => {
|
|
|
71
72
|
expect(res.status).toBe(204);
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
it("writes binary response bodies without JSON serialization", async () => {
|
|
76
|
+
mock = schmock();
|
|
77
|
+
mock("GET /binary", new Uint8Array([0, 1, 255]));
|
|
78
|
+
const info = await mock.listen(0);
|
|
79
|
+
|
|
80
|
+
const response = await fetch(`http://127.0.0.1:${info.port}/binary`);
|
|
81
|
+
expect(response.headers.get("content-type")).toBe(
|
|
82
|
+
"application/octet-stream",
|
|
83
|
+
);
|
|
84
|
+
expect(new Uint8Array(await response.arrayBuffer())).toEqual(
|
|
85
|
+
new Uint8Array([0, 1, 255]),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("writes dynamic and tuple binary responses with binary content type", async () => {
|
|
90
|
+
mock = schmock();
|
|
91
|
+
const dynamicBytes = new Uint8Array([1, 2, 3]);
|
|
92
|
+
const tupleBytes = new Uint8Array([4, 5, 6]);
|
|
93
|
+
mock("GET /dynamic-binary", () => dynamicBytes.buffer);
|
|
94
|
+
mock(
|
|
95
|
+
"GET /tuple-binary",
|
|
96
|
+
() => [206, new DataView(tupleBytes.buffer)] satisfies [number, unknown],
|
|
97
|
+
);
|
|
98
|
+
const info = await mock.listen(0);
|
|
99
|
+
|
|
100
|
+
const dynamicResponse = await fetch(
|
|
101
|
+
`http://127.0.0.1:${info.port}/dynamic-binary`,
|
|
102
|
+
);
|
|
103
|
+
expect(dynamicResponse.headers.get("content-type")).toBe(
|
|
104
|
+
"application/octet-stream",
|
|
105
|
+
);
|
|
106
|
+
expect(new Uint8Array(await dynamicResponse.arrayBuffer())).toEqual(
|
|
107
|
+
dynamicBytes,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const tupleResponse = await fetch(
|
|
111
|
+
`http://127.0.0.1:${info.port}/tuple-binary`,
|
|
112
|
+
);
|
|
113
|
+
expect(tupleResponse.status).toBe(206);
|
|
114
|
+
expect(tupleResponse.headers.get("content-type")).toBe(
|
|
115
|
+
"application/octet-stream",
|
|
116
|
+
);
|
|
117
|
+
expect(new Uint8Array(await tupleResponse.arrayBuffer())).toEqual(
|
|
118
|
+
tupleBytes,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
74
122
|
it("reset stops the server", async () => {
|
|
75
123
|
mock = schmock();
|
|
76
124
|
mock("GET /test", { ok: true });
|
|
@@ -81,12 +129,9 @@ describe("Standalone Server", () => {
|
|
|
81
129
|
|
|
82
130
|
mock.reset();
|
|
83
131
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
// Expected: connection refused
|
|
89
|
-
}
|
|
132
|
+
await expect(
|
|
133
|
+
fetch(`http://127.0.0.1:${info.port}/test`),
|
|
134
|
+
).rejects.toBeDefined();
|
|
90
135
|
});
|
|
91
136
|
|
|
92
137
|
it("handles route params in server mode", async () => {
|
|
@@ -106,6 +151,33 @@ describe("Standalone Server", () => {
|
|
|
106
151
|
expect(() => mock.listen(0)).toThrow("Server is already running");
|
|
107
152
|
});
|
|
108
153
|
|
|
154
|
+
it("can retry listening after an occupied-port failure", async () => {
|
|
155
|
+
const occupiedServer = createServer();
|
|
156
|
+
await new Promise<void>((resolve, reject) => {
|
|
157
|
+
occupiedServer.once("error", reject);
|
|
158
|
+
occupiedServer.listen(0, "127.0.0.1", resolve);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const address = occupiedServer.address();
|
|
163
|
+
if (address === null || typeof address === "string") {
|
|
164
|
+
throw new Error("Expected an address with a numeric port");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
mock = schmock();
|
|
168
|
+
await expect(mock.listen(address.port)).rejects.toMatchObject({
|
|
169
|
+
code: "EADDRINUSE",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const retry = await mock.listen(0);
|
|
173
|
+
expect(retry.port).toBeGreaterThan(0);
|
|
174
|
+
} finally {
|
|
175
|
+
await new Promise<void>((resolve) =>
|
|
176
|
+
occupiedServer.close(() => resolve()),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
109
181
|
it("close is idempotent", async () => {
|
|
110
182
|
mock = schmock();
|
|
111
183
|
mock("GET /test", { ok: true });
|
|
@@ -155,11 +227,8 @@ describe("Standalone Server", () => {
|
|
|
155
227
|
mock.close();
|
|
156
228
|
|
|
157
229
|
// Subsequent request should fail immediately (not hang on keep-alive)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
} catch {
|
|
162
|
-
// Expected: connection refused
|
|
163
|
-
}
|
|
230
|
+
await expect(
|
|
231
|
+
fetch(`http://127.0.0.1:${info.port}/test`),
|
|
232
|
+
).rejects.toBeDefined();
|
|
164
233
|
});
|
|
165
234
|
});
|
|
@@ -5,6 +5,10 @@ import type { CallableMockInstance, Plugin } from "../types";
|
|
|
5
5
|
|
|
6
6
|
const feature = await loadFeature("../../features/async-support.feature");
|
|
7
7
|
|
|
8
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
8
12
|
describeFeature(feature, ({ Scenario }) => {
|
|
9
13
|
let mock: CallableMockInstance;
|
|
10
14
|
let response: any;
|
|
@@ -110,18 +114,30 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
110
114
|
mock = schmock();
|
|
111
115
|
const syncPlugin: Plugin = {
|
|
112
116
|
name: "sync-step",
|
|
113
|
-
process: (ctx,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
process: (ctx, pluginResponse) => {
|
|
118
|
+
if (!isRecord(pluginResponse)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
"Expected the sync plugin response to be an object",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
context: ctx,
|
|
125
|
+
response: { ...pluginResponse, syncStep: true },
|
|
126
|
+
};
|
|
127
|
+
},
|
|
117
128
|
};
|
|
118
129
|
const asyncPlugin: Plugin = {
|
|
119
130
|
name: "async-step",
|
|
120
|
-
process: async (ctx,
|
|
131
|
+
process: async (ctx, pluginResponse) => {
|
|
121
132
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
133
|
+
if (!isRecord(pluginResponse)) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
"Expected the async plugin response to be an object",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
122
138
|
return {
|
|
123
139
|
context: ctx,
|
|
124
|
-
response: { ...
|
|
140
|
+
response: { ...pluginResponse, asyncStep: true },
|
|
125
141
|
};
|
|
126
142
|
},
|
|
127
143
|
};
|
|
@@ -365,13 +381,18 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
365
381
|
};
|
|
366
382
|
const plugin2: Plugin = {
|
|
367
383
|
name: "async-step-2",
|
|
368
|
-
process: async (ctx,
|
|
384
|
+
process: async (ctx, pluginResponse) => {
|
|
369
385
|
await new Promise(resolve => setTimeout(resolve, 3));
|
|
370
386
|
const step1Status = ctx.state.get("step1");
|
|
387
|
+
if (!isRecord(pluginResponse)) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
"Expected the stateful plugin response to be an object",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
371
392
|
return {
|
|
372
393
|
context: ctx,
|
|
373
394
|
response: {
|
|
374
|
-
...
|
|
395
|
+
...pluginResponse,
|
|
375
396
|
step1: step1Status,
|
|
376
397
|
step2: "completed",
|
|
377
398
|
},
|