@schmock/core 1.2.2 → 1.4.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/builder.d.ts CHANGED
@@ -11,6 +11,9 @@ export declare class CallableMockInstance {
11
11
  private logger;
12
12
  private requestHistory;
13
13
  private callableRef;
14
+ private server;
15
+ private serverInfo;
16
+ private listeners;
14
17
  constructor(globalConfig?: Schmock.GlobalConfig);
15
18
  defineRoute(route: Schmock.RouteKey, generator: Schmock.Generator, config: Schmock.RouteConfig): this;
16
19
  setCallableRef(ref: Schmock.CallableMockInstance): void;
@@ -19,9 +22,16 @@ export declare class CallableMockInstance {
19
22
  called(method?: Schmock.HttpMethod, path?: string): boolean;
20
23
  callCount(method?: Schmock.HttpMethod, path?: string): number;
21
24
  lastRequest(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord | undefined;
25
+ getRoutes(): Schmock.RouteInfo[];
26
+ getState(): Record<string, unknown>;
27
+ on(event: string, listener: (data: unknown) => void): this;
28
+ off(event: string, listener: (data: unknown) => void): this;
29
+ private emit;
22
30
  reset(): void;
23
31
  resetHistory(): void;
24
32
  resetState(): void;
33
+ listen(port?: number, hostname?: string): Promise<Schmock.ServerInfo>;
34
+ close(): void;
25
35
  handle(method: Schmock.HttpMethod, path: string, options?: Schmock.RequestOptions): Promise<Schmock.Response>;
26
36
  /**
27
37
  * Apply configured response delay
@@ -1 +1 @@
1
- {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AA0EA;;;;GAIG;AACH,qBAAa,oBAAoB;IAQnB,OAAO,CAAC,YAAY;IAPhC,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;gBAE1C,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;IAqFP,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,KAAK,IAAI,IAAI;IAab,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IASZ,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;IAoL5B;;;;OAIG;YACW,UAAU;IAcxB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;IAwDrB;;;;;;;;;;OAUG;YACW,iBAAiB;IAqG/B;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS;IA0BjB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;CActB"}
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AA4EA;;;;GAIG;AACH,qBAAa,oBAAoB;IAWnB,OAAO,CAAC,YAAY;IAVhC,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,SAAS,CAAmD;gBAEhD,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;IAqFP,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,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI;IAU1D,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI;IAK3D,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAeb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAmFrE,KAAK,IAAI,IAAI;IAUP,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;IAgN5B;;;;OAIG;YACW,UAAU;IAgBxB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;IAwDrB;;;;;;;;;;OAUG;YACW,iBAAiB;IAqG/B;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS;IA0BjB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;CActB"}
package/dist/builder.js CHANGED
@@ -1,4 +1,5 @@
1
- import { isStatusTuple } from "./constants.js";
1
+ import { createServer } from "node:http";
2
+ import { isStatusTuple, toHttpMethod } from "./constants.js";
2
3
  import { PluginError, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
3
4
  import { parseRouteKey } from "./parser.js";
4
5
  function errorMessage(error) {
@@ -57,6 +58,9 @@ export class CallableMockInstance {
57
58
  logger;
58
59
  requestHistory = [];
59
60
  callableRef;
61
+ server;
62
+ serverInfo;
63
+ listeners = new Map();
60
64
  constructor(globalConfig = {}) {
61
65
  this.globalConfig = globalConfig;
62
66
  this.logger = new DebugLogger(globalConfig.debug || false);
@@ -179,12 +183,47 @@ export class CallableMockInstance {
179
183
  }
180
184
  return this.requestHistory[this.requestHistory.length - 1];
181
185
  }
186
+ // ===== Introspection =====
187
+ getRoutes() {
188
+ return this.routes.map((r) => ({
189
+ method: r.method,
190
+ path: r.path,
191
+ hasParams: r.params.length > 0,
192
+ }));
193
+ }
194
+ getState() {
195
+ return this.globalConfig.state || {};
196
+ }
197
+ // ===== Lifecycle Events =====
198
+ on(event, listener) {
199
+ let set = this.listeners.get(event);
200
+ if (!set) {
201
+ set = new Set();
202
+ this.listeners.set(event, set);
203
+ }
204
+ set.add(listener);
205
+ return this;
206
+ }
207
+ off(event, listener) {
208
+ this.listeners.get(event)?.delete(listener);
209
+ return this;
210
+ }
211
+ emit(event, data) {
212
+ const set = this.listeners.get(event);
213
+ if (set) {
214
+ for (const listener of set) {
215
+ listener(data);
216
+ }
217
+ }
218
+ }
182
219
  // ===== Reset / Lifecycle =====
183
220
  reset() {
221
+ this.close();
184
222
  this.routes = [];
185
223
  this.staticRoutes.clear();
186
224
  this.plugins = [];
187
225
  this.requestHistory = [];
226
+ this.listeners.clear();
188
227
  if (this.globalConfig.state) {
189
228
  for (const key of Object.keys(this.globalConfig.state)) {
190
229
  delete this.globalConfig.state[key];
@@ -204,14 +243,96 @@ export class CallableMockInstance {
204
243
  }
205
244
  this.logger.log("lifecycle", "State cleared");
206
245
  }
246
+ // ===== Standalone Server =====
247
+ listen(port = 0, hostname = "127.0.0.1") {
248
+ if (this.server) {
249
+ throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
250
+ }
251
+ const httpServer = createServer((req, res) => {
252
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
253
+ const method = toHttpMethod(req.method ?? "GET");
254
+ const path = url.pathname;
255
+ const headers = {};
256
+ for (const [key, value] of Object.entries(req.headers)) {
257
+ if (typeof value === "string") {
258
+ headers[key] = value;
259
+ }
260
+ }
261
+ const query = {};
262
+ url.searchParams.forEach((value, key) => {
263
+ query[key] = value;
264
+ });
265
+ const chunks = [];
266
+ req.on("data", (chunk) => chunks.push(chunk));
267
+ req.on("end", () => {
268
+ const raw = Buffer.concat(chunks).toString();
269
+ let body;
270
+ const contentType = headers["content-type"] ?? "";
271
+ if (raw && contentType.includes("json")) {
272
+ try {
273
+ body = JSON.parse(raw);
274
+ }
275
+ catch {
276
+ body = raw;
277
+ }
278
+ }
279
+ else if (raw) {
280
+ body = raw;
281
+ }
282
+ void this.handle(method, path, { headers, body, query }).then((schmockResponse) => {
283
+ const responseHeaders = {
284
+ ...schmockResponse.headers,
285
+ };
286
+ if (!responseHeaders["content-type"] &&
287
+ schmockResponse.body !== undefined &&
288
+ typeof schmockResponse.body !== "string") {
289
+ responseHeaders["content-type"] = "application/json";
290
+ }
291
+ const responseBody = schmockResponse.body === undefined
292
+ ? undefined
293
+ : typeof schmockResponse.body === "string"
294
+ ? schmockResponse.body
295
+ : JSON.stringify(schmockResponse.body);
296
+ res.writeHead(schmockResponse.status, responseHeaders);
297
+ res.end(responseBody);
298
+ });
299
+ });
300
+ });
301
+ this.server = httpServer;
302
+ return new Promise((resolve, reject) => {
303
+ httpServer.on("error", reject);
304
+ httpServer.listen(port, hostname, () => {
305
+ const addr = httpServer.address();
306
+ const actualPort = addr !== null && typeof addr === "object" ? addr.port : port;
307
+ this.serverInfo = { port: actualPort, hostname };
308
+ this.logger.log("server", `Listening on ${hostname}:${actualPort}`);
309
+ resolve(this.serverInfo);
310
+ });
311
+ });
312
+ }
313
+ close() {
314
+ if (!this.server) {
315
+ return;
316
+ }
317
+ this.server.close();
318
+ this.server = undefined;
319
+ this.serverInfo = undefined;
320
+ this.logger.log("server", "Server stopped");
321
+ }
207
322
  async handle(method, path, options) {
208
323
  const requestId = crypto.randomUUID();
324
+ const handleStart = performance.now();
209
325
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
210
326
  headers: options?.headers,
211
327
  query: options?.query,
212
328
  bodyType: options?.body ? typeof options.body : "none",
213
329
  });
214
330
  this.logger.time(`request-${requestId}`);
331
+ this.emit("request:start", {
332
+ method,
333
+ path,
334
+ headers: options?.headers || {},
335
+ });
215
336
  try {
216
337
  // Apply namespace if configured
217
338
  let requestPath = path;
@@ -244,18 +365,31 @@ export class CallableMockInstance {
244
365
  const matchedRoute = this.findRoute(method, requestPath);
245
366
  if (!matchedRoute) {
246
367
  this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
368
+ this.emit("request:notfound", { method, path: requestPath });
247
369
  const error = new RouteNotFoundError(method, path);
248
370
  const response = {
249
371
  status: 404,
250
372
  body: { error: error.message, code: error.code },
251
373
  headers: {},
252
374
  };
375
+ this.emit("request:end", {
376
+ method,
377
+ path: requestPath,
378
+ status: 404,
379
+ duration: performance.now() - handleStart,
380
+ });
253
381
  this.logger.timeEnd(`request-${requestId}`);
254
382
  return response;
255
383
  }
256
384
  this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
257
385
  // Extract parameters from the matched route
258
386
  const params = this.extractParams(matchedRoute, requestPath);
387
+ this.emit("request:match", {
388
+ method,
389
+ path: requestPath,
390
+ routePath: matchedRoute.path,
391
+ params,
392
+ });
259
393
  // Generate initial response from route handler
260
394
  const context = {
261
395
  method,
@@ -297,8 +431,8 @@ export class CallableMockInstance {
297
431
  }
298
432
  // Parse and prepare response
299
433
  const response = this.parseResponse(result, matchedRoute.config);
300
- // Apply global delay if configured
301
- await this.applyDelay();
434
+ // Apply delay (route-level overrides global)
435
+ await this.applyDelay(matchedRoute.config.delay);
302
436
  // Record request in history
303
437
  this.requestHistory.push({
304
438
  method,
@@ -310,6 +444,12 @@ export class CallableMockInstance {
310
444
  timestamp: Date.now(),
311
445
  response: { status: response.status, body: response.body },
312
446
  });
447
+ this.emit("request:end", {
448
+ method,
449
+ path: requestPath,
450
+ status: response.status,
451
+ duration: performance.now() - handleStart,
452
+ });
313
453
  // Log successful response
314
454
  this.logger.log("response", `[${requestId}] Sending response ${response.status}`, {
315
455
  status: response.status,
@@ -330,7 +470,7 @@ export class CallableMockInstance {
330
470
  },
331
471
  headers: {},
332
472
  };
333
- // Apply global delay if configured (even for error responses)
473
+ // Apply delay even for error responses
334
474
  await this.applyDelay();
335
475
  this.logger.log("error", `[${requestId}] Returning error response 500`);
336
476
  this.logger.timeEnd(`request-${requestId}`);
@@ -342,16 +482,16 @@ export class CallableMockInstance {
342
482
  * Supports both fixed delays and random delays within a range
343
483
  * @private
344
484
  */
345
- async applyDelay() {
346
- if (!this.globalConfig.delay) {
485
+ async applyDelay(routeDelay) {
486
+ const effectiveDelay = routeDelay ?? this.globalConfig.delay;
487
+ if (!effectiveDelay) {
347
488
  return;
348
489
  }
349
- const delay = Array.isArray(this.globalConfig.delay)
350
- ? Math.random() *
351
- (this.globalConfig.delay[1] - this.globalConfig.delay[0]) +
352
- this.globalConfig.delay[0]
353
- : this.globalConfig.delay;
354
- await new Promise((resolve) => setTimeout(resolve, delay));
490
+ const ms = Array.isArray(effectiveDelay)
491
+ ? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
492
+ effectiveDelay[0]
493
+ : effectiveDelay;
494
+ await new Promise((resolve) => setTimeout(resolve, ms));
355
495
  }
356
496
  /**
357
497
  * Parse and normalize response result into Response object
package/dist/index.d.ts CHANGED
@@ -24,5 +24,5 @@
24
24
  export declare function schmock(config?: Schmock.GlobalConfig): Schmock.CallableMockInstance;
25
25
  export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, } from "./constants.js";
26
26
  export { PluginError, ResourceLimitError, ResponseGenerationError, RouteDefinitionError, RouteNotFoundError, RouteParseError, SchemaGenerationError, SchemaValidationError, SchmockError, } from "./errors.js";
27
- export type { CallableMockInstance, Generator, GeneratorFunction, GlobalConfig, HttpMethod, Plugin, PluginContext, PluginResult, RequestContext, RequestOptions, RequestRecord, Response, ResponseBody, ResponseResult, RouteConfig, RouteKey, StaticData, } from "./types.js";
27
+ export type { CallableMockInstance, Generator, GeneratorFunction, GlobalConfig, HttpMethod, Plugin, PluginContext, PluginResult, RequestContext, RequestOptions, RequestRecord, Response, ResponseBody, ResponseResult, RouteConfig, RouteInfo, RouteKey, ServerInfo, StaticData, } from "./types.js";
28
28
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,CAiC9B;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,YAAY,GACb,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,QAAQ,EACR,UAAU,GACX,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,CA6C9B;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,YAAY,GACb,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACV,UAAU,GACX,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -42,6 +42,18 @@ export function schmock(config) {
42
42
  reset: instance.reset.bind(instance),
43
43
  resetHistory: instance.resetHistory.bind(instance),
44
44
  resetState: instance.resetState.bind(instance),
45
+ on: ((event, listener) => {
46
+ instance.on(event, listener);
47
+ return callableInstance;
48
+ }),
49
+ off: ((event, listener) => {
50
+ instance.off(event, listener);
51
+ return callableInstance;
52
+ }),
53
+ getRoutes: instance.getRoutes.bind(instance),
54
+ getState: instance.getState.bind(instance),
55
+ listen: instance.listen.bind(instance),
56
+ close: instance.close.bind(instance),
45
57
  });
46
58
  instance.setCallableRef(callableInstance);
47
59
  return callableInstance;
package/dist/types.d.ts CHANGED
@@ -15,4 +15,6 @@ export type PluginContext = Schmock.PluginContext;
15
15
  export type PluginResult = Schmock.PluginResult;
16
16
  export type StaticData = Schmock.StaticData;
17
17
  export type RequestRecord = Schmock.RequestRecord;
18
+ export type ServerInfo = Schmock.ServerInfo;
19
+ export type RouteInfo = Schmock.RouteInfo;
18
20
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;AAC9C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;AAC1D,MAAM,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;AAChE,MAAM,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AACpC,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC;AAC9C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;AAC1C,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC;AAC1D,MAAM,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,CAAC;AAChE,MAAM,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AACpC,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;AAChD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC"}
package/dist/types.js CHANGED
@@ -1,2 +1,2 @@
1
- /// <reference path="../../../types/schmock.d.ts" />
1
+ /// <reference path="../schmock.d.ts" />
2
2
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/core",
3
3
  "description": "Core functionality for Schmock",
4
- "version": "1.2.2",
4
+ "version": "1.4.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "build": "bun build:lib && bun build:types",
22
- "build:lib": "bun build --minify --outdir=dist src/index.ts",
22
+ "build:lib": "bun build --minify --target node --outdir=dist src/index.ts",
23
23
  "build:types": "rm -f tsconfig.tsbuildinfo && tsc --build",
24
24
  "pretest": "rm -f src/*.js src/*.d.ts || true",
25
25
  "test": "vitest",
package/src/builder.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { isStatusTuple } from "./constants.js";
1
+ import type { Server } from "node:http";
2
+ import { createServer } from "node:http";
3
+ import { isStatusTuple, toHttpMethod } from "./constants.js";
2
4
  import {
3
5
  PluginError,
4
6
  RouteDefinitionError,
@@ -84,6 +86,9 @@ export class CallableMockInstance {
84
86
  private logger: DebugLogger;
85
87
  private requestHistory: Schmock.RequestRecord[] = [];
86
88
  private callableRef: Schmock.CallableMockInstance | undefined;
89
+ private server: Server | undefined;
90
+ private serverInfo: Schmock.ServerInfo | undefined;
91
+ private listeners = new Map<string, Set<(data: unknown) => void>>();
87
92
 
88
93
  constructor(private globalConfig: Schmock.GlobalConfig = {}) {
89
94
  this.logger = new DebugLogger(globalConfig.debug || false);
@@ -251,13 +256,55 @@ export class CallableMockInstance {
251
256
  return this.requestHistory[this.requestHistory.length - 1];
252
257
  }
253
258
 
259
+ // ===== Introspection =====
260
+
261
+ getRoutes(): Schmock.RouteInfo[] {
262
+ return this.routes.map((r) => ({
263
+ method: r.method,
264
+ path: r.path,
265
+ hasParams: r.params.length > 0,
266
+ }));
267
+ }
268
+
269
+ getState(): Record<string, unknown> {
270
+ return this.globalConfig.state || {};
271
+ }
272
+
273
+ // ===== Lifecycle Events =====
274
+
275
+ on(event: string, listener: (data: unknown) => void): this {
276
+ let set = this.listeners.get(event);
277
+ if (!set) {
278
+ set = new Set();
279
+ this.listeners.set(event, set);
280
+ }
281
+ set.add(listener);
282
+ return this;
283
+ }
284
+
285
+ off(event: string, listener: (data: unknown) => void): this {
286
+ this.listeners.get(event)?.delete(listener);
287
+ return this;
288
+ }
289
+
290
+ private emit(event: string, data: unknown): void {
291
+ const set = this.listeners.get(event);
292
+ if (set) {
293
+ for (const listener of set) {
294
+ listener(data);
295
+ }
296
+ }
297
+ }
298
+
254
299
  // ===== Reset / Lifecycle =====
255
300
 
256
301
  reset(): void {
302
+ this.close();
257
303
  this.routes = [];
258
304
  this.staticRoutes.clear();
259
305
  this.plugins = [];
260
306
  this.requestHistory = [];
307
+ this.listeners.clear();
261
308
  if (this.globalConfig.state) {
262
309
  for (const key of Object.keys(this.globalConfig.state)) {
263
310
  delete this.globalConfig.state[key];
@@ -280,12 +327,108 @@ export class CallableMockInstance {
280
327
  this.logger.log("lifecycle", "State cleared");
281
328
  }
282
329
 
330
+ // ===== Standalone Server =====
331
+
332
+ listen(port = 0, hostname = "127.0.0.1"): Promise<Schmock.ServerInfo> {
333
+ if (this.server) {
334
+ throw new SchmockError(
335
+ "Server is already running",
336
+ "SERVER_ALREADY_RUNNING",
337
+ );
338
+ }
339
+
340
+ const httpServer = createServer((req, res) => {
341
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
342
+ const method = toHttpMethod(req.method ?? "GET");
343
+ const path = url.pathname;
344
+
345
+ const headers: Record<string, string> = {};
346
+ for (const [key, value] of Object.entries(req.headers)) {
347
+ if (typeof value === "string") {
348
+ headers[key] = value;
349
+ }
350
+ }
351
+
352
+ const query: Record<string, string> = {};
353
+ url.searchParams.forEach((value, key) => {
354
+ query[key] = value;
355
+ });
356
+
357
+ const chunks: Buffer[] = [];
358
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
359
+ req.on("end", () => {
360
+ const raw = Buffer.concat(chunks).toString();
361
+ let body: unknown;
362
+ const contentType = headers["content-type"] ?? "";
363
+ if (raw && contentType.includes("json")) {
364
+ try {
365
+ body = JSON.parse(raw);
366
+ } catch {
367
+ body = raw;
368
+ }
369
+ } else if (raw) {
370
+ body = raw;
371
+ }
372
+
373
+ void this.handle(method, path, { headers, body, query }).then(
374
+ (schmockResponse) => {
375
+ const responseHeaders: Record<string, string> = {
376
+ ...schmockResponse.headers,
377
+ };
378
+ if (
379
+ !responseHeaders["content-type"] &&
380
+ schmockResponse.body !== undefined &&
381
+ typeof schmockResponse.body !== "string"
382
+ ) {
383
+ responseHeaders["content-type"] = "application/json";
384
+ }
385
+
386
+ const responseBody =
387
+ schmockResponse.body === undefined
388
+ ? undefined
389
+ : typeof schmockResponse.body === "string"
390
+ ? schmockResponse.body
391
+ : JSON.stringify(schmockResponse.body);
392
+
393
+ res.writeHead(schmockResponse.status, responseHeaders);
394
+ res.end(responseBody);
395
+ },
396
+ );
397
+ });
398
+ });
399
+
400
+ this.server = httpServer;
401
+
402
+ return new Promise((resolve, reject) => {
403
+ httpServer.on("error", reject);
404
+ httpServer.listen(port, hostname, () => {
405
+ const addr = httpServer.address();
406
+ const actualPort =
407
+ addr !== null && typeof addr === "object" ? addr.port : port;
408
+ this.serverInfo = { port: actualPort, hostname };
409
+ this.logger.log("server", `Listening on ${hostname}:${actualPort}`);
410
+ resolve(this.serverInfo);
411
+ });
412
+ });
413
+ }
414
+
415
+ close(): void {
416
+ if (!this.server) {
417
+ return;
418
+ }
419
+ this.server.close();
420
+ this.server = undefined;
421
+ this.serverInfo = undefined;
422
+ this.logger.log("server", "Server stopped");
423
+ }
424
+
283
425
  async handle(
284
426
  method: Schmock.HttpMethod,
285
427
  path: string,
286
428
  options?: Schmock.RequestOptions,
287
429
  ): Promise<Schmock.Response> {
288
430
  const requestId = crypto.randomUUID();
431
+ const handleStart = performance.now();
289
432
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
290
433
  headers: options?.headers,
291
434
  query: options?.query,
@@ -293,6 +436,12 @@ export class CallableMockInstance {
293
436
  });
294
437
  this.logger.time(`request-${requestId}`);
295
438
 
439
+ this.emit("request:start", {
440
+ method,
441
+ path,
442
+ headers: options?.headers || {},
443
+ });
444
+
296
445
  try {
297
446
  // Apply namespace if configured
298
447
  let requestPath = path;
@@ -340,12 +489,19 @@ export class CallableMockInstance {
340
489
  "route",
341
490
  `[${requestId}] No route found for ${method} ${requestPath}`,
342
491
  );
492
+ this.emit("request:notfound", { method, path: requestPath });
343
493
  const error = new RouteNotFoundError(method, path);
344
494
  const response = {
345
495
  status: 404,
346
496
  body: { error: error.message, code: error.code },
347
497
  headers: {},
348
498
  };
499
+ this.emit("request:end", {
500
+ method,
501
+ path: requestPath,
502
+ status: 404,
503
+ duration: performance.now() - handleStart,
504
+ });
349
505
  this.logger.timeEnd(`request-${requestId}`);
350
506
  return response;
351
507
  }
@@ -358,6 +514,13 @@ export class CallableMockInstance {
358
514
  // Extract parameters from the matched route
359
515
  const params = this.extractParams(matchedRoute, requestPath);
360
516
 
517
+ this.emit("request:match", {
518
+ method,
519
+ path: requestPath,
520
+ routePath: matchedRoute.path,
521
+ params,
522
+ });
523
+
361
524
  // Generate initial response from route handler
362
525
  const context: Schmock.RequestContext = {
363
526
  method,
@@ -410,8 +573,8 @@ export class CallableMockInstance {
410
573
  // Parse and prepare response
411
574
  const response = this.parseResponse(result, matchedRoute.config);
412
575
 
413
- // Apply global delay if configured
414
- await this.applyDelay();
576
+ // Apply delay (route-level overrides global)
577
+ await this.applyDelay(matchedRoute.config.delay);
415
578
 
416
579
  // Record request in history
417
580
  this.requestHistory.push({
@@ -425,6 +588,13 @@ export class CallableMockInstance {
425
588
  response: { status: response.status, body: response.body },
426
589
  });
427
590
 
591
+ this.emit("request:end", {
592
+ method,
593
+ path: requestPath,
594
+ status: response.status,
595
+ duration: performance.now() - handleStart,
596
+ });
597
+
428
598
  // Log successful response
429
599
  this.logger.log(
430
600
  "response",
@@ -455,7 +625,7 @@ export class CallableMockInstance {
455
625
  headers: {},
456
626
  };
457
627
 
458
- // Apply global delay if configured (even for error responses)
628
+ // Apply delay even for error responses
459
629
  await this.applyDelay();
460
630
 
461
631
  this.logger.log("error", `[${requestId}] Returning error response 500`);
@@ -469,18 +639,20 @@ export class CallableMockInstance {
469
639
  * Supports both fixed delays and random delays within a range
470
640
  * @private
471
641
  */
472
- private async applyDelay(): Promise<void> {
473
- if (!this.globalConfig.delay) {
642
+ private async applyDelay(
643
+ routeDelay?: number | [number, number],
644
+ ): Promise<void> {
645
+ const effectiveDelay = routeDelay ?? this.globalConfig.delay;
646
+ if (!effectiveDelay) {
474
647
  return;
475
648
  }
476
649
 
477
- const delay = Array.isArray(this.globalConfig.delay)
478
- ? Math.random() *
479
- (this.globalConfig.delay[1] - this.globalConfig.delay[0]) +
480
- this.globalConfig.delay[0]
481
- : this.globalConfig.delay;
650
+ const ms = Array.isArray(effectiveDelay)
651
+ ? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
652
+ effectiveDelay[0]
653
+ : effectiveDelay;
482
654
 
483
- await new Promise((resolve) => setTimeout(resolve, delay));
655
+ await new Promise((resolve) => setTimeout(resolve, ms));
484
656
  }
485
657
 
486
658
  /**
package/src/index.ts CHANGED
@@ -52,6 +52,18 @@ export function schmock(
52
52
  reset: instance.reset.bind(instance),
53
53
  resetHistory: instance.resetHistory.bind(instance),
54
54
  resetState: instance.resetState.bind(instance),
55
+ on: ((event: string, listener: (data: unknown) => void) => {
56
+ instance.on(event, listener);
57
+ return callableInstance;
58
+ }) as Schmock.CallableMockInstance["on"],
59
+ off: ((event: string, listener: (data: unknown) => void) => {
60
+ instance.off(event, listener);
61
+ return callableInstance;
62
+ }) as Schmock.CallableMockInstance["off"],
63
+ getRoutes: instance.getRoutes.bind(instance),
64
+ getState: instance.getState.bind(instance),
65
+ listen: instance.listen.bind(instance),
66
+ close: instance.close.bind(instance),
55
67
  },
56
68
  );
57
69
 
@@ -97,6 +109,8 @@ export type {
97
109
  ResponseBody,
98
110
  ResponseResult,
99
111
  RouteConfig,
112
+ RouteInfo,
100
113
  RouteKey,
114
+ ServerInfo,
101
115
  StaticData,
102
116
  } from "./types.js";
@@ -0,0 +1,116 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { schmock } from "./index";
3
+
4
+ describe("Standalone Server", () => {
5
+ let mock: Schmock.CallableMockInstance;
6
+
7
+ afterEach(() => {
8
+ mock?.close();
9
+ });
10
+
11
+ it("returns actual port when port=0", async () => {
12
+ mock = schmock();
13
+ mock("GET /test", { ok: true });
14
+ const info = await mock.listen(0);
15
+ expect(info.port).toBeGreaterThan(0);
16
+ expect(info.hostname).toBe("127.0.0.1");
17
+ });
18
+
19
+ it("propagates request headers", async () => {
20
+ mock = schmock();
21
+ mock("GET /headers", ({ headers }) => ({
22
+ auth: headers.authorization,
23
+ }));
24
+ const info = await mock.listen(0);
25
+
26
+ const res = await fetch(`http://127.0.0.1:${info.port}/headers`, {
27
+ headers: { authorization: "Bearer secret" },
28
+ });
29
+ const body = await res.json();
30
+ expect(body.auth).toBe("Bearer secret");
31
+ });
32
+
33
+ it("handles concurrent requests", async () => {
34
+ mock = schmock();
35
+ mock("GET /slow", () => ({ value: "done" }));
36
+ const info = await mock.listen(0);
37
+
38
+ const results = await Promise.all(
39
+ Array.from({ length: 10 }, () =>
40
+ fetch(`http://127.0.0.1:${info.port}/slow`).then((r) => r.json()),
41
+ ),
42
+ );
43
+
44
+ for (const body of results) {
45
+ expect(body.value).toBe("done");
46
+ }
47
+ });
48
+
49
+ it("parses text body when content-type is not JSON", async () => {
50
+ mock = schmock();
51
+ mock("POST /text", ({ body: reqBody }) => ({ received: reqBody }));
52
+ const info = await mock.listen(0);
53
+
54
+ const res = await fetch(`http://127.0.0.1:${info.port}/text`, {
55
+ method: "POST",
56
+ headers: { "content-type": "text/plain" },
57
+ body: "hello world",
58
+ });
59
+ const resBody = await res.json();
60
+ expect(resBody.received).toBe("hello world");
61
+ });
62
+
63
+ it("returns 204 with no body for undefined response", async () => {
64
+ mock = schmock();
65
+ mock("DELETE /item", () => undefined);
66
+ const info = await mock.listen(0);
67
+
68
+ const res = await fetch(`http://127.0.0.1:${info.port}/item`, {
69
+ method: "DELETE",
70
+ });
71
+ expect(res.status).toBe(204);
72
+ });
73
+
74
+ it("reset stops the server", async () => {
75
+ mock = schmock();
76
+ mock("GET /test", { ok: true });
77
+ const info = await mock.listen(0);
78
+
79
+ const res = await fetch(`http://127.0.0.1:${info.port}/test`);
80
+ expect(res.status).toBe(200);
81
+
82
+ mock.reset();
83
+
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
+ }
90
+ });
91
+
92
+ it("handles route params in server mode", async () => {
93
+ mock = schmock();
94
+ mock("GET /users/:id", ({ params }) => ({ id: params.id }));
95
+ const info = await mock.listen(0);
96
+
97
+ const res = await fetch(`http://127.0.0.1:${info.port}/users/42`);
98
+ const body = await res.json();
99
+ expect(body.id).toBe("42");
100
+ });
101
+
102
+ it("double listen throws", async () => {
103
+ mock = schmock();
104
+ mock("GET /test", { ok: true });
105
+ await mock.listen(0);
106
+ expect(() => mock.listen(0)).toThrow("Server is already running");
107
+ });
108
+
109
+ it("close is idempotent", async () => {
110
+ mock = schmock();
111
+ mock("GET /test", { ok: true });
112
+ await mock.listen(0);
113
+ mock.close();
114
+ expect(() => mock.close()).not.toThrow();
115
+ });
116
+ });
@@ -0,0 +1,142 @@
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/lifecycle-events.feature");
6
+
7
+ describeFeature(feature, ({ Scenario }) => {
8
+ let mock: Schmock.CallableMockInstance;
9
+ let events: Array<{ type: string; data: unknown }>;
10
+ let removedFired: boolean;
11
+
12
+ function collectEvent(type: string) {
13
+ return (data: unknown) => {
14
+ events.push({ type, data });
15
+ };
16
+ }
17
+
18
+ Scenario("Events fire at correct times", ({ Given, And, When, Then }) => {
19
+ Given('a mock with a route "GET /items"', () => {
20
+ mock = schmock({ state: {} });
21
+ mock("GET /items", [{ id: 1 }], {});
22
+ });
23
+
24
+ And("I register listeners for all events", () => {
25
+ events = [];
26
+ mock.on("request:start", collectEvent("request:start"));
27
+ mock.on("request:match", collectEvent("request:match"));
28
+ mock.on("request:notfound", collectEvent("request:notfound"));
29
+ mock.on("request:end", collectEvent("request:end"));
30
+ });
31
+
32
+ When('I request "GET /items"', async () => {
33
+ await mock.handle("GET", "/items");
34
+ });
35
+
36
+ Then('the "request:start" event fired', () => {
37
+ expect(events.some((e) => e.type === "request:start")).toBe(true);
38
+ });
39
+
40
+ And('the "request:match" event fired with routePath "/items"', () => {
41
+ const match = events.find((e) => e.type === "request:match");
42
+ expect(match).toBeDefined();
43
+ const data = match?.data as Record<string, unknown>;
44
+ expect(data.routePath).toBe("/items");
45
+ });
46
+
47
+ And('the "request:end" event fired with status 200', () => {
48
+ const end = events.find((e) => e.type === "request:end");
49
+ expect(end).toBeDefined();
50
+ const data = end?.data as Record<string, unknown>;
51
+ expect(data.status).toBe(200);
52
+ });
53
+ });
54
+
55
+ Scenario("Not found event fires for unmatched routes", ({ Given, And, When, Then }) => {
56
+ Given('a mock with a route "GET /items"', () => {
57
+ mock = schmock({ state: {} });
58
+ mock("GET /items", [{ id: 1 }], {});
59
+ });
60
+
61
+ And("I register listeners for all events", () => {
62
+ events = [];
63
+ mock.on("request:start", collectEvent("request:start"));
64
+ mock.on("request:match", collectEvent("request:match"));
65
+ mock.on("request:notfound", collectEvent("request:notfound"));
66
+ mock.on("request:end", collectEvent("request:end"));
67
+ });
68
+
69
+ When('I request "GET /missing"', async () => {
70
+ await mock.handle("GET", "/missing");
71
+ });
72
+
73
+ Then('the "request:start" event fired', () => {
74
+ expect(events.some((e) => e.type === "request:start")).toBe(true);
75
+ });
76
+
77
+ And('the "request:notfound" event fired', () => {
78
+ expect(events.some((e) => e.type === "request:notfound")).toBe(true);
79
+ });
80
+
81
+ And('the "request:end" event fired with status 404', () => {
82
+ const end = events.find((e) => e.type === "request:end");
83
+ expect(end).toBeDefined();
84
+ const data = end?.data as Record<string, unknown>;
85
+ expect(data.status).toBe(404);
86
+ });
87
+ });
88
+
89
+ Scenario("Off removes listener", ({ Given, And, When, Then }) => {
90
+ Given('a mock with a route "GET /items"', () => {
91
+ mock = schmock({ state: {} });
92
+ mock("GET /items", [{ id: 1 }], {});
93
+ });
94
+
95
+ And("I register and remove a listener", () => {
96
+ removedFired = false;
97
+ const listener = () => {
98
+ removedFired = true;
99
+ };
100
+ mock.on("request:start", listener);
101
+ mock.off("request:start", listener);
102
+ });
103
+
104
+ When('I request "GET /items"', async () => {
105
+ await mock.handle("GET", "/items");
106
+ });
107
+
108
+ Then("the removed listener did not fire", () => {
109
+ expect(removedFired).toBe(false);
110
+ });
111
+ });
112
+
113
+ Scenario("Reset clears all listeners", ({ Given, And, When, Then }) => {
114
+ Given('a mock with a route "GET /items"', () => {
115
+ mock = schmock({ state: {} });
116
+ mock("GET /items", [{ id: 1 }], {});
117
+ });
118
+
119
+ And("I register listeners for all events", () => {
120
+ events = [];
121
+ mock.on("request:start", collectEvent("request:start"));
122
+ mock.on("request:end", collectEvent("request:end"));
123
+ });
124
+
125
+ When("I reset the mock", () => {
126
+ mock.reset();
127
+ events = [];
128
+ });
129
+
130
+ And('I add a route "GET /items" again', () => {
131
+ mock("GET /items", [{ id: 1 }], {});
132
+ });
133
+
134
+ And('I request "GET /items" after reset', async () => {
135
+ await mock.handle("GET", "/items");
136
+ });
137
+
138
+ Then("no events were collected after reset", () => {
139
+ expect(events).toHaveLength(0);
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,85 @@
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/response-delay.feature");
6
+
7
+ describeFeature(feature, ({ Scenario }) => {
8
+ let mock: Schmock.CallableMockInstance;
9
+ let elapsed: number;
10
+ let elapsed2: number;
11
+
12
+ Scenario("Route delay overrides global delay", ({ Given, And, When, Then }) => {
13
+ Given("a mock with global delay of 200ms", () => {
14
+ mock = schmock({ delay: 200, state: {} });
15
+ });
16
+
17
+ And('a route "GET /fast" with delay of 10ms', () => {
18
+ mock("GET /fast", { ok: true }, { delay: 10 });
19
+ });
20
+
21
+ And('a route "GET /default" with no delay override', () => {
22
+ mock("GET /default", { ok: true }, {});
23
+ });
24
+
25
+ When('I request "GET /fast"', async () => {
26
+ const start = performance.now();
27
+ await mock.handle("GET", "/fast");
28
+ elapsed = performance.now() - start;
29
+ });
30
+
31
+ Then("the response took less than 100ms", () => {
32
+ expect(elapsed).toBeLessThan(100);
33
+ });
34
+
35
+ When('I request "GET /default" with timing', async () => {
36
+ const start = performance.now();
37
+ await mock.handle("GET", "/default");
38
+ elapsed2 = performance.now() - start;
39
+ });
40
+
41
+ Then("that response took at least 150ms", () => {
42
+ expect(elapsed2).toBeGreaterThanOrEqual(150);
43
+ });
44
+ });
45
+
46
+ Scenario("Route delay supports random range", ({ Given, And, When, Then }) => {
47
+ Given("a mock with no global delay", () => {
48
+ mock = schmock({ state: {} });
49
+ });
50
+
51
+ And('a route "GET /random" with delay range 10 to 30', () => {
52
+ mock("GET /random", { ok: true }, { delay: [10, 30] });
53
+ });
54
+
55
+ When('I request "GET /random"', async () => {
56
+ const start = performance.now();
57
+ await mock.handle("GET", "/random");
58
+ elapsed = performance.now() - start;
59
+ });
60
+
61
+ Then("the response took at least 10ms", () => {
62
+ expect(elapsed).toBeGreaterThanOrEqual(8); // small tolerance
63
+ });
64
+ });
65
+
66
+ Scenario("No route delay inherits global delay", ({ Given, And, When, Then }) => {
67
+ Given("a mock with global delay of 50ms", () => {
68
+ mock = schmock({ delay: 50, state: {} });
69
+ });
70
+
71
+ And('a route "GET /items" with no delay override', () => {
72
+ mock("GET /items", { ok: true }, {});
73
+ });
74
+
75
+ When('I request "GET /items" with timing', async () => {
76
+ const start = performance.now();
77
+ await mock.handle("GET", "/items");
78
+ elapsed = performance.now() - start;
79
+ });
80
+
81
+ Then("that response took at least 40ms", () => {
82
+ expect(elapsed).toBeGreaterThanOrEqual(40);
83
+ });
84
+ });
85
+ });
@@ -0,0 +1,233 @@
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/standalone-server.feature");
6
+
7
+ describeFeature(feature, ({ Scenario }) => {
8
+ let mock: Schmock.CallableMockInstance;
9
+ let serverInfo: Schmock.ServerInfo;
10
+ let httpResponse: Response;
11
+
12
+ function baseUrl(): string {
13
+ return `http://${serverInfo.hostname}:${serverInfo.port}`;
14
+ }
15
+
16
+ Scenario("Start and stop a simple server", ({ Given, When, Then, And }) => {
17
+ Given("I create a mock with a GET /hello route", () => {
18
+ mock = schmock();
19
+ mock("GET /hello", { message: "hello" });
20
+ });
21
+
22
+ When("I start the server on a random port", async () => {
23
+ serverInfo = await mock.listen(0);
24
+ });
25
+
26
+ Then("the server should be running", () => {
27
+ expect(serverInfo.port).toBeGreaterThan(0);
28
+ });
29
+
30
+ When("I fetch {string} from the server", async (_, route: string) => {
31
+ const [, path] = route.split(" ");
32
+ httpResponse = await fetch(`${baseUrl()}${path}`);
33
+ });
34
+
35
+ Then("the HTTP response status should be {int}", (_, status: number) => {
36
+ expect(httpResponse.status).toBe(status);
37
+ });
38
+
39
+ And(
40
+ "the HTTP response body should have {string} equal to {string}",
41
+ async (_, key: string, value: string) => {
42
+ const body = await httpResponse.json();
43
+ expect(body[key]).toBe(value);
44
+ },
45
+ );
46
+
47
+ When("I stop the server", () => {
48
+ mock.close();
49
+ });
50
+
51
+ Then("the server should not be running", async () => {
52
+ try {
53
+ await fetch(`${baseUrl()}/hello`);
54
+ expect.unreachable("Should not be able to connect");
55
+ } catch {
56
+ // Connection refused — server is down
57
+ }
58
+ });
59
+ });
60
+
61
+ Scenario("Handle POST with JSON body", ({ Given, When, Then, And }) => {
62
+ Given("I create a mock echoing POST at {string}", (_, path: string) => {
63
+ mock = schmock();
64
+ mock(`POST ${path}`, ({ body }) => body);
65
+ });
66
+
67
+ When("I start the server on a random port", async () => {
68
+ serverInfo = await mock.listen(0);
69
+ });
70
+
71
+ And(
72
+ "I fetch {string} with JSON body:",
73
+ async (_, route: string, docString: string) => {
74
+ const [method, path] = route.split(" ");
75
+ httpResponse = await fetch(`${baseUrl()}${path}`, {
76
+ method,
77
+ headers: { "content-type": "application/json" },
78
+ body: docString,
79
+ });
80
+ },
81
+ );
82
+
83
+ Then(
84
+ "the response status from POST should be {int}",
85
+ (_, status: number) => {
86
+ expect(httpResponse.status).toBe(status);
87
+ },
88
+ );
89
+
90
+ And(
91
+ "the response body from POST should have {string} equal to {string}",
92
+ async (_, key: string, value: string) => {
93
+ const body = await httpResponse.json();
94
+ expect(body[key]).toBe(value);
95
+ },
96
+ );
97
+
98
+ When("I stop the server", () => {
99
+ mock.close();
100
+ });
101
+ });
102
+
103
+ Scenario(
104
+ "Return 404 for unregistered routes",
105
+ ({ Given, When, And, Then }) => {
106
+ Given("I create a mock with a GET /hello route", () => {
107
+ mock = schmock();
108
+ mock("GET /hello", { message: "hello" });
109
+ });
110
+
111
+ When("I start the server on a random port", async () => {
112
+ serverInfo = await mock.listen(0);
113
+ });
114
+
115
+ And("I fetch {string} from the server", async (_, route: string) => {
116
+ const [, path] = route.split(" ");
117
+ httpResponse = await fetch(`${baseUrl()}${path}`);
118
+ });
119
+
120
+ Then("the HTTP response status should be {int}", (_, status: number) => {
121
+ expect(httpResponse.status).toBe(status);
122
+ });
123
+
124
+ When("I stop the server", () => {
125
+ mock.close();
126
+ });
127
+ },
128
+ );
129
+
130
+ Scenario("Query parameters are forwarded", ({ Given, When, And, Then }) => {
131
+ let parsedBody: Record<string, string>;
132
+
133
+ Given(
134
+ "I create a mock reflecting query params at {string}",
135
+ (_, route: string) => {
136
+ const [method, path] = route.split(" ");
137
+ mock = schmock();
138
+ mock(`${method} ${path}` as Schmock.RouteKey, ({ query }) => query);
139
+ },
140
+ );
141
+
142
+ When("I start the server on a random port", async () => {
143
+ serverInfo = await mock.listen(0);
144
+ });
145
+
146
+ And("I fetch {string} from the server", async (_, route: string) => {
147
+ const [, pathWithQuery] = route.split(" ");
148
+ httpResponse = await fetch(`${baseUrl()}${pathWithQuery}`);
149
+ parsedBody = await httpResponse.json();
150
+ });
151
+
152
+ Then("the HTTP response status should be {int}", (_, status: number) => {
153
+ expect(httpResponse.status).toBe(status);
154
+ });
155
+
156
+ And(
157
+ "the HTTP response body should have {string} equal to {string}",
158
+ (_, key: string, value: string) => {
159
+ expect(parsedBody[key]).toBe(value);
160
+ },
161
+ );
162
+
163
+ And(
164
+ "the query param {string} should equal {string}",
165
+ (_, key: string, value: string) => {
166
+ expect(parsedBody[key]).toBe(value);
167
+ },
168
+ );
169
+
170
+ When("I stop the server", () => {
171
+ mock.close();
172
+ });
173
+ });
174
+
175
+ Scenario("Double listen throws an error", ({ Given, When, Then }) => {
176
+ Given("I create a mock with a GET /hello route", () => {
177
+ mock = schmock();
178
+ mock("GET /hello", { message: "hello" });
179
+ });
180
+
181
+ When("I start the server on a random port", async () => {
182
+ serverInfo = await mock.listen(0);
183
+ });
184
+
185
+ Then("starting the server again should throw", () => {
186
+ expect(() => mock.listen(0)).toThrow("Server is already running");
187
+ mock.close();
188
+ });
189
+ });
190
+
191
+ Scenario("Close is idempotent", ({ Given, When, And, Then }) => {
192
+ Given("I create a mock with a GET /hello route", () => {
193
+ mock = schmock();
194
+ mock("GET /hello", { message: "hello" });
195
+ });
196
+
197
+ When("I start the server on a random port", async () => {
198
+ serverInfo = await mock.listen(0);
199
+ });
200
+
201
+ And("I stop the server", () => {
202
+ mock.close();
203
+ });
204
+
205
+ Then("stopping the server again should not throw", () => {
206
+ expect(() => mock.close()).not.toThrow();
207
+ });
208
+ });
209
+
210
+ Scenario("Reset stops the server", ({ Given, When, And, Then }) => {
211
+ Given("I create a mock with a GET /hello route", () => {
212
+ mock = schmock();
213
+ mock("GET /hello", { message: "hello" });
214
+ });
215
+
216
+ When("I start the server on a random port", async () => {
217
+ serverInfo = await mock.listen(0);
218
+ });
219
+
220
+ And("I reset the mock", () => {
221
+ mock.reset();
222
+ });
223
+
224
+ Then("the server should not be running", async () => {
225
+ try {
226
+ await fetch(`${baseUrl()}/hello`);
227
+ expect.unreachable("Should not be able to connect");
228
+ } catch {
229
+ // Connection refused — server is down
230
+ }
231
+ });
232
+ });
233
+ });
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
- /// <reference path="../../../types/schmock.d.ts" />
1
+ /// <reference path="../schmock.d.ts" />
2
2
 
3
- // Re-export types for internal use
3
+ // Re-export ambient types for consumers
4
4
  export type HttpMethod = Schmock.HttpMethod;
5
5
  export type RouteKey = Schmock.RouteKey;
6
6
  export type ResponseBody = Schmock.ResponseBody;
@@ -18,3 +18,5 @@ export type PluginContext = Schmock.PluginContext;
18
18
  export type PluginResult = Schmock.PluginResult;
19
19
  export type StaticData = Schmock.StaticData;
20
20
  export type RequestRecord = Schmock.RequestRecord;
21
+ export type ServerInfo = Schmock.ServerInfo;
22
+ export type RouteInfo = Schmock.RouteInfo;
@@ -1 +0,0 @@
1
- {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["builder.ts"],"names":[],"mappings":"AAAA,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;AAW7B,OAAO,KAAK,EACV,OAAO,EACP,aAAa,EAEb,YAAY,EAGZ,MAAM,EACP,MAAM,SAAS,CAAC;AAajB;;;;GAIG;AACH,qBAAa,cAAc,CAAC,MAAM,GAAG,OAAO,CAAE,YAAW,OAAO,CAAC,MAAM,CAAC;IACtE,OAAO,CAAC,OAAO,CAEb;IAEF,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;IAK/C,KAAK,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAShC,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAKpC,KAAK,IAAI,YAAY,CAAC,MAAM,CAAC;IAQ7B;;;OAGG;IACH,OAAO,CAAC,aAAa;CA0BtB"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,YAAa,SAAQ,KAAK;aAGnB,IAAI,EAAE,MAAM;aACZ,OAAO,CAAC,EAAE,OAAO;gBAFjC,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,YAAA;CAMpC;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAQzC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,YAAY;gBACnC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,YAAY;gBAC3C,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAQxC;AAED;;GAEG;AACH,qBAAa,WAAY,SAAQ,YAAY;gBAC/B,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAQ7C;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;gBACxC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;CAQnE;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,OAAO;CAQ1D;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAQ7D"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,OAAO,IAAI,OAAO,CAEjC;AAGD,YAAY,EACV,OAAO,EACP,aAAa,EACb,UAAU,EACV,YAAY,EACZ,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,QAAQ,EACR,MAAM,GACP,MAAM,SAAS,CAAC;AAGjB,OAAO,EACL,YAAY,EACZ,kBAAkB,EAClB,eAAe,EACf,uBAAuB,EACvB,WAAW,EACX,oBAAoB,EACpB,qBAAqB,EACrB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,UAAU,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["parser.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAuC3D"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC;AAC5C,MAAM,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;AACxC,MAAM,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC;AAClD,MAAM,MAAM,eAAe,CAAC,CAAC,GAAG,OAAO,IAAI,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;AACtE,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC;AACpD,MAAM,MAAM,gBAAgB,CAAC,CAAC,GAAG,OAAO,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC;AACxE,MAAM,MAAM,eAAe,CAAC,CAAC,GAAG,OAAO,IAAI,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;AACtE,MAAM,MAAM,MAAM,CAAC,CAAC,GAAG,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AACpD,MAAM,MAAM,OAAO,CAAC,CAAC,GAAG,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AACtD,MAAM,MAAM,YAAY,CAAC,CAAC,GAAG,OAAO,IAAI,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC"}