miravo 0.1.4 → 0.2.1

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.
@@ -11,9 +11,52 @@ import {
11
11
  lockLogger,
12
12
  logger,
13
13
  mqttLog,
14
+ opcuaLog,
14
15
  unlockLogger,
15
16
  } from "../logger.ts";
16
- import { installShutdownHandlers } from "./shutdown.ts";
17
+ import { createShutdownController } from "./shutdown.ts";
18
+
19
+ const noopReporter = { log() {} };
20
+
21
+ function configureSummaryLogger(
22
+ options: { plain?: boolean; logFormat?: "pretty" | "json" } = {},
23
+ ): void {
24
+ unlockLogger();
25
+ configureLogger({
26
+ plain: options.plain ?? false,
27
+ logFormat: options.logFormat ?? "pretty",
28
+ });
29
+ for (const l of [
30
+ logger,
31
+ engineLog,
32
+ mqttLog,
33
+ opcuaLog,
34
+ configLog,
35
+ lifecycleLog,
36
+ faultLog,
37
+ controlLog,
38
+ ]) {
39
+ l.level = -999;
40
+ l.setReporters([noopReporter]);
41
+ }
42
+ lockLogger();
43
+ }
44
+
45
+ function setStdoutIsTTY(value: boolean): () => void {
46
+ const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
47
+ Object.defineProperty(process.stdout, "isTTY", {
48
+ configurable: true,
49
+ value,
50
+ });
51
+
52
+ return () => {
53
+ if (descriptor) {
54
+ Object.defineProperty(process.stdout, "isTTY", descriptor);
55
+ return;
56
+ }
57
+ delete (process.stdout as { isTTY?: boolean }).isTTY;
58
+ };
59
+ }
17
60
 
