@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,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<{ context: Schmock.PluginContext; response?: unknown }> {
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 (const plugin of plugins) {
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?.context) {
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
- // Try error handling if plugin has onError hook
58
- if (plugin.onError) {
59
- try {
60
- const pluginError =
61
- error instanceof Error ? error : new Error(errorMessage(error));
62
- const errorResult = await plugin.onError(pluginError, currentContext);
63
- if (errorResult) {
64
- logger.log("pipeline", `Plugin ${plugin.name} handled error`);
65
-
66
- // Error return → transform the thrown error
67
- if (errorResult instanceof Error) {
68
- throw new PluginError(plugin.name, errorResult);
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
 
@@ -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 (value as Record<string, unknown>).status === "number" &&
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
- return {
34
- status: result.status,
35
- body: result.body,
36
- headers: result.headers || {},
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
- if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
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
- // Handle special conversion cases when contentType is explicitly set
61
- if (routeConfig.contentType === "text/plain" && body !== undefined) {
62
- if (typeof body === "object" && !Buffer.isBuffer(body)) {
63
- body = JSON.stringify(body);
64
- } else if (typeof body !== "string") {
65
- body = String(body);
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
 
@@ -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
- try {
85
- await fetch(`http://127.0.0.1:${info.port}/test`);
86
- expect.unreachable("Should have thrown");
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
- try {
159
- await fetch(`http://127.0.0.1:${info.port}/test`);
160
- expect.unreachable("Should have thrown");
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, response) => ({
114
- context: ctx,
115
- response: { ...response, syncStep: true },
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, response) => {
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: { ...response, asyncStep: true },
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, response) => {
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
- ...response,
395
+ ...pluginResponse,
375
396
  step1: step1Status,
376
397
  step2: "completed",
377
398
  },