miravo 0.5.0 → 0.6.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.
Files changed (69) hide show
  1. package/LICENSE +199 -0
  2. package/README.md +7 -70
  3. package/bin/miravo.js +84 -0
  4. package/package.json +20 -36
  5. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  6. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  7. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  8. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  9. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  10. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  11. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  12. package/console-dist/assets/index-C1oOHU1J.js +0 -119
  13. package/console-dist/assets/index-C3XMYq26.css +0 -2
  14. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  15. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  16. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  17. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  18. package/console-dist/favicon.svg +0 -37
  19. package/console-dist/index.html +0 -14
  20. package/console-dist/miravo-logo.png +0 -0
  21. package/src/__tests__/integration/executable-methods-runner.ts +0 -261
  22. package/src/__tests__/integration/executable-methods.integration.ts +0 -2256
  23. package/src/commands/adapter.ts +0 -76
  24. package/src/commands/cli-test-setup.ts +0 -91
  25. package/src/commands/fork.ts +0 -219
  26. package/src/commands/inject.test.ts +0 -57
  27. package/src/commands/inject.ts +0 -212
  28. package/src/commands/load.ts +0 -167
  29. package/src/commands/logs.ts +0 -175
  30. package/src/commands/models.test.ts +0 -162
  31. package/src/commands/models.ts +0 -338
  32. package/src/commands/pause.ts +0 -67
  33. package/src/commands/ps.ts +0 -116
  34. package/src/commands/remove.ts +0 -38
  35. package/src/commands/reset.ts +0 -44
  36. package/src/commands/rm.ts +0 -97
  37. package/src/commands/spawn.ts +0 -45
  38. package/src/commands/speed.ts +0 -40
  39. package/src/commands/start.test.ts +0 -481
  40. package/src/commands/start.ts +0 -2253
  41. package/src/commands/status.test.ts +0 -20
  42. package/src/commands/status.ts +0 -470
  43. package/src/commands/stop.test.ts +0 -84
  44. package/src/commands/stop.ts +0 -158
  45. package/src/commands/templates.test.ts +0 -58
  46. package/src/commands/templates.ts +0 -279
  47. package/src/index.ts +0 -158
  48. package/src/lib/admin-client.test.ts +0 -35
  49. package/src/lib/admin-client.ts +0 -226
  50. package/src/lib/admin-server.test.ts +0 -562
  51. package/src/lib/admin-server.ts +0 -658
  52. package/src/lib/daemon-registry.test.ts +0 -429
  53. package/src/lib/daemon-registry.ts +0 -345
  54. package/src/lib/mqtt-client.ts +0 -116
  55. package/src/lib/parse.test.ts +0 -123
  56. package/src/lib/parse.ts +0 -87
  57. package/src/lib/resolve-simulation.test.ts +0 -118
  58. package/src/lib/resolve-simulation.ts +0 -186
  59. package/src/lib/shutdown.test.ts +0 -380
  60. package/src/lib/shutdown.ts +0 -561
  61. package/src/lib/template-category.test.ts +0 -71
  62. package/src/lib/template-category.ts +0 -72
  63. package/src/lib/template-locator.ts +0 -49
  64. package/src/lib/template-utils.ts +0 -45
  65. package/src/lib/ws-hub.ts +0 -255
  66. package/src/logger.test.ts +0 -172
  67. package/src/logger.ts +0 -394
  68. package/src/output.test.ts +0 -64
  69. package/src/output.ts +0 -157
