immortal-js 1.0.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +577 -0
  3. package/docs/CHANGELOG.md +21 -0
  4. package/docs/CONTRIBUTING.md +41 -0
  5. package/docs/api/chaos.md +179 -0
  6. package/docs/api/dashboard.md +109 -0
  7. package/docs/api/isolation.md +191 -0
  8. package/docs/api/lifecycle.md +187 -0
  9. package/docs/api/monitoring.md +313 -0
  10. package/docs/api/plugins.md +217 -0
  11. package/docs/api/recovery.md +267 -0
  12. package/docs/api/safe-zone.md +236 -0
  13. package/docs/api/supervision.md +285 -0
  14. package/docs/guides/configuration.md +168 -0
  15. package/docs/guides/express.md +171 -0
  16. package/docs/guides/fastify.md +188 -0
  17. package/docs/guides/koa.md +102 -0
  18. package/docs/guides/nestjs.md +182 -0
  19. package/docs/guides/testing.md +91 -0
  20. package/examples/express-basic/index.ts +462 -0
  21. package/examples/express-basic/package.json +21 -0
  22. package/examples/express-basic/tsconfig.json +24 -0
  23. package/examples/fastify-microservice/index.ts +342 -0
  24. package/examples/fastify-microservice/package.json +22 -0
  25. package/examples/fastify-microservice/tsconfig.json +24 -0
  26. package/examples/invoice-service/data/invoices.db +0 -0
  27. package/examples/invoice-service/data/invoices.db-shm +0 -0
  28. package/examples/invoice-service/data/invoices.db-wal +0 -0
  29. package/examples/invoice-service/package.json +25 -0
  30. package/examples/invoice-service/public/index.html +5025 -0
  31. package/examples/invoice-service/src/db.ts +608 -0
  32. package/examples/invoice-service/src/pdf.ts +358 -0
  33. package/examples/invoice-service/src/server.ts +527 -0
  34. package/examples/invoice-service/src/store.ts +159 -0
  35. package/examples/invoice-service/src/types.ts +193 -0
  36. package/examples/invoice-service/tsconfig.json +23 -0
  37. package/examples/nestjs-enterprise/app.module.ts +561 -0
  38. package/examples/nestjs-enterprise/main.ts +67 -0
  39. package/examples/nestjs-enterprise/package.json +26 -0
  40. package/examples/nestjs-enterprise/tsconfig.json +27 -0
  41. package/immortal-js-1.0.0.tgz +0 -0
  42. package/package.json +33 -0
  43. package/packages/adapter-express/package.json +34 -0
  44. package/packages/adapter-express/src/index.ts +349 -0
  45. package/packages/adapter-express/tsconfig.json +14 -0
  46. package/packages/adapter-fastify/package.json +56 -0
  47. package/packages/adapter-fastify/src/plugin.ts +226 -0
  48. package/packages/adapter-fastify/tsconfig.json +14 -0
  49. package/packages/adapter-koa/package.json +55 -0
  50. package/packages/adapter-koa/src/index.ts +207 -0
  51. package/packages/adapter-koa/tsconfig.json +14 -0
  52. package/packages/adapter-nestjs/package.json +61 -0
  53. package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
  54. package/packages/adapter-nestjs/src/index.ts +14 -0
  55. package/packages/adapter-nestjs/tsconfig.json +16 -0
  56. package/packages/core/package.json +56 -0
  57. package/packages/core/src/chaos/ChaosEngine.ts +249 -0
  58. package/packages/core/src/config/defaults.ts +200 -0
  59. package/packages/core/src/config/schema.ts +199 -0
  60. package/packages/core/src/event-bus.ts +168 -0
  61. package/packages/core/src/index.ts +164 -0
  62. package/packages/core/src/isolation/BulkheadPool.ts +279 -0
  63. package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
  64. package/packages/core/src/isolation/index.ts +8 -0
  65. package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
  66. package/packages/core/src/logger.ts +104 -0
  67. package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
  68. package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
  69. package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
  70. package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
  71. package/packages/core/src/monitoring/index.ts +10 -0
  72. package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
  73. package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
  74. package/packages/core/src/recovery/FallbackCache.ts +328 -0
  75. package/packages/core/src/recovery/RetryEngine.ts +225 -0
  76. package/packages/core/src/recovery/Timeout.ts +97 -0
  77. package/packages/core/src/recovery/index.ts +11 -0
  78. package/packages/core/src/runtime.ts +242 -0
  79. package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
  80. package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
  81. package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
  82. package/packages/core/src/safe-zone/index.ts +23 -0
  83. package/packages/core/src/supervision/ClusterManager.ts +243 -0
  84. package/packages/core/src/supervision/RestartStrategy.ts +68 -0
  85. package/packages/core/src/supervision/Supervisor.ts +311 -0
  86. package/packages/core/src/supervision/index.ts +11 -0
  87. package/packages/core/src/types.ts +470 -0
  88. package/packages/core/test/bulkhead.test.ts +310 -0
  89. package/packages/core/test/circuit-breaker.test.ts +153 -0
  90. package/packages/core/test/memory-guard.test.ts +213 -0
  91. package/packages/core/test/retry.test.ts +110 -0
  92. package/packages/core/test/safe-zone.test.ts +271 -0
  93. package/packages/core/test/supervisor.test.ts +310 -0
  94. package/packages/core/tsconfig.json +13 -0
  95. package/packages/dashboard/package.json +56 -0
  96. package/packages/dashboard/server/DashboardServer.ts +454 -0
  97. package/packages/dashboard/tsconfig.json +14 -0
  98. package/tsconfig.json +25 -0