18
61
  function createMockEngine(): Engine & { stopCalled: boolean; stopReason: string | undefined } {
19
62
  const metrics: EngineMetricsSnapshot = {
@@ -23,7 +66,7 @@ function createMockEngine(): Engine & { stopCalled: boolean; stopReason: string
23
66
  tickCount: 100,
24
67
  simulationTimeSeconds: 100,
25
68
  instanceCount: 5,
26
- unsEntryCount: 25,
69
+ memberValueCount: 25,
27
70
  quarantinedInstanceCount: 0,
28
71
  engineErrorsCount: 0,
29
72
  tickOverrunsCount: 0,
@@ -53,31 +96,33 @@ function createMockEngine(): Engine & { stopCalled: boolean; stopReason: string
53
96
  return mock;
54
97
  }
55
98
 
56
- describe("installShutdownHandlers", () => {
57
- let teardown: (() => void) | undefined;
99
+ describe("createShutdownController", () => {
100
+ const teardowns: Array<() => void> = [];
58
101
 
59
102
  afterEach(() => {
60
- teardown?.();
61
- teardown = undefined;
103
+ while (teardowns.length > 0) {
104
+ teardowns.pop()?.();
105
+ }
106
+ muteAllLoggers();
62
107
  });
63
108
 
64
109
  test("first SIGINT calls engine.stop and exit(0)", async () => {
65
110
  const engine = createMockEngine();
66
111
  let exitCode: number | undefined;
67
112
 
68
- teardown = installShutdownHandlers({
69
- engine,
113
+ const controller = createShutdownController({
70
114
  seed: 42,
71
115
  seedExplicit: true,
72
116
  exit: (code) => {
73
117
  exitCode = code;
74
118
  },
75
119
  });
120
+ teardowns.push(controller.teardown);
121
+ controller.setEngine(engine);
76
122
 
77
123
  process.emit("SIGINT");
78
-
79
- // Allow async shutdown to complete
80
- await new Promise((resolve) => setTimeout(resolve, 50));
124
+ await controller.waitForShutdownRequest();
125
+ await controller.runShutdown();
81
126
 
82
127
  expect(engine.stopCalled).toBe(true);
83
128
  expect(engine.stopReason).toBe("user interrupt");
@@ -88,153 +133,236 @@ describe("installShutdownHandlers", () => {
88
133
  const engine = createMockEngine();
89
134
  const exitCodes: number[] = [];
90
135
 
91
- // Make stop() slow so we can fire a second signal during shutdown
92
136
  (engine as unknown as Record<string, unknown>)["stop"] = () =>
93
137
  new Promise((resolve) => setTimeout(resolve, 200));
94
138
 
95
- teardown = installShutdownHandlers({
96
- engine,
139
+ const controller = createShutdownController({
97
140
  seed: 42,
98
- seedExplicit: false,
141
+ seedExplicit: true,
99
142
  exit: (code) => {
100
143
  exitCodes.push(code);
101
144
  },
102
145
  });
146
+ teardowns.push(controller.teardown);
147
+ controller.setEngine(engine);
103
148
 
104
- // First signal starts shutdown
105
149
  process.emit("SIGINT");
106
- // Brief delay to let shuttingDown be set
150
+ await controller.waitForShutdownRequest();
151
+ const shutdownPromise = controller.runShutdown();
107
152
  await new Promise((resolve) => setTimeout(resolve, 10));
108
- // Second signal should force exit
109
153
  process.emit("SIGINT");
110
-
111
- await new Promise((resolve) => setTimeout(resolve, 250));
154
+ await controller.completion;
155
+ await shutdownPromise;
112
156
 
113
157
  expect(exitCodes).toContain(1);
114
158
  });
115
159
 
116
- test("engine.stop() throwing still exits 0", async () => {
160
+ test("shutdown request is inert until the owner runs shutdown", async () => {
117
161
  const engine = createMockEngine();
118
- let exitCode: number | undefined;
162
+ let stopCallCount = 0;
119
163
 
120
- // Make stop() throw
121
164
  (engine as unknown as Record<string, unknown>)["stop"] = async () => {
122
- throw new Error("adapter disconnect");
165
+ stopCallCount++;
123
166
  };
124
167
 
125
- teardown = installShutdownHandlers({
126
- engine,
168
+ const controller = createShutdownController({
127
169
  seed: 1,
128
170
  seedExplicit: true,
129
- exit: (code) => {
130
- exitCode = code;
131
- },
171
+ exit: () => {},
132
172
  });
173
+ teardowns.push(controller.teardown);
174
+ controller.setEngine(engine);
133
175
 
134
176
  process.emit("SIGINT");
135
- await new Promise((resolve) => setTimeout(resolve, 50));
177
+ await controller.waitForShutdownRequest();
136
178
 
137
- expect(exitCode).toBe(0);
179
+ expect(stopCallCount).toBe(0);
180
+
181
+ await controller.runShutdown();
182
+
183
+ expect(stopCallCount).toBe(1);
138
184
  });
139
185
 
140
- test("concurrent SIGINT+SIGTERM only runs shutdown once", async () => {
186
+ test("teardown removes signal handlers", async () => {
141
187
  const engine = createMockEngine();
142
- let stopCallCount = 0;
143
-
144
- (engine as unknown as Record<string, unknown>)["stop"] = async () => {
145
- stopCallCount++;
146
- };
147
188
 
148
- teardown = installShutdownHandlers({
149
- engine,
189
+ const controller = createShutdownController({
150
190
  seed: 1,
151
191
  seedExplicit: true,
152
192
  exit: () => {},
153
193
  });
194
+ controller.setEngine(engine);
195
+ controller.teardown();
154
196
 
155
- // Fire both signals nearly simultaneously
156
197
  process.emit("SIGINT");
157
- process.emit("SIGTERM");
158
198
  await new Promise((resolve) => setTimeout(resolve, 50));
159
199
 
160
- // The shuttingDown guard should prevent the second from starting a new shutdown
161
- expect(stopCallCount).toBe(1);
200
+ expect(engine.stopCalled).toBe(false);
162
201
  });
163
202
 
164
- test("teardown removes signal handlers", async () => {
203
+ test("runs cleanup before summary and exit", async () => {
165
204
  const engine = createMockEngine();
205
+ const steps: string[] = [];
206
+ configureSummaryLogger({ plain: false, logFormat: "pretty" });
166
207
 
167
- teardown = installShutdownHandlers({
168
- engine,
169
- seed: 1,
208
+ const controller = createShutdownController({
209
+ seed: 42,
170
210
  seedExplicit: true,
171
- exit: () => {},
211
+ writeFinalOutput: async () => {
212
+ steps.push("summary");
213
+ },
214
+ exit: () => {
215
+ steps.push("exit");
216
+ },
217
+ });
218
+ teardowns.push(controller.teardown);
219
+ controller.setEngine(engine);
220
+ controller.addTask("cleanup", "cleanup", async () => {
221
+ await new Promise((resolve) => setTimeout(resolve, 25));
222
+ steps.push("cleanup");
223
+ });
224
+ controller.addTask("finalize", "finalize", () => {
225
+ steps.push("finalize");
172
226
  });
173
227
 
174
- // Remove handlers
175
- teardown();
176
- teardown = undefined;
177
-
178
- // Fire SIGINT after teardown — our handler should NOT run.
179
- // If handlers weren't removed, the mock engine would get stopped.
180
228
  process.emit("SIGINT");
181
- await new Promise((resolve) => setTimeout(resolve, 50));
182
- expect(engine.stopCalled).toBe(false);
229
+ await controller.waitForShutdownRequest();
230
+ await controller.runShutdown();
231
+
232
+ expect(steps).toEqual(["cleanup", "summary", "finalize", "exit"]);
183
233
  });
184
234
 
185
- test("prints top 5 error contexts in shutdown summary", async () => {
235
+ test("cleanup failure does not suppress the shutdown summary", async () => {
186
236
  const engine = createMockEngine();
187
237
  let exitCode: number | undefined;
188
- const originalLog = console.log;
189
- const calls: string[] = [];
190
- console.log = (...args: unknown[]) => {
191
- calls.push(args.map((arg) => String(arg)).join(" "));
192
- };
238
+ const renders: string[] = [];
193
239
 
194
- // Enable writeOutput (plain mode, not suppressed) so the shutdown summary
195
- // flows through console.log where we can capture it. Then mute consola
196
- // reporters so engineLog/mqttLog don't leak to stdout.
197
- unlockLogger();
198
- configureLogger({ plain: true });
199
- const noopReporter = { log() {} };
200
- for (const l of [logger, engineLog, mqttLog, configLog, lifecycleLog, faultLog, controlLog]) {
201
- l.level = -999;
202
- l.setReporters([noopReporter]);
203
- }
204
- lockLogger();
240
+ configureSummaryLogger({ plain: false, logFormat: "pretty" });
241
+
242
+ const controller = createShutdownController({
243
+ seed: 42,
244
+ seedExplicit: true,
245
+ writeFinalOutput: async (text) => {
246
+ renders.push(text);
247
+ },
248
+ exit: (code) => {
249
+ exitCode = code;
250
+ },
251
+ });
252
+ teardowns.push(controller.teardown);
253
+ controller.setEngine(engine);
254
+ controller.addTask("cleanup", "cleanup", () => {
255
+ throw new Error("cleanup failed");
256
+ });
257
+
258
+ process.emit("SIGINT");
259
+ await controller.waitForShutdownRequest();
260
+ await controller.runShutdown();
261
+
262
+ expect(exitCode).toBe(0);
263
+ expect(renders).toHaveLength(1);
264
+ expect(renders[0]?.includes("Shutdown Summary")).toBe(true);
265
+ });
266
+
267
+ test("renders the rounded shutdown box in pretty mode even when stdout is not a TTY", async () => {
268
+ const engine = createMockEngine();
269
+ let exitCode: number | undefined;
270
+ const renders: string[] = [];
271
+ const restoreTTY = setStdoutIsTTY(false);
272
+ configureSummaryLogger({ plain: false, logFormat: "pretty" });
205
273
 
206
274
  try {
207
- teardown = installShutdownHandlers({
208
- engine,
275
+ const controller = createShutdownController({
209
276
  seed: 42,
210
277
  seedExplicit: true,
211
- errorContextCounts: new Map([
212
- ["mqtt:publish", 9],
213
- ["tick-overrun", 7],
214
- ["mqtt:backpressure", 5],
215
- ["mqtt:reconnect", 3],
216
- ['circuit-breaker instance="pump-001"', 2],
217
- ["mqtt:client", 1],
218
- ]),
278
+ writeFinalOutput: async (text) => {
279
+ renders.push(text);
280
+ },
219
281
  exit: (code) => {
220
282
  exitCode = code;
221
283
  },
222
284
  });
285
+ teardowns.push(controller.teardown);
286
+ controller.setEngine(engine);
223
287
 
224
288
  process.emit("SIGINT");
225
- await new Promise((resolve) => setTimeout(resolve, 50));
289
+ await controller.waitForShutdownRequest();
290
+ await controller.runShutdown();
226
291
  } finally {
227
- console.log = originalLog;
228
- muteAllLoggers();
292
+ restoreTTY();
229
293
  }
230
294
 
231
295
  expect(exitCode).toBe(0);
232
- expect(calls.some((line) => line.includes("Top error contexts:"))).toBe(true);
233
- expect(calls.some((line) => line.includes("mqtt:publish"))).toBe(true);
234
- expect(calls.some((line) => line.includes("tick-overrun"))).toBe(true);
235
- expect(calls.some((line) => line.includes("mqtt:backpressure"))).toBe(true);
236
- expect(calls.some((line) => line.includes("mqtt:reconnect"))).toBe(true);
237
- expect(calls.some((line) => line.includes('circuit-breaker instance="pump-001"'))).toBe(true);
238
- expect(calls.some((line) => line.includes("mqtt:client"))).toBe(false);
296
+ expect(renders).toHaveLength(1);
297
+ expect(renders[0]?.includes("Shutdown Summary")).toBe(true);
298
+ expect(renders[0]?.includes("╭")).toBe(true);
299
+ expect(renders[0]?.includes("╰")).toBe(true);
300
+ });
301
+
302
+ test("keeps shutdown output plain when --plain is enabled", async () => {
303
+ const engine = createMockEngine();
304
+ const renders: string[] = [];
305
+ configureSummaryLogger({ plain: true, logFormat: "pretty" });
306
+
307
+ const controller = createShutdownController({
308
+ seed: 42,
309
+ seedExplicit: true,
310
+ writeFinalOutput: async (text) => {
311
+ renders.push(text);
312
+ },
313
+ exit: () => {},
314
+ });
315
+ teardowns.push(controller.teardown);
316
+ controller.setEngine(engine);
317
+
318
+ process.emit("SIGINT");
319
+ await controller.waitForShutdownRequest();
320
+ await controller.runShutdown();
321
+
322
+ expect(renders).toHaveLength(1);
323
+ expect(renders[0]?.startsWith("Shutdown Summary\n")).toBe(true);
324
+ expect(renders[0]?.includes("╭")).toBe(false);
325
+ });
326
+
327
+ test("prints top 5 error contexts in shutdown summary", async () => {
328
+ const engine = createMockEngine();
329
+ let exitCode: number | undefined;
330
+ const renders: string[] = [];
331
+ configureSummaryLogger({ plain: true, logFormat: "pretty" });
332
+
333
+ const controller = createShutdownController({
334
+ seed: 42,
335
+ seedExplicit: true,
336
+ errorContextCounts: new Map([
337
+ ["mqtt:publish", 9],
338
+ ["tick-overrun", 7],
339
+ ["mqtt:backpressure", 5],
340
+ ["mqtt:reconnect", 3],
341
+ ['circuit-breaker instance="pump-001"', 2],
342
+ ["mqtt:client", 1],
343
+ ]),
344
+ writeFinalOutput: async (text) => {
345
+ renders.push(text);
346
+ },
347
+ exit: (code) => {
348
+ exitCode = code;
349
+ },
350
+ });
351
+ teardowns.push(controller.teardown);
352
+ controller.setEngine(engine);
353
+
354
+ process.emit("SIGINT");
355
+ await controller.waitForShutdownRequest();
356
+ await controller.runShutdown();
357
+
358
+ const output = renders[0] ?? "";
359
+ expect(exitCode).toBe(0);
360
+ expect(output.includes("Top errors")).toBe(true);
361
+ expect(output.includes("mqtt:publish")).toBe(true);
362
+ expect(output.includes("tick-overrun")).toBe(true);
363
+ expect(output.includes("mqtt:backpressure")).toBe(true);
364
+ expect(output.includes("mqtt:reconnect")).toBe(true);
365
+ expect(output.includes('circuit-breaker instance="pump-001"')).toBe(true);
366
+ expect(output.includes("mqtt:client")).toBe(false);
239
367
  });
240
368
  });