miravo 0.2.3 → 0.4.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.
- package/README.md +14 -150
- package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
- package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
- package/console-dist/assets/index-C3XMYq26.css +2 -0
- package/console-dist/assets/index-hUJSpKtB.js +119 -0
- package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
- package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/console-dist/favicon.svg +37 -0
- package/console-dist/index.html +14 -0
- package/console-dist/miravo-logo.png +0 -0
- package/package.json +12 -9
- package/src/__tests__/integration/executable-methods-runner.ts +53 -45
- package/src/__tests__/integration/executable-methods.integration.ts +648 -80
- package/src/commands/adapter.ts +76 -0
- package/src/commands/cli-test-setup.ts +1 -1
- package/src/commands/fork.ts +14 -0
- package/src/commands/inject.test.ts +0 -24
- package/src/commands/inject.ts +16 -8
- package/src/commands/load.ts +167 -0
- package/src/commands/logs.ts +175 -0
- package/src/commands/models.test.ts +23 -2
- package/src/commands/models.ts +20 -0
- package/src/commands/pause.ts +67 -0
- package/src/commands/ps.ts +116 -0
- package/src/commands/remove.ts +38 -0
- package/src/commands/reset.ts +44 -0
- package/src/commands/rm.ts +97 -0
- package/src/commands/spawn.ts +45 -0
- package/src/commands/speed.ts +40 -0
- package/src/commands/start.test.ts +93 -0
- package/src/commands/start.ts +1043 -193
- package/src/commands/status.test.ts +0 -24
- package/src/commands/status.ts +12 -6
- package/src/commands/stop.test.ts +84 -0
- package/src/commands/stop.ts +158 -0
- package/src/commands/templates.test.ts +1 -14
- package/src/commands/templates.ts +13 -0
- package/src/index.ts +49 -7
- package/src/lib/admin-client.test.ts +35 -0
- package/src/lib/admin-client.ts +13 -1
- package/src/lib/admin-server.test.ts +339 -53
- package/src/lib/admin-server.ts +413 -62
- package/src/lib/daemon-registry.test.ts +429 -0
- package/src/lib/daemon-registry.ts +345 -0
- package/src/lib/mqtt-client.ts +1 -1
- package/src/lib/parse.ts +18 -0
- package/src/lib/resolve-simulation.ts +1 -1
- package/src/lib/shutdown.test.ts +16 -4
- package/src/lib/shutdown.ts +3 -3
- package/src/lib/template-category.test.ts +1 -0
- package/src/lib/template-utils.ts +33 -2
- package/src/lib/ws-hub.ts +255 -0
- package/src/logger.test.ts +19 -19
- package/src/logger.ts +34 -6
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EventEmitter } from "node:events";
|
|
2
3
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:net";
|
|
3
5
|
import { tmpdir } from "node:os";
|
|
4
6
|
import { join, resolve } from "node:path";
|
|
5
7
|
import {
|
|
@@ -8,6 +10,7 @@ import {
|
|
|
8
10
|
controlTopicCmdResult,
|
|
9
11
|
EngineMetricsSnapshotSchema,
|
|
10
12
|
EngineStateSnapshotSchema,
|
|
13
|
+
PersistedStateSchema,
|
|
11
14
|
type TwinModel,
|
|
12
15
|
} from "@miravo/shared";
|
|
13
16
|
import type { MqttClient } from "mqtt";
|
|
@@ -42,6 +45,11 @@ interface SmokeRuntimeHandle {
|
|
|
42
45
|
readonly ready: SmokeRuntimeReady;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
const processOutputCache = new WeakMap<
|
|
49
|
+
SmokeRuntimeHandle["process"],
|
|
50
|
+
Promise<{ readonly stdout: string; readonly stderr: string }>
|
|
51
|
+
>();
|
|
52
|
+
|
|
45
53
|
interface InvokeMethodCommandPayload {
|
|
46
54
|
readonly type: "invokeMethod";
|
|
47
55
|
readonly instanceId: string;
|
|
@@ -57,6 +65,68 @@ interface AdminHealthPayload {
|
|
|
57
65
|
readonly reason?: string;
|
|
58
66
|
}
|
|
59
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
|
+
|
|
60
130
|
function createSmokePumpModel(): TwinModel {
|
|
61
131
|
return {
|
|
62
132
|
schema_version: "2",
|
|
@@ -317,12 +387,12 @@ function createLifecycleTestModel(): TwinModel {
|
|
|
317
387
|
stages: [
|
|
318
388
|
{
|
|
319
389
|
name: "healthy",
|
|
320
|
-
duration_hours: [
|
|
390
|
+
duration_hours: [0.1, 0.1],
|
|
321
391
|
effects: {},
|
|
322
392
|
},
|
|
323
393
|
{
|
|
324
394
|
name: "degraded",
|
|
325
|
-
duration_hours: [
|
|
395
|
+
duration_hours: [0.05, 0.05],
|
|
326
396
|
effects: {
|
|
327
397
|
pressure: { multiplier: 0.8, offset: -5 },
|
|
328
398
|
},
|
|
@@ -583,9 +653,14 @@ async function waitForMQTTConnect(client: MqttClient, timeoutMs = 5000): Promise
|
|
|
583
653
|
}
|
|
584
654
|
|
|
585
655
|
async function stopMQTTClient(client: MqttClient): Promise<void> {
|
|
586
|
-
await
|
|
587
|
-
|
|
588
|
-
|
|
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
|
+
]);
|
|
589
664
|
}
|
|
590
665
|
|
|
591
666
|
async function subscribeMQTT(client: MqttClient, topics: readonly string[]): Promise<void> {
|
|
@@ -634,13 +709,22 @@ async function publishControlCommand(
|
|
|
634
709
|
const correlationId = crypto.randomUUID();
|
|
635
710
|
const commandTopic = controlTopicCmd(simulationName);
|
|
636
711
|
const resultTopic = controlTopicCmdResult(simulationName);
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
+
});
|
|
644
728
|
|
|
645
729
|
return waitFor(`MQTT command result for ${String(payload["type"])}`, () => {
|
|
646
730
|
const result = recorder.messages
|
|
@@ -660,15 +744,20 @@ async function waitForMQTTTopicValue(
|
|
|
660
744
|
recorder: ReturnType<typeof createMQTTRecorder>,
|
|
661
745
|
topic: string,
|
|
662
746
|
expected?: number | string | boolean,
|
|
747
|
+
timeoutMs?: number,
|
|
663
748
|
): Promise<number | string | boolean> {
|
|
664
|
-
return waitFor(
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
+
);
|
|
672
761
|
}
|
|
673
762
|
|
|
674
763
|
async function waitForMQTTTopicQuality(
|
|
@@ -735,9 +824,18 @@ async function callOPCUAMethodResult(
|
|
|
735
824
|
async function collectProcessOutput(
|
|
736
825
|
process: SmokeRuntimeHandle["process"],
|
|
737
826
|
): Promise<{ readonly stdout: string; readonly stderr: string }> {
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
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;
|
|
741
839
|
}
|
|
742
840
|
|
|
743
841
|
interface SmokeRuntimeOptions {
|
|
@@ -754,6 +852,14 @@ interface SmokeRuntimeOptions {
|
|
|
754
852
|
};
|
|
755
853
|
}
|
|
756
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
|
+
|
|
757
863
|
async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeRuntimeHandle> {
|
|
758
864
|
const model = options?.model ?? createSmokePumpModel();
|
|
759
865
|
const prefix = options?.namespacePrefix ?? "method-smoke";
|
|
@@ -827,16 +933,21 @@ async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeR
|
|
|
827
933
|
});
|
|
828
934
|
|
|
829
935
|
try {
|
|
830
|
-
const ready = await
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
+
]);
|
|
840
951
|
|
|
841
952
|
return { process, tmpDir, ready };
|
|
842
953
|
} catch (error) {
|
|
@@ -856,17 +967,206 @@ async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeR
|
|
|
856
967
|
}
|
|
857
968
|
}
|
|
858
969
|
|
|
859
|
-
async function
|
|
860
|
-
|
|
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");
|
|
861
1157
|
const exitResult = await Promise.race([
|
|
862
|
-
|
|
1158
|
+
process.exited.then(() => "exited"),
|
|
863
1159
|
Bun.sleep(5000).then(() => "timeout"),
|
|
864
1160
|
]);
|
|
865
1161
|
if (exitResult === "timeout") {
|
|
866
|
-
|
|
867
|
-
await
|
|
1162
|
+
process.kill("SIGKILL");
|
|
1163
|
+
await process.exited;
|
|
868
1164
|
}
|
|
869
|
-
|
|
1165
|
+
return collectProcessOutput(process);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function stopSmokeRuntime(handle: SmokeRuntimeHandle): Promise<void> {
|
|
1169
|
+
await stopProcessGracefully(handle.process);
|
|
870
1170
|
await rm(handle.tmpDir, { recursive: true, force: true });
|
|
871
1171
|
}
|
|
872
1172
|
|
|
@@ -874,17 +1174,7 @@ async function stopSmokeRuntimeWithFailure(
|
|
|
874
1174
|
handle: SmokeRuntimeHandle,
|
|
875
1175
|
cause: unknown,
|
|
876
1176
|
): Promise<never> {
|
|
877
|
-
handle.process
|
|
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);
|
|
1177
|
+
const output = await stopProcessGracefully(handle.process);
|
|
888
1178
|
await rm(handle.tmpDir, { recursive: true, force: true });
|
|
889
1179
|
|
|
890
1180
|
throw new Error(
|
|
@@ -898,7 +1188,284 @@ async function stopSmokeRuntimeWithFailure(
|
|
|
898
1188
|
);
|
|
899
1189
|
}
|
|
900
1190
|
|
|
901
|
-
|
|
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
|
+
|
|
902
1469
|
test("admin HTTP server exposes runtime state, executes core commands, and drives published twin behavior", async () => {
|
|
903
1470
|
const runtime = await createSmokeRuntime();
|
|
904
1471
|
let client: MqttClient | undefined;
|
|
@@ -1415,13 +1982,16 @@ describe("executable methods integration", () => {
|
|
|
1415
1982
|
}, 30000);
|
|
1416
1983
|
|
|
1417
1984
|
test("lifecycle effects apply multiplier-then-offset and fleet metrics track stage transitions", async () => {
|
|
1418
|
-
// time_multiplier=
|
|
1419
|
-
// healthy:
|
|
1420
|
-
//
|
|
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.
|
|
1421
1991
|
const runtime = await createSmokeRuntime({
|
|
1422
1992
|
model: createLifecycleTestModel(),
|
|
1423
1993
|
namespacePrefix: "lifecycle-test",
|
|
1424
|
-
templateSettings: { tick_interval_ms: 100, time_multiplier:
|
|
1994
|
+
templateSettings: { tick_interval_ms: 100, time_multiplier: 60, seed: 42 },
|
|
1425
1995
|
});
|
|
1426
1996
|
let client: MqttClient | undefined;
|
|
1427
1997
|
let stage = "connect MQTT observer";
|
|
@@ -1435,30 +2005,28 @@ describe("executable methods integration", () => {
|
|
|
1435
2005
|
|
|
1436
2006
|
const recorder = createMQTTRecorder(client);
|
|
1437
2007
|
try {
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
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.
|
|
1454
2014
|
stage = "wait for critical stage (pressure = 100 * 0.5 - 20 = 30)";
|
|
1455
|
-
expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 30)).toBe(
|
|
2015
|
+
expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 30, 15000)).toBe(
|
|
2016
|
+
30,
|
|
2017
|
+
);
|
|
1456
2018
|
|
|
1457
|
-
const criticalMetrics = await waitForAsync(
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
+
);
|
|
1461
2027
|
expect(criticalMetrics.fleet.lifecycleBreakdown["critical"]).toBe(1);
|
|
2028
|
+
// Verify lifecycle effects applied correctly: multiplier then offset
|
|
2029
|
+
expect(criticalMetrics.fleet.activeFaultCount).toBe(0);
|
|
1462
2030
|
} finally {
|
|
1463
2031
|
recorder.dispose();
|
|
1464
2032
|
}
|
|
@@ -1515,10 +2083,10 @@ describe("executable methods integration", () => {
|
|
|
1515
2083
|
expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/fault_count`, 1)).toBe(1);
|
|
1516
2084
|
expect(await waitForMQTTTopicValue(recorder, `${topicPrefix}/pressure`, 50)).toBe(50);
|
|
1517
2085
|
|
|
1518
|
-
stage = "verify quality
|
|
1519
|
-
expect(
|
|
1520
|
-
|
|
1521
|
-
)
|
|
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
|
+
);
|
|
1522
2090
|
|
|
1523
2091
|
// Verify fleet metrics show active fault
|
|
1524
2092
|
const faultMetrics = await readAdminJson(runtime, "/metrics", EngineMetricsSnapshotSchema);
|