@schmock/core 2.2.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/binary.d.ts +8 -0
  2. package/dist/binary.d.ts.map +1 -0
  3. package/dist/binary.js +9 -0
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +60 -29
  6. package/dist/http-helpers.d.ts +15 -3
  7. package/dist/http-helpers.d.ts.map +1 -1
  8. package/dist/http-helpers.js +24 -6
  9. package/dist/index.d.ts +90 -27
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/interceptor.d.ts.map +1 -1
  13. package/dist/interceptor.js +71 -20
  14. package/dist/plugin-pipeline.d.ts +11 -4
  15. package/dist/plugin-pipeline.d.ts.map +1 -1
  16. package/dist/plugin-pipeline.js +98 -35
  17. package/dist/response-parser.d.ts.map +1 -1
  18. package/dist/response-parser.js +47 -17
  19. package/package.json +5 -3
  20. package/src/binary.test.ts +126 -0
  21. package/src/binary.ts +11 -0
  22. package/src/builder.ts +82 -35
  23. package/src/http-helpers.test.ts +39 -0
  24. package/src/http-helpers.ts +46 -10
  25. package/src/index.ts +1 -0
  26. package/src/interceptor.test.ts +197 -0
  27. package/src/interceptor.ts +215 -128
  28. package/src/parser.property.test.ts +91 -55
  29. package/src/plugin-pipeline.ts +150 -45
  30. package/src/plugin-system.test.ts +95 -0
  31. package/src/response-parser.ts +58 -19
  32. package/src/server.test.ts +81 -12
  33. package/src/steps/async-support.steps.ts +29 -8
  34. package/src/steps/audit-onerror-tuple.steps.ts +14 -30
  35. package/src/steps/error-handling.steps.ts +463 -253
  36. package/src/steps/fetch-interceptor.steps.ts +457 -0
  37. package/src/steps/http-methods.steps.ts +19 -8
  38. package/src/steps/lifecycle-events.steps.ts +50 -43
  39. package/src/steps/performance-reliability.steps.ts +78 -411
  40. package/src/steps/plugin-integration.steps.ts +54 -0
  41. package/src/steps/request-history.steps.ts +412 -230
  42. package/src/steps/standalone-server.steps.ts +7 -13
  43. package/src/steps/state-concurrency.steps.ts +142 -714
  44. package/src/steps/interceptor.steps.ts +0 -260
  45. package/src/steps/stateful-workflows.steps.ts +0 -353
package/src/builder.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Server } from "node:http";
2
+ import { isBinaryBody } from "./binary.js";
2
3
  import { normalizePath, toHttpMethod } from "./constants.js";
