@schmock/core 2.2.0 → 2.2.2

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/builder.d.ts CHANGED
@@ -20,6 +20,13 @@ export declare class CallableMockInstance {
20
20
  defineRoute(route: Schmock.RouteKey, generator: Schmock.Generator, config: Schmock.RouteConfig): this;
21
21
  setCallableRef(ref: Schmock.CallableMockInstance): void;
22
22
  pipe(plugin: Schmock.Plugin): this;
23
+ /**
24
+ * FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
25
+ * history by mutating nested body/response objects.
26
+ * Falls back to a shallow spread for bodies that are not structuredClone-able
27
+ * (e.g. Buffers, streams — rare in practice but defensively handled).
28
+ */
29
+ private cloneRecord;
23
30
  history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[];
24
31
  called(method?: Schmock.HttpMethod, path?: string): boolean;
25
32
  callCount(method?: Schmock.HttpMethod, path?: string): number;
@@ -1 +1 @@
1
- {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;GAIG;AACH,qBAAa,oBAAoB;;IAanB,OAAO,CAAC,YAAY;IAZhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,WAAW,CAA2C;IAC9D,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAAiC;IACnD,OAAO,CAAC,eAAe,CAAwC;IAE/D,OAAO,CAAC,SAAS,CAAoC;gBAEjC,YAAY,GAAE,OAAO,CAAC,YAAiB;IAa3D,WAAW,CACT,KAAK,EAAE,OAAO,CAAC,QAAQ,EACvB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,MAAM,EAAE,OAAO,CAAC,WAAW,GAC1B,IAAI;IA+EP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAS5E,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO;IAS3D,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAS7D,WAAW,CACT,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,GAAG,SAAS;IAYpC,SAAS,IAAI,OAAO,CAAC,SAAS,EAAE;IAQhC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAMnC,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAC/B,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAUP,GAAG,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAChC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAKP,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAiBb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAgErE,KAAK,IAAI,IAAI;IAab,SAAS,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,gBAAgB,GAAG,OAAO,CAAC,eAAe;IAgBhE,MAAM,CACV,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,GAC/B,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;IA+O5B;;;;OAIG;YACW,UAAU;CAezB"}
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;GAIG;AACH,qBAAa,oBAAoB;;IAanB,OAAO,CAAC,YAAY;IAZhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,WAAW,CAA2C;IAC9D,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAAiC;IACnD,OAAO,CAAC,eAAe,CAAwC;IAE/D,OAAO,CAAC,SAAS,CAAoC;gBAEjC,YAAY,GAAE,OAAO,CAAC,YAAiB;IAa3D,WAAW,CACT,KAAK,EAAE,OAAO,CAAC,QAAQ,EACvB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,MAAM,EAAE,OAAO,CAAC,WAAW,GAC1B,IAAI;IAsFP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAWnB,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAW5E,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO;IAS3D,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAS7D,WAAW,CACT,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,GAAG,SAAS;IAgBpC,SAAS,IAAI,OAAO,CAAC,SAAS,EAAE;IAQhC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAMnC,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAC/B,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAUP,GAAG,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAChC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAKP,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAiBb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAgErE,KAAK,IAAI,IAAI;IAab,SAAS,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,gBAAgB,GAAG,OAAO,CAAC,eAAe;IAgBhE,MAAM,CACV,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,GAC/B,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;IA+O5B;;;;OAIG;YACW,UAAU;CAezB"}
package/dist/builder.js CHANGED
@@ -69,30 +69,32 @@ export class CallableMockInstance {
69
69
  }
70
70
  // Method for defining routes (called when instance is invoked)
71
71
  defineRoute(route, generator, config) {
72
+ // FIX 1.2: shallow-clone the caller's config so mutations below stay private
73
+ const routeConfig = { ...config };
72
74
  // Auto-detect contentType if not provided
73
- if (!config.contentType) {
75
+ if (!routeConfig.contentType) {
74
76
  if (typeof generator === "function") {
75
77
  // Default to JSON for function generators
76
- config.contentType = "application/json";
78
+ routeConfig.contentType = "application/json";
77
79
  }
78
80
  else if (typeof generator === "string" ||
79
81
  typeof generator === "number" ||
80
82
  typeof generator === "boolean") {
81
83
  // Default to plain text for primitives
82
- config.contentType = "text/plain";
84
+ routeConfig.contentType = "text/plain";
83
85
  }
84
86
  else if (Buffer.isBuffer(generator)) {
85
87
  // Default to octet-stream for buffers
86
- config.contentType = "application/octet-stream";
88
+ routeConfig.contentType = "application/octet-stream";
87
89
  }
88
90
  else {
89
91
  // Default to JSON for objects/arrays
90
- config.contentType = "application/json";
92
+ routeConfig.contentType = "application/json";
91
93
  }
92
94
  }
93
95
  // Validate generator matches contentType if it's static data
94
96
  if (typeof generator !== "function" &&
95
- config.contentType === "application/json") {
97
+ routeConfig.contentType === "application/json") {
96
98
  try {
97
99
  JSON.stringify(generator);
98
100
  }
@@ -102,8 +104,11 @@ export class CallableMockInstance {
102
104
  }
103
105
  // Parse the route key to create pattern and extract parameters
104
106
  const parsed = parseRouteKey(route);
105
- // Check for duplicate routes — first registration wins, subsequent ones are dropped
106
- const existing = this.routes.find((r) => r.method === parsed.method && r.path === parsed.path);
107
+ // FIX 2.2: normalize paths before duplicate check so /users and /users/ are
108
+ // treated as the same route (consistent with the static-route Map key below)
109
+ const normalizedParsedPath = normalizePath(parsed.path);
110
+ const existing = this.routes.find((r) => r.method === parsed.method &&
111
+ normalizePath(r.path) === normalizedParsedPath);
107
112
  if (existing) {
108
113
  this.logger.log("warning", `Duplicate route: ${route} — first registration wins`);
109
114
  return this;
@@ -115,7 +120,7 @@ export class CallableMockInstance {
115
120
  method: parsed.method,
116
121
  path: parsed.path,
117
122
  generator,
118
- config,
123
+ config: routeConfig,
119
124
  };
120
125
  this.routes.push(compiledRoute);
121
126
  // Store static routes (no params) in Map for O(1) lookup
@@ -124,7 +129,7 @@ export class CallableMockInstance {
124
129
  this.staticRoutes.set(key, compiledRoute);
125
130
  }
126
131
  this.logger.log("route", `Route defined: ${route}`, {
127
- contentType: config.contentType,
132
+ contentType: routeConfig.contentType,
128
133
  generatorType: typeof generator,
129
134
  hasParams: parsed.params.length > 0,
130
135
  });
@@ -147,11 +152,30 @@ export class CallableMockInstance {
147
152
  return this;
148
153
  }
149
154
  // ===== Request Spy / History API =====
155
+ /**
156
+ * FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
157
+ * history by mutating nested body/response objects.
158
+ * Falls back to a shallow spread for bodies that are not structuredClone-able
159
+ * (e.g. Buffers, streams — rare in practice but defensively handled).
160
+ */
161
+ cloneRecord(r) {
162
+ try {
163
+ return structuredClone(r);
164
+ }
165
+ catch {
166
+ return {
167
+ ...r,
168
+ response: { ...r.response },
169
+ };
170
+ }
171
+ }
150
172
  history(method, path) {
151
173
  if (method || path) {
152
- return this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
174
+ return this.requestHistory
175
+ .filter((r) => (!method || r.method === method) && (!path || r.path === path))
176
+ .map((r) => this.cloneRecord(r));
153
177
  }
154
- return [...this.requestHistory];
178
+ return this.requestHistory.map((r) => this.cloneRecord(r));
155
179
  }
156
180
  called(method, path) {
157
181
  if (method || path) {
@@ -168,9 +192,13 @@ export class CallableMockInstance {
168
192
  lastRequest(method, path) {
169
193
  if (method || path) {
170
194
  const filtered = this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
171
- return filtered[filtered.length - 1];
195
+ const last = filtered[filtered.length - 1];
196
+ // FIX 2.3: return a deep clone so callers cannot corrupt internal history
197
+ return last ? this.cloneRecord(last) : undefined;
172
198
  }
173
- return this.requestHistory[this.requestHistory.length - 1];
199
+ const last = this.requestHistory[this.requestHistory.length - 1];
200
+ // FIX 2.3: return a deep clone so callers cannot corrupt internal history
201
+ return last ? this.cloneRecord(last) : undefined;
174
202
  }
175
203
  // ===== Introspection =====
176
204
  getRoutes() {
@@ -215,10 +243,10 @@ export class CallableMockInstance {
215
243
  this.plugins = [];
216
244
  this.requestHistory = [];
217
245
  this.listeners.clear();
246
+ // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
247
+ // reference — this avoids mutating the external state object passed by the user
218
248
  if (this.globalConfig.state) {
219
- for (const key of Object.keys(this.globalConfig.state)) {
220
- delete this.globalConfig.state[key];
221
- }
249
+ this.globalConfig.state = {};
222
250
  }
223
251
  this.logger.log("lifecycle", "Mock fully reset");
224
252
  }
@@ -227,10 +255,10 @@ export class CallableMockInstance {
227
255
  this.logger.log("lifecycle", "Request history cleared");
228
256
  }
229
257
  resetState() {
258
+ // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
259
+ // reference — this avoids mutating the external state object passed by the user
230
260
  if (this.globalConfig.state) {
231
- for (const key of Object.keys(this.globalConfig.state)) {
232
- delete this.globalConfig.state[key];
233
- }
261
+ this.globalConfig.state = {};
234
262
  }
235
263
  this.logger.log("lifecycle", "State cleared");
236
264
  }
@@ -1 +1 @@
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;;;;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,CAkFjE"}
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,3 +1,4 @@
1
+ import { isStatusTuple } from "./constants.js";
1
2
  import { errorMessage, PluginError } from "./errors.js";
2
3
  /**
3
4
  * Run all registered plugins in sequence
@@ -41,9 +42,11 @@ export async function runPluginPipeline(plugins, context, initialResponse, logge
41
42
  throw new PluginError(plugin.name, errorResult);
42
43
  }
43
44
  // ResponseResult return → recover, stop pipeline
44
- if (typeof errorResult === "object" &&
45
- errorResult !== null &&
46
- "status" in errorResult) {
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)) {
47
50
  response = errorResult;
48
51
  break;
49
52
  }
@@ -1 +1 @@
1
- {"version":3,"file":"response-parser.d.ts","sourceRoot":"","sources":["../src/response-parser.ts"],"names":[],"mappings":"AAeA;;;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":"AAgBA;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,WAAW,EAAE,OAAO,CAAC,WAAW,GAC/B,OAAO,CAAC,QAAQ,CAmDlB"}
@@ -3,6 +3,7 @@ function isResponseObject(value) {
3
3
  return (typeof value === "object" &&
4
4
  value !== null &&
5
5
  "status" in value &&
6
+ typeof value.status === "number" &&
6
7
  "body" in value);
7
8
  }
8
9
  /**
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.0",
4
+ "version": "2.2.2",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -32,10 +32,10 @@
32
32
  },
33
33
  "license": "MIT",
34
34
  "devDependencies": {
35
- "@amiceli/vitest-cucumber": "^6.5.0",
35
+ "@amiceli/vitest-cucumber": "^7.0.0",
36
36
  "@types/json-schema": "^7.0.15",
37
- "@types/node": "^25.8.0",
38
- "@vitest/ui": "^4.1.6",
39
- "vitest": "^4.1.7"
37
+ "@types/node": "^26.1.0",
38
+ "@vitest/ui": "^4.1.9",
39
+ "vitest": "^4.1.9"
40
40
  }
41
41
  }
@@ -0,0 +1,218 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { schmock } from "./index";
3
+
4
+ // ── FIX 1.2: defineRoute must shallow-clone the config ────────────────────────
5
+
6
+ describe("FIX 1.2 — defineRoute must not mutate the caller's config", () => {
7
+ it("does not add contentType to the caller's config object", () => {
8
+ const sharedConfig: Schmock.RouteConfig = {};
9
+ const mock = schmock();
10
+ mock("GET /a", { x: 1 }, sharedConfig);
11
+ // sharedConfig must remain empty — defineRoute must work on a clone
12
+ expect(Object.keys(sharedConfig)).toHaveLength(0);
13
+ });
14
+
15
+ it("second route registered with same config is not polluted by first route detection", () => {
16
+ const sharedConfig: Schmock.RouteConfig = {};
17
+ const mock = schmock();
18
+ mock("GET /first", { x: 1 }, sharedConfig);
19
+ mock("GET /second", "hello", sharedConfig);
20
+ // sharedConfig must still be clean — neither call should have written to it
21
+ expect(Object.keys(sharedConfig)).toHaveLength(0);
22
+ });
23
+
24
+ it("mutating caller's config after registration does not change the registered route response", async () => {
25
+ const config: Schmock.RouteConfig = {};
26
+ const mock = schmock();
27
+ mock("GET /item", { value: 99 }, config);
28
+
29
+ // After registration, mutate the caller's config
30
+ config.status = 503;
31
+ config.contentType = "text/html";
32
+
33
+ const response = await mock.handle("GET", "/item");
34
+ // The registered route must use its own cloned config — not 503
35
+ expect(response.status).toBe(200);
36
+ const ct =
37
+ response.headers?.["content-type"] ?? response.headers?.["Content-Type"];
38
+ expect(ct).toContain("application/json");
39
+ });
40
+
41
+ it("each route keeps its own detected contentType even when sharing the same config object", async () => {
42
+ const sharedConfig: Schmock.RouteConfig = {};
43
+ const mock = schmock();
44
+ // First route: object → application/json
45
+ mock("GET /obj", { value: 1 }, sharedConfig);
46
+ // Second route: primitive string → text/plain
47
+ mock("GET /str", "hello", sharedConfig);
48
+
49
+ const objResp = await mock.handle("GET", "/obj");
50
+ const strResp = await mock.handle("GET", "/str");
51
+
52
+ const ctObj =
53
+ objResp.headers?.["content-type"] ?? objResp.headers?.["Content-Type"];
54
+ const ctStr =
55
+ strResp.headers?.["content-type"] ?? strResp.headers?.["Content-Type"];
56
+
57
+ expect(ctObj).toContain("application/json");
58
+ expect(ctStr).toContain("text/plain");
59
+ });
60
+ });
61
+
62
+ // ── FIX 2.2: duplicate route detection must normalize trailing slash ──────────
63
+
64
+ describe("FIX 2.2 — duplicate-route detection uses normalized paths", () => {
65
+ it("GET /users/ is treated as duplicate of GET /users", () => {
66
+ const mock = schmock();
67
+ mock("GET /users", [{ id: 1 }], {});
68
+ // Registering with trailing slash must not add a second route
69
+ mock("GET /users/", [{ id: 2 }], {});
70
+
71
+ const routes = mock.getRoutes();
72
+ const userRoutes = routes.filter(
73
+ (r) =>
74
+ r.method === "GET" && (r.path === "/users" || r.path === "/users/"),
75
+ );
76
+ expect(userRoutes).toHaveLength(1);
77
+ });
78
+
79
+ it("first registration wins — trailing-slash variant does not overwrite", async () => {
80
+ const mock = schmock();
81
+ mock("GET /items", [{ id: 1 }], {});
82
+ mock("GET /items/", [{ id: 999 }], {});
83
+
84
+ // Should still get the first registration's data
85
+ const resp = await mock.handle("GET", "/items");
86
+ expect(resp.status).toBe(200);
87
+ expect(Array.isArray(resp.body)).toBe(true);
88
+ expect((resp.body as { id: number }[])[0].id).toBe(1);
89
+ });
90
+
91
+ it("registering verbatim duplicate also produces only one route", () => {
92
+ const mock = schmock();
93
+ mock("GET /same", { ok: true }, {});
94
+ mock("GET /same", { ok: false }, {});
95
+
96
+ const routes = mock.getRoutes();
97
+ const sameRoutes = routes.filter(
98
+ (r) => r.method === "GET" && r.path === "/same",
99
+ );
100
+ expect(sameRoutes).toHaveLength(1);
101
+ });
102
+ });
103
+
104
+ // ── FIX 3.3: reset()/resetState() must reassign, not delete keys ─────────────
105
+
106
+ describe("FIX 3.3 — reset()/resetState() must not mutate caller's state", () => {
107
+ it("reset() leaves the external state object untouched", () => {
108
+ const st = { a: 1 };
109
+ const mock = schmock({ state: st });
110
+ mock.reset();
111
+ expect(st).toEqual({ a: 1 });
112
+ });
113
+
114
+ it("resetState() leaves the external state object untouched", () => {
115
+ const st = { b: 2 };
116
+ const mock = schmock({ state: st });
117
+ mock.resetState();
118
+ expect(st).toEqual({ b: 2 });
119
+ });
120
+
121
+ it("mock.getState() is empty after reset()", () => {
122
+ const st = { x: 10 };
123
+ const mock = schmock({ state: st });
124
+ mock.reset();
125
+ expect(mock.getState()).toEqual({});
126
+ });
127
+
128
+ it("mock.getState() is empty after resetState()", () => {
129
+ const st = { y: 20 };
130
+ const mock = schmock({ state: st });
131
+ mock.resetState();
132
+ expect(mock.getState()).toEqual({});
133
+ });
134
+
135
+ it("generator sees empty state after resetState", async () => {
136
+ const st: Record<string, unknown> = { counter: 0 };
137
+ const mock = schmock({ state: st });
138
+ mock("GET /stateful", (ctx: Schmock.RequestContext) => {
139
+ return { stateKeys: Object.keys(ctx.state) };
140
+ });
141
+
142
+ mock.resetState();
143
+
144
+ const resp = await mock.handle("GET", "/stateful");
145
+ expect(resp.status).toBe(200);
146
+ // After resetState, the generator should see an empty state
147
+ expect((resp.body as { stateKeys: string[] }).stateKeys).toHaveLength(0);
148
+ });
149
+ });
150
+
151
+ // ── FIX 2.3: history()/lastRequest() must return deep clones ─────────────────
152
+
153
+ describe("FIX 2.3 — history() and lastRequest() return deep clones", () => {
154
+ it("mutating a history() record's nested body does not corrupt internal history", async () => {
155
+ const mock = schmock();
156
+ mock("GET /nested", { deep: { value: "original" } }, {});
157
+
158
+ await mock.handle("GET", "/nested");
159
+
160
+ // First read: mutate the returned record's body
161
+ const records1 = mock.history();
162
+ const firstRecord = records1[0];
163
+ (firstRecord.response.body as { deep: { value: string } }).deep.value =
164
+ "MUTATED";
165
+
166
+ // Second read: internal history must be unchanged
167
+ const records2 = mock.history();
168
+ expect(
169
+ (records2[0].response.body as { deep: { value: string } }).deep.value,
170
+ ).toBe("original");
171
+ });
172
+
173
+ it("mutating filtered history() record does not corrupt internal history", async () => {
174
+ const mock = schmock();
175
+ mock("GET /data", { info: { count: 42 } }, {});
176
+ await mock.handle("GET", "/data");
177
+
178
+ const filtered = mock.history("GET", "/data");
179
+ (filtered[0].response.body as { info: { count: number } }).info.count = 0;
180
+
181
+ const again = mock.history("GET", "/data");
182
+ expect(
183
+ (again[0].response.body as { info: { count: number } }).info.count,
184
+ ).toBe(42);
185
+ });
186
+
187
+ it("mutating lastRequest() record does not corrupt internal history", async () => {
188
+ const mock = schmock();
189
+ mock("GET /last", { meta: { tag: "safe" } }, {});
190
+ await mock.handle("GET", "/last");
191
+
192
+ const last = mock.lastRequest();
193
+ if (!last) throw new Error("expected a record");
194
+ (last.response.body as { meta: { tag: string } }).meta.tag = "corrupted";
195
+
196
+ const again = mock.lastRequest();
197
+ if (!again) throw new Error("expected a record");
198
+ expect((again.response.body as { meta: { tag: string } }).meta.tag).toBe(
199
+ "safe",
200
+ );
201
+ });
202
+
203
+ it("mutating filtered lastRequest() record does not corrupt internal history", async () => {
204
+ const mock = schmock();
205
+ mock("POST /echo", { msg: { text: "hello" } }, {});
206
+ await mock.handle("POST", "/echo");
207
+
208
+ const last = mock.lastRequest("POST", "/echo");
209
+ if (!last) throw new Error("expected a record");
210
+ (last.response.body as { msg: { text: string } }).msg.text = "CHANGED";
211
+
212
+ const again = mock.lastRequest("POST", "/echo");
213
+ if (!again) throw new Error("expected a record");
214
+ expect((again.response.body as { msg: { text: string } }).msg.text).toBe(
215
+ "hello",
216
+ );
217
+ });
218
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { schmock } from "./index";
3
+
4
+ describe("isResponseObject guard — status type check", () => {
5
+ describe("non-numeric status must NOT be treated as response envelope", () => {
6
+ it("returns status 200 (default) when generator returns { status: 'ok', body: 'hello' }", async () => {
7
+ const mock = schmock();
8
+ mock("GET /bad-envelope", () => ({ status: "ok", body: "hello" }));
9
+
10
+ const response = await mock.handle("GET", "/bad-envelope");
11
+
12
+ // The string status must never leak into response.status
13
+ expect(response.status).toBe(200);
14
+ });
15
+
16
+ it("returns the whole object as body when generator returns { status: 'ok', body: 'hello' }", async () => {
17
+ const mock = schmock();
18
+ mock("GET /bad-envelope-body", () => ({ status: "ok", body: "hello" }));
19
+
20
+ const response = await mock.handle("GET", "/bad-envelope-body");
21
+
22
+ // The original object must be treated as plain body, not unwrapped
23
+ expect(response.body).toEqual({ status: "ok", body: "hello" });
24
+ });
25
+ });
26
+
27
+ describe("numeric status IS correctly treated as response envelope", () => {
28
+ it("returns status 201 and unwrapped body when generator returns { status: 201, body: { id: 1 } }", async () => {
29
+ const mock = schmock();
30
+ mock("POST /create", () => ({ status: 201, body: { id: 1 } }));
31
+
32
+ const response = await mock.handle("POST", "/create");
33
+
34
+ expect(response.status).toBe(201);
35
+ expect(response.body).toEqual({ id: 1 });
36
+ });
37
+ });
38
+ });
package/src/builder.ts CHANGED
@@ -89,31 +89,34 @@ export class CallableMockInstance {
89
89
  generator: Schmock.Generator,
90
90
  config: Schmock.RouteConfig,
91
91
  ): this {
92
+ // FIX 1.2: shallow-clone the caller's config so mutations below stay private
93
+ const routeConfig = { ...config };
94
+
92
95
  // Auto-detect contentType if not provided
93
- if (!config.contentType) {
96
+ if (!routeConfig.contentType) {
94
97
  if (typeof generator === "function") {
95
98
  // Default to JSON for function generators
96
- config.contentType = "application/json";
99
+ routeConfig.contentType = "application/json";
97
100
  } else if (
98
101
  typeof generator === "string" ||
99
102
  typeof generator === "number" ||
100
103
  typeof generator === "boolean"
101
104
  ) {
102
105
  // Default to plain text for primitives
103
- config.contentType = "text/plain";
106
+ routeConfig.contentType = "text/plain";
104
107
  } else if (Buffer.isBuffer(generator)) {
105
108
  // Default to octet-stream for buffers
106
- config.contentType = "application/octet-stream";
109
+ routeConfig.contentType = "application/octet-stream";
107
110
  } else {
108
111
  // Default to JSON for objects/arrays
109
- config.contentType = "application/json";
112
+ routeConfig.contentType = "application/json";
110
113
  }
111
114
  }
112
115
 
113
116
  // Validate generator matches contentType if it's static data
114
117
  if (
115
118
  typeof generator !== "function" &&
116
- config.contentType === "application/json"
119
+ routeConfig.contentType === "application/json"
117
120
  ) {
118
121
  try {
119
122
  JSON.stringify(generator);
@@ -128,9 +131,13 @@ export class CallableMockInstance {
128
131
  // Parse the route key to create pattern and extract parameters
129
132
  const parsed = parseRouteKey(route);
130
133
 
131
- // Check for duplicate routes — first registration wins, subsequent ones are dropped
134
+ // FIX 2.2: normalize paths before duplicate check so /users and /users/ are
135
+ // treated as the same route (consistent with the static-route Map key below)
136
+ const normalizedParsedPath = normalizePath(parsed.path);
132
137
  const existing = this.routes.find(
133
- (r) => r.method === parsed.method && r.path === parsed.path,
138
+ (r) =>
139
+ r.method === parsed.method &&
140
+ normalizePath(r.path) === normalizedParsedPath,
134
141
  );
135
142
  if (existing) {
136
143
  this.logger.log(
@@ -147,7 +154,7 @@ export class CallableMockInstance {
147
154
  method: parsed.method,
148
155
  path: parsed.path,
149
156
  generator,
150
- config,
157
+ config: routeConfig,
151
158
  };
152
159
 
153
160
  this.routes.push(compiledRoute);
@@ -159,7 +166,7 @@ export class CallableMockInstance {
159
166
  }
160
167
 
161
168
  this.logger.log("route", `Route defined: ${route}`, {
162
- contentType: config.contentType,
169
+ contentType: routeConfig.contentType,
163
170
  generatorType: typeof generator,
164
171
  hasParams: parsed.params.length > 0,
165
172
  });
@@ -191,13 +198,32 @@ export class CallableMockInstance {
191
198
 
192
199
  // ===== Request Spy / History API =====
193
200
 
201
+ /**
202
+ * FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
203
+ * history by mutating nested body/response objects.
204
+ * Falls back to a shallow spread for bodies that are not structuredClone-able
205
+ * (e.g. Buffers, streams — rare in practice but defensively handled).
206
+ */
207
+ private cloneRecord(r: Schmock.RequestRecord): Schmock.RequestRecord {
208
+ try {
209
+ return structuredClone(r);
210
+ } catch {
211
+ return {
212
+ ...r,
213
+ response: { ...r.response },
214
+ };
215
+ }
216
+ }
217
+
194
218
  history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
195
219
  if (method || path) {
196
- return this.requestHistory.filter(
197
- (r) => (!method || r.method === method) && (!path || r.path === path),
198
- );
220
+ return this.requestHistory
221
+ .filter(
222
+ (r) => (!method || r.method === method) && (!path || r.path === path),
223
+ )
224
+ .map((r) => this.cloneRecord(r));
199
225
  }
200
- return [...this.requestHistory];
226
+ return this.requestHistory.map((r) => this.cloneRecord(r));
201
227
  }
202
228
 
203
229
  called(method?: Schmock.HttpMethod, path?: string): boolean {
@@ -226,9 +252,13 @@ export class CallableMockInstance {
226
252
  const filtered = this.requestHistory.filter(
227
253
  (r) => (!method || r.method === method) && (!path || r.path === path),
228
254
  );
229
- return filtered[filtered.length - 1];
255
+ const last = filtered[filtered.length - 1];
256
+ // FIX 2.3: return a deep clone so callers cannot corrupt internal history
257
+ return last ? this.cloneRecord(last) : undefined;
230
258
  }
231
- return this.requestHistory[this.requestHistory.length - 1];
259
+ const last = this.requestHistory[this.requestHistory.length - 1];
260
+ // FIX 2.3: return a deep clone so callers cannot corrupt internal history
261
+ return last ? this.cloneRecord(last) : undefined;
232
262
  }
233
263
 
234
264
  // ===== Introspection =====
@@ -288,10 +318,10 @@ export class CallableMockInstance {
288
318
  this.plugins = [];
289
319
  this.requestHistory = [];
290
320
  this.listeners.clear();
321
+ // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
322
+ // reference — this avoids mutating the external state object passed by the user
291
323
  if (this.globalConfig.state) {
292
- for (const key of Object.keys(this.globalConfig.state)) {
293
- delete this.globalConfig.state[key];
294
- }
324
+ this.globalConfig.state = {};
295
325
  }
296
326
  this.logger.log("lifecycle", "Mock fully reset");
297
327
  }
@@ -302,10 +332,10 @@ export class CallableMockInstance {
302
332
  }
303
333
 
304
334
  resetState(): void {
335
+ // FIX 3.3: assign a fresh object instead of deleting keys off the caller's
336
+ // reference — this avoids mutating the external state object passed by the user
305
337
  if (this.globalConfig.state) {
306
- for (const key of Object.keys(this.globalConfig.state)) {
307
- delete this.globalConfig.state[key];
308
- }
338
+ this.globalConfig.state = {};
309
339
  }
310
340
  this.logger.log("lifecycle", "State cleared");
311
341
  }
@@ -1,3 +1,4 @@
1
+ import { isStatusTuple } from "./constants.js";
1
2
  import { errorMessage, PluginError } from "./errors.js";
2
3
 
3
4
  /** Structural typing — DebugLogger satisfies this without an import */
@@ -68,10 +69,12 @@ export async function runPluginPipeline(
68
69
  }
69
70
 
70
71
  // ResponseResult return → recover, stop pipeline
72
+ // Accept both object-with-status and status tuple [code, body] / [code, body, headers]
71
73
  if (
72
- typeof errorResult === "object" &&
73
- errorResult !== null &&
74
- "status" in errorResult
74
+ isStatusTuple(errorResult) ||
75
+ (typeof errorResult === "object" &&
76
+ errorResult !== null &&
77
+ "status" in errorResult)
75
78
  ) {
76
79
  response = errorResult;
77
80
  break;
@@ -9,6 +9,7 @@ function isResponseObject(value: unknown): value is {
9
9
  typeof value === "object" &&
10
10
  value !== null &&
11
11
  "status" in value &&
12
+ typeof (value as Record<string, unknown>).status === "number" &&
12
13
  "body" in value
13
14
  );
14
15
  }
@@ -0,0 +1,188 @@
1
+ import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
2
+ import { expect } from "vitest";
3
+ import { schmock } from "../index";
4
+
5
+ const feature = await loadFeature("../../features/audit-core-builder.feature");
6
+
7
+ describeFeature(feature, ({ Scenario }) => {
8
+ // ── FIX 1.2: defineRoute must shallow-clone the config ───────────────────
9
+
10
+ Scenario(
11
+ "defineRoute does not mutate the caller's config object",
12
+ ({ Given, When, Then }) => {
13
+ let sharedConfig: Schmock.RouteConfig;
14
+ let mock: Schmock.CallableMockInstance;
15
+
16
+ Given("a shared config object with no contentType", () => {
17
+ sharedConfig = {};
18
+ });
19
+
20
+ When("I register two routes with the same config object", () => {
21
+ mock = schmock();
22
+ mock("GET /route1", { name: "route1" }, sharedConfig);
23
+ mock("GET /route2", { name: "route2" }, sharedConfig);
24
+ });
25
+
26
+ Then(
27
+ "the shared config object remains unchanged after registration",
28
+ () => {
29
+ expect(Object.keys(sharedConfig)).toHaveLength(0);
30
+ },
31
+ );
32
+ },
33
+ );
34
+
35
+ Scenario(
36
+ "Mutating a returned config does not affect the registered route",
37
+ ({ Given, When, Then, And }) => {
38
+ let singleConfig: Schmock.RouteConfig;
39
+ let mock: Schmock.CallableMockInstance;
40
+
41
+ Given("a single config object with no contentType", () => {
42
+ singleConfig = {};
43
+ });
44
+
45
+ When("I register one route with that config object", () => {
46
+ mock = schmock();
47
+ mock("GET /item", { value: 99 }, singleConfig);
48
+ });
49
+
50
+ And("I mutate the config object after registration", () => {
51
+ singleConfig.status = 503;
52
+ singleConfig.contentType = "text/html";
53
+ });
54
+
55
+ Then(
56
+ "the registered route still responds with application/json content type",
57
+ async () => {
58
+ const response = await mock.handle("GET", "/item");
59
+ expect(response.status).toBe(200);
60
+ const ct =
61
+ response.headers?.["content-type"] ??
62
+ response.headers?.["Content-Type"];
63
+ expect(ct).toContain("application/json");
64
+ },
65
+ );
66
+ },
67
+ );
68
+
69
+ // ── FIX 2.2: duplicate route detection must normalize trailing slash ──────
70
+
71
+ Scenario(
72
+ "Trailing-slash path is treated as duplicate",
73
+ ({ Given, And, Then }) => {
74
+ let mock: Schmock.CallableMockInstance;
75
+
76
+ Given("a fresh mock instance", () => {
77
+ mock = schmock();
78
+ });
79
+
80
+ And("I register GET /users without trailing slash", () => {
81
+ mock("GET /users", [{ id: 1 }], {});
82
+ });
83
+
84
+ And("I register GET /users with trailing slash", () => {
85
+ mock("GET /users/", [{ id: 2 }], {});
86
+ });
87
+
88
+ Then("only one route exists for GET /users", () => {
89
+ const routes = mock.getRoutes();
90
+ const userRoutes = routes.filter(
91
+ (r) =>
92
+ r.method === "GET" && (r.path === "/users" || r.path === "/users/"),
93
+ );
94
+ expect(userRoutes).toHaveLength(1);
95
+ });
96
+ },
97
+ );
98
+
99
+ // ── FIX 3.3: reset()/resetState() must not mutate caller's state ─────────
100
+
101
+ Scenario(
102
+ "reset() does not mutate the caller's state object",
103
+ ({ Given, When, Then, And }) => {
104
+ let externalState: Record<string, unknown>;
105
+ let mock: Schmock.CallableMockInstance;
106
+
107
+ Given('a mock with external state containing key "a" equal to 1', () => {
108
+ externalState = { a: 1 };
109
+ mock = schmock({ state: externalState });
110
+ });
111
+
112
+ When("I call mock reset", () => {
113
+ mock.reset();
114
+ });
115
+
116
+ Then('the external state still has key "a" equal to 1', () => {
117
+ expect(externalState).toEqual({ a: 1 });
118
+ });
119
+
120
+ And("the mock internal state is empty", () => {
121
+ expect(mock.getState()).toEqual({});
122
+ });
123
+ },
124
+ );
125
+
126
+ Scenario(
127
+ "resetState() does not mutate the caller's state object",
128
+ ({ Given, When, Then, And }) => {
129
+ let externalState: Record<string, unknown>;
130
+ let mock: Schmock.CallableMockInstance;
131
+
132
+ Given('a mock with external state containing key "b" equal to 2', () => {
133
+ externalState = { b: 2 };
134
+ mock = schmock({ state: externalState });
135
+ });
136
+
137
+ When("I call mock resetState", () => {
138
+ mock.resetState();
139
+ });
140
+
141
+ Then('the external state still has key "b" equal to 2', () => {
142
+ expect(externalState).toEqual({ b: 2 });
143
+ });
144
+
145
+ And("the mock internal state is empty after resetState", () => {
146
+ expect(mock.getState()).toEqual({});
147
+ });
148
+ },
149
+ );
150
+
151
+ // ── FIX 2.3: history()/lastRequest() must return deep clones ─────────────
152
+
153
+ Scenario(
154
+ "history() returns deep clones of request records",
155
+ ({ Given, When, Then, And }) => {
156
+ let mock: Schmock.CallableMockInstance;
157
+
158
+ Given("a fresh mock with a route returning a nested body", () => {
159
+ mock = schmock();
160
+ mock("GET /data", { nested: { value: "original" } }, {});
161
+ });
162
+
163
+ When("I handle that route once", async () => {
164
+ await mock.handle("GET", "/data");
165
+ });
166
+
167
+ And("I mutate the response body of the first history record", () => {
168
+ const records = mock.history();
169
+ expect(records).toHaveLength(1);
170
+ const body = records[0].response.body as { nested: { value: string } };
171
+ body.nested.value = "MUTATED";
172
+ });
173
+
174
+ Then("history returns the original body unchanged", () => {
175
+ const records = mock.history();
176
+ const body = records[0].response.body as { nested: { value: string } };
177
+ expect(body.nested.value).toBe("original");
178
+ });
179
+
180
+ And("lastRequest returns the original body unchanged", () => {
181
+ const last = mock.lastRequest();
182
+ if (!last) throw new Error("expected a record");
183
+ const body = last.response.body as { nested: { value: string } };
184
+ expect(body.nested.value).toBe("original");
185
+ });
186
+ },
187
+ );
188
+ });
@@ -0,0 +1,99 @@
1
+ import { describeFeature, loadFeature } from "@amiceli/vitest-cucumber";
2
+ import { expect } from "vitest";
3
+ import { schmock } from "../index";
4
+
5
+ const feature = await loadFeature("../../features/audit-onerror-tuple.feature");
6
+
7
+ describeFeature(feature, ({ Scenario }) => {
8
+ let mock: Schmock.CallableMockInstance;
9
+ let response: Awaited<ReturnType<Schmock.CallableMockInstance["handle"]>>;
10
+ let thrownError: unknown;
11
+
12
+ Scenario(
13
+ "2-element tuple from onError recovers with correct status and body",
14
+ ({ Given, When, Then, And }) => {
15
+ Given(
16
+ "a mock with a plugin whose process throws and onError returns a 2-element tuple",
17
+ () => {
18
+ mock = schmock({ state: {} });
19
+ mock("GET /fail", null, {}).pipe({
20
+ name: "failing-plugin",
21
+ process: (_ctx, _response) => {
22
+ throw new Error("simulated failure");
23
+ },
24
+ onError: (_error, _ctx) => {
25
+ return [503, { error: "recovered" }] as [number, unknown];
26
+ },
27
+ });
28
+ },
29
+ );
30
+
31
+ When('I handle a request to "GET /fail"', async () => {
32
+ thrownError = undefined;
33
+ try {
34
+ response = await mock.handle("GET", "/fail");
35
+ } catch (err) {
36
+ thrownError = err;
37
+ }
38
+ });
39
+
40
+ Then("the response status is 503", () => {
41
+ expect(response.status).toBe(503);
42
+ });
43
+
44
+ And('the response body is \'{"error":"recovered"}\'', () => {
45
+ expect(response.body).toEqual({ error: "recovered" });
46
+ });
47
+
48
+ And("no error is thrown", () => {
49
+ expect(thrownError).toBeUndefined();
50
+ });
51
+ },
52
+ );
53
+
54
+ Scenario(
55
+ "3-element tuple from onError recovers and includes custom headers",
56
+ ({ Given, When, Then, And }) => {
57
+ Given(
58
+ "a mock with a plugin whose process throws and onError returns a 3-element tuple with headers",
59
+ () => {
60
+ mock = schmock({ state: {} });
61
+ mock("GET /fail-with-headers", null, {}).pipe({
62
+ name: "failing-plugin-headers",
63
+ process: (_ctx, _response) => {
64
+ throw new Error("simulated failure with headers");
65
+ },
66
+ onError: (_error, _ctx) => {
67
+ return [
68
+ 503,
69
+ { error: "recovered with headers" },
70
+ { "x-recovery": "true" },
71
+ ] as [number, unknown, Record<string, string>];
72
+ },
73
+ });
74
+ },
75
+ );
76
+
77
+ When('I handle a request to "GET /fail-with-headers"', async () => {
78
+ thrownError = undefined;
79
+ try {
80
+ response = await mock.handle("GET", "/fail-with-headers");
81
+ } catch (err) {
82
+ thrownError = err;
83
+ }
84
+ });
85
+
86
+ Then("the response status is 503", () => {
87
+ expect(response.status).toBe(503);
88
+ });
89
+
90
+ And('the response body is \'{"error":"recovered with headers"}\'', () => {
91
+ expect(response.body).toEqual({ error: "recovered with headers" });
92
+ });
93
+
94
+ And("no error is thrown", () => {
95
+ expect(thrownError).toBeUndefined();
96
+ });
97
+ },
98
+ );
99
+ });
@@ -64,6 +64,9 @@ describeFeature(feature, ({ Scenario }) => {
64
64
  }
65
65
  });
66
66
 
67
+ // Generous ceiling, not a perf target: in-process handlers are sub-millisecond,
68
+ // so this only guards against a pathological hang. A tight bound (e.g. 50ms)
69
+ // flakes under CI load where 100 concurrent requests contend for the event loop.
67
70
  And("the average response time should be under {int}ms", (_, maxTime: number) => {
68
71
  const avgTime = responsesTimes.reduce((a, b) => a + b, 0) / responsesTimes.length;
69
72
  expect(avgTime).toBeLessThan(maxTime);
@@ -28,8 +28,11 @@ describeFeature(feature, ({ Scenario }) => {
28
28
  elapsed = performance.now() - start;
29
29
  });
30
30
 
31
- Then("the response took less than 100ms", () => {
32
- expect(elapsed).toBeLessThan(100);
31
+ // Upper bound is intentionally generous: it only needs to prove the 10ms
32
+ // route delay was used instead of the 200ms global. A tight bound here
33
+ // flakes under CI load (a single timer can be delayed tens of ms).
34
+ Then("the response took less than 150ms", () => {
35
+ expect(elapsed).toBeLessThan(150);
33
36
  });
34
37
 
35
38
  When('I request "GET /default" with timing', async () => {