miravo 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1688 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import {
6
+ type CommandResult,
7
+ controlTopicCmd,
8
+ controlTopicCmdResult,
9
+ EngineMetricsSnapshotSchema,
10
+ EngineStateSnapshotSchema,
11
+ type TwinModel,
12
+ } from "@miravo/shared";
13
+ import type { MqttClient } from "mqtt";
14
+ import { connect } from "mqtt";
15
+ import {
16
+ type ClientSession,
17
+ MessageSecurityMode,
18
+ OPCUAClient,
19
+ SecurityPolicy,
20
+ } from "node-opcua-client";
21
+ import { DataType } from "node-opcua-variant";
22
+ import { stringify as stringifyYAML } from "yaml";
23
+
24
+ interface MQTTMessageRecord {
25
+ readonly topic: string;
26
+ readonly raw: string;
27
+ readonly parsed: unknown;
28
+ }
29
+
30
+ interface SmokeRuntimeReady {
31
+ readonly simulationName: string;
32
+ readonly adminUrl: string;
33
+ readonly mqttBrokerUrl: string;
34
+ readonly opcuaEndpointUrl: string;
35
+ readonly instanceId: string;
36
+ readonly instancePath: string;
37
+ }
38
+
39
+ interface SmokeRuntimeHandle {
40
+ readonly process: Bun.Subprocess<"ignore", "pipe", "pipe">;
41
+ readonly tmpDir: string;
42
+ readonly ready: SmokeRuntimeReady;
43
+ }
44
+
45
+ interface InvokeMethodCommandPayload {
46
+ readonly type: "invokeMethod";
47
+ readonly instanceId: string;
48
+ readonly method: string;
49
+ readonly arguments?: Readonly<Record<string, number | string | boolean>> | undefined;
50
+ }
51
+
52
+ interface AdminHealthPayload {
53
+ readonly status: "ok" | "fail";
54
+ readonly simulationState: string;
55
+ readonly tickCount: number;
56
+ readonly stalled: boolean;
57
+ readonly reason?: string;
58
+ }
59
+
60
+ function createSmokePumpModel(): TwinModel {
61
+ return {
62
+ schema_version: "2",
63
+ model: {
64
+ name: "method-smoke-pump",
65
+ namespace: "com.miravo.testing.method-smoke-pump",
66
+ description: "Deterministic smoke-test pump for executable method transport validation.",
67
+ },
68
+ parameters: {
69
+ target_value: {
70
+ type: "float",
71
+ default: 5,
72
+ min: 0,
73
+ max: 100,
74
+ unit: "bar",
75
+ },
76
+ },
77
+ methods: {
78
+ Start: {
79
+ description: "Start the smoke-test pump.",
80
+ runtime: {
81
+ type: "set-members",
82
+ writes: [
83
+ { member: "run_command", value: true },
84
+ { member: "speed_command", value: 1200 },
85
+ ],
86
+ },
87
+ output_arguments: {
88
+ accepted: {
89
+ data_type: "Boolean",
90
+ },
91
+ },
92
+ },
93
+ Stop: {
94
+ description: "Stop the smoke-test pump.",
95
+ runtime: {
96
+ type: "set-members",
97
+ writes: [
98
+ { member: "run_command", value: false },
99
+ { member: "speed_command", value: 0 },
100
+ ],
101
+ },
102
+ output_arguments: {
103
+ accepted: {
104
+ data_type: "Boolean",
105
+ },
106
+ },
107
+ },
108
+ SetSpeed: {
109
+ description: "Set the pump speed command.",
110
+ input_arguments: {
111
+ speed_rpm: {
112
+ data_type: "Double",
113
+ unit: "rpm",
114
+ },
115
+ },
116
+ runtime: {
117
+ type: "set-members",
118
+ writes: [{ member: "speed_command", argument: "speed_rpm" }],
119
+ },
120
+ output_arguments: {
121
+ accepted: {
122
+ data_type: "Boolean",
123
+ },
124
+ },
125
+ },
126
+ SetTarget: {
127
+ description: "Set the parameter echo value.",
128
+ input_arguments: {
129
+ target_value: {
130
+ data_type: "Double",
131
+ unit: "bar",
132
+ },
133
+ },
134
+ runtime: {
135
+ type: "set-parameters",
136
+ writes: [{ parameter: "target_value", argument: "target_value" }],
137
+ },
138
+ output_arguments: {
139
+ accepted: {
140
+ data_type: "Boolean",
141
+ },
142
+ },
143
+ },
144
+ ResetFault: {
145
+ description: "Reset active smoke-test pump faults.",
146
+ runtime: {
147
+ type: "clear-faults",
148
+ },
149
+ output_arguments: {
150
+ accepted: {
151
+ data_type: "Boolean",
152
+ },
153
+ },
154
+ },
155
+ },
156
+ members: {
157
+ manufacturer: {
158
+ kind: "property",
159
+ data_type: "String",
160
+ access: "read",
161
+ default: "Miravo Labs",
162
+ },
163
+ serial_number: {
164
+ kind: "property",
165
+ data_type: "String",
166
+ access: "read",
167
+ default: "SMOKE-001",
168
+ },
169
+ run_command: {
170
+ kind: "variable",
171
+ data_type: "Boolean",
172
+ access: "readwrite",
173
+ default: false,
174
+ },
175
+ speed_command: {
176
+ kind: "variable",
177
+ data_type: "Double",
178
+ access: "readwrite",
179
+ unit: "rpm",
180
+ default: 0,
181
+ },
182
+ speed_feedback: {
183
+ kind: "variable",
184
+ data_type: "Double",
185
+ access: "read",
186
+ unit: "rpm",
187
+ generator: {
188
+ type: "gate",
189
+ enabled_member: "run_command",
190
+ disabled_value: 0,
191
+ input: { member: "speed_command" },
192
+ },
193
+ },
194
+ flow_rate: {
195
+ kind: "variable",
196
+ data_type: "Double",
197
+ access: "read",
198
+ unit: "m3/h",
199
+ generator: {
200
+ type: "correlated",
201
+ source: "speed_feedback",
202
+ factor: 0.02,
203
+ offset: 0,
204
+ },
205
+ },
206
+ parameter_echo: {
207
+ kind: "variable",
208
+ data_type: "Double",
209
+ access: "read",
210
+ unit: "bar",
211
+ generator: {
212
+ type: "weighted-sum",
213
+ bias: 0,
214
+ terms: [
215
+ {
216
+ weight: 1,
217
+ input: { param: "target_value" },
218
+ },
219
+ ],
220
+ min: 0,
221
+ max: 100,
222
+ },
223
+ },
224
+ running: {
225
+ kind: "variable",
226
+ data_type: "Boolean",
227
+ access: "read",
228
+ binding: {
229
+ type: "threshold",
230
+ member: "speed_feedback",
231
+ operator: ">",
232
+ threshold: 1,
233
+ },
234
+ default: false,
235
+ },
236
+ fault_active: {
237
+ kind: "variable",
238
+ data_type: "Boolean",
239
+ access: "read",
240
+ binding: {
241
+ type: "fault-active",
242
+ },
243
+ default: false,
244
+ },
245
+ fault_count: {
246
+ kind: "variable",
247
+ data_type: "UInt16",
248
+ access: "read",
249
+ unit: "count",
250
+ binding: {
251
+ type: "fault-count",
252
+ },
253
+ default: 0,
254
+ },
255
+ active_fault_code: {
256
+ kind: "variable",
257
+ data_type: "String",
258
+ access: "read",
259
+ enum_values: ["none", "trip"],
260
+ binding: {
261
+ type: "active-fault-code",
262
+ fallback: "none",
263
+ },
264
+ default: "none",
265
+ },
266
+ availability_state: {
267
+ kind: "variable",
268
+ data_type: "String",
269
+ access: "read",
270
+ enum_values: ["stopped", "running", "faulted"],
271
+ binding: {
272
+ type: "availability-state",
273
+ member: "speed_feedback",
274
+ operator: ">",
275
+ threshold: 1,
276
+ running_state: "running",
277
+ stopped_state: "stopped",
278
+ faulted_state: "faulted",
279
+ },
280
+ default: "stopped",
281
+ },
282
+ },
283
+ faults: [
284
+ {
285
+ name: "trip",
286
+ trigger: {
287
+ probability: 0,
288
+ },
289
+ effects: {
290
+ speed_feedback: {
291
+ offset: -100,
292
+ },
293
+ },
294
+ },
295
+ ],
296
+ };
297
+ }
298
+
299
+ function createLifecycleTestModel(): TwinModel {
300
+ return {
301
+ schema_version: "2",
302
+ model: {
303
+ name: "lifecycle-test-pump",
304
+ namespace: "com.miravo.testing.lifecycle-test",
305
+ description: "Deterministic pump for lifecycle effects integration testing.",
306
+ },
307
+ parameters: {
308
+ baseline_pressure: {
309
+ type: "float",
310
+ default: 100,
311
+ min: 0,
312
+ max: 200,
313
+ unit: "bar",
314
+ },
315
+ },
316
+ lifecycle: {
317
+ stages: [
318
+ {
319
+ name: "healthy",
320
+ duration_hours: [3.0, 3.0],
321
+ effects: {},
322
+ },
323
+ {
324
+ name: "degraded",
325
+ duration_hours: [1.5, 1.5],
326
+ effects: {
327
+ pressure: { multiplier: 0.8, offset: -5 },
328
+ },
329
+ },
330
+ {
331
+ name: "critical",
332
+ duration_hours: [10000, 10000],
333
+ effects: {
334
+ pressure: { multiplier: 0.5, offset: -20 },
335
+ },
336
+ },
337
+ ],
338
+ },
339
+ methods: {},
340
+ members: {
341
+ pressure: {
342
+ kind: "variable",
343
+ data_type: "Double",
344
+ access: "read",
345
+ unit: "bar",
346
+ generator: {
347
+ type: "weighted-sum",
348
+ bias: 0,
349
+ terms: [{ weight: 1, input: { param: "baseline_pressure" } }],
350
+ min: 0,
351
+ max: 200,
352
+ },
353
+ },
354
+ },
355
+ faults: [],
356
+ };
357
+ }
358
+
359
+ function createConditionalFaultTestModel(): TwinModel {
360
+ return {
361
+ schema_version: "2",
362
+ model: {
363
+ name: "fault-test-pump",
364
+ namespace: "com.miravo.testing.fault-test",
365
+ description: "Deterministic pump for conditional fault integration testing.",
366
+ },
367
+ parameters: {},
368
+ methods: {
369
+ SetSpeed: {
370
+ description: "Set the speed command.",
371
+ input_arguments: {
372
+ speed: { data_type: "Double", unit: "rpm" },
373
+ },
374
+ runtime: {
375
+ type: "set-members",
376
+ writes: [{ member: "speed_command", argument: "speed" }],
377
+ },
378
+ output_arguments: {
379
+ accepted: { data_type: "Boolean" },
380
+ },
381
+ },
382
+ ResetFault: {
383
+ description: "Clear active faults.",
384
+ runtime: { type: "clear-faults" },
385
+ output_arguments: {
386
+ accepted: { data_type: "Boolean" },
387
+ },
388
+ },
389
+ },
390
+ members: {
391
+ speed_command: {
392
+ kind: "variable",
393
+ data_type: "Double",
394
+ access: "readwrite",
395
+ unit: "rpm",
396
+ default: 0,
397
+ },
398
+ pressure: {
399
+ kind: "variable",
400
+ data_type: "Double",
401
+ access: "read",
402
+ unit: "bar",
403
+ generator: {
404
+ type: "correlated",
405
+ source: "speed_command",
406
+ factor: 0.1,
407
+ offset: 0,
408
+ },
409
+ },
410
+ fault_active: {
411
+ kind: "variable",
412
+ data_type: "Boolean",
413
+ access: "read",
414
+ binding: { type: "fault-active" },
415
+ default: false,
416
+ },
417
+ fault_count: {
418
+ kind: "variable",
419
+ data_type: "UInt16",
420
+ access: "read",
421
+ unit: "count",
422
+ binding: { type: "fault-count" },
423
+ default: 0,
424
+ },
425
+ },
426
+ faults: [
427
+ {
428
+ name: "overpressure",
429
+ trigger: {
430
+ condition: "pressure > 80",
431
+ probability: 1,
432
+ },
433
+ effects: {
434
+ pressure: { multiplier: 0.5 },
435
+ },
436
+ },
437
+ ],
438
+ };
439
+ }
440
+
441
+ async function waitFor<T>(label: string, read: () => T | undefined, timeoutMs = 5000): Promise<T> {
442
+ const deadline = Date.now() + timeoutMs;
443
+ while (Date.now() < deadline) {
444
+ const value = read();
445
+ if (value !== undefined) {
446
+ return value;
447
+ }
448
+ await Bun.sleep(20);
449
+ }
450
+ throw new Error(`Timed out waiting for ${label}.`);
451
+ }
452
+
453
+ async function waitForAsync<T>(
454
+ label: string,
455
+ read: () => Promise<T | undefined>,
456
+ timeoutMs = 5000,
457
+ ): Promise<T> {
458
+ const deadline = Date.now() + timeoutMs;
459
+ while (Date.now() < deadline) {
460
+ const value = await read();
461
+ if (value !== undefined) {
462
+ return value;
463
+ }
464
+ await Bun.sleep(20);
465
+ }
466
+ throw new Error(`Timed out waiting for ${label}.`);
467
+ }
468
+
469
+ async function readAdminJson<T>(
470
+ runtime: SmokeRuntimeHandle,
471
+ path: string,
472
+ schema: {
473
+ safeParse(value: unknown): { success: boolean; data?: T };
474
+ },
475
+ ): Promise<T> {
476
+ const response = await fetch(new URL(path, `${runtime.ready.adminUrl}/`));
477
+ if (!response.ok) {
478
+ throw new Error(`Admin request ${path} failed with HTTP ${response.status}.`);
479
+ }
480
+ const payload = (await response.json()) as unknown;
481
+ const parsed = schema.safeParse(payload);
482
+ if (!parsed.success || parsed.data === undefined) {
483
+ throw new Error(`Admin request ${path} returned an unexpected payload.`);
484
+ }
485
+ return parsed.data;
486
+ }
487
+
488
+ async function readAdminHealth(runtime: SmokeRuntimeHandle, path: "/healthz" | "/readyz") {
489
+ const response = await fetch(new URL(path, `${runtime.ready.adminUrl}/`));
490
+ const payload = (await response.json()) as AdminHealthPayload;
491
+ return { response, payload };
492
+ }
493
+
494
+ async function postAdminCommand(
495
+ runtime: SmokeRuntimeHandle,
496
+ payload: Readonly<Record<string, unknown>>,
497
+ ): Promise<CommandResult> {
498
+ const response = await fetch(new URL("/commands", `${runtime.ready.adminUrl}/`), {
499
+ method: "POST",
500
+ headers: {
501
+ "content-type": "application/json",
502
+ },
503
+ body: JSON.stringify(payload),
504
+ });
505
+ if (!response.ok) {
506
+ const text = await response.text();
507
+ throw new Error(`Admin command failed with HTTP ${response.status}: ${text}`);
508
+ }
509
+ return (await response.json()) as CommandResult;
510
+ }
511
+
512
+ async function waitForAdminMetricsState(
513
+ runtime: SmokeRuntimeHandle,
514
+ expectedState: "running" | "paused" | "stopped",
515
+ ) {
516
+ return waitForAsync(`admin metrics state ${expectedState}`, async () => {
517
+ const metrics = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
518
+ return metrics.core.state === expectedState ? metrics : undefined;
519
+ });
520
+ }
521
+
522
+ async function waitForAdminInstanceCount(runtime: SmokeRuntimeHandle, expectedCount: number) {
523
+ return waitForAsync(`admin instance count ${expectedCount}`, async () => {
524
+ const state = await readAdminJson(runtime, "/state", EngineStateSnapshotSchema);
525
+ return state.instances.length === expectedCount ? state : undefined;
526
+ });
527
+ }
528
+
529
+ function readPayloadValue(payload: unknown): number | string | boolean | undefined {
530
+ if (typeof payload !== "object" || payload === null) {
531
+ return undefined;
532
+ }
533
+ if (!("value" in payload)) {
534
+ return undefined;
535
+ }
536
+ const value = (payload as { readonly value?: unknown }).value;
537
+ return typeof value === "number" || typeof value === "string" || typeof value === "boolean"
538
+ ? value
539
+ : undefined;
540
+ }
541
+
542
+ function readPayloadQuality(payload: unknown): string | undefined {
543
+ if (typeof payload !== "object" || payload === null) {
544
+ return undefined;
545
+ }
546
+ if (!("quality" in payload)) {
547
+ return undefined;
548
+ }
549
+ const quality = (payload as { readonly quality?: unknown }).quality;
550
+ return typeof quality === "string" ? quality : undefined;
551
+ }
552
+
553
+ async function waitForMQTTConnect(client: MqttClient, timeoutMs = 5000): Promise<void> {
554
+ if (client.connected) {
555
+ return;
556
+ }
557
+
558
+ await new Promise<void>((resolvePromise, reject) => {
559
+ const timer = setTimeout(() => {
560
+ cleanup();
561
+ reject(new Error(`MQTT client did not connect within ${timeoutMs}ms.`));
562
+ }, timeoutMs);
563
+
564
+ const cleanup = () => {
565
+ clearTimeout(timer);
566
+ client.off("connect", onConnect);
567
+ client.off("error", onError);
568
+ };
569
+
570
+ const onConnect = () => {
571
+ cleanup();
572
+ resolvePromise();
573
+ };
574
+
575
+ const onError = (error: Error) => {
576
+ cleanup();
577
+ reject(error);
578
+ };
579
+
580
+ client.once("connect", onConnect);
581
+ client.once("error", onError);
582
+ });
583
+ }
584
+
585
+ async function stopMQTTClient(client: MqttClient): Promise<void> {
586
+ await new Promise<void>((resolvePromise) => {
587
+ client.end(true, {}, () => resolvePromise());
588
+ });
589
+ }
590
+
591
+ async function subscribeMQTT(client: MqttClient, topics: readonly string[]): Promise<void> {
592
+ await new Promise<void>((resolvePromise, reject) => {
593
+ client.subscribe([...topics], (error) => {
594
+ if (error) {
595
+ reject(error);
596
+ return;
597
+ }
598
+ resolvePromise();
599
+ });
600
+ });
601
+ }
602
+
603
+ function createMQTTRecorder(client: MqttClient) {
604
+ const messages: MQTTMessageRecord[] = [];
605
+ const latestByTopic = new Map<string, MQTTMessageRecord>();
606
+ const onMessage = (topic: string, payload: Buffer) => {
607
+ const raw = payload.toString();
608
+ let parsed: unknown = raw;
609
+ try {
610
+ parsed = JSON.parse(raw);
611
+ } catch {
612
+ // Keep raw payload when it is not JSON.
613
+ }
614
+ const record = { topic, raw, parsed };
615
+ messages.push(record);
616
+ latestByTopic.set(topic, record);
617
+ };
618
+ client.on("message", onMessage);
619
+ return {
620
+ messages,
621
+ latestByTopic,
622
+ dispose() {
623
+ client.off("message", onMessage);
624
+ },
625
+ };
626
+ }
627
+
628
+ async function publishControlCommand(
629
+ client: MqttClient,
630
+ recorder: ReturnType<typeof createMQTTRecorder>,
631
+ simulationName: string,
632
+ payload: Readonly<Record<string, unknown>>,
633
+ ): Promise<CommandResult> {
634
+ const correlationId = crypto.randomUUID();
635
+ const commandTopic = controlTopicCmd(simulationName);
636
+ const resultTopic = controlTopicCmdResult(simulationName);
637
+ client.publish(
638
+ commandTopic,
639
+ JSON.stringify({
640
+ correlationId,
641
+ ...payload,
642
+ }),
643
+ );
644
+
645
+ return waitFor(`MQTT command result for ${String(payload["type"])}`, () => {
646
+ const result = recorder.messages
647
+ .filter((message) => message.topic === resultTopic)
648
+ .map((message) => message.parsed)
649
+ .find((parsed): parsed is CommandResult => {
650
+ if (typeof parsed !== "object" || parsed === null) {
651
+ return false;
652
+ }
653
+ return (parsed as { readonly correlationId?: string }).correlationId === correlationId;
654
+ });
655
+ return result;
656
+ });
657
+ }
658
+
659
+ async function waitForMQTTTopicValue(
660
+ recorder: ReturnType<typeof createMQTTRecorder>,
661
+ topic: string,
662
+ expected?: number | string | boolean,
663
+ ): Promise<number | string | boolean> {
664
+ return waitFor(`MQTT topic ${topic}`, () => {
665
+ const payload = recorder.latestByTopic.get(topic)?.parsed;
666
+ const value = readPayloadValue(payload);
667
+ if (value === undefined) {
668
+ return undefined;
669
+ }
670
+ return expected === undefined || value === expected ? value : undefined;
671
+ });
672
+ }
673
+
674
+ async function waitForMQTTTopicQuality(
675
+ recorder: ReturnType<typeof createMQTTRecorder>,
676
+ topic: string,
677
+ expected: string,
678
+ ): Promise<string> {
679
+ return waitFor(`MQTT topic ${topic} quality=${expected}`, () => {
680
+ const record = recorder.latestByTopic.get(topic);
681
+ if (!record) {
682
+ return undefined;
683
+ }
684
+ const quality = readPayloadQuality(record.parsed);
685
+ return quality === expected ? quality : undefined;
686
+ });
687
+ }
688
+
689
+ async function waitForOPCUAValue<T extends number | string | boolean>(
690
+ session: ClientSession,
691
+ nodeId: string,
692
+ expected?: T,
693
+ ): Promise<T> {
694
+ return waitForAsync(`OPC UA value ${nodeId}`, async () => {
695
+ const dataValue = await session.readVariableValue(nodeId);
696
+ const value = dataValue.value.value;
697
+ if (typeof value !== "number" && typeof value !== "string" && typeof value !== "boolean") {
698
+ return undefined;
699
+ }
700
+ return expected === undefined || value === expected ? (value as T) : undefined;
701
+ });
702
+ }
703
+
704
+ async function callOPCUAMethod(
705
+ session: ClientSession,
706
+ objectPath: string,
707
+ methodName: string,
708
+ inputArguments: Array<{
709
+ readonly dataType: DataType;
710
+ readonly value: number | string | boolean;
711
+ }> = [],
712
+ ): Promise<void> {
713
+ const result = await callOPCUAMethodResult(session, objectPath, methodName, inputArguments);
714
+ expect(result.statusCode.isGood()).toBe(true);
715
+ expect(result.outputArguments?.[0]?.value).toBe(true);
716
+ }
717
+
718
+ async function callOPCUAMethodResult(
719
+ session: ClientSession,
720
+ objectPath: string,
721
+ methodName: string,
722
+ inputArguments: Array<{
723
+ readonly dataType: DataType;
724
+ readonly value: number | string | boolean;
725
+ }> = [],
726
+ ) {
727
+ const result = await session.call({
728
+ objectId: `ns=1;s=${objectPath}`,
729
+ methodId: `ns=1;s=${objectPath}/${methodName}`,
730
+ inputArguments,
731
+ });
732
+ return result;
733
+ }
734
+
735
+ async function collectProcessOutput(
736
+ process: SmokeRuntimeHandle["process"],
737
+ ): Promise<{ readonly stdout: string; readonly stderr: string }> {
738
+ const stdout = process.stdout ? await new Response(process.stdout).text() : "";
739
+ const stderr = process.stderr ? await new Response(process.stderr).text() : "";
740
+ return { stdout, stderr };
741
+ }
742
+
743
+ interface SmokeRuntimeOptions {
744
+ readonly model?: TwinModel;
745
+ readonly namespacePrefix?: string;
746
+ readonly templateSettings?: {
747
+ readonly tick_interval_ms?: number;
748
+ readonly time_multiplier?: number;
749
+ readonly seed?: number;
750
+ };
751
+ readonly rbe?: {
752
+ readonly enabled: boolean;
753
+ readonly deadband: number;
754
+ };
755
+ }
756
+
757
+ async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeRuntimeHandle> {
758
+ const model = options?.model ?? createSmokePumpModel();
759
+ const prefix = options?.namespacePrefix ?? "method-smoke";
760
+ const tickIntervalMs = options?.templateSettings?.tick_interval_ms ?? 100;
761
+ const timeMultiplier = options?.templateSettings?.time_multiplier ?? 1;
762
+ const seed = options?.templateSettings?.seed ?? 42;
763
+
764
+ const tmpDir = await mkdtemp(join(tmpdir(), `miravo-${prefix}-`));
765
+ const modelsDir = join(tmpDir, "models");
766
+ const templatePath = join(tmpDir, `${prefix}.miravo.yaml`);
767
+ const modelPath = join(modelsDir, `${model.model.name}.twin.yaml`);
768
+ const readyFile = join(tmpDir, "ready.json");
769
+
770
+ await mkdir(modelsDir, { recursive: true });
771
+ await Bun.write(modelPath, stringifyYAML(model));
772
+ await Bun.write(
773
+ templatePath,
774
+ stringifyYAML({
775
+ kind: "MiravoTemplate",
776
+ version: "1",
777
+ name: `${prefix} Template`,
778
+ namespace: {
779
+ prefix,
780
+ },
781
+ areas: [
782
+ {
783
+ name: "validation",
784
+ lines: [
785
+ {
786
+ name: "protocols",
787
+ twins: [
788
+ {
789
+ model: model.model.name,
790
+ count: 1,
791
+ variation: 0,
792
+ },
793
+ ],
794
+ },
795
+ ],
796
+ },
797
+ ],
798
+ settings: {
799
+ seed,
800
+ tick_interval_ms: tickIntervalMs,
801
+ time_multiplier: timeMultiplier,
802
+ },
803
+ }),
804
+ );
805
+
806
+ const cmd = [
807
+ "bun",
808
+ "run",
809
+ resolve(import.meta.dir, "executable-methods-runner.ts"),
810
+ "--template",
811
+ templatePath,
812
+ "--models-dir",
813
+ modelsDir,
814
+ "--ready-file",
815
+ readyFile,
816
+ ];
817
+ if (options?.rbe?.enabled) {
818
+ cmd.push("--rbe-enabled", "true", "--rbe-deadband", String(options.rbe.deadband));
819
+ }
820
+
821
+ const process = Bun.spawn({
822
+ cmd,
823
+ cwd: resolve("."),
824
+ stdin: "ignore",
825
+ stdout: "pipe",
826
+ stderr: "pipe",
827
+ });
828
+
829
+ try {
830
+ const ready = await waitForAsync(
831
+ "smoke runtime ready file",
832
+ async () => {
833
+ if (!(await Bun.file(readyFile).exists())) {
834
+ return undefined;
835
+ }
836
+ return (await Bun.file(readyFile).json()) as SmokeRuntimeReady;
837
+ },
838
+ 15000,
839
+ );
840
+
841
+ return { process, tmpDir, ready };
842
+ } catch (error) {
843
+ process.kill("SIGTERM");
844
+ await process.exited;
845
+ const output = await collectProcessOutput(process);
846
+ await rm(tmpDir, { recursive: true, force: true });
847
+ throw new Error(
848
+ [
849
+ `Failed to start smoke runtime: ${error instanceof Error ? error.message : String(error)}`,
850
+ output.stdout.trim().length > 0 ? `stdout:\n${output.stdout.trim()}` : "",
851
+ output.stderr.trim().length > 0 ? `stderr:\n${output.stderr.trim()}` : "",
852
+ ]
853
+ .filter(Boolean)
854
+ .join("\n\n"),
855
+ );
856
+ }
857
+ }
858
+
859
+ async function stopSmokeRuntime(handle: SmokeRuntimeHandle): Promise<void> {
860
+ handle.process.kill("SIGTERM");
861
+ const exitResult = await Promise.race([
862
+ handle.process.exited.then(() => "exited"),
863
+ Bun.sleep(5000).then(() => "timeout"),
864
+ ]);
865
+ if (exitResult === "timeout") {
866
+ handle.process.kill("SIGKILL");
867
+ await handle.process.exited;
868
+ }
869
+ await collectProcessOutput(handle.process);
870
+ await rm(handle.tmpDir, { recursive: true, force: true });
871
+ }
872
+
873
+ async function stopSmokeRuntimeWithFailure(
874
+ handle: SmokeRuntimeHandle,
875
+ cause: unknown,
876
+ ): Promise<never> {
877
+ handle.process.kill("SIGTERM");
878
+ const exitResult = await Promise.race([
879
+ handle.process.exited.then(() => "exited"),
880
+ Bun.sleep(5000).then(() => "timeout"),
881
+ ]);
882
+ if (exitResult === "timeout") {
883
+ handle.process.kill("SIGKILL");
884
+ await handle.process.exited;
885
+ }
886
+
887
+ const output = await collectProcessOutput(handle.process);
888
+ await rm(handle.tmpDir, { recursive: true, force: true });
889
+
890
+ throw new Error(
891
+ [
892
+ cause instanceof Error ? cause.message : String(cause),
893
+ output.stdout.trim().length > 0 ? `runner stdout:\n${output.stdout.trim()}` : "",
894
+ output.stderr.trim().length > 0 ? `runner stderr:\n${output.stderr.trim()}` : "",
895
+ ]
896
+ .filter(Boolean)
897
+ .join("\n\n"),
898
+ );
899
+ }
900
+
901
+ describe("executable methods integration", () => {
902
+ test("admin HTTP server exposes runtime state, executes core commands, and drives published twin behavior", async () => {
903
+ const runtime = await createSmokeRuntime();
904
+ let client: MqttClient | undefined;
905
+ let stage = "connect MQTT observer";
906
+
907
+ try {
908
+ const { ready } = runtime;
909
+ const topicPrefix = ready.instancePath;
910
+ client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
911
+
912
+ await waitForMQTTConnect(client);
913
+ await subscribeMQTT(client, [`${topicPrefix}/#`]);
914
+
915
+ const recorder = createMQTTRecorder(client);
916
+ try {
917
+ stage = "read admin health and readiness";
918
+ const health = await readAdminHealth(runtime, "/healthz");
919
+ const readyz = await readAdminHealth(runtime, "/readyz");
920
+ expect(health.response.status).toBe(200);
921
+ expect(health.payload.status).toBe("ok");
922
+ expect(readyz.response.status).toBe(200);
923
+ expect(readyz.payload.status).toBe("ok");
924
+
925
+ stage = "read admin metrics and state";
926
+ const initialMetrics = await readAdminJson(
927
+ runtime,
928
+ "/metrics",
929
+ EngineMetricsSnapshotSchema,
930
+ );
931
+ const initialState = await readAdminJson(runtime, "/state", EngineStateSnapshotSchema);
932
+ expect(initialMetrics.simulationName).toBe(ready.simulationName);
933
+ expect(initialMetrics.core.state).toBe("running");
934
+ expect(initialState.simulationName).toBe(ready.simulationName);
935
+ expect(initialState.instances).toHaveLength(1);
936
+ expect(initialState.config.adapters).toContain("mqtt");
937
+ expect(initialState.config.adapters).toContain("opcua");
938
+
939
+ stage = "reject malformed admin command";
940
+ const invalidResponse = await fetch(new URL("/commands", `${ready.adminUrl}/`), {
941
+ method: "POST",
942
+ headers: { "content-type": "application/json" },
943
+ body: JSON.stringify({ type: "setSpeed" }),
944
+ });
945
+ expect(invalidResponse.status).toBe(400);
946
+
947
+ stage = "invoke method through admin server";
948
+ const startResult = await postAdminCommand(runtime, {
949
+ type: "invokeMethod",
950
+ instanceId: ready.instanceId,
951
+ method: "Start",
952
+ });
953
+ expect(startResult.success).toBe(true);
954
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/run_command`, true)).toBe(
955
+ true,
956
+ );
957
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 1200)).toBe(
958
+ 1200,
959
+ );
960
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/running`, true)).toBe(true);
961
+
962
+ stage = "pause and resume through admin server";
963
+ const pauseResult = await postAdminCommand(runtime, { type: "pause" });
964
+ expect(pauseResult.success).toBe(true);
965
+ expect((await waitForAdminMetricsState(runtime, "paused")).core.state).toBe("paused");
966
+ const pausedReady = await readAdminHealth(runtime, "/readyz");
967
+ expect(pausedReady.response.status).toBe(503);
968
+ expect(pausedReady.payload.status).toBe("fail");
969
+
970
+ const resumeResult = await postAdminCommand(runtime, { type: "resume" });
971
+ expect(resumeResult.success).toBe(true);
972
+ expect((await waitForAdminMetricsState(runtime, "running")).core.state).toBe("running");
973
+ const resumedReady = await readAdminHealth(runtime, "/readyz");
974
+ expect(resumedReady.response.status).toBe(200);
975
+ expect(resumedReady.payload.status).toBe("ok");
976
+
977
+ stage = "change simulation speed through admin server";
978
+ const speedResult = await postAdminCommand(runtime, { type: "setSpeed", multiplier: 5 });
979
+ expect(speedResult.success).toBe(true);
980
+ const updatedState = await waitForAsync("admin time multiplier 5", async () => {
981
+ const state = await readAdminJson(runtime, "/state", EngineStateSnapshotSchema);
982
+ return state.config.timeMultiplier === 5 ? state : undefined;
983
+ });
984
+ expect(updatedState.config.timeMultiplier).toBe(5);
985
+
986
+ stage = "spawn and remove instances through admin server";
987
+ const spawnResult = await postAdminCommand(runtime, {
988
+ type: "spawn",
989
+ model: "method-smoke-pump",
990
+ count: 1,
991
+ variation: 0,
992
+ unsBasePath: "method-smoke/validation/protocols",
993
+ });
994
+ expect(spawnResult.success).toBe(true);
995
+ const spawnedIds = Array.isArray(spawnResult.data)
996
+ ? spawnResult.data.filter((value): value is string => typeof value === "string")
997
+ : [];
998
+ expect(spawnedIds).toHaveLength(1);
999
+ const spawnedState = await waitForAdminInstanceCount(runtime, 2);
1000
+ expect(spawnedState.instances.some((instance) => instance.id === spawnedIds[0])).toBe(true);
1001
+
1002
+ const removeResult = await postAdminCommand(runtime, {
1003
+ type: "removeInstance",
1004
+ instanceId: spawnedIds[0],
1005
+ });
1006
+ expect(removeResult.success).toBe(true);
1007
+ const removedState = await waitForAdminInstanceCount(runtime, 1);
1008
+ expect(removedState.instances.some((instance) => instance.id === spawnedIds[0])).toBe(
1009
+ false,
1010
+ );
1011
+ } finally {
1012
+ recorder.dispose();
1013
+ }
1014
+
1015
+ await stopMQTTClient(client);
1016
+ await stopSmokeRuntime(runtime);
1017
+ } catch (error) {
1018
+ if (client) {
1019
+ await stopMQTTClient(client).catch(() => {});
1020
+ }
1021
+ await stopSmokeRuntimeWithFailure(
1022
+ runtime,
1023
+ new Error(
1024
+ `Admin integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1025
+ ),
1026
+ );
1027
+ }
1028
+ }, 30000);
1029
+
1030
+ test("mqtt.js invokes methods through the control plane and template-backed twin topics reflect the result", async () => {
1031
+ const runtime = await createSmokeRuntime();
1032
+ let client: MqttClient | undefined;
1033
+ let stage = "connect MQTT client";
1034
+
1035
+ try {
1036
+ const { ready } = runtime;
1037
+ const cmdResultTopic = controlTopicCmdResult(ready.simulationName);
1038
+ const topicPrefix = ready.instancePath;
1039
+ client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
1040
+
1041
+ await waitForMQTTConnect(client);
1042
+ await subscribeMQTT(client, [`${topicPrefix}/#`, cmdResultTopic]);
1043
+
1044
+ const recorder = createMQTTRecorder(client);
1045
+ try {
1046
+ // MQTT now uses a delta-driven publish pipeline. With retain disabled in this
1047
+ // smoke runtime, a late subscriber is only guaranteed to observe values that
1048
+ // change after it subscribes. The test therefore validates post-subscription
1049
+ // command-driven changes rather than initial state replay.
1050
+
1051
+ stage = "start asset through MQTT";
1052
+ const startResult = await publishControlCommand(client, recorder, ready.simulationName, {
1053
+ type: "invokeMethod",
1054
+ instanceId: ready.instanceId,
1055
+ method: "Start",
1056
+ } satisfies InvokeMethodCommandPayload);
1057
+ expect(startResult.success).toBe(true);
1058
+ expect(
1059
+ (startResult.data as { readonly outputs?: { readonly accepted?: boolean } }).outputs
1060
+ ?.accepted,
1061
+ ).toBe(true);
1062
+
1063
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/run_command`, true)).toBe(
1064
+ true,
1065
+ );
1066
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 1200)).toBe(
1067
+ 1200,
1068
+ );
1069
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/flow_rate`, 24)).toBe(24);
1070
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/running`, true)).toBe(true);
1071
+ expect(
1072
+ await waitForMQTTTopicValue(recorder, `${topicPrefix}/availability_state`, "running"),
1073
+ ).toBe("running");
1074
+
1075
+ stage = "set speed through MQTT";
1076
+ const setSpeedResult = await publishControlCommand(client, recorder, ready.simulationName, {
1077
+ type: "invokeMethod",
1078
+ instanceId: ready.instanceId,
1079
+ method: "SetSpeed",
1080
+ arguments: { speed_rpm: 800 },
1081
+ } satisfies InvokeMethodCommandPayload);
1082
+ expect(setSpeedResult.success).toBe(true);
1083
+
1084
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_command`, 800)).toBe(
1085
+ 800,
1086
+ );
1087
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 800)).toBe(
1088
+ 800,
1089
+ );
1090
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/flow_rate`, 16)).toBe(16);
1091
+
1092
+ stage = "reject invalid MQTT method invocation";
1093
+ const invalidSetSpeedResult = await publishControlCommand(
1094
+ client,
1095
+ recorder,
1096
+ ready.simulationName,
1097
+ {
1098
+ type: "invokeMethod",
1099
+ instanceId: ready.instanceId,
1100
+ method: "SetSpeed",
1101
+ } satisfies InvokeMethodCommandPayload,
1102
+ );
1103
+ expect(invalidSetSpeedResult.success).toBe(false);
1104
+ expect(
1105
+ (invalidSetSpeedResult.data as { readonly outputs?: { readonly accepted?: boolean } })
1106
+ .outputs?.accepted,
1107
+ ).toBe(false);
1108
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_command`, 800)).toBe(
1109
+ 800,
1110
+ );
1111
+
1112
+ stage = "trigger fault through control plane";
1113
+ const triggerFaultResult = await publishControlCommand(
1114
+ client,
1115
+ recorder,
1116
+ ready.simulationName,
1117
+ {
1118
+ type: "triggerFault",
1119
+ instanceId: ready.instanceId,
1120
+ fault: "trip",
1121
+ },
1122
+ );
1123
+ expect(triggerFaultResult.success).toBe(true);
1124
+
1125
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_active`, true)).toBe(
1126
+ true,
1127
+ );
1128
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_count`, 1)).toBe(1);
1129
+ expect(
1130
+ await waitForMQTTTopicValue(recorder, `${topicPrefix}/active_fault_code`, "trip"),
1131
+ ).toBe("trip");
1132
+
1133
+ stage = "reset fault through MQTT";
1134
+ const resetFaultResult = await publishControlCommand(
1135
+ client,
1136
+ recorder,
1137
+ ready.simulationName,
1138
+ {
1139
+ type: "invokeMethod",
1140
+ instanceId: ready.instanceId,
1141
+ method: "ResetFault",
1142
+ } satisfies InvokeMethodCommandPayload,
1143
+ );
1144
+ expect(resetFaultResult.success).toBe(true);
1145
+
1146
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_active`, false)).toBe(
1147
+ false,
1148
+ );
1149
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_count`, 0)).toBe(0);
1150
+ expect(
1151
+ await waitForMQTTTopicValue(recorder, `${topicPrefix}/active_fault_code`, "none"),
1152
+ ).toBe("none");
1153
+
1154
+ stage = "set parameter through MQTT";
1155
+ const setTargetResult = await publishControlCommand(
1156
+ client,
1157
+ recorder,
1158
+ ready.simulationName,
1159
+ {
1160
+ type: "invokeMethod",
1161
+ instanceId: ready.instanceId,
1162
+ method: "SetTarget",
1163
+ arguments: { target_value: 9.5 },
1164
+ } satisfies InvokeMethodCommandPayload,
1165
+ );
1166
+ expect(setTargetResult.success).toBe(true);
1167
+
1168
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/parameter_echo`, 9.5)).toBe(
1169
+ 9.5,
1170
+ );
1171
+
1172
+ stage = "stop asset through MQTT";
1173
+ const stopResult = await publishControlCommand(client, recorder, ready.simulationName, {
1174
+ type: "invokeMethod",
1175
+ instanceId: ready.instanceId,
1176
+ method: "Stop",
1177
+ } satisfies InvokeMethodCommandPayload);
1178
+ expect(stopResult.success).toBe(true);
1179
+
1180
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/run_command`, false)).toBe(
1181
+ false,
1182
+ );
1183
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 0)).toBe(0);
1184
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/flow_rate`, 0)).toBe(0);
1185
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/running`, false)).toBe(false);
1186
+ expect(
1187
+ await waitForMQTTTopicValue(recorder, `${topicPrefix}/availability_state`, "stopped"),
1188
+ ).toBe("stopped");
1189
+ } finally {
1190
+ recorder.dispose();
1191
+ }
1192
+ await stopMQTTClient(client);
1193
+ await stopSmokeRuntime(runtime);
1194
+ } catch (error) {
1195
+ if (client) {
1196
+ await stopMQTTClient(client).catch(() => {});
1197
+ }
1198
+ await stopSmokeRuntimeWithFailure(
1199
+ runtime,
1200
+ new Error(
1201
+ `MQTT integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1202
+ ),
1203
+ );
1204
+ }
1205
+ }, 30000);
1206
+
1207
+ test("OPC UA method calls update template-backed runtime state and exposed variable values", async () => {
1208
+ const runtime = await createSmokeRuntime();
1209
+ let mqttClient: MqttClient | undefined;
1210
+ let opcuaClient: ReturnType<typeof OPCUAClient.create> | undefined;
1211
+ let session: ClientSession | undefined;
1212
+ let stage = "connect OPC UA client";
1213
+
1214
+ try {
1215
+ const { ready } = runtime;
1216
+ const objectPath = ready.instancePath;
1217
+ mqttClient = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
1218
+ opcuaClient = OPCUAClient.create({
1219
+ securityMode: MessageSecurityMode.None,
1220
+ securityPolicy: SecurityPolicy.None,
1221
+ endpointMustExist: false,
1222
+ });
1223
+
1224
+ await waitForMQTTConnect(mqttClient);
1225
+ await subscribeMQTT(mqttClient, [controlTopicCmdResult(ready.simulationName)]);
1226
+ const recorder = createMQTTRecorder(mqttClient);
1227
+ try {
1228
+ await opcuaClient.connect(ready.opcuaEndpointUrl);
1229
+ session = await opcuaClient.createSession();
1230
+
1231
+ stage = "verify startup publication through OPC UA";
1232
+ expect(
1233
+ await waitForOPCUAValue<string>(
1234
+ session,
1235
+ `ns=1;s=${objectPath}/manufacturer`,
1236
+ "Miravo Labs",
1237
+ ),
1238
+ ).toBe("Miravo Labs");
1239
+ expect(
1240
+ await waitForOPCUAValue<string>(
1241
+ session,
1242
+ `ns=1;s=${objectPath}/serial_number`,
1243
+ "SMOKE-001",
1244
+ ),
1245
+ ).toBe("SMOKE-001");
1246
+ expect(
1247
+ await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/running`, false),
1248
+ ).toBe(false);
1249
+ expect(
1250
+ await waitForOPCUAValue<string>(
1251
+ session,
1252
+ `ns=1;s=${objectPath}/availability_state`,
1253
+ "stopped",
1254
+ ),
1255
+ ).toBe("stopped");
1256
+
1257
+ stage = "start asset through OPC UA";
1258
+ await callOPCUAMethod(session, objectPath, "Start");
1259
+
1260
+ expect(
1261
+ await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/run_command`, true),
1262
+ ).toBe(true);
1263
+ expect(
1264
+ await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_feedback`, 1200),
1265
+ ).toBe(1200);
1266
+ expect(await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/flow_rate`, 24)).toBe(
1267
+ 24,
1268
+ );
1269
+ expect(
1270
+ await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/running`, true),
1271
+ ).toBe(true);
1272
+ expect(
1273
+ await waitForOPCUAValue<string>(
1274
+ session,
1275
+ `ns=1;s=${objectPath}/manufacturer`,
1276
+ "Miravo Labs",
1277
+ ),
1278
+ ).toBe("Miravo Labs");
1279
+
1280
+ stage = "set speed through OPC UA";
1281
+ await callOPCUAMethod(session, objectPath, "SetSpeed", [
1282
+ { dataType: DataType.Double, value: 800 },
1283
+ ]);
1284
+
1285
+ expect(
1286
+ await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_command`, 800),
1287
+ ).toBe(800);
1288
+ expect(
1289
+ await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_feedback`, 800),
1290
+ ).toBe(800);
1291
+ expect(await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/flow_rate`, 16)).toBe(
1292
+ 16,
1293
+ );
1294
+
1295
+ stage = "reject invalid OPC UA method invocation";
1296
+ const invalidSetSpeedResult = await callOPCUAMethodResult(session, objectPath, "SetSpeed");
1297
+ expect(invalidSetSpeedResult.statusCode.isGood()).toBe(false);
1298
+ expect(
1299
+ await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_command`, 800),
1300
+ ).toBe(800);
1301
+
1302
+ stage = "trigger fault through control plane";
1303
+ const triggerFaultResult = await publishControlCommand(
1304
+ mqttClient,
1305
+ recorder,
1306
+ ready.simulationName,
1307
+ {
1308
+ type: "triggerFault",
1309
+ instanceId: ready.instanceId,
1310
+ fault: "trip",
1311
+ },
1312
+ );
1313
+ expect(triggerFaultResult.success).toBe(true);
1314
+
1315
+ expect(
1316
+ await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/fault_active`, true),
1317
+ ).toBe(true);
1318
+ expect(
1319
+ await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/fault_count`, 1),
1320
+ ).toBe(1);
1321
+ expect(
1322
+ await waitForOPCUAValue<string>(
1323
+ session,
1324
+ `ns=1;s=${objectPath}/active_fault_code`,
1325
+ "trip",
1326
+ ),
1327
+ ).toBe("trip");
1328
+ expect(
1329
+ await waitForOPCUAValue<string>(
1330
+ session,
1331
+ `ns=1;s=${objectPath}/manufacturer`,
1332
+ "Miravo Labs",
1333
+ ),
1334
+ ).toBe("Miravo Labs");
1335
+
1336
+ stage = "reset fault through OPC UA";
1337
+ await callOPCUAMethod(session, objectPath, "ResetFault");
1338
+
1339
+ expect(
1340
+ await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/fault_active`, false),
1341
+ ).toBe(false);
1342
+ expect(
1343
+ await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/fault_count`, 0),
1344
+ ).toBe(0);
1345
+ expect(
1346
+ await waitForOPCUAValue<string>(
1347
+ session,
1348
+ `ns=1;s=${objectPath}/active_fault_code`,
1349
+ "none",
1350
+ ),
1351
+ ).toBe("none");
1352
+
1353
+ stage = "set parameter through OPC UA";
1354
+ await callOPCUAMethod(session, objectPath, "SetTarget", [
1355
+ { dataType: DataType.Double, value: 9.5 },
1356
+ ]);
1357
+
1358
+ expect(
1359
+ await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/parameter_echo`, 9.5),
1360
+ ).toBe(9.5);
1361
+
1362
+ stage = "stop asset through OPC UA";
1363
+ await callOPCUAMethod(session, objectPath, "Stop");
1364
+
1365
+ expect(
1366
+ await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/run_command`, false),
1367
+ ).toBe(false);
1368
+ expect(
1369
+ await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/speed_feedback`, 0),
1370
+ ).toBe(0);
1371
+ expect(await waitForOPCUAValue<number>(session, `ns=1;s=${objectPath}/flow_rate`, 0)).toBe(
1372
+ 0,
1373
+ );
1374
+ expect(
1375
+ await waitForOPCUAValue<boolean>(session, `ns=1;s=${objectPath}/running`, false),
1376
+ ).toBe(false);
1377
+ expect(
1378
+ await waitForOPCUAValue<string>(
1379
+ session,
1380
+ `ns=1;s=${objectPath}/availability_state`,
1381
+ "stopped",
1382
+ ),
1383
+ ).toBe("stopped");
1384
+ expect(
1385
+ await waitForOPCUAValue<string>(
1386
+ session,
1387
+ `ns=1;s=${objectPath}/manufacturer`,
1388
+ "Miravo Labs",
1389
+ ),
1390
+ ).toBe("Miravo Labs");
1391
+ } finally {
1392
+ recorder.dispose();
1393
+ }
1394
+ if (session) {
1395
+ await session.close();
1396
+ }
1397
+ await opcuaClient.disconnect();
1398
+ await stopMQTTClient(mqttClient);
1399
+ await stopSmokeRuntime(runtime);
1400
+ } catch (error) {
1401
+ if (session) {
1402
+ await session.close().catch(() => {});
1403
+ }
1404
+ await opcuaClient?.disconnect().catch(() => {});
1405
+ if (mqttClient) {
1406
+ await stopMQTTClient(mqttClient).catch(() => {});
1407
+ }
1408
+ await stopSmokeRuntimeWithFailure(
1409
+ runtime,
1410
+ new Error(
1411
+ `OPC UA integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1412
+ ),
1413
+ );
1414
+ }
1415
+ }, 30000);
1416
+
1417
+ test("lifecycle effects apply multiplier-then-offset and fleet metrics track stage transitions", async () => {
1418
+ // time_multiplier=10000: each 100ms tick = 0.278 simulated hours.
1419
+ // healthy: 3.0h (~11 ticks), degraded: 1.5h (~5 ticks), critical: terminal.
1420
+ // Runner waits ~3 ticks (0.83h) before ready. Test observes healthy→degraded→critical.
1421
+ const runtime = await createSmokeRuntime({
1422
+ model: createLifecycleTestModel(),
1423
+ namespacePrefix: "lifecycle-test",
1424
+ templateSettings: { tick_interval_ms: 100, time_multiplier: 10000, seed: 42 },
1425
+ });
1426
+ let client: MqttClient | undefined;
1427
+ let stage = "connect MQTT observer";
1428
+
1429
+ try {
1430
+ const { ready } = runtime;
1431
+ const topicPrefix = ready.instancePath;
1432
+ client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
1433
+ await waitForMQTTConnect(client);
1434
+ await subscribeMQTT(client, [`${topicPrefix}/#`]);
1435
+
1436
+ const recorder = createMQTTRecorder(client);
1437
+ try {
1438
+ stage = "verify healthy lifecycle metrics";
1439
+ const healthyMetrics = await waitForAsync("fleet lifecycle healthy", async () => {
1440
+ const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
1441
+ return m.fleet.lifecycleBreakdown["healthy"] === 1 ? m : undefined;
1442
+ });
1443
+ expect(healthyMetrics.fleet.lifecycleBreakdown["healthy"]).toBe(1);
1444
+
1445
+ stage = "wait for degraded stage (pressure = 100 * 0.8 - 5 = 75)";
1446
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 75)).toBe(75);
1447
+
1448
+ const degradedMetrics = await waitForAsync("fleet lifecycle degraded", async () => {
1449
+ const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
1450
+ return m.fleet.lifecycleBreakdown["degraded"] === 1 ? m : undefined;
1451
+ });
1452
+ expect(degradedMetrics.fleet.lifecycleBreakdown["degraded"]).toBe(1);
1453
+
1454
+ stage = "wait for critical stage (pressure = 100 * 0.5 - 20 = 30)";
1455
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 30)).toBe(30);
1456
+
1457
+ const criticalMetrics = await waitForAsync("fleet lifecycle critical", async () => {
1458
+ const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
1459
+ return m.fleet.lifecycleBreakdown["critical"] === 1 ? m : undefined;
1460
+ });
1461
+ expect(criticalMetrics.fleet.lifecycleBreakdown["critical"]).toBe(1);
1462
+ } finally {
1463
+ recorder.dispose();
1464
+ }
1465
+
1466
+ await stopMQTTClient(client);
1467
+ await stopSmokeRuntime(runtime);
1468
+ } catch (error) {
1469
+ if (client) {
1470
+ await stopMQTTClient(client).catch(() => {});
1471
+ }
1472
+ await stopSmokeRuntimeWithFailure(
1473
+ runtime,
1474
+ new Error(
1475
+ `Lifecycle integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1476
+ ),
1477
+ );
1478
+ }
1479
+ }, 30000);
1480
+
1481
+ test("condition-based faults auto-trigger when member exceeds threshold and quality reflects fault state", async () => {
1482
+ const runtime = await createSmokeRuntime({
1483
+ model: createConditionalFaultTestModel(),
1484
+ namespacePrefix: "fault-test",
1485
+ templateSettings: { seed: 42 },
1486
+ });
1487
+ let client: MqttClient | undefined;
1488
+ let stage = "connect MQTT client";
1489
+
1490
+ try {
1491
+ const { ready } = runtime;
1492
+ const topicPrefix = ready.instancePath;
1493
+ const cmdResultTopic = controlTopicCmdResult(ready.simulationName);
1494
+ client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
1495
+ await waitForMQTTConnect(client);
1496
+ await subscribeMQTT(client, [`${topicPrefix}/#`, cmdResultTopic]);
1497
+
1498
+ const recorder = createMQTTRecorder(client);
1499
+ try {
1500
+ stage = "set speed to trigger overpressure (pressure = 1000 * 0.1 = 100 > 80)";
1501
+ const setSpeedResult = await publishControlCommand(client, recorder, ready.simulationName, {
1502
+ type: "invokeMethod",
1503
+ instanceId: ready.instanceId,
1504
+ method: "SetSpeed",
1505
+ arguments: { speed: 1000 },
1506
+ } satisfies InvokeMethodCommandPayload);
1507
+ expect(setSpeedResult.success).toBe(true);
1508
+
1509
+ // Fault triggers on next tick after condition evaluates against pressure=100.
1510
+ // Fault effect: pressure = 100 * 0.5 = 50.
1511
+ stage = "verify fault auto-triggered";
1512
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_active`, true)).toBe(
1513
+ true,
1514
+ );
1515
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_count`, 1)).toBe(1);
1516
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 50)).toBe(50);
1517
+
1518
+ stage = "verify quality is uncertain during active fault";
1519
+ expect(
1520
+ await waitForMQTTTopicQuality(recorder, `${topicPrefix}/pressure`, "uncertain"),
1521
+ ).toBe("uncertain");
1522
+
1523
+ // Verify fleet metrics show active fault
1524
+ const faultMetrics = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
1525
+ expect(faultMetrics.fleet.activeFaultCount).toBe(1);
1526
+ expect(faultMetrics.fleet.faultBreakdown).toContainEqual({
1527
+ fault: "overpressure",
1528
+ count: 1,
1529
+ });
1530
+
1531
+ // Lower speed below threshold BEFORE clearing to prevent re-trigger.
1532
+ // speed=500 → pressure=50 (with fault: 50*0.5=25, without: 50).
1533
+ stage = "lower speed and clear fault";
1534
+ const lowerSpeedResult = await publishControlCommand(
1535
+ client,
1536
+ recorder,
1537
+ ready.simulationName,
1538
+ {
1539
+ type: "invokeMethod",
1540
+ instanceId: ready.instanceId,
1541
+ method: "SetSpeed",
1542
+ arguments: { speed: 500 },
1543
+ } satisfies InvokeMethodCommandPayload,
1544
+ );
1545
+ expect(lowerSpeedResult.success).toBe(true);
1546
+
1547
+ const resetResult = await publishControlCommand(client, recorder, ready.simulationName, {
1548
+ type: "invokeMethod",
1549
+ instanceId: ready.instanceId,
1550
+ method: "ResetFault",
1551
+ } satisfies InvokeMethodCommandPayload);
1552
+ expect(resetResult.success).toBe(true);
1553
+
1554
+ // After fault cleared: pressure = 500 * 0.1 = 50, condition 50>80 = false.
1555
+ stage = "verify fault cleared and quality recovered";
1556
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_active`, false)).toBe(
1557
+ false,
1558
+ );
1559
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 50)).toBe(50);
1560
+ expect(await waitForMQTTTopicQuality(recorder, `${topicPrefix}/pressure`, "good")).toBe(
1561
+ "good",
1562
+ );
1563
+
1564
+ // Fleet metrics should show zero active faults
1565
+ const clearedMetrics = await waitForAsync("fleet fault count 0", async () => {
1566
+ const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
1567
+ return m.fleet.activeFaultCount === 0 ? m : undefined;
1568
+ });
1569
+ expect(clearedMetrics.fleet.activeFaultCount).toBe(0);
1570
+ } finally {
1571
+ recorder.dispose();
1572
+ }
1573
+
1574
+ await stopMQTTClient(client);
1575
+ await stopSmokeRuntime(runtime);
1576
+ } catch (error) {
1577
+ if (client) {
1578
+ await stopMQTTClient(client).catch(() => {});
1579
+ }
1580
+ await stopSmokeRuntimeWithFailure(
1581
+ runtime,
1582
+ new Error(
1583
+ `Fault integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1584
+ ),
1585
+ );
1586
+ }
1587
+ }, 30000);
1588
+
1589
+ test("RBE filtering suppresses redundant MQTT messages within deadband", async () => {
1590
+ const runtime = await createSmokeRuntime({
1591
+ rbe: { enabled: true, deadband: 50 },
1592
+ templateSettings: { seed: 42 },
1593
+ });
1594
+ let client: MqttClient | undefined;
1595
+ let stage = "connect MQTT observer";
1596
+
1597
+ try {
1598
+ const { ready } = runtime;
1599
+ const topicPrefix = ready.instancePath;
1600
+ client = connect(ready.mqttBrokerUrl, { reconnectPeriod: 0 });
1601
+ await waitForMQTTConnect(client);
1602
+ await subscribeMQTT(client, [`${topicPrefix}/#`]);
1603
+
1604
+ const recorder = createMQTTRecorder(client);
1605
+ try {
1606
+ stage = "start pump to establish RBE baseline";
1607
+ const startResult = await postAdminCommand(runtime, {
1608
+ type: "invokeMethod",
1609
+ instanceId: ready.instanceId,
1610
+ method: "Start",
1611
+ });
1612
+ expect(startResult.success).toBe(true);
1613
+
1614
+ // speed_feedback=1200 should be published (|1200 - 0| > 50).
1615
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 1200)).toBe(
1616
+ 1200,
1617
+ );
1618
+
1619
+ const baselineMetrics = await readAdminJson(
1620
+ runtime,
1621
+ "/metrics",
1622
+ EngineMetricsSnapshotSchema,
1623
+ );
1624
+ const baselineMqtt = baselineMetrics.adapters.find((a) => a.name === "mqtt")?.metrics;
1625
+ expect(baselineMqtt).toBeDefined();
1626
+ const filteredBefore = baselineMqtt?.messagesFiltered ?? 0;
1627
+ const publishedBefore = baselineMqtt?.messagesPublished ?? 0;
1628
+
1629
+ stage = "set speed within RBE deadband";
1630
+ const smallChangeResult = await postAdminCommand(runtime, {
1631
+ type: "invokeMethod",
1632
+ instanceId: ready.instanceId,
1633
+ method: "SetSpeed",
1634
+ arguments: { speed_rpm: 1230 },
1635
+ });
1636
+ expect(smallChangeResult.success).toBe(true);
1637
+
1638
+ const filteredMetrics = await waitForAsync("messagesFiltered increased", async () => {
1639
+ const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
1640
+ const mqtt = m.adapters.find((a) => a.name === "mqtt");
1641
+ return mqtt && mqtt.metrics.messagesFiltered > filteredBefore ? mqtt.metrics : undefined;
1642
+ });
1643
+ expect(filteredMetrics.messagesFiltered).toBeGreaterThan(filteredBefore);
1644
+ expect(
1645
+ readPayloadValue(recorder.latestByTopic.get(`${topicPrefix}/speed_feedback`)?.parsed),
1646
+ ).toBe(1200);
1647
+
1648
+ stage = "set speed beyond RBE deadband";
1649
+ const largeChangeResult = await postAdminCommand(runtime, {
1650
+ type: "invokeMethod",
1651
+ instanceId: ready.instanceId,
1652
+ method: "SetSpeed",
1653
+ arguments: { speed_rpm: 1300 },
1654
+ });
1655
+ expect(largeChangeResult.success).toBe(true);
1656
+
1657
+ expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/speed_feedback`, 1300)).toBe(
1658
+ 1300,
1659
+ );
1660
+
1661
+ // Verify published count increased after the large value change.
1662
+ const afterMetrics = await waitForAsync("messagesPublished increased", async () => {
1663
+ const m = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
1664
+ const mqtt = m.adapters.find((a) => a.name === "mqtt");
1665
+ return mqtt && mqtt.metrics.messagesPublished > publishedBefore
1666
+ ? mqtt.metrics
1667
+ : undefined;
1668
+ });
1669
+ expect(afterMetrics.messagesPublished).toBeGreaterThan(publishedBefore);
1670
+ } finally {
1671
+ recorder.dispose();
1672
+ }
1673
+
1674
+ await stopMQTTClient(client);
1675
+ await stopSmokeRuntime(runtime);
1676
+ } catch (error) {
1677
+ if (client) {
1678
+ await stopMQTTClient(client).catch(() => {});
1679
+ }
1680
+ await stopSmokeRuntimeWithFailure(
1681
+ runtime,
1682
+ new Error(
1683
+ `RBE integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1684
+ ),
1685
+ );
1686
+ }
1687
+ }, 30000);
1688
+ });