3
4
  import {
4
5
  errorMessage,
@@ -14,7 +15,11 @@ import {
14
15
  } from "./http-helpers.js";
15
16
  import { createFetchInterceptor } from "./interceptor.js";
16
17
  import { parseRouteKey } from "./parser.js";
17
- import { runPluginPipeline } from "./plugin-pipeline.js";
18
+ import {
19
+ recoverGeneratorError,
20
+ runPluginBeforeRequest,
21
+ runPluginPipeline,
22
+ } from "./plugin-pipeline.js";
18
23
  import { parseResponse } from "./response-parser.js";
19
24
  import type { CompiledCallableRoute } from "./route-matcher.js";
20
25
  import {
@@ -104,8 +109,8 @@ export class CallableMockInstance {
104
109
  ) {
105
110
  // Default to plain text for primitives
106
111
  routeConfig.contentType = "text/plain";
107
- } else if (Buffer.isBuffer(generator)) {
108
- // Default to octet-stream for buffers
112
+ } else if (isBinaryBody(generator)) {
113
+ // Default to octet-stream for browser and Node binary values
109
114
  routeConfig.contentType = "application/octet-stream";
110
115
  } else {
111
116
  // Default to JSON for objects/arrays
@@ -394,8 +399,16 @@ export class CallableMockInstance {
394
399
  this.server = httpServer;
395
400
 
396
401
  return new Promise((resolve, reject) => {
397
- httpServer.on("error", reject);
402
+ const handleStartupError = (error: Error) => {
403
+ if (this.server === httpServer) {
404
+ this.server = undefined;
405
+ this.serverInfo = undefined;
406
+ }
407
+ reject(error);
408
+ };
409
+ httpServer.once("error", handleStartupError);
398
410
  httpServer.listen(port, hostname, () => {
411
+ httpServer.off("error", handleStartupError);
399
412
  const addr = httpServer.address();
400
413
  const actualPort =
401
414
  addr !== null && typeof addr === "object" ? addr.port : port;
@@ -485,7 +498,7 @@ export class CallableMockInstance {
485
498
  const response = {
486
499
  status: 404,
487
500
  body: { error: error.message, code: error.code },
488
- headers: {},
501
+ headers: { "content-type": "application/json" },
489
502
  };
490
503
  this.emit("request:end", {
491
504
  method,
@@ -520,7 +533,7 @@ export class CallableMockInstance {
520
533
  const response = {
521
534
  status: 404,
522
535
  body: { error: error.message, code: error.code },
523
- headers: {},
536
+ headers: { "content-type": "application/json" },
524
537
  };
525
538
  this.emit("request:end", {
526
539
  method,
@@ -547,25 +560,8 @@ export class CallableMockInstance {
547
560
  params,
548
561
  });
549
562
 
550
- // Generate initial response from route handler
551
- const context: Schmock.RequestContext = {
552
- method,
553
- path: requestPath,
554
- params,
555
- query: reqQuery,
556
- headers: reqHeaders,
557
- body: options?.body,
558
- state: this.globalConfig.state || {},
559
- };
560
-
561
- let result: unknown;
562
- if (isGeneratorFunction(matchedRoute.generator)) {
563
- result = await matchedRoute.generator(context);
564
- } else {
565
- result = matchedRoute.generator;
566
- }
567
-
568
- // Build plugin context
563
+ // Build plugin context before route code so request guards can reject
564
+ // invalid or unauthorized requests without triggering side effects.
569
565
  let pluginContext: Schmock.PluginContext = {
570
566
  path: requestPath,
571
567
  route: matchedRoute.config,
@@ -578,16 +574,66 @@ export class CallableMockInstance {
578
574
  routeState: this.globalConfig.state || {},
579
575
  };
580
576
 
577
+ const preflightResult = await runPluginBeforeRequest(
578
+ this.plugins,
579
+ pluginContext,
580
+ this.logger,
581
+ );
582
+ pluginContext = preflightResult.context;
583
+ if (preflightResult.requestShortCircuited === true) {
584
+ pluginContext = { ...pluginContext, requestShortCircuited: true };
585
+ }
586
+
587
+ let result: unknown = preflightResult.response;
588
+ let skipPostProcessing = preflightResult.recoveredFromError === true;
589
+
590
+ if (result === undefined) {
591
+ const context: Schmock.RequestContext = {
592
+ method: pluginContext.method,
593
+ path: pluginContext.path,
594
+ params: pluginContext.params,
595
+ query: pluginContext.query,
596
+ headers: pluginContext.headers,
597
+ body: pluginContext.body,
598
+ state: pluginContext.routeState ?? this.globalConfig.state ?? {},
599
+ };
600
+
601
+ try {
602
+ if (isGeneratorFunction(matchedRoute.generator)) {
603
+ result = await matchedRoute.generator(context);
604
+ } else {
605
+ result = matchedRoute.generator;
606
+ }
607
+ } catch (error) {
608
+ const recovery = await recoverGeneratorError(
609
+ this.plugins,
610
+ pluginContext,
611
+ error,
612
+ this.logger,
613
+ );
614
+ pluginContext = recovery.context;
615
+ result = recovery.response;
616
+ skipPostProcessing = recovery.recoveredFromError === true;
617
+ }
618
+ }
619
+
581
620
  // Run plugin pipeline to transform the response
582
621
  try {
583
- const pipelineResult = await runPluginPipeline(
584
- this.plugins,
585
- pluginContext,
586
- result,
587
- this.logger,
588
- );
589
- pluginContext = pipelineResult.context;
590
- result = pipelineResult.response;
622
+ if (skipPostProcessing) {
623
+ this.logger.log(
624
+ "pipeline",
625
+ "Skipping response processors after error recovery",
626
+ );
627
+ } else {
628
+ const pipelineResult = await runPluginPipeline(
629
+ this.plugins,
630
+ pluginContext,
631
+ result,
632
+ this.logger,
633
+ );
634
+ pluginContext = pipelineResult.context;
635
+ result = pipelineResult.response;
636
+ }
591
637
  } catch (error) {
592
638
  this.logger.log(
593
639
  "error",
@@ -659,7 +705,7 @@ export class CallableMockInstance {
659
705
  error: errorMessage(error),
660
706
  code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
661
707
  },
662
- headers: {},
708
+ headers: { "content-type": "application/json" },
663
709
  };
664
710
 
665
711
  // Apply delay even for error responses
@@ -691,10 +737,11 @@ export class CallableMockInstance {
691
737
  return;
692
738
  }
693
739
 
694
- const ms = Array.isArray(effectiveDelay)
740
+ const configuredMs = Array.isArray(effectiveDelay)
695
741
  ? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
696
742
  effectiveDelay[0]
697
743
  : effectiveDelay;
744
+ const ms = Math.max(0, configuredMs);
698
745
 
699
746
  await new Promise((resolve) => setTimeout(resolve, ms));
700
747
  }
@@ -0,0 +1,39 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { describe, expect, it } from "vitest";
3
+ import { collectBody } from "./http-helpers.js";
4
+
5
+ function bodyStream(body: Buffer): PassThrough {
6
+ const stream = new PassThrough();
7
+ stream.end(body);
8
+ return stream;
9
+ }
10
+
11
+ describe("collectBody size limits", () => {
12
+ it("accepts a body exactly at the configured limit", async () => {
13
+ const body = Buffer.from("12345");
14
+ await expect(collectBody(bodyStream(body), {}, body.length)).resolves.toBe(
15
+ "12345",
16
+ );
17
+ });
18
+
19
+ it("accepts and parses JSON below the configured limit", async () => {
20
+ const body = Buffer.from('{"ok":true}');
21
+ await expect(
22
+ collectBody(
23
+ bodyStream(body),
24
+ { "content-type": "application/json" },
25
+ body.length + 1,
26
+ ),
27
+ ).resolves.toEqual({ ok: true });
28
+ });
29
+
30
+ it("rejects a body above the configured limit with status 413", async () => {
31
+ const body = Buffer.from("123456");
32
+ await expect(
33
+ collectBody(bodyStream(body), {}, body.length - 1),
34
+ ).rejects.toMatchObject({
35
+ message: "Request body too large",
36
+ status: 413,
37
+ });
38
+ });
39
+ });
@@ -1,10 +1,26 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
1
+ import type { ServerResponse } from "node:http";
2
+ import { isBinaryBody } from "./binary.js";
3
+
4
+ interface RequestWithHeaders {
5
+ readonly headers: {
6
+ readonly [header: string]: string | string[] | undefined;
7
+ };
8
+ }
9
+
10
+ interface BodyReadable {
11
+ on(event: "error", listener: (error: Error) => void): this;
12
+ on(event: "data", listener: (chunk: Buffer) => void): this;
13
+ on(event: "end", listener: () => void): this;
14
+ destroy(error?: Error): this;
15
+ }
2
16
 
3
17
  /**
4
18
  * Convert Node.js IncomingMessage headers to a flat Record<string, string>.
5
19
  * Drops array-valued headers (keeps only string values).
6
20
  */
7
- export function parseNodeHeaders(req: IncomingMessage): Record<string, string> {
21
+ export function parseNodeHeaders(
22
+ req: RequestWithHeaders,
23
+ ): Record<string, string> {
8
24
  const headers: Record<string, string> = {};
9
25
  for (const [key, value] of Object.entries(req.headers)) {
10
26
  if (typeof value === "string") {
@@ -37,7 +53,7 @@ const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
37
53
  * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
38
54
  */
39
55
  export function collectBody(
40
- req: IncomingMessage,
56
+ req: BodyReadable,
41
57
  headers: Record<string, string>,
42
58
  maxBodySize = DEFAULT_MAX_BODY_SIZE,
43
59
  ): Promise<unknown> {
@@ -93,20 +109,40 @@ export function writeSchmockResponse(
93
109
  ...extraHeaders,
94
110
  };
95
111
 
112
+ const hasContentType = Object.keys(responseHeaders).some(
113
+ (header) => header.toLowerCase() === "content-type",
114
+ );
115
+
96
116
  if (
97
- !responseHeaders["content-type"] &&
117
+ !hasContentType &&
118
+ response.body !== undefined &&
119
+ isBinaryBody(response.body)
120
+ ) {
121
+ responseHeaders["content-type"] = "application/octet-stream";
122
+ } else if (
123
+ !hasContentType &&
98
124
  response.body !== undefined &&
99
125
  typeof response.body !== "string"
100
126
  ) {
101
127
  responseHeaders["content-type"] = "application/json";
102
128
  }
103
129
 
104
- const responseBody =
105
- response.body === undefined
106
- ? undefined
107
- : typeof response.body === "string"
108
- ? response.body
109
- : JSON.stringify(response.body);
130
+ let responseBody: string | Uint8Array | undefined;
131
+ if (response.body === undefined) {
132
+ responseBody = undefined;
133
+ } else if (typeof response.body === "string") {
134
+ responseBody = response.body;
135
+ } else if (response.body instanceof ArrayBuffer) {
136
+ responseBody = new Uint8Array(response.body);
137
+ } else if (ArrayBuffer.isView(response.body)) {
138
+ responseBody = new Uint8Array(
139
+ response.body.buffer,
140
+ response.body.byteOffset,
141
+ response.body.byteLength,
142
+ );
143
+ } else {
144
+ responseBody = JSON.stringify(response.body);
145
+ }
110
146
 
111
147
  res.writeHead(response.status, responseHeaders);
112
148
  res.end(responseBody);
package/src/index.ts CHANGED
@@ -80,6 +80,7 @@ export function schmock(
80
80
  return callableInstance;
81
81
  }
82
82
 
83
+ export { isBinaryBody } from "./binary.js";
83
84
  // Re-export constants and utilities
84
85
  export {
85
86
  HTTP_METHODS,
@@ -26,6 +26,83 @@ describe("mock.intercept()", () => {
26
26
  handle.restore();
27
27
  });
28
28
 
29
+ it("returns browser binary bodies without JSON serialization", async () => {
30
+ const bytes = new Uint8Array([1, 2, 3]);
31
+ mock("GET /api/binary", bytes);
32
+ const handle = mock.intercept();
33
+
34
+ try {
35
+ const response = await fetch("http://localhost/api/binary");
36
+ expect(response.headers.get("content-type")).toBe(
37
+ "application/octet-stream",
38
+ );
39
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes);
40
+ } finally {
41
+ handle.restore();
42
+ }
43
+ });
44
+
45
+ it("returns dynamic binary bodies with binary content type", async () => {
46
+ const bytes = new Uint8Array([4, 5, 6]);
47
+ mock("GET /api/dynamic-binary", () => bytes.buffer);
48
+ const handle = mock.intercept();
49
+
50
+ try {
51
+ const response = await fetch("http://localhost/api/dynamic-binary");
52
+ expect(response.headers.get("content-type")).toBe(
53
+ "application/octet-stream",
54
+ );
55
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes);
56
+ } finally {
57
+ handle.restore();
58
+ }
59
+ });
60
+
61
+ it("returns tuple binary bodies with binary content type", async () => {
62
+ const bytes = new Uint8Array([7, 8, 9]);
63
+ mock(
64
+ "GET /api/tuple-binary",
65
+ () => [206, new DataView(bytes.buffer)] satisfies [number, unknown],
66
+ );
67
+ const handle = mock.intercept();
68
+
69
+ try {
70
+ const response = await fetch("http://localhost/api/tuple-binary");
71
+ expect(response.status).toBe(206);
72
+ expect(response.headers.get("content-type")).toBe(
73
+ "application/octet-stream",
74
+ );
75
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes);
76
+ } finally {
77
+ handle.restore();
78
+ }
79
+ });
80
+
81
+ it("normalizes a binary body supplied by beforeResponse", async () => {
82
+ mock("GET /api/source", "source");
83
+ const sharedBytes = new Uint8Array(new SharedArrayBuffer(3));
84
+ sharedBytes.set([10, 11, 12]);
85
+ const handle = mock.intercept({
86
+ beforeResponse: () => ({
87
+ status: 200,
88
+ body: new DataView(sharedBytes.buffer),
89
+ headers: {},
90
+ }),
91
+ });
92
+
93
+ try {
94
+ const response = await fetch("http://localhost/api/source");
95
+ expect(response.headers.get("content-type")).toBe(
96
+ "application/octet-stream",
97
+ );
98
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(
99
+ new Uint8Array([10, 11, 12]),
100
+ );
101
+ } finally {
102
+ handle.restore();
103
+ }
104
+ });
105
+
29
106
  it("passes through unmatched routes when passthrough is true", async () => {
30
107
  mock("GET /api/users", [{ id: 1 }]);
31
108
  // Save reference to the vi.fn() mock that the interceptor will call on passthrough
@@ -288,4 +365,124 @@ describe("mock.intercept()", () => {
288
365
 
289
366
  handle.restore();
290
367
  });
368
+
369
+ it("composes mock instances newest-first and chains passthrough", async () => {
370
+ const olderMock = schmock();
371
+ olderMock("GET /api/shared", { source: "older" });
372
+ olderMock("GET /api/older", { source: "older" });
373
+
374
+ const newerMock = schmock();
375
+ newerMock("GET /api/shared", { source: "newer" });
376
+
377
+ const olderHandle = olderMock.intercept();
378
+ const newerHandle = newerMock.intercept();
379
+
380
+ try {
381
+ const sharedResponse = await fetch("http://localhost/api/shared");
382
+ expect(await sharedResponse.json()).toEqual({ source: "newer" });
383
+
384
+ const olderResponse = await fetch("http://localhost/api/older");
385
+ expect(await olderResponse.json()).toEqual({ source: "older" });
386
+ } finally {
387
+ newerHandle.restore();
388
+ olderHandle.restore();
389
+ }
390
+ });
391
+
392
+ it("keeps remaining interceptors installed after out-of-order restore", async () => {
393
+ const baseline = globalThis.fetch;
394
+ const olderMock = schmock();
395
+ const newerMock = schmock();
396
+ newerMock("GET /api/newer", { source: "newer" });
397
+
398
+ const olderHandle = olderMock.intercept();
399
+ const newerHandle = newerMock.intercept();
400
+
401
+ olderHandle.restore();
402
+ expect(olderHandle.active).toBe(false);
403
+ expect(newerHandle.active).toBe(true);
404
+ expect(globalThis.fetch).not.toBe(baseline);
405
+
406
+ try {
407
+ const response = await fetch("http://localhost/api/newer");
408
+ expect(await response.json()).toEqual({ source: "newer" });
409
+ } finally {
410
+ newerHandle.restore();
411
+ }
412
+
413
+ expect(globalThis.fetch).toBe(baseline);
414
+ });
415
+
416
+ it("restores idempotently", () => {
417
+ const baseline = globalThis.fetch;
418
+ const handle = mock.intercept();
419
+
420
+ handle.restore();
421
+ handle.restore();
422
+
423
+ expect(handle.active).toBe(false);
424
+ expect(globalThis.fetch).toBe(baseline);
425
+ });
426
+
427
+ it("does not overwrite a fetch replacement installed after Schmock", () => {
428
+ const handle = mock.intercept();
429
+ const replacementFetch = vi
430
+ .fn()
431
+ .mockResolvedValue(new Response("third-party backend"));
432
+ globalThis.fetch = replacementFetch;
433
+
434
+ handle.restore();
435
+
436
+ expect(globalThis.fetch).toBe(replacementFetch);
437
+ });
438
+
439
+ it("keeps a third-party wrapper's captured dispatcher usable", async () => {
440
+ const baseline = globalThis.fetch;
441
+ const handle = mock.intercept();
442
+ const schmockDispatcher = globalThis.fetch;
443
+ const replacementFetch = vi.fn(
444
+ (input: RequestInfo | URL, init?: RequestInit) =>
445
+ schmockDispatcher(input, init),
446
+ );
447
+ globalThis.fetch = replacementFetch;
448
+
449
+ handle.restore();
450
+
451
+ const response = await fetch("http://localhost/real-backend");
452
+ expect(await response.text()).toBe("real backend");
453
+ expect(replacementFetch).toHaveBeenCalledOnce();
454
+ expect(baseline).toHaveBeenCalledOnce();
455
+ });
456
+
457
+ it("wraps the current fetch when a later interceptor follows a replacement", async () => {
458
+ const olderMock = schmock();
459
+ olderMock("GET /api/older", { source: "older" });
460
+ const olderHandle = olderMock.intercept();
461
+
462
+ const replacementFetch = vi
463
+ .fn()
464
+ .mockResolvedValue(new Response("third-party backend"));
465
+ globalThis.fetch = replacementFetch;
466
+
467
+ const newerMock = schmock();
468
+ newerMock("GET /api/newer", { source: "newer" });
469
+ const newerHandle = newerMock.intercept();
470
+
471
+ olderHandle.restore();
472
+ expect(olderHandle.active).toBe(false);
473
+ expect(newerHandle.active).toBe(true);
474
+
475
+ try {
476
+ const mockedResponse = await fetch("http://localhost/api/newer");
477
+ expect(await mockedResponse.json()).toEqual({ source: "newer" });
478
+
479
+ const passthroughResponse = await fetch("http://localhost/real-backend");
480
+ expect(await passthroughResponse.text()).toBe("third-party backend");
481
+ expect(replacementFetch).toHaveBeenCalledOnce();
482
+ } finally {
483
+ newerHandle.restore();
484
+ }
485
+
486
+ expect(globalThis.fetch).toBe(replacementFetch);
487
+ });
291
488
  });