@@ -1,2256 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { EventEmitter } from "node:events";
3
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
- import { createServer } from "node:net";
5
- import { tmpdir } from "node:os";
6
- import { join, resolve } from "node:path";
7
- import {
8
- type CommandResult,
9
- controlTopicCmd,
10
- controlTopicCmdResult,
11
- EngineMetricsSnapshotSchema,
12
- EngineStateSnapshotSchema,
13
- PersistedStateSchema,
14
- type TwinModel,
15
- } from "@miravo/shared";
16
- import type { MqttClient } from "mqtt";
17
- import { connect } from "mqtt";
18
- import {
19
- type ClientSession,
20
- MessageSecurityMode,
21
- OPCUAClient,
22
- SecurityPolicy,
23
- } from "node-opcua-client";
24
- import { DataType } from "node-opcua-variant";
25
- import { stringify as stringifyYAML } from "yaml";
26
-
27
- interface MQTTMessageRecord {
28
- readonly topic: string;
29
- readonly raw: string;
30
- readonly parsed: unknown;
31
- }
32
-
33
- interface SmokeRuntimeReady {
34
- readonly simulationName: string;
35
- readonly adminUrl: string;
36
- readonly mqttBrokerUrl: string;
37
- readonly opcuaEndpointUrl: string;
38
- readonly instanceId: string;
39
- readonly instancePath: string;
40
- }
41
-
42
- interface SmokeRuntimeHandle {
43
- readonly process: Bun.Subprocess<"ignore", "pipe", "pipe">;
44
- readonly tmpDir: string;
45
- readonly ready: SmokeRuntimeReady;
46
- }
47
-
48
- const processOutputCache = new WeakMap<
49
- SmokeRuntimeHandle["process"],
50
- Promise<{ readonly stdout: string; readonly stderr: string }>
51
- >();
52
-
53
- interface InvokeMethodCommandPayload {
54
- readonly type: "invokeMethod";
55
- readonly instanceId: string;
56
- readonly method: string;
57
- readonly arguments?: Readonly<Record<string, number | string | boolean>> | undefined;
58
- }
59
-
60
- interface AdminHealthPayload {
61
- readonly status: "ok" | "fail";
62
- readonly simulationState: string;
63
- readonly tickCount: number;
64
- readonly stalled: boolean;
65
- readonly reason?: string;
66
- }
67
-
68
- async function canBindLocalTcp(): Promise<boolean> {
69
- const probe = createServer();
70
- return new Promise((resolvePromise) => {
71
- const cleanup = () => {
72
- try {
73
- probe.close();
74
- } catch {
75
- // Ignore close races in probe cleanup.
76
- }
77
- };
78
-
79
- (probe as unknown as EventEmitter).once("error", () => {
80
- cleanup();
81
- resolvePromise(false);
82
- });
83
- (probe as unknown as EventEmitter).once("listening", () => {
84
- cleanup();
85
- resolvePromise(true);
86
- });
87
- probe.listen(0, "127.0.0.1");
88
- });
89
- }
90
-
91
- const TCP_AVAILABLE = await canBindLocalTcp();
92
- const describeIfTcp = TCP_AVAILABLE ? describe : describe.skip;
93
-
94
- async function reservePort(): Promise<number> {
95
- const probe = createServer();
96
- return new Promise((resolvePromise, reject) => {
97
- const cleanup = () => {
98
- try {
99
- probe.close();
100
- } catch {
101
- // Ignore close races in probe cleanup.
102
- }
103
- };
104
-
105
- (probe as unknown as EventEmitter).once("error", (error: Error) => {
106
- cleanup();
107
- reject(error);
108
- });
109
- (probe as unknown as EventEmitter).once("listening", () => {
110
- const address = probe.address();
111
- if (!address || typeof address === "string") {
112
- cleanup();
113
- reject(new Error("Failed to reserve a local TCP port for integration test."));
114
- return;
115
- }
116
-
117
- const { port } = address;
118
- probe.close((error) => {
119
- if (error) {
120
- reject(error);
121
- return;
122
- }
123
- resolvePromise(port);
124
- });
125
- });
126
- probe.listen(0, "127.0.0.1");
127
- });
128
- }
129
-
130
- function createSmokePumpModel(): TwinModel {
131
- return {
132
- schema_version: "2",
133
- model: {
134
- name: "method-smoke-pump",
135
- namespace: "com.miravo.testing.method-smoke-pump",
136
- description: "Deterministic smoke-test pump for executable method transport validation.",
137
- },
138
- parameters: {
139
- target_value: {
140
- type: "float",
141
- default: 5,
142
- min: 0,
143
- max: 100,
144
- unit: "bar",
145
- },
146
- },
147
- methods: {
148
- Start: {
149
- description: "Start the smoke-test pump.",
150
- runtime: {
151
- type: "set-members",
152
- writes: [
153
- { member: "run_command", value: true },
154
- { member: "speed_command", value: 1200 },
155
- ],
156
- },
157
- output_arguments: {
158
- accepted: {
159
- data_type: "Boolean",
160
- },
161
- },
162
- },
163
- Stop: {
164
- description: "Stop the smoke-test pump.",
165
- runtime: {
166
- type: "set-members",
167
- writes: [
168
- { member: "run_command", value: false },
169
- { member: "speed_command", value: 0 },
170
- ],
171
- },
172
- output_arguments: {
173
- accepted: {
174
- data_type: "Boolean",
175
- },
176
- },
177
- },
178
- SetSpeed: {
179
- description: "Set the pump speed command.",
180
- input_arguments: {
181
- speed_rpm: {
182
- data_type: "Double",
183
- unit: "rpm",
184
- },
185
- },
186
- runtime: {
187
- type: "set-members",
188
- writes: [{ member: "speed_command", argument: "speed_rpm" }],
189
- },
190
- output_arguments: {
191
- accepted: {
192
- data_type: "Boolean",
193
- },
194
- },
195
- },
196
- SetTarget: {
197
- description: "Set the parameter echo value.",
198
- input_arguments: {
199
- target_value: {
200
- data_type: "Double",
201
- unit: "bar",
202
- },
203
- },
204
- runtime: {
205
- type: "set-parameters",
206
- writes: [{ parameter: "target_value", argument: "target_value" }],
207
- },
208
- output_arguments: {
209
- accepted: {
210
- data_type: "Boolean",
211
- },
212
- },
213
- },
214
- ResetFault: {
215
- description: "Reset active smoke-test pump faults.",
216
- runtime: {
217
- type: "clear-faults",
218
- },
219
- output_arguments: {
220
- accepted: {
221
- data_type: "Boolean",
222
- },
223
- },
224
- },
225
- },
226
- members: {
227
- manufacturer: {
228
- kind: "property",
229
- data_type: "String",
230
- access: "read",
231
- default: "Miravo Labs",
232
- },
233
- serial_number: {
234
- kind: "property",
235
- data_type: "String",
236
- access: "read",
237
- default: "SMOKE-001",
238
- },
239
- run_command: {
240
- kind: "variable",
241
- data_type: "Boolean",
242
- access: "readwrite",
243
- default: false,
244
- },
245
- speed_command: {
246
- kind: "variable",
247
- data_type: "Double",
248
- access: "readwrite",
249
- unit: "rpm",
250
- default: 0,
251
- },
252
- speed_feedback: {
253
- kind: "variable",
254
- data_type: "Double",
255
- access: "read",
256
- unit: "rpm",
257
- generator: {
258
- type: "gate",
259
- enabled_member: "run_command",
260
- disabled_value: 0,
261
- input: { member: "speed_command" },
262
- },
263
- },
264
- flow_rate: {
265
- kind: "variable",
266
- data_type: "Double",
267
- access: "read",
268
- unit: "m3/h",
269
- generator: {
270
- type: "correlated",
271
- source: "speed_feedback",
272
- factor: 0.02,
273
- offset: 0,
274
- },
275
- },
276
- parameter_echo: {
277
- kind: "variable",
278
- data_type: "Double",
279
- access: "read",
280
- unit: "bar",
281
- generator: {
282
- type: "weighted-sum",
283
- bias: 0,
284
- terms: [
285
- {
286
- weight: 1,
287
- input: { param: "target_value" },
288
- },
289
- ],
290
- min: 0,
291
- max: 100,
292
- },
293
- },
294
- running: {
295
- kind: "variable",
296
- data_type: "Boolean",
297
- access: "read",
298
- binding: {
299
- type: "threshold",
300
- member: "speed_feedback",
301
- operator: ">",
302
- threshold: 1,
303
- },
304
- default: false,
305
- },
306
- fault_active: {
307
- kind: "variable",
308
- data_type: "Boolean",
309
- access: "read",
310
- binding: {
311
- type: "fault-active",
312
- },
313
- default: false,
314
- },
315
- fault_count: {
316
- kind: "variable",
317
- data_type: "UInt16",
318
- access: "read",
319
- unit: "count",
320
- binding: {
321
- type: "fault-count",
322
- },
323
- default: 0,
324
- },
325
- active_fault_code: {
326
- kind: "variable",
327
- data_type: "String",
328
- access: "read",
329
- enum_values: ["none", "trip"],
330
- binding: {
331
- type: "active-fault-code",
332
- fallback: "none",
333
- },
334
- default: "none",
335
- },
336
- availability_state: {
337
- kind: "variable",
338
- data_type: "String",
339
- access: "read",
340
- enum_values: ["stopped", "running", "faulted"],
341
- binding: {
342
- type: "availability-state",
343
- member: "speed_feedback",
344
- operator: ">",
345
- threshold: 1,
346
- running_state: "running",
347
- stopped_state: "stopped",
348
- faulted_state: "faulted",
349
- },
350
- default: "stopped",
351
- },
352
- },
353
- faults: [
354
- {
355
- name: "trip",
356
- trigger: {
357
- probability: 0,
358
- },
359
- effects: {
360
- speed_feedback: {
361
- offset: -100,
362
- },
363
- },
364
- },
365
- ],
366
- };
367
- }
368
-
369
- function createLifecycleTestModel(): TwinModel {
370
- return {
371
- schema_version: "2",
372
- model: {
373
- name: "lifecycle-test-pump",
374
- namespace: "com.miravo.testing.lifecycle-test",
375
- description: "Deterministic pump for lifecycle effects integration testing.",
376
- },
377
- parameters: {
378
- baseline_pressure: {
379
- type: "float",
380
- default: 100,
381
- min: 0,
382
- max: 200,
383
- unit: "bar",
384
- },
385
- },
386
- lifecycle: {
387
- stages: [
388
- {
389
- name: "healthy",
390
- duration_hours: [0.1, 0.1],
391
- effects: {},
392
- },
393
- {
394
- name: "degraded",
395
- duration_hours: [0.05, 0.05],
396
- effects: {
397
- pressure: { multiplier: 0.8, offset: -5 },
398
- },
399
- },
400
- {
401
- name: "critical",
402
- duration_hours: [10000, 10000],
403
- effects: {
404
- pressure: { multiplier: 0.5, offset: -20 },
405
- },
406
- },
407
- ],
408
- },
409
- methods: {},
410
- members: {
411
- pressure: {
412
- kind: "variable",
413
- data_type: "Double",
414
- access: "read",
415
- unit: "bar",
416
- generator: {
417
- type: "weighted-sum",
418
- bias: 0,
419
- terms: [{ weight: 1, input: { param: "baseline_pressure" } }],
420
- min: 0,
421
- max: 200,
422
- },
423
- },
424
- },
425
- faults: [],
426
- };
427
- }
428
-
429
- function createConditionalFaultTestModel(): TwinModel {
430
- return {
431
- schema_version: "2",
432
- model: {
433
- name: "fault-test-pump",
434
- namespace: "com.miravo.testing.fault-test",
435
- description: "Deterministic pump for conditional fault integration testing.",
436
- },
437
- parameters: {},
438
- methods: {
439
- SetSpeed: {
440
- description: "Set the speed command.",
441
- input_arguments: {
442
- speed: { data_type: "Double", unit: "rpm" },
443
- },
444
- runtime: {
445
- type: "set-members",
446
- writes: [{ member: "speed_command", argument: "speed" }],
447
- },
448
- output_arguments: {
449
- accepted: { data_type: "Boolean" },
450
- },
451
- },
452
- ResetFault: {
453
- description: "Clear active faults.",
454
- runtime: { type: "clear-faults" },
455
- output_arguments: {
456
- accepted: { data_type: "Boolean" },
457
- },
458
- },
459
- },
460
- members: {
461
- speed_command: {
462
- kind: "variable",
463
- data_type: "Double",
464
- access: "readwrite",
465
- unit: "rpm",
466
- default: 0,
467
- },
468
- pressure: {
469
- kind: "variable",
470
- data_type: "Double",
471
- access: "read",
472
- unit: "bar",
473
- generator: {
474
- type: "correlated",
475
- source: "speed_command",
476
- factor: 0.1,
477
- offset: 0,
478
- },
479
- },
480
- fault_active: {
481
- kind: "variable",
482
- data_type: "Boolean",
483
- access: "read",
484
- binding: { type: "fault-active" },
485
- default: false,
486
- },
487
- fault_count: {
488
- kind: "variable",
489
- data_type: "UInt16",
490
- access: "read",
491
- unit: "count",
492
- binding: { type: "fault-count" },
493
- default: 0,
494
- },
495
- },
496
- faults: [
497
- {
498
- name: "overpressure",
499
- trigger: {
500
- condition: "pressure > 80",
501
- probability: 1,
502
- },
503
- effects: {
504
- pressure: { multiplier: 0.5 },
505
- },
506
- },
507
- ],
508
- };
509
- }
510
-
511
- async function waitFor<T>(label: string, read: () => T | undefined, timeoutMs = 5000): Promise<T> {
512
- const deadline = Date.now() + timeoutMs;
513
- while (Date.now() < deadline) {
514
- const value = read();
515
- if (value !== undefined) {
516
- return value;
517
- }
518
- await Bun.sleep(20);
519
- }
520
- throw new Error(`Timed out waiting for ${label}.`);
521
- }
522
-
523
- async function waitForAsync<T>(
524
- label: string,
525
- read: () => Promise<T | undefined>,
526
- timeoutMs = 5000,
527
- ): Promise<T> {
528
- const deadline = Date.now() + timeoutMs;
529
- while (Date.now() < deadline) {
530
- const value = await read();
531
- if (value !== undefined) {
532
- return value;
533
- }
534
- await Bun.sleep(20);
535
- }
536
- throw new Error(`Timed out waiting for ${label}.`);
537
- }
538
-
539
- async function readAdminJson<T>(
540
- runtime: SmokeRuntimeHandle,
541
- path: string,
542
- schema: {
543
- safeParse(value: unknown): { success: boolean; data?: T };
544
- },
545
- ): Promise<T> {
546
- const response = await fetch(new URL(path, `${runtime.ready.adminUrl}/`));
547
- if (!response.ok) {
548
- throw new Error(`Admin request ${path} failed with HTTP ${response.status}.`);
549
- }
550
- const payload = (await response.json()) as unknown;
551
- const parsed = schema.safeParse(payload);
552
- if (!parsed.success || parsed.data === undefined) {
553
- throw new Error(`Admin request ${path} returned an unexpected payload.`);
554
- }
555
- return parsed.data;
556
- }
557
-
558
- async function readAdminHealth(runtime: SmokeRuntimeHandle, path: "/healthz" | "/readyz") {
559
- const response = await fetch(new URL(path, `${runtime.ready.adminUrl}/`));
560
- const payload = (await response.json()) as AdminHealthPayload;
561
- return { response, payload };
562
- }
563
-
564
- async function postAdminCommand(
565
- runtime: SmokeRuntimeHandle,
566
- payload: Readonly<Record<string, unknown>>,
567
- ): Promise<CommandResult> {
568
- const response = await fetch(new URL("/commands", `${runtime.ready.adminUrl}/`), {
569
- method: "POST",
570
- headers: {
571
- "content-type": "application/json",
572
- },
573
- body: JSON.stringify(payload),
574
- });
575
- if (!response.ok) {
576
- const text = await response.text();
577
- throw new Error(`Admin command failed with HTTP ${response.status}: ${text}`);
578
- }
579
- return (await response.json()) as CommandResult;
580
- }
581
-
582
- async function waitForAdminMetricsState(
583
- runtime: SmokeRuntimeHandle,
584
- expectedState: "running" | "paused" | "stopped",
585
- ) {
586
- return waitForAsync(`admin metrics state ${expectedState}`, async () => {
587
- const metrics = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
588
- return metrics.core.state === expectedState ? metrics : undefined;
589
- });
590
- }
591
-
592
- async function waitForAdminInstanceCount(runtime: SmokeRuntimeHandle, expectedCount: number) {
593
- return waitForAsync(`admin instance count ${expectedCount}`, async () => {
594
- const state = await readAdminJson(runtime, "/state", EngineStateSnapshotSchema);
595
- return state.instances.length === expectedCount ? state : undefined;
596
- });
597
- }
598
-
599
- function readPayloadValue(payload: unknown): number | string | boolean | undefined {
600
- if (typeof payload !== "object" || payload === null) {
601
- return undefined;
602
- }
603
- if (!("value" in payload)) {
604
- return undefined;
605
- }
606
- const value = (payload as { readonly value?: unknown }).value;
607
- return typeof value === "number" || typeof value === "string" || typeof value === "boolean"
608
- ? value
609
- : undefined;
610
- }
611
-
612
- function readPayloadQuality(payload: unknown): string | undefined {
613
- if (typeof payload !== "object" || payload === null) {
614
- return undefined;
615
- }
616
- if (!("quality" in payload)) {
617
- return undefined;
618
- }
619
- const quality = (payload as { readonly quality?: unknown }).quality;
620
- return typeof quality === "string" ? quality : undefined;
621
- }
622
-
623
- async function waitForMQTTConnect(client: MqttClient, timeoutMs = 5000): Promise<void> {
624
- if (client.connected) {
625
- return;
626
- }
627
-
628
- await new Promise<void>((resolvePromise, reject) => {
629
- const timer = setTimeout(() => {
630
- cleanup();
631
- reject(new Error(`MQTT client did not connect within ${timeoutMs}ms.`));
632
- }, timeoutMs);
633
-
634
- const cleanup = () => {
635
- clearTimeout(timer);
636
- client.off("connect", onConnect);
637
- client.off("error", onError);
638
- };
639
-
640
- const onConnect = () => {
641
- cleanup();
642
- resolvePromise();
643
- };
644
-
645
- const onError = (error: Error) => {
646
- cleanup();
647
- reject(error);
648
- };
649
-
650
- client.once("connect", onConnect);
651
- client.once("error", onError);
652
- });
653
- }
654
-
655
- async function stopMQTTClient(client: MqttClient): Promise<void> {
656
- await Promise.race([
657
- new Promise<void>((resolvePromise) => {
658
- client.end(true, {}, () => resolvePromise());
659
- }),
660
- Bun.sleep(5_000).then(() => {
661
- throw new Error("MQTT client did not close within 5000ms.");
662
- }),
663
- ]);
664
- }
665
-
666
- async function subscribeMQTT(client: MqttClient, topics: readonly string[]): Promise<void> {
667
- await new Promise<void>((resolvePromise, reject) => {
668
- client.subscribe([...topics], (error) => {
669
- if (error) {
670
- reject(error);
671
- return;
672
- }
673
- resolvePromise();
674
- });
675
- });
676
- }
677
-
678
- function createMQTTRecorder(client: MqttClient) {
679
- const messages: MQTTMessageRecord[] = [];
680
- const latestByTopic = new Map<string, MQTTMessageRecord>();
681
- const onMessage = (topic: string, payload: Buffer) => {
682
- const raw = payload.toString();
683
- let parsed: unknown = raw;
684
- try {
685
- parsed = JSON.parse(raw);
686
- } catch {
687
- // Keep raw payload when it is not JSON.
688
- }
689
- const record = { topic, raw, parsed };
690
- messages.push(record);
691
- latestByTopic.set(topic, record);
692
- };
693
- client.on("message", onMessage);
694
- return {
695
- messages,
696
- latestByTopic,
697
- dispose() {
698
- client.off("message", onMessage);
699
- },
700
- };
701
- }
702
-
703
- async function publishControlCommand(
704
- client: MqttClient,
705
- recorder: ReturnType<typeof createMQTTRecorder>,
706
- simulationName: string,
707
- payload: Readonly<Record<string, unknown>>,
708
- ): Promise<CommandResult> {
709
- const correlationId = crypto.randomUUID();
710
- const commandTopic = controlTopicCmd(simulationName);
711
- const resultTopic = controlTopicCmdResult(simulationName);
712
- await new Promise<void>((resolvePromise, reject) => {
713
- client.publish(
714
- commandTopic,
715
- JSON.stringify({
716
- correlationId,
717
- ...payload,
718
- }),
719
- (error) => {
720
- if (error) {
721
- reject(error);
722
- return;
723
- }
724
- resolvePromise();
725
- },
726
- );
727
- });
728
-
729
- return waitFor(`MQTT command result for ${String(payload["type"])}`, () => {
730
- const result = recorder.messages
731
- .filter((message) => message.topic === resultTopic)
732
- .map((message) => message.parsed)
733
- .find((parsed): parsed is CommandResult => {
734
- if (typeof parsed !== "object" || parsed === null) {
735
- return false;
736
- }
737
- return (parsed as { readonly correlationId?: string }).correlationId === correlationId;
738
- });
739
- return result;
740
- });
741
- }
742
-
743
- async function waitForMQTTTopicValue(
744
- recorder: ReturnType<typeof createMQTTRecorder>,
745
- topic: string,
746
- expected?: number | string | boolean,
747
- timeoutMs?: number,
748
- ): Promise<number | string | boolean> {
749
- return waitFor(
750
- `MQTT topic ${topic}`,
751
- () => {
752
- const payload = recorder.latestByTopic.get(topic)?.parsed;
753
- const value = readPayloadValue(payload);
754
- if (value === undefined) {
755
- return undefined;
756
- }
757
- return expected === undefined || value === expected ? value : undefined;
758
- },
759
- timeoutMs,
760
- );
761
- }
762
-
763
- async function waitForMQTTTopicQuality(
764
- recorder: ReturnType<typeof createMQTTRecorder>,
765
- topic: string,
766
- expected: string,
767
- ): Promise<string> {
768
- return waitFor(`MQTT topic ${topic} quality=${expected}`, () => {
769
- const record = recorder.latestByTopic.get(topic);
770
- if (!record) {
771
- return undefined;
772
- }
773
- const quality = readPayloadQuality(record.parsed);
774
- return quality === expected ? quality : undefined;
775
- });
776
- }
777
-
778
- async function waitForOPCUAValue<T extends number | string | boolean>(
779
- session: ClientSession,
780
- nodeId: string,
781
- expected?: T,
782
- ): Promise<T> {
783
- return waitForAsync(`OPC UA value ${nodeId}`, async () => {
784
- const dataValue = await session.readVariableValue(nodeId);
785
- const value = dataValue.value.value;
786
- if (typeof value !== "number" && typeof value !== "string" && typeof value !== "boolean") {
787
- return undefined;
788
- }
789
- return expected === undefined || value === expected ? (value as T) : undefined;
790
- });
791
- }
792
-
793
- async function callOPCUAMethod(
794
- session: ClientSession,
795
- objectPath: string,
796
- methodName: string,
797
- inputArguments: Array<{
798
- readonly dataType: DataType;
799
- readonly value: number | string | boolean;
800
- }> = [],
801
- ): Promise<void> {
802
- const result = await callOPCUAMethodResult(session, objectPath, methodName, inputArguments);
803
- expect(result.statusCode.isGood()).toBe(true);
804
- expect(result.outputArguments?.[0]?.value).toBe(true);
805
- }
806
-
807
- async function callOPCUAMethodResult(
808
- session: ClientSession,
809
- objectPath: string,
810
- methodName: string,
811
- inputArguments: Array<{
812
- readonly dataType: DataType;
813
- readonly value: number | string | boolean;
814
- }> = [],
815
- ) {
816
- const result = await session.call({
817
- objectId: `ns=1;s=${objectPath}`,
818
- methodId: `ns=1;s=${objectPath}/${methodName}`,
819
- inputArguments,
820
- });
821
- return result;
822
- }
823
-
824
- async function collectProcessOutput(
825
- process: SmokeRuntimeHandle["process"],
826
- ): Promise<{ readonly stdout: string; readonly stderr: string }> {
827
- const cached = processOutputCache.get(process);
828
- if (cached) {
829
- return cached;
830
- }
831
-
832
- const pending = (async () => {
833
- const stdout = process.stdout ? await new Response(process.stdout).text() : "";
834
- const stderr = process.stderr ? await new Response(process.stderr).text() : "";
835
- return { stdout, stderr };
836
- })();
837
- processOutputCache.set(process, pending);
838
- return pending;
839
- }
840
-
841
- interface SmokeRuntimeOptions {
842
- readonly model?: TwinModel;
843
- readonly namespacePrefix?: string;
844
- readonly templateSettings?: {
845
- readonly tick_interval_ms?: number;
846
- readonly time_multiplier?: number;
847
- readonly seed?: number;
848
- };
849
- readonly rbe?: {
850
- readonly enabled: boolean;
851
- readonly deadband: number;
852
- };
853
- }
854
-
855
- interface CliPersistenceRuntimeHandle {
856
- readonly process: Bun.Subprocess<"ignore", "pipe", "pipe">;
857
- readonly tmpDir: string;
858
- readonly homeDir: string;
859
- readonly stateFilePath: string;
860
- readonly ready: SmokeRuntimeReady;
861
- }
862
-
863
- async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeRuntimeHandle> {
864
- const model = options?.model ?? createSmokePumpModel();
865
- const prefix = options?.namespacePrefix ?? "method-smoke";
866
- const tickIntervalMs = options?.templateSettings?.tick_interval_ms ?? 100;
867
- const timeMultiplier = options?.templateSettings?.time_multiplier ?? 1;
868
- const seed = options?.templateSettings?.seed ?? 42;
869
-
870
- const tmpDir = await mkdtemp(join(tmpdir(), `miravo-${prefix}-`));
871
- const modelsDir = join(tmpDir, "models");
872
- const templatePath = join(tmpDir, `${prefix}.miravo.yaml`);
873
- const modelPath = join(modelsDir, `${model.model.name}.twin.yaml`);
874
- const readyFile = join(tmpDir, "ready.json");
875
-
876
- await mkdir(modelsDir, { recursive: true });
877
- await Bun.write(modelPath, stringifyYAML(model));
878
- await Bun.write(
879
- templatePath,
880
- stringifyYAML({
881
- kind: "MiravoTemplate",
882
- version: "1",
883
- name: `${prefix} Template`,
884
- namespace: {
885
- prefix,
886
- },
887
- areas: [
888
- {
889
- name: "validation",
890
- lines: [
891
- {
892
- name: "protocols",
893
- twins: [
894
- {
895
- model: model.model.name,
896
- count: 1,
897
- variation: 0,
898
- },
899
- ],
900
- },
901
- ],
902
- },
903
- ],
904
- settings: {
905
- seed,
906
- tick_interval_ms: tickIntervalMs,
907
- time_multiplier: timeMultiplier,
908
- },
909
- }),
910
- );
911
-
912
- const cmd = [
913
- "bun",
914
- "run",
915
- resolve(import.meta.dir, "executable-methods-runner.ts"),
916
- "--template",
917
- templatePath,
918
- "--models-dir",
919
- modelsDir,
920
- "--ready-file",
921
- readyFile,
922
- ];
923
- if (options?.rbe?.enabled) {
924
- cmd.push("--rbe-enabled", "true", "--rbe-deadband", String(options.rbe.deadband));
925
- }
926
-
927
- const process = Bun.spawn({
928
- cmd,
929
- cwd: resolve("."),
930
- stdin: "ignore",
931
- stdout: "pipe",
932
- stderr: "pipe",
933
- });
934
-
935
- try {
936
- const ready = await Promise.race([
937
- waitForAsync(
938
- "smoke runtime ready file",
939
- async () => {
940
- if (!(await Bun.file(readyFile).exists())) {
941
- return undefined;
942
- }
943
- return (await Bun.file(readyFile).json()) as SmokeRuntimeReady;
944
- },
945
- 15000,
946
- ),
947
- process.exited.then((exitCode) => {
948
- throw new Error(`Smoke runtime exited before it reported ready (exit code ${exitCode}).`);
949
- }),
950
- ]);
951
-
952
- return { process, tmpDir, ready };
953
- } catch (error) {
954
- process.kill("SIGTERM");
955
- await process.exited;
956
- const output = await collectProcessOutput(process);
957
- await rm(tmpDir, { recursive: true, force: true });
958
- throw new Error(
959
- [
960
- `Failed to start smoke runtime: ${error instanceof Error ? error.message : String(error)}`,
961
- output.stdout.trim().length > 0 ? `stdout:\n${output.stdout.trim()}` : "",
962
- output.stderr.trim().length > 0 ? `stderr:\n${output.stderr.trim()}` : "",
963
- ]
964
- .filter(Boolean)
965
- .join("\n\n"),
966
- );
967
- }
968
- }
969
-
970
- async function createCliPersistenceRuntime(input: {
971
- readonly tmpDir?: string;
972
- readonly homeDir?: string;
973
- readonly templatePath?: string;
974
- readonly modelsDir?: string;
975
- readonly model?: TwinModel;
976
- readonly simulationName?: string;
977
- readonly includeOpcuaFlag?: boolean;
978
- readonly mqttPort?: number;
979
- readonly opcuaPort?: number;
980
- readonly adminPort?: number;
981
- }): Promise<CliPersistenceRuntimeHandle> {
982
- const model = input.model ?? createSmokePumpModel();
983
- const simulationName = input.simulationName ?? "persistence-e2e";
984
- const tmpDir = input.tmpDir ?? (await mkdtemp(join(tmpdir(), `miravo-${simulationName}-`)));
985
- const homeDir = input.homeDir ?? join(tmpDir, "home");
986
- const modelsDir = input.modelsDir ?? join(tmpDir, "models");
987
- const templatePath = input.templatePath ?? join(tmpDir, `${simulationName}.miravo.yaml`);
988
- const modelPath = join(modelsDir, `${model.model.name}.twin.yaml`);
989
- const stateFilePath = join(homeDir, "state", `${simulationName}.json`);
990
- const adminPort = input.adminPort ?? (await reservePort());
991
-
992
- await mkdir(modelsDir, { recursive: true });
993
- await mkdir(homeDir, { recursive: true });
994
-
995
- if (!(await Bun.file(modelPath).exists())) {
996
- await Bun.write(modelPath, stringifyYAML(model));
997
- }
998
- if (!(await Bun.file(templatePath).exists())) {
999
- await Bun.write(
1000
- templatePath,
1001
- stringifyYAML({
1002
- kind: "MiravoTemplate",
1003
- version: "1",
1004
- name: `${simulationName} Template`,
1005
- namespace: {
1006
- prefix: simulationName,
1007
- },
1008
- areas: [
1009
- {
1010
- name: "validation",
1011
- lines: [
1012
- {
1013
- name: "protocols",
1014
- twins: [
1015
- {
1016
- model: model.model.name,
1017
- count: 1,
1018
- variation: 0,
1019
- },
1020
- ],
1021
- },
1022
- ],
1023
- },
1024
- ],
1025
- settings: {
1026
- seed: 42,
1027
- tick_interval_ms: 100,
1028
- time_multiplier: 1,
1029
- },
1030
- }),
1031
- );
1032
- }
1033
-
1034
- const cmd = [
1035
- "bun",
1036
- "run",
1037
- resolve(import.meta.dir, "..", "..", "index.ts"),
1038
- "start",
1039
- "--template",
1040
- templatePath,
1041
- "--models-dir",
1042
- modelsDir,
1043
- "--name",
1044
- simulationName,
1045
- "--admin-host",
1046
- "127.0.0.1",
1047
- "--admin-port",
1048
- String(adminPort),
1049
- "--no-ui",
1050
- ];
1051
- if (input.mqttPort !== undefined) {
1052
- cmd.push("--mqtt-host", "127.0.0.1", "--mqtt-port", String(input.mqttPort));
1053
- }
1054
- if (input.includeOpcuaFlag === true) {
1055
- cmd.push("--opcua");
1056
- if (input.opcuaPort !== undefined) {
1057
- cmd.push("--opcua-port", String(input.opcuaPort));
1058
- }
1059
- cmd.push("--opcua-host", "127.0.0.1");
1060
- }
1061
-
1062
- const process = Bun.spawn({
1063
- cmd,
1064
- cwd: resolve("."),
1065
- stdin: "ignore",
1066
- stdout: "pipe",
1067
- stderr: "pipe",
1068
- env: {
1069
- ...Bun.env,
1070
- MIRAVO_HOME: homeDir,
1071
- },
1072
- });
1073
-
1074
- const adminUrl = `http://127.0.0.1:${adminPort}`;
1075
- try {
1076
- await Promise.race([
1077
- waitForAsync(
1078
- "CLI runtime ready",
1079
- async () => {
1080
- try {
1081
- const response = await fetch(new URL("/readyz", `${adminUrl}/`));
1082
- if (!response.ok) {
1083
- return undefined;
1084
- }
1085
- const payload = (await response.json()) as AdminHealthPayload;
1086
- return payload.status === "ok" ? payload : undefined;
1087
- } catch {
1088
- return undefined;
1089
- }
1090
- },
1091
- 15000,
1092
- ),
1093
- process.exited.then((exitCode) => {
1094
- throw new Error(`CLI runtime exited before it reported ready (exit code ${exitCode}).`);
1095
- }),
1096
- ]);
1097
-
1098
- const state = await readAdminJson(
1099
- {
1100
- process,
1101
- tmpDir,
1102
- ready: {
1103
- simulationName,
1104
- adminUrl,
1105
- mqttBrokerUrl: input.mqttPort ? `mqtt://127.0.0.1:${input.mqttPort}` : "",
1106
- opcuaEndpointUrl: input.opcuaPort ? `opc.tcp://127.0.0.1:${input.opcuaPort}` : "",
1107
- instanceId: "",
1108
- instancePath: "",
1109
- },
1110
- },
1111
- "/state",
1112
- EngineStateSnapshotSchema,
1113
- );
1114
- const instance = state.instances[0];
1115
- if (!instance) {
1116
- throw new Error("CLI runtime did not expose any instances after startup.");
1117
- }
1118
-
1119
- return {
1120
- process,
1121
- tmpDir,
1122
- homeDir,
1123
- stateFilePath,
1124
- ready: {
1125
- simulationName,
1126
- adminUrl,
1127
- mqttBrokerUrl:
1128
- input.mqttPort !== undefined ? `mqtt://127.0.0.1:${input.mqttPort}` : "mqtt://127.0.0.1",
1129
- opcuaEndpointUrl:
1130
- input.opcuaPort !== undefined
1131
- ? `opc.tcp://127.0.0.1:${input.opcuaPort}`
1132
- : "opc.tcp://127.0.0.1",
1133
- instanceId: instance.id,
1134
- instancePath: instance.browsePath.join("/"),
1135
- },
1136
- };
1137
- } catch (error) {
1138
- process.kill("SIGTERM");
1139
- await process.exited;
1140
- const output = await collectProcessOutput(process);
1141
- throw new Error(
1142
- [
1143
- `Failed to start CLI persistence runtime: ${error instanceof Error ? error.message : String(error)}`,
1144
- output.stdout.trim().length > 0 ? `stdout:\n${output.stdout.trim()}` : "",
1145
- output.stderr.trim().length > 0 ? `stderr:\n${output.stderr.trim()}` : "",
1146
- ]
1147
- .filter(Boolean)
1148
- .join("\n\n"),
1149
- );
1150
- }
1151
- }
1152
-
1153
- async function stopProcessGracefully(
1154
- process: SmokeRuntimeHandle["process"],
1155
- ): Promise<{ readonly stdout: string; readonly stderr: string }> {
1156
- process.kill("SIGTERM");
1157
- const exitResult = await Promise.race([
1158
- process.exited.then(() => "exited"),
1159
- Bun.sleep(5000).then(() => "timeout"),
1160
- ]);
1161
- if (exitResult === "timeout") {
1162
- process.kill("SIGKILL");
1163
- await process.exited;
1164
- }
1165
- return collectProcessOutput(process);
1166
- }
1167
-
1168
- async function stopSmokeRuntime(handle: SmokeRuntimeHandle): Promise<void> {
1169
- await stopProcessGracefully(handle.process);
1170
- await rm(handle.tmpDir, { recursive: true, force: true });
1171
- }
1172
-
1173
- async function stopSmokeRuntimeWithFailure(
1174
- handle: SmokeRuntimeHandle,
1175
- cause: unknown,
1176
- ): Promise<never> {
1177
- const output = await stopProcessGracefully(handle.process);
1178
- await rm(handle.tmpDir, { recursive: true, force: true });
1179
-
1180
- throw new Error(
1181
- [
1182
- cause instanceof Error ? cause.message : String(cause),
1183
- output.stdout.trim().length > 0 ? `runner stdout:\n${output.stdout.trim()}` : "",
1184
- output.stderr.trim().length > 0 ? `runner stderr:\n${output.stderr.trim()}` : "",
1185
- ]
1186
- .filter(Boolean)
1187
- .join("\n\n"),
1188
- );
1189
- }
1190
-
1191
- describeIfTcp("executable methods integration", () => {
1192
- test("CLI restart restores persisted state and restored adapters remain operational", async () => {
1193
- const simulationName = "persistence-e2e";
1194
- const mqttPort = await reservePort();
1195
- const opcuaPort = await reservePort();
1196
- const adminPort = await reservePort();
1197
- const restartAdminPort = await reservePort();
1198
- const firstRuntime = await createCliPersistenceRuntime({
1199
- simulationName,
1200
- includeOpcuaFlag: true,
1201
- mqttPort,
1202
- opcuaPort,
1203
- adminPort,
1204
- });
1205
- let restartRuntime: CliPersistenceRuntimeHandle | undefined;
1206
- let mqttClient: MqttClient | undefined;
1207
- let opcuaClient: ReturnType<typeof OPCUAClient.create> | undefined;
1208
- let session: ClientSession | undefined;
1209
- let firstRuntimeStopped = false;
1210
- let stage = "mutate first runtime";
1211
-
1212
- try {
1213
- const firstState = await readAdminJson(
1214
- {
1215
- process: firstRuntime.process,
1216
- tmpDir: firstRuntime.tmpDir,
1217
- ready: firstRuntime.ready,
1218
- },
1219
- "/state",
1220
- EngineStateSnapshotSchema,
1221
- );
1222
- const originalInstanceId = firstState.instances[0]?.id;
1223
- const originalInstancePath = firstState.instances[0]?.browsePath.join("/");
1224
- if (!originalInstanceId || !originalInstancePath) {
1225
- throw new Error("First runtime did not expose a boot instance.");
1226
- }
1227
-
1228
- stage = "mutate persisted runtime state";
1229
- expect(
1230
- (
1231
- await postAdminCommand(
1232
- {
1233
- process: firstRuntime.process,
1234
- tmpDir: firstRuntime.tmpDir,
1235
- ready: firstRuntime.ready,
1236
- },
1237
- {
1238
- type: "invokeMethod",
1239
- instanceId: originalInstanceId,
1240
- method: "SetTarget",
1241
- arguments: { target_value: 9.5 },
1242
- },
1243
- )
1244
- ).success,
1245
- ).toBe(true);
1246
- expect(
1247
- (
1248
- await postAdminCommand(
1249
- {
1250
- process: firstRuntime.process,
1251
- tmpDir: firstRuntime.tmpDir,
1252
- ready: firstRuntime.ready,
1253
- },
1254
- {
1255
- type: "triggerFault",
1256
- instanceId: originalInstanceId,
1257
- fault: "trip",
1258
- },
1259
- )
1260
- ).success,
1261
- ).toBe(true);
1262
- const spawnResult = await postAdminCommand(
1263
- {
1264
- process: firstRuntime.process,
1265
- tmpDir: firstRuntime.tmpDir,
1266
- ready: firstRuntime.ready,
1267
- },
1268
- {
1269
- type: "spawn",
1270
- model: "method-smoke-pump",
1271
- count: 1,
1272
- variation: 0,
1273
- unsBasePath: `${simulationName}/validation/protocols`,
1274
- },
1275
- );
1276
- expect(spawnResult.success).toBe(true);
1277
- const spawnedIds = Array.isArray(spawnResult.data)
1278
- ? spawnResult.data.filter((value): value is string => typeof value === "string")
1279
- : [];
1280
- expect(spawnedIds).toHaveLength(1);
1281
- const spawnedInstanceId = spawnedIds[0];
1282
- if (!spawnedInstanceId) {
1283
- throw new Error("Spawn command did not return the new instance ID.");
1284
- }
1285
-
1286
- const persistedStateBeforeRestart = await waitForAsync(
1287
- "persisted runtime state before restart",
1288
- async () => {
1289
- const state = await readAdminJson(
1290
- {
1291
- process: firstRuntime.process,
1292
- tmpDir: firstRuntime.tmpDir,
1293
- ready: firstRuntime.ready,
1294
- },
1295
- "/state",
1296
- EngineStateSnapshotSchema,
1297
- );
1298
- const original = state.instances.find((instance) => instance.id === originalInstanceId);
1299
- return state.instances.length === 2 && original?.activeFaults.includes("trip") === true
1300
- ? state
1301
- : undefined;
1302
- },
1303
- );
1304
- expect(
1305
- persistedStateBeforeRestart.instances.some((instance) => instance.id === spawnedInstanceId),
1306
- ).toBe(true);
1307
-
1308
- stage = "stop first runtime and inspect saved snapshot";
1309
- await stopProcessGracefully(firstRuntime.process);
1310
- firstRuntimeStopped = true;
1311
- const persistedSnapshot = PersistedStateSchema.parse(
1312
- await Bun.file(firstRuntime.stateFilePath).json(),
1313
- );
1314
- expect(persistedSnapshot.instances).toHaveLength(2);
1315
- expect(persistedSnapshot.adapters).toEqual(
1316
- expect.arrayContaining([
1317
- expect.objectContaining({ name: "mqtt", enabled: true }),
1318
- expect.objectContaining({ name: "opcua", enabled: true }),
1319
- ]),
1320
- );
1321
-
1322
- stage = "restart CLI against persisted snapshot";
1323
- restartRuntime = await createCliPersistenceRuntime({
1324
- tmpDir: firstRuntime.tmpDir,
1325
- homeDir: firstRuntime.homeDir,
1326
- templatePath: join(firstRuntime.tmpDir, `${simulationName}.miravo.yaml`),
1327
- modelsDir: join(firstRuntime.tmpDir, "models"),
1328
- simulationName,
1329
- adminPort: restartAdminPort,
1330
- });
1331
- const restartedRuntime = restartRuntime;
1332
-
1333
- const restoredState = await readAdminJson(
1334
- {
1335
- process: restartedRuntime.process,
1336
- tmpDir: restartedRuntime.tmpDir,
1337
- ready: restartedRuntime.ready,
1338
- },
1339
- "/state",
1340
- EngineStateSnapshotSchema,
1341
- );
1342
- expect(restoredState.instances).toHaveLength(2);
1343
- expect(restoredState.instances.map((instance) => instance.id).sort()).toEqual(
1344
- [originalInstanceId, spawnedInstanceId].sort(),
1345
- );
1346
- expect(
1347
- restoredState.instances.find((instance) => instance.id === originalInstanceId)
1348
- ?.activeFaults,
1349
- ).toEqual(["trip"]);
1350
- expect(
1351
- restoredState.availableAdapters?.map((adapter) => ({
1352
- name: adapter.name,
1353
- enabled: adapter.enabled,
1354
- })),
1355
- ).toEqual(
1356
- expect.arrayContaining([
1357
- { name: "mqtt", enabled: true },
1358
- { name: "opcua", enabled: true },
1359
- ]),
1360
- );
1361
-
1362
- stage = "verify restored OPC UA state";
1363
- opcuaClient = OPCUAClient.create({
1364
- securityMode: MessageSecurityMode.None,
1365
- securityPolicy: SecurityPolicy.None,
1366
- endpointMustExist: false,
1367
- });
1368
- await opcuaClient.connect(`opc.tcp://127.0.0.1:${opcuaPort}`);
1369
- session = await opcuaClient.createSession();
1370
- expect(
1371
- await waitForOPCUAValue<number>(
1372
- session,
1373
- `ns=1;s=${originalInstancePath}/parameter_echo`,
1374
- 9.5,
1375
- ),
1376
- ).toBe(9.5);
1377
- expect(
1378
- await waitForOPCUAValue<string>(
1379
- session,
1380
- `ns=1;s=${originalInstancePath}/active_fault_code`,
1381
- "trip",
1382
- ),
1383
- ).toBe("trip");
1384
-
1385
- stage = "verify restored MQTT control plane";
1386
- mqttClient = connect(`mqtt://127.0.0.1:${mqttPort}`, { reconnectPeriod: 0 });
1387
- await waitForMQTTConnect(mqttClient);
1388
- const resultTopic = controlTopicCmdResult(simulationName);
1389
- await subscribeMQTT(mqttClient, [`${originalInstancePath}/#`, resultTopic]);
1390
- const recorder = createMQTTRecorder(mqttClient);
1391
- try {
1392
- const resetResult = await publishControlCommand(mqttClient, recorder, simulationName, {
1393
- type: "invokeMethod",
1394
- instanceId: originalInstanceId,
1395
- method: "ResetFault",
1396
- } satisfies InvokeMethodCommandPayload);
1397
- expect(resetResult.success).toBe(true);
1398
- expect(
1399
- await waitForMQTTTopicValue(recorder, `${originalInstancePath}/fault_active`, false),
1400
- ).toBe(false);
1401
- const clearedState = await waitForAsync("cleared restored fault state", async () => {
1402
- const state = await readAdminJson(
1403
- {
1404
- process: restartedRuntime.process,
1405
- tmpDir: restartedRuntime.tmpDir,
1406
- ready: restartedRuntime.ready,
1407
- },
1408
- "/state",
1409
- EngineStateSnapshotSchema,
1410
- );
1411
- const original = state.instances.find((instance) => instance.id === originalInstanceId);
1412
- return original?.activeFaults.length === 0 ? state : undefined;
1413
- });
1414
- expect(
1415
- clearedState.instances.find((instance) => instance.id === originalInstanceId)
1416
- ?.activeFaults,
1417
- ).toEqual([]);
1418
- } finally {
1419
- recorder.dispose();
1420
- }
1421
-
1422
- if (session) {
1423
- await session.close();
1424
- session = undefined;
1425
- }
1426
- await opcuaClient.disconnect();
1427
- opcuaClient = undefined;
1428
- await stopMQTTClient(mqttClient);
1429
- mqttClient = undefined;
1430
- await stopProcessGracefully(restartRuntime.process);
1431
- await rm(firstRuntime.tmpDir, { recursive: true, force: true });
1432
- } catch (error) {
1433
- if (session) {
1434
- await session.close().catch(() => {});
1435
- }
1436
- await opcuaClient?.disconnect().catch(() => {});
1437
- if (mqttClient) {
1438
- await stopMQTTClient(mqttClient).catch(() => {});
1439
- }
1440
- if (!firstRuntimeStopped) {
1441
- await stopProcessGracefully(firstRuntime.process).catch(() => ({
1442
- stdout: "",
1443
- stderr: "",
1444
- }));
1445
- }
1446
- if (restartRuntime) {
1447
- const output = await stopProcessGracefully(restartRuntime.process).catch(() => ({
1448
- stdout: "",
1449
- stderr: "",
1450
- }));
1451
- await rm(restartRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
1452
- throw new Error(
1453
- [
1454
- `Persistence restart integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1455
- output.stdout.trim().length > 0 ? `restart stdout:\n${output.stdout.trim()}` : "",
1456
- output.stderr.trim().length > 0 ? `restart stderr:\n${output.stderr.trim()}` : "",
1457
- ]
1458
- .filter(Boolean)
1459
- .join("\n\n"),
1460
- );
1461
- }
1462
- await rm(firstRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
1463
- throw new Error(
1464
- `Persistence restart integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1465
- );
1466
- }
1467
- }, 45000);
1468
-
1469
- test("admin HTTP server exposes runtime state, executes core commands, and drives published twin behavior", async () => {
1470
- const runtime = await createSmokeRuntime();
1471
- let client: MqttClient | undefined;
1472
- let stage = "connect MQTT observer";
1473
-
1474
- try {
1475
- const { ready } = runtime;
1476
- const topicPrefix = ready.instancePath;
1477
- client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
1478
-
1479
- await waitForMQTTConnect(client);
1480
- await subscribeMQTT(client, [`${topicPrefix}/#`]);
1481
-
1482
- const recorder = createMQTTRecorder(client);
1483
- try {
1484
- stage = "read admin health and readiness";
1485
- const health = await readAdminHealth(runtime, "/healthz");
1486
- const readyz = await readAdminHealth(runtime, "/readyz");
1487
- expect(health.response.status).toBe(200);
1488
- expect(health.payload.status).toBe("ok");
1489
- expect(readyz.response.status).toBe(200);
1490
- expect(readyz.payload.status).toBe("ok");
1491
-
1492
- stage = "read admin metrics and state";
1493
- const initialMetrics = await readAdminJson(
1494
- runtime,
1495
- "/metrics",
1496
- EngineMetricsSnapshotSchema,
1497
- );
1498
- const initialState = await readAdminJson(runtime, "/state", EngineStateSnapshotSchema);
1499
- expect(initialMetrics.simulationName).toBe(ready.simulationName);
1500
- expect(initialMetrics.core.state).toBe("running");
1501
- expect(initialState.simulationName).toBe(ready.simulationName);
1502
- expect(initialState.instances).toHaveLength(1);
1503
- expect(initialState.config.adapters).toContain("mqtt");
1504
- expect(initialState.config.adapters).toContain("opcua");
1505
-
1506
- stage = "reject malformed admin command";
1507
- const invalidResponse = await fetch(new URL("/commands", `${ready.adminUrl}/`), {
1508
- method: "POST",
1509
- headers: { "content-type": "application/json" },
1510
- body: JSON.stringify({ type: "setSpeed" }),
1511
- });
1512
- expect(invalidResponse.status).toBe(400);
1513
-
1514
- stage = "invoke method through admin server";
1515
- const startResult = await postAdminCommand(runtime, {
1516
- type: "invokeMethod",
1517
- instanceId: ready.instanceId,
1518
- method: "Start",
1519
- });
1520
- expect(startResult.success).toBe(true);
1521
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/run_command`, true)).toBe(
1522
- true,
1523
- );
1524
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 1200)).toBe(
1525
- 1200,
1526
- );
1527
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/running`, true)).toBe(true);
1528
-
1529
- stage = "pause and resume through admin server";
1530
- const pauseResult = await postAdminCommand(runtime, { type: "pause" });
1531
- expect(pauseResult.success).toBe(true);
1532
- expect((await waitForAdminMetricsState(runtime, "paused")).core.state).toBe("paused");
1533
- const pausedReady = await readAdminHealth(runtime, "/readyz");
1534
- expect(pausedReady.response.status).toBe(503);
1535
- expect(pausedReady.payload.status).toBe("fail");
1536
-
1537
- const resumeResult = await postAdminCommand(runtime, { type: "resume" });
1538
- expect(resumeResult.success).toBe(true);
1539
- expect((await waitForAdminMetricsState(runtime, "running")).core.state).toBe("running");
1540
- const resumedReady = await readAdminHealth(runtime, "/readyz");
1541
- expect(resumedReady.response.status).toBe(200);
1542
- expect(resumedReady.payload.status).toBe("ok");
1543
-
1544
- stage = "change simulation speed through admin server";
1545
- const speedResult = await postAdminCommand(runtime, { type: "setSpeed", multiplier: 5 });
1546
- expect(speedResult.success).toBe(true);
1547
- const updatedState = await waitForAsync("admin time multiplier 5", async () => {
1548
- const state = await readAdminJson(runtime, "/state", EngineStateSnapshotSchema);
1549
- return state.config.timeMultiplier === 5 ? state : undefined;
1550
- });
1551
- expect(updatedState.config.timeMultiplier).toBe(5);
1552
-
1553
- stage = "spawn and remove instances through admin server";
1554
- const spawnResult = await postAdminCommand(runtime, {
1555
- type: "spawn",
1556
- model: "method-smoke-pump",
1557
- count: 1,
1558
- variation: 0,
1559
- unsBasePath: "method-smoke/validation/protocols",
1560
- });
1561
- expect(spawnResult.success).toBe(true);
1562
- const spawnedIds = Array.isArray(spawnResult.data)
1563
- ? spawnResult.data.filter((value): value is string => typeof value === "string")
1564
- : [];
1565
- expect(spawnedIds).toHaveLength(1);
1566
- const spawnedState = await waitForAdminInstanceCount(runtime, 2);
1567
- expect(spawnedState.instances.some((instance) => instance.id === spawnedIds[0])).toBe(true);
1568
-
1569
- const removeResult = await postAdminCommand(runtime, {
1570
- type: "removeInstance",
1571
- instanceId: spawnedIds[0],
1572
- });
1573
- expect(removeResult.success).toBe(true);
1574
- const removedState = await waitForAdminInstanceCount(runtime, 1);
1575
- expect(removedState.instances.some((instance) => instance.id === spawnedIds[0])).toBe(
1576
- false,
1577
- );
1578
- } finally {
1579
- recorder.dispose();
1580
- }
1581
-
1582
- await stopMQTTClient(client);
1583
- await stopSmokeRuntime(runtime);
1584
- } catch (error) {
1585
- if (client) {
1586
- await stopMQTTClient(client).catch(() => {});
1587
- }
1588
- await stopSmokeRuntimeWithFailure(
1589
- runtime,
1590
- new Error(
1591
- `Admin integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1592
- ),
1593
- );
1594
- }
1595
- }, 30000);
1596
-
1597
- test("mqtt.js invokes methods through the control plane and template-backed twin topics reflect the result", async () => {
1598
- const runtime = await createSmokeRuntime();
1599
- let client: MqttClient | undefined;
1600
- let stage = "connect MQTT client";
1601
-
1602
- try {
1603
- const { ready } = runtime;
1604
- const cmdResultTopic = controlTopicCmdResult(ready.simulationName);
1605
- const topicPrefix = ready.instancePath;
1606
- client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
1607
-
1608
- await waitForMQTTConnect(client);
1609
- await subscribeMQTT(client, [`${topicPrefix}/#`, cmdResultTopic]);
1610
-
1611
- const recorder = createMQTTRecorder(client);
1612
- try {
1613
- // MQTT now uses a delta-driven publish pipeline. With retain disabled in this
1614
- // smoke runtime, a late subscriber is only guaranteed to observe values that
1615
- // change after it subscribes. The test therefore validates post-subscription
1616
- // command-driven changes rather than initial state replay.
1617
-
1618
- stage = "start asset through MQTT";
1619
- const startResult = await publishControlCommand(client, recorder, ready.simulationName, {
1620
- type: "invokeMethod",
1621
- instanceId: ready.instanceId,
1622
- method: "Start",
1623
- } satisfies InvokeMethodCommandPayload);
1624
- expect(startResult.success).toBe(true);
1625
- expect(
1626
- (startResult.data as { readonly outputs?: { readonly accepted?: boolean } }).outputs
1627
- ?.accepted,
1628
- ).toBe(true);
1629
-
1630
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/run_command`, true)).toBe(
1631
- true,
1632
- );
1633
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 1200)).toBe(
1634
- 1200,
1635
- );
1636
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/flow_rate`, 24)).toBe(24);
1637
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/running`, true)).toBe(true);
1638
- expect(
1639
- await waitForMQTTTopicValue(recorder, `${topicPrefix}/availability_state`, "running"),
1640
- ).toBe("running");
1641
-
1642
- stage = "set speed through MQTT";
1643
- const setSpeedResult = await publishControlCommand(client, recorder, ready.simulationName, {
1644
- type: "invokeMethod",
1645
- instanceId: ready.instanceId,
1646
- method: "SetSpeed",
1647
- arguments: { speed_rpm: 800 },
1648
- } satisfies InvokeMethodCommandPayload);
1649
- expect(setSpeedResult.success).toBe(true);
1650
-
1651
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_command`, 800)).toBe(
1652
- 800,
1653
- );
1654
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 800)).toBe(
1655
- 800,
1656
- );
1657
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/flow_rate`, 16)).toBe(16);
1658
-
1659
- stage = "reject invalid MQTT method invocation";
1660
- const invalidSetSpeedResult = await publishControlCommand(
1661
- client,
1662
- recorder,
1663
- ready.simulationName,
1664
- {
1665
- type: "invokeMethod",
1666
- instanceId: ready.instanceId,
1667
- method: "SetSpeed",
1668
- } satisfies InvokeMethodCommandPayload,
1669
- );
1670
- expect(invalidSetSpeedResult.success).toBe(false);
1671
- expect(
1672
- (invalidSetSpeedResult.data as { readonly outputs?: { readonly accepted?: boolean } })
1673
- .outputs?.accepted,
1674
- ).toBe(false);
1675
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_command`, 800)).toBe(
1676
- 800,
1677
- );
1678
-
1679
- stage = "trigger fault through control plane";
1680
- const triggerFaultResult = await publishControlCommand(
1681
- client,
1682
- recorder,
1683
- ready.simulationName,
1684
- {
1685
- type: "triggerFault",
1686
- instanceId: ready.instanceId,
1687
- fault: "trip",
1688
- },
1689
- );
1690
- expect(triggerFaultResult.success).toBe(true);
1691
-
1692
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_active`, true)).toBe(
1693
- true,
1694
- );
1695
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_count`, 1)).toBe(1);
1696
- expect(
1697
- await waitForMQTTTopicValue(recorder, `${topicPrefix}/active_fault_code`, "trip"),
1698
- ).toBe("trip");
1699
-
1700
- stage = "reset fault through MQTT";
1701
- const resetFaultResult = await publishControlCommand(
1702
- client,
1703
- recorder,
1704
- ready.simulationName,
1705
- {
1706
- type: "invokeMethod",
1707
- instanceId: ready.instanceId,
1708
- method: "ResetFault",
1709
- } satisfies InvokeMethodCommandPayload,
1710
- );
1711
- expect(resetFaultResult.success).toBe(true);
1712
-
1713
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_active`, false)).toBe(
1714
- false,
1715
- );
1716
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_count`, 0)).toBe(0);
1717
- expect(
1718
- await waitForMQTTTopicValue(recorder, `${topicPrefix}/active_fault_code`, "none"),
1719
- ).toBe("none");
1720
-
1721
- stage = "set parameter through MQTT";
1722
- const setTargetResult = await publishControlCommand(
1723
- client,
1724
- recorder,
1725
- ready.simulationName,
1726
- {
1727
- type: "invokeMethod",
1728
- instanceId: ready.instanceId,
1729
- method: "SetTarget",
1730
- arguments: { target_value: 9.5 },
1731
- } satisfies InvokeMethodCommandPayload,
1732
- );
1733
- expect(setTargetResult.success).toBe(true);
1734
-
1735
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/parameter_echo`, 9.5)).toBe(
1736
- 9.5,
1737
- );
1738
-
1739
- stage = "stop asset through MQTT";
1740
- const stopResult = await publishControlCommand(client, recorder, ready.simulationName, {
1741
- type: "invokeMethod",
1742
- instanceId: ready.instanceId,
1743
- method: "Stop",
1744
- } satisfies InvokeMethodCommandPayload);
1745
- expect(stopResult.success).toBe(true);
1746
-
1747
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/run_command`, false)).toBe(
1748
- false,
1749
- );
1750
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 0)).toBe(0);
1751
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/flow_rate`, 0)).toBe(0);
1752
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/running`, false)).toBe(false);
1753
- expect(
1754
- await waitForMQTTTopicValue(recorder, `${topicPrefix}/availability_state`, "stopped"),
1755
- ).toBe("stopped");
1756
- } finally {
1757
- recorder.dispose();
1758
- }
1759
- await stopMQTTClient(client);
1760
- await stopSmokeRuntime(runtime);
1761
- } catch (error) {
1762
- if (client) {
1763
- await stopMQTTClient(client).catch(() => {});
1764
- }
1765
- await stopSmokeRuntimeWithFailure(
1766
- runtime,
1767
- new Error(
1768
- `MQTT integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1769
- ),
1770
- );
1771
- }
1772
- }, 30000);
1773
-
1774
- test("OPC UA method calls update template-backed runtime state and exposed variable values", async () => {
1775
- const runtime = await createSmokeRuntime();
1776
- let mqttClient: MqttClient | undefined;
1777
- let opcuaClient: ReturnType<typeof OPCUAClient.create> | undefined;
1778
- let session: ClientSession | undefined;
1779
- let stage = "connect OPC UA client";
1780
-
1781
- try {
1782
- const { ready } = runtime;
1783
- const objectPath = ready.instancePath;
1784
- mqttClient = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
1785
- opcuaClient = OPCUAClient.create({
1786
- securityMode: MessageSecurityMode.None,
1787
- securityPolicy: SecurityPolicy.None,
1788
- endpointMustExist: false,
1789
- });
1790
-
1791
- await waitForMQTTConnect(mqttClient);
1792
- await subscribeMQTT(mqttClient, [controlTopicCmdResult(ready.simulationName)]);
1793
- const recorder = createMQTTRecorder(mqttClient);
1794
- try {
1795
- await opcuaClient.connect(ready.opcuaEndpointUrl);
1796
- session = await opcuaClient.createSession();
1797
-
1798
- stage = "verify startup publication through OPC UA";
1799
- expect(
1800
- await waitForOPCUAValue<string>(
1801
- session,
1802
- `ns=1;s=${objectPath}/manufacturer`,
1803
- "Miravo Labs",
1804
- ),
1805
- ).toBe("Miravo Labs");
1806
- expect(
1807
- await waitForOPCUAValue<string>(
1808
- session,
1809
- `ns=1;s=${objectPath}/serial_number`,
1810
- "SMOKE-001",
1811
- ),
1812
- ).toBe("SMOKE-001");
1813
- expect(
1814
- await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/running`, false),
1815
- ).toBe(false);
1816
- expect(
1817
- await waitForOPCUAValue<string>(
1818
- session,
1819
- `ns=1;s=${objectPath}/availability_state`,
1820
- "stopped",
1821
- ),
1822
- ).toBe("stopped");
1823
-
1824
- stage = "start asset through OPC UA";
1825
- await callOPCUAMethod(session, objectPath, "Start");
1826
-
1827
- expect(
1828
- await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/run_command`, true),
1829
- ).toBe(true);
1830
- expect(
1831
- await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_feedback`, 1200),
1832
- ).toBe(1200);
1833
- expect(await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/flow_rate`, 24)).toBe(
1834
- 24,
1835
- );
1836
- expect(
1837
- await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/running`, true),
1838
- ).toBe(true);
1839
- expect(
1840
- await waitForOPCUAValue<string>(
1841
- session,
1842
- `ns=1;s=${objectPath}/manufacturer`,
1843
- "Miravo Labs",
1844
- ),
1845
- ).toBe("Miravo Labs");
1846
-
1847
- stage = "set speed through OPC UA";
1848
- await callOPCUAMethod(session, objectPath, "SetSpeed", [
1849
- { dataType: DataType.Double, value: 800 },
1850
- ]);
1851
-
1852
- expect(
1853
- await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_command`, 800),
1854
- ).toBe(800);
1855
- expect(
1856
- await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_feedback`, 800),
1857
- ).toBe(800);
1858
- expect(await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/flow_rate`, 16)).toBe(
1859
- 16,
1860
- );
1861
-
1862
- stage = "reject invalid OPC UA method invocation";
1863
- const invalidSetSpeedResult = await callOPCUAMethodResult(session, objectPath, "SetSpeed");
1864
- expect(invalidSetSpeedResult.statusCode.isGood()).toBe(false);
1865
- expect(
1866
- await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_command`, 800),
1867
- ).toBe(800);
1868
-
1869
- stage = "trigger fault through control plane";
1870
- const triggerFaultResult = await publishControlCommand(
1871
- mqttClient,
1872
- recorder,
1873
- ready.simulationName,
1874
- {
1875
- type: "triggerFault",
1876
- instanceId: ready.instanceId,
1877
- fault: "trip",
1878
- },
1879
- );
1880
- expect(triggerFaultResult.success).toBe(true);
1881
-
1882
- expect(
1883
- await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/fault_active`, true),
1884
- ).toBe(true);
1885
- expect(
1886
- await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/fault_count`, 1),
1887
- ).toBe(1);
1888
- expect(
1889
- await waitForOPCUAValue<string>(
1890
- session,
1891
- `ns=1;s=${objectPath}/active_fault_code`,
1892
- "trip",
1893
- ),
1894
- ).toBe("trip");
1895
- expect(
1896
- await waitForOPCUAValue<string>(
1897
- session,
1898
- `ns=1;s=${objectPath}/manufacturer`,
1899
- "Miravo Labs",
1900
- ),
1901
- ).toBe("Miravo Labs");
1902
-
1903
- stage = "reset fault through OPC UA";
1904
- await callOPCUAMethod(session, objectPath, "ResetFault");
1905
-
1906
- expect(
1907
- await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/fault_active`, false),
1908
- ).toBe(false);
1909
- expect(
1910
- await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/fault_count`, 0),
1911
- ).toBe(0);
1912
- expect(
1913
- await waitForOPCUAValue<string>(
1914
- session,
1915
- `ns=1;s=${objectPath}/active_fault_code`,
1916
- "none",
1917
- ),
1918
- ).toBe("none");
1919
-
1920
- stage = "set parameter through OPC UA";
1921
- await callOPCUAMethod(session, objectPath, "SetTarget", [
1922
- { dataType: DataType.Double, value: 9.5 },
1923
- ]);
1924
-
1925
- expect(
1926
- await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/parameter_echo`, 9.5),
1927
- ).toBe(9.5);
1928
-
1929
- stage = "stop asset through OPC UA";
1930
- await callOPCUAMethod(session, objectPath, "Stop");
1931
-
1932
- expect(
1933
- await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/run_command`, false),
1934
- ).toBe(false);
1935
- expect(
1936
- await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_feedback`, 0),
1937
- ).toBe(0);
1938
- expect(await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/flow_rate`, 0)).toBe(
1939
- 0,
1940
- );
1941
- expect(
1942
- await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/running`, false),
1943
- ).toBe(false);
1944
- expect(
1945
- await waitForOPCUAValue<string>(
1946
- session,
1947
- `ns=1;s=${objectPath}/availability_state`,
1948
- "stopped",
1949
- ),
1950
- ).toBe("stopped");
1951
- expect(
1952
- await waitForOPCUAValue<string>(
1953
- session,
1954
- `ns=1;s=${objectPath}/manufacturer`,
1955
- "Miravo Labs",
1956
- ),
1957
- ).toBe("Miravo Labs");
1958
- } finally {
1959
- recorder.dispose();
1960
- }
1961
- if (session) {
1962
- await session.close();
1963
- }
1964
- await opcuaClient.disconnect();
1965
- await stopMQTTClient(mqttClient);
1966
- await stopSmokeRuntime(runtime);
1967
- } catch (error) {
1968
- if (session) {
1969
- await session.close().catch(() => {});
1970
- }
1971
- await opcuaClient?.disconnect().catch(() => {});
1972
- if (mqttClient) {
1973
- await stopMQTTClient(mqttClient).catch(() => {});
1974
- }
1975
- await stopSmokeRuntimeWithFailure(
1976
- runtime,
1977
- new Error(
1978
- `OPC UA integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1979
- ),
1980
- );
1981
- }
1982
- }, 30000);
1983
-
1984
- test("lifecycle effects apply multiplier-then-offset and fleet metrics track stage transitions", async () => {
1985
- // time_multiplier=60: each 100ms tick = 0.00167 simulated hours.
1986
- // healthy: 2h (~1200 ticks / 120s), degraded: 1h (~600 ticks / 60s), critical: terminal.
1987
- // Must be slow enough for the test to connect MQTT and observe healthy before
1988
- // it transitions, but fast enough to reach critical within the 30s test timeout.
1989
- // With 60× multiplier, healthy lasts ~6s (enough to observe) and the full
1990
- // healthy→degraded→critical path completes in ~12s including ramp blends.
1991
- const runtime = await createSmokeRuntime({
1992
- model: createLifecycleTestModel(),
1993
- namespacePrefix: "lifecycle-test",
1994
- templateSettings: { tick_interval_ms: 100, time_multiplier: 60, seed: 42 },
1995
- });
1996
- let client: MqttClient | undefined;
1997
- let stage = "connect MQTT observer";
1998
-
1999
- try {
2000
- const { ready } = runtime;
2001
- const topicPrefix = ready.instancePath;
2002
- client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
2003
- await waitForMQTTConnect(client);
2004
- await subscribeMQTT(client, [`${topicPrefix}/#`]);
2005
-
2006
- const recorder = createMQTTRecorder(client);
2007
- try {
2008
- // The scheduler has been ticking since engine.start(). With fast
2009
- // time_multiplier the lifecycle may have already advanced past healthy
2010
- // by the time we connect. Instead of racing to catch each stage,
2011
- // verify that lifecycle reaches critical and that the pressure value
2012
- // matches the critical-stage effect (multiplier 0.5, offset -20):
2013
- // pressure = 100 * 0.5 - 20 = 30.
2014
- stage = "wait for critical stage (pressure = 100 * 0.5 - 20 = 30)";
2015
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 30, 15000)).toBe(
2016
- 30,
2017
- );
2018
-
2019
- const criticalMetrics = await waitForAsync(
2020
- "fleet lifecycle critical",
2021
- async () => {
2022
- const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
2023
- return m.fleet.lifecycleBreakdown["critical"] === 1 ? m : undefined;
2024
- },
2025
- 15000,
2026
- );
2027
- expect(criticalMetrics.fleet.lifecycleBreakdown["critical"]).toBe(1);
2028
- // Verify lifecycle effects applied correctly: multiplier then offset
2029
- expect(criticalMetrics.fleet.activeFaultCount).toBe(0);
2030
- } finally {
2031
- recorder.dispose();
2032
- }
2033
-
2034
- await stopMQTTClient(client);
2035
- await stopSmokeRuntime(runtime);
2036
- } catch (error) {
2037
- if (client) {
2038
- await stopMQTTClient(client).catch(() => {});
2039
- }
2040
- await stopSmokeRuntimeWithFailure(
2041
- runtime,
2042
- new Error(
2043
- `Lifecycle integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
2044
- ),
2045
- );
2046
- }
2047
- }, 30000);
2048
-
2049
- test("condition-based faults auto-trigger when member exceeds threshold and quality reflects fault state", async () => {
2050
- const runtime = await createSmokeRuntime({
2051
- model: createConditionalFaultTestModel(),
2052
- namespacePrefix: "fault-test",
2053
- templateSettings: { seed: 42 },
2054
- });
2055
- let client: MqttClient | undefined;
2056
- let stage = "connect MQTT client";
2057
-
2058
- try {
2059
- const { ready } = runtime;
2060
- const topicPrefix = ready.instancePath;
2061
- const cmdResultTopic = controlTopicCmdResult(ready.simulationName);
2062
- client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
2063
- await waitForMQTTConnect(client);
2064
- await subscribeMQTT(client, [`${topicPrefix}/#`, cmdResultTopic]);
2065
-
2066
- const recorder = createMQTTRecorder(client);
2067
- try {
2068
- stage = "set speed to trigger overpressure (pressure = 1000 * 0.1 = 100 > 80)";
2069
- const setSpeedResult = await publishControlCommand(client, recorder, ready.simulationName, {
2070
- type: "invokeMethod",
2071
- instanceId: ready.instanceId,
2072
- method: "SetSpeed",
2073
- arguments: { speed: 1000 },
2074
- } satisfies InvokeMethodCommandPayload);
2075
- expect(setSpeedResult.success).toBe(true);
2076
-
2077
- // Fault triggers on next tick after condition evaluates against pressure=100.
2078
- // Fault effect: pressure = 100 * 0.5 = 50.
2079
- stage = "verify fault auto-triggered";
2080
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_active`, true)).toBe(
2081
- true,
2082
- );
2083
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_count`, 1)).toBe(1);
2084
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 50)).toBe(50);
2085
-
2086
- stage = "verify quality stays good during active fault (faults change plant, not sensor)";
2087
- expect(await waitForMQTTTopicQuality(recorder, `${topicPrefix}/pressure`, "good")).toBe(
2088
- "good",
2089
- );
2090
-
2091
- // Verify fleet metrics show active fault
2092
- const faultMetrics = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
2093
- expect(faultMetrics.fleet.activeFaultCount).toBe(1);
2094
- expect(faultMetrics.fleet.faultBreakdown).toContainEqual({
2095
- fault: "overpressure",
2096
- count: 1,
2097
- });
2098
-
2099
- // Lower speed below threshold BEFORE clearing to prevent re-trigger.
2100
- // speed=500 → pressure=50 (with fault: 50*0.5=25, without: 50).
2101
- stage = "lower speed and clear fault";
2102
- const lowerSpeedResult = await publishControlCommand(
2103
- client,
2104
- recorder,
2105
- ready.simulationName,
2106
- {
2107
- type: "invokeMethod",
2108
- instanceId: ready.instanceId,
2109
- method: "SetSpeed",
2110
- arguments: { speed: 500 },
2111
- } satisfies InvokeMethodCommandPayload,
2112
- );
2113
- expect(lowerSpeedResult.success).toBe(true);
2114
-
2115
- const resetResult = await publishControlCommand(client, recorder, ready.simulationName, {
2116
- type: "invokeMethod",
2117
- instanceId: ready.instanceId,
2118
- method: "ResetFault",
2119
- } satisfies InvokeMethodCommandPayload);
2120
- expect(resetResult.success).toBe(true);
2121
-
2122
- // After fault cleared: pressure = 500 * 0.1 = 50, condition 50>80 = false.
2123
- stage = "verify fault cleared and quality recovered";
2124
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_active`, false)).toBe(
2125
- false,
2126
- );
2127
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 50)).toBe(50);
2128
- expect(await waitForMQTTTopicQuality(recorder, `${topicPrefix}/pressure`, "good")).toBe(
2129
- "good",
2130
- );
2131
-
2132
- // Fleet metrics should show zero active faults
2133
- const clearedMetrics = await waitForAsync("fleet fault count 0", async () => {
2134
- const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
2135
- return m.fleet.activeFaultCount === 0 ? m : undefined;
2136
- });
2137
- expect(clearedMetrics.fleet.activeFaultCount).toBe(0);
2138
- } finally {
2139
- recorder.dispose();
2140
- }
2141
-
2142
- await stopMQTTClient(client);
2143
- await stopSmokeRuntime(runtime);
2144
- } catch (error) {
2145
- if (client) {
2146
- await stopMQTTClient(client).catch(() => {});
2147
- }
2148
- await stopSmokeRuntimeWithFailure(
2149
- runtime,
2150
- new Error(
2151
- `Fault integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
2152
- ),
2153
- );
2154
- }
2155
- }, 30000);
2156
-
2157
- test("RBE filtering suppresses redundant MQTT messages within deadband", async () => {
2158
- const runtime = await createSmokeRuntime({
2159
- rbe: { enabled: true, deadband: 50 },
2160
- templateSettings: { seed: 42 },
2161
- });
2162
- let client: MqttClient | undefined;
2163
- let stage = "connect MQTT observer";
2164
-
2165
- try {
2166
- const { ready } = runtime;
2167
- const topicPrefix = ready.instancePath;
2168
- client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
2169
- await waitForMQTTConnect(client);
2170
- await subscribeMQTT(client, [`${topicPrefix}/#`]);
2171
-
2172
- const recorder = createMQTTRecorder(client);
2173
- try {
2174
- stage = "start pump to establish RBE baseline";
2175
- const startResult = await postAdminCommand(runtime, {
2176
- type: "invokeMethod",
2177
- instanceId: ready.instanceId,
2178
- method: "Start",
2179
- });
2180
- expect(startResult.success).toBe(true);
2181
-
2182
- // speed_feedback=1200 should be published (|1200 - 0| > 50).
2183
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 1200)).toBe(
2184
- 1200,
2185
- );
2186
-
2187
- const baselineMetrics = await readAdminJson(
2188
- runtime,
2189
- "/metrics",
2190
- EngineMetricsSnapshotSchema,
2191
- );
2192
- const baselineMqtt = baselineMetrics.adapters.find((a) => a.name === "mqtt")?.metrics;
2193
- expect(baselineMqtt).toBeDefined();
2194
- const filteredBefore = baselineMqtt?.messagesFiltered ?? 0;
2195
- const publishedBefore = baselineMqtt?.messagesPublished ?? 0;
2196
-
2197
- stage = "set speed within RBE deadband";
2198
- const smallChangeResult = await postAdminCommand(runtime, {
2199
- type: "invokeMethod",
2200
- instanceId: ready.instanceId,
2201
- method: "SetSpeed",
2202
- arguments: { speed_rpm: 1230 },
2203
- });
2204
- expect(smallChangeResult.success).toBe(true);
2205
-
2206
- const filteredMetrics = await waitForAsync("messagesFiltered increased", async () => {
2207
- const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
2208
- const mqtt = m.adapters.find((a) => a.name === "mqtt");
2209
- return mqtt && mqtt.metrics.messagesFiltered > filteredBefore ? mqtt.metrics : undefined;
2210
- });
2211
- expect(filteredMetrics.messagesFiltered).toBeGreaterThan(filteredBefore);
2212
- expect(
2213
- readPayloadValue(recorder.latestByTopic.get(`${topicPrefix}/speed_feedback`)?.parsed),
2214
- ).toBe(1200);
2215
-
2216
- stage = "set speed beyond RBE deadband";
2217
- const largeChangeResult = await postAdminCommand(runtime, {
2218
- type: "invokeMethod",
2219
- instanceId: ready.instanceId,
2220
- method: "SetSpeed",
2221
- arguments: { speed_rpm: 1300 },
2222
- });
2223
- expect(largeChangeResult.success).toBe(true);
2224
-
2225
- expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 1300)).toBe(
2226
- 1300,
2227
- );
2228
-
2229
- // Verify published count increased after the large value change.
2230
- const afterMetrics = await waitForAsync("messagesPublished increased", async () => {
2231
- const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
2232
- const mqtt = m.adapters.find((a) => a.name === "mqtt");
2233
- return mqtt && mqtt.metrics.messagesPublished > publishedBefore
2234
- ? mqtt.metrics
2235
- : undefined;
2236
- });
2237
- expect(afterMetrics.messagesPublished).toBeGreaterThan(publishedBefore);
2238
- } finally {
2239
- recorder.dispose();
2240
- }
2241
-
2242
- await stopMQTTClient(client);
2243
- await stopSmokeRuntime(runtime);
2244
- } catch (error) {
2245
- if (client) {
2246
- await stopMQTTClient(client).catch(() => {});
2247
- }
2248
- await stopSmokeRuntimeWithFailure(
2249
- runtime,
2250
- new Error(
2251
- `RBE integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
2252
- ),
2253
- );
2254
- }
2255
- }, 30000);
2256
- });