@@ -0,0 +1,310 @@
1
+ /**
2
+ * @file bulkhead.test.ts
3
+ * @description Tests for BulkheadPool and BulkheadRegistry.
4
+ *
5
+ * Actual API (from source):
6
+ * new BulkheadPool(name: string, config?: BulkheadConfig)
7
+ * pool.run(task, priority?, timeoutMs?) — priority: "critical"|"high"|"medium"|"low"
8
+ * pool.getStatus() → { name, active, queued, maxConcurrent, maxQueueSize, rejected, totalExecuted }
9
+ * pool.drain() — rejects all queued items, synchronous
10
+ *
11
+ * new BulkheadRegistry(defaultConfig?)
12
+ * registry.get(name, config?) → BulkheadPool (creates or reuses)
13
+ * registry.getAllStatuses() → Record<string, BulkheadStatus>
14
+ * registry.drainAll()
15
+ *
16
+ * BulkheadStatus.rejected (NOT "totalRejected")
17
+ * BulkheadStatus.totalExecuted
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
21
+ import { BulkheadPool, BulkheadRegistry } from "../src/isolation/BulkheadPool.js";
22
+ import { BulkheadRejectedError } from "../src/types.js";
23
+
24
+ // ─── helpers ─────────────────────────────────────────────────────────────────
25
+
26
+ function sleep(ms: number): Promise<void> {
27
+ return new Promise((r) => setTimeout(r, ms));
28
+ }
29
+
30
+ function slowTask<T>(value: T, holdMs: number): () => Promise<T> {
31
+ return () => sleep(holdMs).then(() => value);
32
+ }
33
+
34
+ // ─── BulkheadPool — basic concurrency ────────────────────────────────────────
35
+
36
+ describe("BulkheadPool — basic execution", () => {
37
+ it("executes a single task and returns its result", async () => {
38
+ const pool = new BulkheadPool("test", { maxConcurrent: 2, maxQueueSize: 5 });
39
+ const result = await pool.run(() => Promise.resolve(42));
40
+ expect(result).toBe(42);
41
+ pool.drain();
42
+ });
43
+
44
+ it("runs up to maxConcurrent tasks simultaneously", async () => {
45
+ const pool = new BulkheadPool("concurrency", { maxConcurrent: 3, maxQueueSize: 10 });
46
+ let running = 0;
47
+ let maxObserved = 0;
48
+
49
+ const tasks = Array.from({ length: 3 }, () =>
50
+ pool.run(async () => {
51
+ running++;
52
+ maxObserved = Math.max(maxObserved, running);
53
+ await sleep(40);
54
+ running--;
55
+ })
56
+ );
57
+
58
+ await Promise.all(tasks);
59
+ expect(maxObserved).toBe(3);
60
+ pool.drain();
61
+ });
62
+
63
+ it("propagates task errors without blocking the pool", async () => {
64
+ const pool = new BulkheadPool("errors", { maxConcurrent: 2, maxQueueSize: 5 });
65
+ await expect(pool.run(() => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
66
+ // Pool should still be usable
67
+ const result = await pool.run(() => Promise.resolve("ok"));
68
+ expect(result).toBe("ok");
69
+ pool.drain();
70
+ });
71
+
72
+ it("queues tasks beyond maxConcurrent and executes them in order", async () => {
73
+ const pool = new BulkheadPool("ordering", { maxConcurrent: 1, maxQueueSize: 5 });
74
+ const order: number[] = [];
75
+
76
+ const p1 = pool.run(async () => { await sleep(30); order.push(1); });
77
+ const p2 = pool.run(async () => { order.push(2); });
78
+ const p3 = pool.run(async () => { order.push(3); });
79
+
80
+ await Promise.all([p1, p2, p3]);
81
+ expect(order).toEqual([1, 2, 3]);
82
+ pool.drain();
83
+ });
84
+ });
85
+
86
+ // ─── BulkheadPool — rejection ────────────────────────────────────────────────
87
+
88
+ describe("BulkheadPool — rejection when saturated", () => {
89
+ it("throws BulkheadRejectedError when queue is full", async () => {
90
+ // maxQueueSize: 0 → any task that can't run immediately is rejected
91
+ const pool = new BulkheadPool("saturated", { maxConcurrent: 1, maxQueueSize: 0 });
92
+
93
+ const blocker = pool.run(slowTask(null, 100));
94
+ await expect(pool.run(() => Promise.resolve("x"))).rejects.toBeInstanceOf(BulkheadRejectedError);
95
+
96
+ await blocker;
97
+ pool.drain();
98
+ });
99
+
100
+ it("includes pool name in BulkheadRejectedError message", async () => {
101
+ const pool = new BulkheadPool("payments", { maxConcurrent: 1, maxQueueSize: 0 });
102
+ const blocker = pool.run(slowTask(null, 100));
103
+
104
+ try {
105
+ await pool.run(() => Promise.resolve("x"));
106
+ expect.fail("Should have thrown");
107
+ } catch (err) {
108
+ expect(err).toBeInstanceOf(BulkheadRejectedError);
109
+ expect((err as BulkheadRejectedError).message).toContain("payments");
110
+ }
111
+
112
+ await blocker;
113
+ pool.drain();
114
+ });
115
+
116
+ it("accepts new tasks once a slot frees up", async () => {
117
+ const pool = new BulkheadPool("recover", { maxConcurrent: 1, maxQueueSize: 0 });
118
+ const p1 = pool.run(slowTask("first", 30));
119
+ await expect(pool.run(() => Promise.resolve("x"))).rejects.toBeInstanceOf(BulkheadRejectedError);
120
+
121
+ await p1;
122
+ const result = await pool.run(() => Promise.resolve("second"));
123
+ expect(result).toBe("second");
124
+ pool.drain();
125
+ });
126
+
127
+ it("increments rejected count in getStatus()", async () => {
128
+ const pool = new BulkheadPool("count", { maxConcurrent: 1, maxQueueSize: 0 });
129
+ const blocker = pool.run(slowTask(null, 100));
130
+
131
+ try { await pool.run(() => Promise.resolve(1)); } catch { /* expected */ }
132
+ try { await pool.run(() => Promise.resolve(2)); } catch { /* expected */ }
133
+
134
+ const status = pool.getStatus();
135
+ expect(status.rejected).toBeGreaterThanOrEqual(2);
136
+
137
+ await blocker;
138
+ pool.drain();
139
+ });
140
+ });
141
+
142
+ // ─── BulkheadPool — priority queues ──────────────────────────────────────────
143
+
144
+ describe("BulkheadPool — priority queuing", () => {
145
+ it("drains critical tasks before lower-priority ones", async () => {
146
+ const pool = new BulkheadPool("priority", { maxConcurrent: 1, maxQueueSize: 20 });
147
+ const completed: string[] = [];
148
+
149
+ // Occupy the one concurrent slot
150
+ const blocker = pool.run(slowTask(null, 80));
151
+
152
+ const promises = [
153
+ pool.run(async () => { completed.push("low"); }, "low"),
154
+ pool.run(async () => { completed.push("critical-1"); }, "critical"),
155
+ pool.run(async () => { completed.push("medium"); }, "medium"),
156
+ pool.run(async () => { completed.push("high"); }, "high"),
157
+ pool.run(async () => { completed.push("critical-2"); }, "critical"),
158
+ ];
159
+
160
+ await blocker;
161
+ await Promise.all(promises);
162
+
163
+ const critIdx1 = completed.indexOf("critical-1");
164
+ const critIdx2 = completed.indexOf("critical-2");
165
+ const highIdx = completed.indexOf("high");
166
+ const medIdx = completed.indexOf("medium");
167
+ const lowIdx = completed.indexOf("low");
168
+
169
+ // critical before high
170
+ expect(Math.max(critIdx1, critIdx2)).toBeLessThan(highIdx);
171
+ // high before medium
172
+ expect(highIdx).toBeLessThan(medIdx);
173
+ // medium before low
174
+ expect(medIdx).toBeLessThan(lowIdx);
175
+ pool.drain();
176
+ });
177
+ });
178
+
179
+ // ─── BulkheadPool — queue timeout ────────────────────────────────────────────
180
+
181
+ describe("BulkheadPool — per-item queue timeout", () => {
182
+ it("rejects queued tasks that wait longer than queueTimeoutMs", async () => {
183
+ const pool = new BulkheadPool("timeout", {
184
+ maxConcurrent: 1,
185
+ maxQueueSize: 5,
186
+ queueTimeoutMs: 50,
187
+ });
188
+
189
+ const blocker = pool.run(slowTask(null, 300));
190
+
191
+ // This task will queue, but queue timeout is 50ms
192
+ await expect(
193
+ pool.run(() => Promise.resolve("queued-task"))
194
+ ).rejects.toBeInstanceOf(BulkheadRejectedError);
195
+
196
+ await blocker;
197
+ pool.drain();
198
+ });
199
+ });
200
+
201
+ // ─── BulkheadPool — status reporting ─────────────────────────────────────────
202
+
203
+ describe("BulkheadPool — getStatus()", () => {
204
+ it("reports correct active, queued, maxConcurrent counts", async () => {
205
+ const pool = new BulkheadPool("status", { maxConcurrent: 2, maxQueueSize: 10 });
206
+
207
+ const b1 = pool.run(slowTask(null, 200));
208
+ const b2 = pool.run(slowTask(null, 200));
209
+ const q1 = pool.run(slowTask(null, 10));
210
+ const q2 = pool.run(slowTask(null, 10));
211
+
212
+ await sleep(10); // let tasks settle
213
+
214
+ const status = pool.getStatus();
215
+ expect(status.active).toBe(2);
216
+ expect(status.queued).toBe(2);
217
+ expect(status.maxConcurrent).toBe(2);
218
+ expect(status.maxQueueSize).toBe(10);
219
+ expect(status.name).toBe("status");
220
+
221
+ await Promise.all([b1, b2, q1, q2]);
222
+ pool.drain();
223
+ });
224
+
225
+ it("increments totalExecuted after successful runs", async () => {
226
+ const pool = new BulkheadPool("execcount", { maxConcurrent: 5, maxQueueSize: 10 });
227
+ await pool.run(() => Promise.resolve("a"));
228
+ await pool.run(() => Promise.resolve("b"));
229
+ await pool.run(() => Promise.resolve("c"));
230
+ const status = pool.getStatus();
231
+ expect(status.totalExecuted).toBeGreaterThanOrEqual(3);
232
+ pool.drain();
233
+ });
234
+ });
235
+
236
+ // ─── BulkheadPool — drain() ───────────────────────────────────────────────────
237
+
238
+ describe("BulkheadPool — drain()", () => {
239
+ it("rejects all queued tasks immediately", async () => {
240
+ const pool = new BulkheadPool("drain-test", { maxConcurrent: 1, maxQueueSize: 10 });
241
+ const _blocker = pool.run(slowTask(null, 500));
242
+
243
+ const queued1 = pool.run(() => Promise.resolve("q1"));
244
+ const queued2 = pool.run(() => Promise.resolve("q2"));
245
+
246
+ // Drain immediately — queued tasks should be rejected
247
+ pool.drain();
248
+
249
+ await expect(queued1).rejects.toBeInstanceOf(BulkheadRejectedError);
250
+ await expect(queued2).rejects.toBeInstanceOf(BulkheadRejectedError);
251
+ });
252
+ });
253
+
254
+ // ─── BulkheadRegistry ────────────────────────────────────────────────────────
255
+
256
+ describe("BulkheadRegistry", () => {
257
+ let registry: BulkheadRegistry;
258
+
259
+ beforeEach(() => {
260
+ registry = new BulkheadRegistry();
261
+ });
262
+
263
+ afterEach(() => {
264
+ registry.drainAll();
265
+ });
266
+
267
+ it("returns the same BulkheadPool instance for the same name", () => {
268
+ const p1 = registry.get("payments");
269
+ const p2 = registry.get("payments");
270
+ expect(p1).toBe(p2);
271
+ });
272
+
273
+ it("returns different pool instances for different names", () => {
274
+ const payments = registry.get("payments");
275
+ const orders = registry.get("orders");
276
+ expect(payments).not.toBe(orders);
277
+ });
278
+
279
+ it("applies custom config when creating a named pool", async () => {
280
+ const pool = registry.get("narrow", { maxConcurrent: 1, maxQueueSize: 0 });
281
+ const blocker = pool.run(slowTask(null, 50));
282
+ await expect(pool.run(() => Promise.resolve("x"))).rejects.toBeInstanceOf(BulkheadRejectedError);
283
+ await blocker;
284
+ pool.drain();
285
+ });
286
+
287
+ it("getAllStatuses() includes all registered pool names", () => {
288
+ registry.get("svc-a", { maxConcurrent: 5 });
289
+ registry.get("svc-b", { maxConcurrent: 3 });
290
+
291
+ const all = registry.getAllStatuses();
292
+ expect(all).toHaveProperty("svc-a");
293
+ expect(all).toHaveProperty("svc-b");
294
+ expect(all["svc-a"]?.maxConcurrent).toBe(5);
295
+ expect(all["svc-b"]?.maxConcurrent).toBe(3);
296
+ });
297
+
298
+ it("drainAll() does not throw even with empty registry", () => {
299
+ expect(() => registry.drainAll()).not.toThrow();
300
+ });
301
+
302
+ it("uses default config for pools created without explicit config", () => {
303
+ const reg = new BulkheadRegistry({ maxConcurrent: 10, maxQueueSize: 50 });
304
+ const pool = reg.get("default-pool");
305
+ const status = pool.getStatus();
306
+ expect(status.maxConcurrent).toBe(10);
307
+ expect(status.maxQueueSize).toBe(50);
308
+ reg.drainAll();
309
+ });
310
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @file circuit-breaker.test.ts
3
+ * @description Tests for CircuitBreaker state machine
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach } from "vitest";
7
+
8
+ vi.mock("../src/event-bus.js", () => ({
9
+ getEventBus: () => ({ emit: vi.fn() }),
10
+ }));
11
+
12
+ vi.mock("../src/logger.js", () => ({
13
+ getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
14
+ }));
15
+
16
+ const { CircuitBreaker, CircuitBreakerRegistry } = await import("../src/recovery/CircuitBreaker.js");
17
+ const { CircuitOpenError } = await import("../src/types.js");
18
+
19
+ describe("CircuitBreaker", () => {
20
+ it("should start in CLOSED state", () => {
21
+ const breaker = new CircuitBreaker("test", {});
22
+ expect(breaker.getCurrentState()).toBe("CLOSED");
23
+ });
24
+
25
+ it("should pass through operations when CLOSED", async () => {
26
+ const breaker = new CircuitBreaker("test", { threshold: 5, minimumRequests: 5 });
27
+ const result = await breaker.execute(async () => "hello");
28
+ expect(result).toBe("hello");
29
+ expect(breaker.getCurrentState()).toBe("CLOSED");
30
+ });
31
+
32
+ it("should OPEN after threshold failures", async () => {
33
+ const breaker = new CircuitBreaker("test", {
34
+ threshold: 3,
35
+ minimumRequests: 3,
36
+ failureRateThreshold: 50,
37
+ slidingWindowSize: 10,
38
+ });
39
+
40
+ const failFn = async () => { throw new Error("service down"); };
41
+
42
+ // Trigger enough failures to open the circuit
43
+ for (let i = 0; i < 5; i++) {
44
+ try { await breaker.execute(failFn); } catch { /* expected */ }
45
+ }
46
+
47
+ expect(breaker.getCurrentState()).toBe("OPEN");
48
+ });
49
+
50
+ it("should use fallback when OPEN", async () => {
51
+ const breaker = new CircuitBreaker("test", {
52
+ threshold: 2,
53
+ minimumRequests: 2,
54
+ failureRateThreshold: 50,
55
+ slidingWindowSize: 5,
56
+ });
57
+
58
+ // Open the circuit
59
+ for (let i = 0; i < 5; i++) {
60
+ try {
61
+ await breaker.execute(async () => { throw new Error("fail"); });
62
+ } catch { /* expected */ }
63
+ }
64
+
65
+ expect(breaker.getCurrentState()).toBe("OPEN");
66
+
67
+ // Should use fallback without throwing
68
+ const result = await breaker.execute(
69
+ async () => "normal",
70
+ () => "fallback-value"
71
+ );
72
+ expect(result).toBe("fallback-value");
73
+ });
74
+
75
+ it("should throw CircuitOpenError when OPEN and no fallback", async () => {
76
+ const breaker = new CircuitBreaker("payment", {
77
+ threshold: 2,
78
+ minimumRequests: 2,
79
+ failureRateThreshold: 50,
80
+ slidingWindowSize: 5,
81
+ });
82
+
83
+ for (let i = 0; i < 5; i++) {
84
+ try {
85
+ await breaker.execute(async () => { throw new Error("fail"); });
86
+ } catch { /* expected */ }
87
+ }
88
+
89
+ await expect(
90
+ breaker.execute(async () => "should not run")
91
+ ).rejects.toBeInstanceOf(CircuitOpenError);
92
+ });
93
+
94
+ it("should reset to CLOSED after successful HALF_OPEN probe", async () => {
95
+ const breaker = new CircuitBreaker("test", {
96
+ threshold: 2,
97
+ minimumRequests: 2,
98
+ failureRateThreshold: 50,
99
+ slidingWindowSize: 5,
100
+ cooldownMs: 0, // Instant cooldown for testing
101
+ });
102
+
103
+ // Open circuit
104
+ for (let i = 0; i < 5; i++) {
105
+ try {
106
+ await breaker.execute(async () => { throw new Error("fail"); });
107
+ } catch { /* expected */ }
108
+ }
109
+
110
+ expect(breaker.getCurrentState()).toBe("OPEN");
111
+
112
+ // Force state to HALF_OPEN (simulating cooldown elapsed)
113
+ breaker.forceState("HALF_OPEN");
114
+ expect(breaker.getCurrentState()).toBe("HALF_OPEN");
115
+
116
+ // Successful probe should close circuit
117
+ const result = await breaker.execute(async () => "probe-success");
118
+ expect(result).toBe("probe-success");
119
+ expect(breaker.getCurrentState()).toBe("CLOSED");
120
+ });
121
+
122
+ it("should correctly report status", () => {
123
+ const breaker = new CircuitBreaker("metrics-test", { threshold: 10 });
124
+ const status = breaker.getStatus();
125
+
126
+ expect(status.name).toBe("metrics-test");
127
+ expect(status.state).toBe("CLOSED");
128
+ expect(status.failures).toBe(0);
129
+ expect(status.failureRate).toBe(0);
130
+ });
131
+ });
132
+
133
+ describe("CircuitBreakerRegistry", () => {
134
+ it("should create and reuse named circuit breakers", () => {
135
+ const registry = new CircuitBreakerRegistry({ threshold: 5 });
136
+
137
+ const c1 = registry.get("payments");
138
+ const c2 = registry.get("payments");
139
+
140
+ expect(c1).toBe(c2); // Same instance
141
+ expect(c1.name).toBe("payments");
142
+ });
143
+
144
+ it("should return all statuses", () => {
145
+ const registry = new CircuitBreakerRegistry();
146
+ registry.get("service-a");
147
+ registry.get("service-b");
148
+
149
+ const statuses = registry.getAllStatuses();
150
+ expect(Object.keys(statuses)).toContain("service-a");
151
+ expect(Object.keys(statuses)).toContain("service-b");
152
+ });
153
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @file memory-guard.test.ts
3
+ * @description Tests for MemoryLeakGuard.
4
+ *
5
+ * Actual API (from source):
6
+ * new MemoryLeakGuard(config?: MemoryGuardConfig, bus: ImmortalEventBus)
7
+ * guard.start()
8
+ * guard.stop()
9
+ * guard.trackObject(obj, name, expectedGcMs?)
10
+ * guard["computeGrowthRate"](values) — private, tested via bracket access
11
+ *
12
+ * The class does NOT expose: isRunning(), getCurrentStats(), getTrackedNames()
13
+ * It uses the ImmortalEventBus for all emitted events.
14
+ * restartCooldownMs is not a config field; the field is "growthWindowCount".
15
+ */
16
+
17
+ import { describe, it, expect, vi } from "vitest";
18
+ import { MemoryLeakGuard } from "../src/monitoring/MemoryLeakGuard.js";
19
+ import { getEventBus } from "../src/event-bus.js";
20
+ import type { ImmortalEventBus } from "../src/event-bus.js";
21
+
22
+ // ─── helpers ─────────────────────────────────────────────────────────────────
23
+
24
+ function sleep(ms: number): Promise<void> {
25
+ return new Promise((r) => setTimeout(r, ms));
26
+ }
27
+
28
+ /** Returns a real event bus instance (singleton) */
29
+ function bus(): ImmortalEventBus {
30
+ return getEventBus();
31
+ }
32
+
33
+ // ─── Construction ─────────────────────────────────────────────────────────────
34
+
35
+ describe("MemoryLeakGuard — construction", () => {
36
+ it("creates an instance with default config", () => {
37
+ const guard = new MemoryLeakGuard({}, bus());
38
+ expect(guard).toBeDefined();
39
+ guard.stop();
40
+ });
41
+
42
+ it("creates an instance with custom config", () => {
43
+ const guard = new MemoryLeakGuard(
44
+ { checkIntervalMs: 5_000, growthWindowCount: 5, warningThresholdMb: 512 },
45
+ bus()
46
+ );
47
+ expect(guard).toBeDefined();
48
+ guard.stop();
49
+ });
50
+
51
+ it("does not start monitoring automatically", () => {
52
+ // If it auto-started, we'd see interval handles — just verify no throw
53
+ expect(() => {
54
+ const g = new MemoryLeakGuard({}, bus());
55
+ g.stop(); // stop immediately — should be a no-op
56
+ }).not.toThrow();
57
+ });
58
+ });
59
+
60
+ // ─── Start / Stop lifecycle ───────────────────────────────────────────────────
61
+
62
+ describe("MemoryLeakGuard — start / stop lifecycle", () => {
63
+ it("start() does not throw", () => {
64
+ const guard = new MemoryLeakGuard({ checkIntervalMs: 60_000 }, bus());
65
+ expect(() => guard.start()).not.toThrow();
66
+ guard.stop();
67
+ });
68
+
69
+ it("calling start() twice is idempotent (no throw)", () => {
70
+ const guard = new MemoryLeakGuard({ checkIntervalMs: 60_000 }, bus());
71
+ guard.start();
72
+ guard.start(); // second call should be a no-op
73
+ guard.stop();
74
+ });
75
+
76
+ it("stop() before start() does not throw", () => {
77
+ const guard = new MemoryLeakGuard({}, bus());
78
+ expect(() => guard.stop()).not.toThrow();
79
+ });
80
+
81
+ it("calling stop() twice is idempotent (no throw)", () => {
82
+ const guard = new MemoryLeakGuard({ checkIntervalMs: 60_000 }, bus());
83
+ guard.start();
84
+ guard.stop();
85
+ expect(() => guard.stop()).not.toThrow(); // second stop is a no-op
86
+ });
87
+ });
88
+
89
+ // ─── trackObject ─────────────────────────────────────────────────────────────
90
+
91
+ describe("MemoryLeakGuard — trackObject", () => {
92
+ it("does not throw when object tracking is enabled", () => {
93
+ const guard = new MemoryLeakGuard({ enableObjectTracking: true }, bus());
94
+ const obj = { type: "db-conn" };
95
+ expect(() => guard.trackObject(obj, "pg-conn-1")).not.toThrow();
96
+ guard.stop();
97
+ });
98
+
99
+ it("does nothing (no throw) when object tracking is disabled", () => {
100
+ const guard = new MemoryLeakGuard({ enableObjectTracking: false }, bus());
101
+ const obj = { type: "conn" };
102
+ expect(() => guard.trackObject(obj, "ignored-conn")).not.toThrow();
103
+ guard.stop();
104
+ });
105
+
106
+ it("accepts optional expectedGcMs parameter", () => {
107
+ const guard = new MemoryLeakGuard({ enableObjectTracking: true }, bus());
108
+ const obj = { x: 1 };
109
+ expect(() => guard.trackObject(obj, "my-obj", 60_000)).not.toThrow();
110
+ guard.stop();
111
+ });
112
+ });
113
+
114
+ // ─── Internal: computeGrowthRate (linear regression) ─────────────────────────
115
+
116
+ describe("MemoryLeakGuard — computeGrowthRate (linear regression)", () => {
117
+ it("returns a positive rate for monotonically increasing samples", () => {
118
+ const guard = new MemoryLeakGuard({ checkIntervalMs: 1_000 }, bus());
119
+ // Use bracket access to test private method
120
+ const lr = (guard as unknown as { computeGrowthRate(v: number[]): number }).computeGrowthRate;
121
+ if (!lr) { guard.stop(); return; }
122
+
123
+ // y = 2i + 100 → slope = 2MB per interval → positive growth rate
124
+ const samples = [100, 102, 104, 106, 108, 110];
125
+ const rate = lr.call(guard, samples);
126
+ expect(rate).toBeGreaterThan(0);
127
+ guard.stop();
128
+ });
129
+
130
+ it("returns near-zero rate for flat / sawtooth samples", () => {
131
+ const guard = new MemoryLeakGuard({ checkIntervalMs: 1_000 }, bus());
132
+ const lr = (guard as unknown as { computeGrowthRate(v: number[]): number }).computeGrowthRate;
133
+ if (!lr) { guard.stop(); return; }
134
+
135
+ // Flat
136
+ const flat = [200, 200, 200, 200, 200, 200];
137
+ const rate = lr.call(guard, flat);
138
+ expect(Math.abs(rate)).toBeLessThan(1);
139
+ guard.stop();
140
+ });
141
+
142
+ it("returns 0 for a single-element array", () => {
143
+ const guard = new MemoryLeakGuard({ checkIntervalMs: 1_000 }, bus());
144
+ const lr = (guard as unknown as { computeGrowthRate(v: number[]): number }).computeGrowthRate;
145
+ if (!lr) { guard.stop(); return; }
146
+
147
+ expect(lr.call(guard, [200])).toBe(0);
148
+ guard.stop();
149
+ });
150
+
151
+ it("returns 0 for empty array", () => {
152
+ const guard = new MemoryLeakGuard({ checkIntervalMs: 1_000 }, bus());
153
+ const lr = (guard as unknown as { computeGrowthRate(v: number[]): number }).computeGrowthRate;
154
+ if (!lr) { guard.stop(); return; }
155
+
156
+ expect(lr.call(guard, [])).toBe(0);
157
+ guard.stop();
158
+ });
159
+ });
160
+
161
+ // ─── Event emission ───────────────────────────────────────────────────────────
162
+
163
+ describe("MemoryLeakGuard — sampling emits events", () => {
164
+ it("emits memory:warning or memory:critical from the bus after a forced sample", async () => {
165
+ const eventBus = bus();
166
+ const guard = new MemoryLeakGuard(
167
+ {
168
+ checkIntervalMs: 60_000,
169
+ // Set thresholds below current heap to force events
170
+ warningThresholdMb: 0.001,
171
+ restartThresholdMb: 0.001,
172
+ growthWindowCount: 1000, // prevent restart
173
+ adaptiveThresholds: false,
174
+ },
175
+ eventBus
176
+ );
177
+
178
+ const events: string[] = [];
179
+ eventBus.on("memory:warning", () => events.push("warning"));
180
+ eventBus.on("memory:critical", () => events.push("critical"));
181
+
182
+ // Force a sample by calling the private method
183
+ const sampleFn = (guard as unknown as { sample(): void }).sample;
184
+ if (sampleFn) {
185
+ sampleFn.call(guard);
186
+ await sleep(10);
187
+ // We expect at least one event (warning or critical)
188
+ expect(events.length).toBeGreaterThan(0);
189
+ }
190
+
191
+ guard.stop();
192
+ });
193
+ });
194
+
195
+ // ─── Default config values ────────────────────────────────────────────────────
196
+
197
+ describe("MemoryLeakGuard — config merging", () => {
198
+ it("uses default checkIntervalMs if not provided", () => {
199
+ const guard = new MemoryLeakGuard({}, bus());
200
+ // Access private config via bracket — just verify it merged
201
+ const cfg = (guard as unknown as { config: { checkIntervalMs: number } }).config;
202
+ expect(typeof cfg.checkIntervalMs).toBe("number");
203
+ expect(cfg.checkIntervalMs).toBeGreaterThan(0);
204
+ guard.stop();
205
+ });
206
+
207
+ it("overrides defaults with provided config", () => {
208
+ const guard = new MemoryLeakGuard({ checkIntervalMs: 99_000 }, bus());
209
+ const cfg = (guard as unknown as { config: { checkIntervalMs: number } }).config;
210
+ expect(cfg.checkIntervalMs).toBe(99_000);
211
+ guard.stop();
212
+ });
213
+ });