miravo 0.2.2 → 0.3.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 +116 -30
- 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-BXtW73cF.css +2 -0
- package/console-dist/assets/index-ChSocZFW.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 +20 -0
- package/console-dist/index.html +14 -0
- package/console-dist/miravo-logo.png +0 -0
- package/package.json +10 -7
- package/src/__tests__/integration/executable-methods-runner.ts +52 -45
- package/src/__tests__/integration/executable-methods.integration.ts +609 -47
- 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 +1 -1
- 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 +22 -1
- package/src/commands/models.ts +14 -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 +976 -192
- package/src/commands/status.test.ts +3 -1
- 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 -1
- 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: [3
|
|
390
|
+
duration_hours: [0.3, 0.3],
|
|
321
391
|
effects: {},
|
|
322
392
|
},
|
|
323
393
|
{
|
|
324
394
|
name: "degraded",
|
|
325
|
-
duration_hours: [
|
|
395
|
+
duration_hours: [0.15, 0.15],
|
|
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
|
|
@@ -735,9 +819,18 @@ async function callOPCUAMethodResult(
|
|
|
735
819
|
async function collectProcessOutput(
|
|
736
820
|
process: SmokeRuntimeHandle["process"],
|
|
737
821
|
): Promise<{ readonly stdout: string; readonly stderr: string }> {
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
822
|
+
const cached = processOutputCache.get(process);
|
|
823
|
+
if (cached) {
|
|
824
|
+
return cached;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const pending = (async () => {
|
|
828
|
+
const stdout = process.stdout ? await new Response(process.stdout).text() : "";
|
|
829
|
+
const stderr = process.stderr ? await new Response(process.stderr).text() : "";
|
|
830
|
+
return { stdout, stderr };
|
|
831
|
+
})();
|
|
832
|
+
processOutputCache.set(process, pending);
|
|
833
|
+
return pending;
|
|
741
834
|
}
|
|
742
835
|
|
|
743
836
|
interface SmokeRuntimeOptions {
|
|
@@ -754,6 +847,14 @@ interface SmokeRuntimeOptions {
|
|
|
754
847
|
};
|
|
755
848
|
}
|
|
756
849
|
|
|
850
|
+
interface CliPersistenceRuntimeHandle {
|
|
851
|
+
readonly process: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
852
|
+
readonly tmpDir: string;
|
|
853
|
+
readonly homeDir: string;
|
|
854
|
+
readonly stateFilePath: string;
|
|
855
|
+
readonly ready: SmokeRuntimeReady;
|
|
856
|
+
}
|
|
857
|
+
|
|
757
858
|
async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeRuntimeHandle> {
|
|
758
859
|
const model = options?.model ?? createSmokePumpModel();
|
|
759
860
|
const prefix = options?.namespacePrefix ?? "method-smoke";
|
|
@@ -827,16 +928,21 @@ async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeR
|
|
|
827
928
|
});
|
|
828
929
|
|
|
829
930
|
try {
|
|
830
|
-
const ready = await
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
931
|
+
const ready = await Promise.race([
|
|
932
|
+
waitForAsync(
|
|
933
|
+
"smoke runtime ready file",
|
|
934
|
+
async () => {
|
|
935
|
+
if (!(await Bun.file(readyFile).exists())) {
|
|
936
|
+
return undefined;
|
|
937
|
+
}
|
|
938
|
+
return (await Bun.file(readyFile).json()) as SmokeRuntimeReady;
|
|
939
|
+
},
|
|
940
|
+
15000,
|
|
941
|
+
),
|
|
942
|
+
process.exited.then((exitCode) => {
|
|
943
|
+
throw new Error(`Smoke runtime exited before it reported ready (exit code ${exitCode}).`);
|
|
944
|
+
}),
|
|
945
|
+
]);
|
|
840
946
|
|
|
841
947
|
return { process, tmpDir, ready };
|
|
842
948
|
} catch (error) {
|
|
@@ -856,17 +962,206 @@ async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeR
|
|
|
856
962
|
}
|
|
857
963
|
}
|
|
858
964
|
|
|
859
|
-
async function
|
|
860
|
-
|
|
965
|
+
async function createCliPersistenceRuntime(input: {
|
|
966
|
+
readonly tmpDir?: string;
|
|
967
|
+
readonly homeDir?: string;
|
|
968
|
+
readonly templatePath?: string;
|
|
969
|
+
readonly modelsDir?: string;
|
|
970
|
+
readonly model?: TwinModel;
|
|
971
|
+
readonly simulationName?: string;
|
|
972
|
+
readonly includeOpcuaFlag?: boolean;
|
|
973
|
+
readonly mqttPort?: number;
|
|
974
|
+
readonly opcuaPort?: number;
|
|
975
|
+
readonly adminPort?: number;
|
|
976
|
+
}): Promise<CliPersistenceRuntimeHandle> {
|
|
977
|
+
const model = input.model ?? createSmokePumpModel();
|
|
978
|
+
const simulationName = input.simulationName ?? "persistence-e2e";
|
|
979
|
+
const tmpDir = input.tmpDir ?? (await mkdtemp(join(tmpdir(), `miravo-${simulationName}-`)));
|
|
980
|
+
const homeDir = input.homeDir ?? join(tmpDir, "home");
|
|
981
|
+
const modelsDir = input.modelsDir ?? join(tmpDir, "models");
|
|
982
|
+
const templatePath = input.templatePath ?? join(tmpDir, `${simulationName}.miravo.yaml`);
|
|
983
|
+
const modelPath = join(modelsDir, `${model.model.name}.twin.yaml`);
|
|
984
|
+
const stateFilePath = join(homeDir, "state", `${simulationName}.json`);
|
|
985
|
+
const adminPort = input.adminPort ?? (await reservePort());
|
|
986
|
+
|
|
987
|
+
await mkdir(modelsDir, { recursive: true });
|
|
988
|
+
await mkdir(homeDir, { recursive: true });
|
|
989
|
+
|
|
990
|
+
if (!(await Bun.file(modelPath).exists())) {
|
|
991
|
+
await Bun.write(modelPath, stringifyYAML(model));
|
|
992
|
+
}
|
|
993
|
+
if (!(await Bun.file(templatePath).exists())) {
|
|
994
|
+
await Bun.write(
|
|
995
|
+
templatePath,
|
|
996
|
+
stringifyYAML({
|
|
997
|
+
kind: "MiravoTemplate",
|
|
998
|
+
version: "1",
|
|
999
|
+
name: `${simulationName} Template`,
|
|
1000
|
+
namespace: {
|
|
1001
|
+
prefix: simulationName,
|
|
1002
|
+
},
|
|
1003
|
+
areas: [
|
|
1004
|
+
{
|
|
1005
|
+
name: "validation",
|
|
1006
|
+
lines: [
|
|
1007
|
+
{
|
|
1008
|
+
name: "protocols",
|
|
1009
|
+
twins: [
|
|
1010
|
+
{
|
|
1011
|
+
model: model.model.name,
|
|
1012
|
+
count: 1,
|
|
1013
|
+
variation: 0,
|
|
1014
|
+
},
|
|
1015
|
+
],
|
|
1016
|
+
},
|
|
1017
|
+
],
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
1020
|
+
settings: {
|
|
1021
|
+
seed: 42,
|
|
1022
|
+
tick_interval_ms: 100,
|
|
1023
|
+
time_multiplier: 1,
|
|
1024
|
+
},
|
|
1025
|
+
}),
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const cmd = [
|
|
1030
|
+
"bun",
|
|
1031
|
+
"run",
|
|
1032
|
+
resolve(import.meta.dir, "..", "..", "index.ts"),
|
|
1033
|
+
"start",
|
|
1034
|
+
"--template",
|
|
1035
|
+
templatePath,
|
|
1036
|
+
"--models-dir",
|
|
1037
|
+
modelsDir,
|
|
1038
|
+
"--name",
|
|
1039
|
+
simulationName,
|
|
1040
|
+
"--admin-host",
|
|
1041
|
+
"127.0.0.1",
|
|
1042
|
+
"--admin-port",
|
|
1043
|
+
String(adminPort),
|
|
1044
|
+
"--no-ui",
|
|
1045
|
+
];
|
|
1046
|
+
if (input.mqttPort !== undefined) {
|
|
1047
|
+
cmd.push("--mqtt-host", "127.0.0.1", "--mqtt-port", String(input.mqttPort));
|
|
1048
|
+
}
|
|
1049
|
+
if (input.includeOpcuaFlag === true) {
|
|
1050
|
+
cmd.push("--opcua");
|
|
1051
|
+
if (input.opcuaPort !== undefined) {
|
|
1052
|
+
cmd.push("--opcua-port", String(input.opcuaPort));
|
|
1053
|
+
}
|
|
1054
|
+
cmd.push("--opcua-host", "127.0.0.1");
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const process = Bun.spawn({
|
|
1058
|
+
cmd,
|
|
1059
|
+
cwd: resolve("."),
|
|
1060
|
+
stdin: "ignore",
|
|
1061
|
+
stdout: "pipe",
|
|
1062
|
+
stderr: "pipe",
|
|
1063
|
+
env: {
|
|
1064
|
+
...Bun.env,
|
|
1065
|
+
MIRAVO_HOME: homeDir,
|
|
1066
|
+
},
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
const adminUrl = `http://127.0.0.1:${adminPort}`;
|
|
1070
|
+
try {
|
|
1071
|
+
await Promise.race([
|
|
1072
|
+
waitForAsync(
|
|
1073
|
+
"CLI runtime ready",
|
|
1074
|
+
async () => {
|
|
1075
|
+
try {
|
|
1076
|
+
const response = await fetch(new URL("/readyz", `${adminUrl}/`));
|
|
1077
|
+
if (!response.ok) {
|
|
1078
|
+
return undefined;
|
|
1079
|
+
}
|
|
1080
|
+
const payload = (await response.json()) as AdminHealthPayload;
|
|
1081
|
+
return payload.status === "ok" ? payload : undefined;
|
|
1082
|
+
} catch {
|
|
1083
|
+
return undefined;
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
15000,
|
|
1087
|
+
),
|
|
1088
|
+
process.exited.then((exitCode) => {
|
|
1089
|
+
throw new Error(`CLI runtime exited before it reported ready (exit code ${exitCode}).`);
|
|
1090
|
+
}),
|
|
1091
|
+
]);
|
|
1092
|
+
|
|
1093
|
+
const state = await readAdminJson(
|
|
1094
|
+
{
|
|
1095
|
+
process,
|
|
1096
|
+
tmpDir,
|
|
1097
|
+
ready: {
|
|
1098
|
+
simulationName,
|
|
1099
|
+
adminUrl,
|
|
1100
|
+
mqttBrokerUrl: input.mqttPort ? `mqtt://127.0.0.1:${input.mqttPort}` : "",
|
|
1101
|
+
opcuaEndpointUrl: input.opcuaPort ? `opc.tcp://127.0.0.1:${input.opcuaPort}` : "",
|
|
1102
|
+
instanceId: "",
|
|
1103
|
+
instancePath: "",
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
"/state",
|
|
1107
|
+
EngineStateSnapshotSchema,
|
|
1108
|
+
);
|
|
1109
|
+
const instance = state.instances[0];
|
|
1110
|
+
if (!instance) {
|
|
1111
|
+
throw new Error("CLI runtime did not expose any instances after startup.");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
process,
|
|
1116
|
+
tmpDir,
|
|
1117
|
+
homeDir,
|
|
1118
|
+
stateFilePath,
|
|
1119
|
+
ready: {
|
|
1120
|
+
simulationName,
|
|
1121
|
+
adminUrl,
|
|
1122
|
+
mqttBrokerUrl:
|
|
1123
|
+
input.mqttPort !== undefined ? `mqtt://127.0.0.1:${input.mqttPort}` : "mqtt://127.0.0.1",
|
|
1124
|
+
opcuaEndpointUrl:
|
|
1125
|
+
input.opcuaPort !== undefined
|
|
1126
|
+
? `opc.tcp://127.0.0.1:${input.opcuaPort}`
|
|
1127
|
+
: "opc.tcp://127.0.0.1",
|
|
1128
|
+
instanceId: instance.id,
|
|
1129
|
+
instancePath: instance.browsePath.join("/"),
|
|
1130
|
+
},
|
|
1131
|
+
};
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
process.kill("SIGTERM");
|
|
1134
|
+
await process.exited;
|
|
1135
|
+
const output = await collectProcessOutput(process);
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
[
|
|
1138
|
+
`Failed to start CLI persistence runtime: ${error instanceof Error ? error.message : String(error)}`,
|
|
1139
|
+
output.stdout.trim().length > 0 ? `stdout:\n${output.stdout.trim()}` : "",
|
|
1140
|
+
output.stderr.trim().length > 0 ? `stderr:\n${output.stderr.trim()}` : "",
|
|
1141
|
+
]
|
|
1142
|
+
.filter(Boolean)
|
|
1143
|
+
.join("\n\n"),
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async function stopProcessGracefully(
|
|
1149
|
+
process: SmokeRuntimeHandle["process"],
|
|
1150
|
+
): Promise<{ readonly stdout: string; readonly stderr: string }> {
|
|
1151
|
+
process.kill("SIGTERM");
|
|
861
1152
|
const exitResult = await Promise.race([
|
|
862
|
-
|
|
1153
|
+
process.exited.then(() => "exited"),
|
|
863
1154
|
Bun.sleep(5000).then(() => "timeout"),
|
|
864
1155
|
]);
|
|
865
1156
|
if (exitResult === "timeout") {
|
|
866
|
-
|
|
867
|
-
await
|
|
1157
|
+
process.kill("SIGKILL");
|
|
1158
|
+
await process.exited;
|
|
868
1159
|
}
|
|
869
|
-
|
|
1160
|
+
return collectProcessOutput(process);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async function stopSmokeRuntime(handle: SmokeRuntimeHandle): Promise<void> {
|
|
1164
|
+
await stopProcessGracefully(handle.process);
|
|
870
1165
|
await rm(handle.tmpDir, { recursive: true, force: true });
|
|
871
1166
|
}
|
|
872
1167
|
|
|
@@ -874,17 +1169,7 @@ async function stopSmokeRuntimeWithFailure(
|
|
|
874
1169
|
handle: SmokeRuntimeHandle,
|
|
875
1170
|
cause: unknown,
|
|
876
1171
|
): 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);
|
|
1172
|
+
const output = await stopProcessGracefully(handle.process);
|
|
888
1173
|
await rm(handle.tmpDir, { recursive: true, force: true });
|
|
889
1174
|
|
|
890
1175
|
throw new Error(
|
|
@@ -898,7 +1183,284 @@ async function stopSmokeRuntimeWithFailure(
|
|
|
898
1183
|
);
|
|
899
1184
|
}
|
|
900
1185
|
|
|
901
|
-
|
|
1186
|
+
describeIfTcp("executable methods integration", () => {
|
|
1187
|
+
test("CLI restart restores persisted state and restored adapters remain operational", async () => {
|
|
1188
|
+
const simulationName = "persistence-e2e";
|
|
1189
|
+
const mqttPort = await reservePort();
|
|
1190
|
+
const opcuaPort = await reservePort();
|
|
1191
|
+
const adminPort = await reservePort();
|
|
1192
|
+
const restartAdminPort = await reservePort();
|
|
1193
|
+
const firstRuntime = await createCliPersistenceRuntime({
|
|
1194
|
+
simulationName,
|
|
1195
|
+
includeOpcuaFlag: true,
|
|
1196
|
+
mqttPort,
|
|
1197
|
+
opcuaPort,
|
|
1198
|
+
adminPort,
|
|
1199
|
+
});
|
|
1200
|
+
let restartRuntime: CliPersistenceRuntimeHandle | undefined;
|
|
1201
|
+
let mqttClient: MqttClient | undefined;
|
|
1202
|
+
let opcuaClient: ReturnType<typeof OPCUAClient.create> | undefined;
|
|
1203
|
+
let session: ClientSession | undefined;
|
|
1204
|
+
let firstRuntimeStopped = false;
|
|
1205
|
+
let stage = "mutate first runtime";
|
|
1206
|
+
|
|
1207
|
+
try {
|
|
1208
|
+
const firstState = await readAdminJson(
|
|
1209
|
+
{
|
|
1210
|
+
process: firstRuntime.process,
|
|
1211
|
+
tmpDir: firstRuntime.tmpDir,
|
|
1212
|
+
ready: firstRuntime.ready,
|
|
1213
|
+
},
|
|
1214
|
+
"/state",
|
|
1215
|
+
EngineStateSnapshotSchema,
|
|
1216
|
+
);
|
|
1217
|
+
const originalInstanceId = firstState.instances[0]?.id;
|
|
1218
|
+
const originalInstancePath = firstState.instances[0]?.browsePath.join("/");
|
|
1219
|
+
if (!originalInstanceId || !originalInstancePath) {
|
|
1220
|
+
throw new Error("First runtime did not expose a boot instance.");
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
stage = "mutate persisted runtime state";
|
|
1224
|
+
expect(
|
|
1225
|
+
(
|
|
1226
|
+
await postAdminCommand(
|
|
1227
|
+
{
|
|
1228
|
+
process: firstRuntime.process,
|
|
1229
|
+
tmpDir: firstRuntime.tmpDir,
|
|
1230
|
+
ready: firstRuntime.ready,
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
type: "invokeMethod",
|
|
1234
|
+
instanceId: originalInstanceId,
|
|
1235
|
+
method: "SetTarget",
|
|
1236
|
+
arguments: { target_value: 9.5 },
|
|
1237
|
+
},
|
|
1238
|
+
)
|
|
1239
|
+
).success,
|
|
1240
|
+
).toBe(true);
|
|
1241
|
+
expect(
|
|
1242
|
+
(
|
|
1243
|
+
await postAdminCommand(
|
|
1244
|
+
{
|
|
1245
|
+
process: firstRuntime.process,
|
|
1246
|
+
tmpDir: firstRuntime.tmpDir,
|
|
1247
|
+
ready: firstRuntime.ready,
|
|
1248
|
+
},
|
|
1249
|
+
{
|
|
1250
|
+
type: "triggerFault",
|
|
1251
|
+
instanceId: originalInstanceId,
|
|
1252
|
+
fault: "trip",
|
|
1253
|
+
},
|
|
1254
|
+
)
|
|
1255
|
+
).success,
|
|
1256
|
+
).toBe(true);
|
|
1257
|
+
const spawnResult = await postAdminCommand(
|
|
1258
|
+
{
|
|
1259
|
+
process: firstRuntime.process,
|
|
1260
|
+
tmpDir: firstRuntime.tmpDir,
|
|
1261
|
+
ready: firstRuntime.ready,
|
|
1262
|
+
},
|
|
1263
|
+
{
|
|
1264
|
+
type: "spawn",
|
|
1265
|
+
model: "method-smoke-pump",
|
|
1266
|
+
count: 1,
|
|
1267
|
+
variation: 0,
|
|
1268
|
+
unsBasePath: `${simulationName}/validation/protocols`,
|
|
1269
|
+
},
|
|
1270
|
+
);
|
|
1271
|
+
expect(spawnResult.success).toBe(true);
|
|
1272
|
+
const spawnedIds = Array.isArray(spawnResult.data)
|
|
1273
|
+
? spawnResult.data.filter((value): value is string => typeof value === "string")
|
|
1274
|
+
: [];
|
|
1275
|
+
expect(spawnedIds).toHaveLength(1);
|
|
1276
|
+
const spawnedInstanceId = spawnedIds[0];
|
|
1277
|
+
if (!spawnedInstanceId) {
|
|
1278
|
+
throw new Error("Spawn command did not return the new instance ID.");
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const persistedStateBeforeRestart = await waitForAsync(
|
|
1282
|
+
"persisted runtime state before restart",
|
|
1283
|
+
async () => {
|
|
1284
|
+
const state = await readAdminJson(
|
|
1285
|
+
{
|
|
1286
|
+
process: firstRuntime.process,
|
|
1287
|
+
tmpDir: firstRuntime.tmpDir,
|
|
1288
|
+
ready: firstRuntime.ready,
|
|
1289
|
+
},
|
|
1290
|
+
"/state",
|
|
1291
|
+
EngineStateSnapshotSchema,
|
|
1292
|
+
);
|
|
1293
|
+
const original = state.instances.find((instance) => instance.id === originalInstanceId);
|
|
1294
|
+
return state.instances.length === 2 && original?.activeFaults.includes("trip") === true
|
|
1295
|
+
? state
|
|
1296
|
+
: undefined;
|
|
1297
|
+
},
|
|
1298
|
+
);
|
|
1299
|
+
expect(
|
|
1300
|
+
persistedStateBeforeRestart.instances.some((instance) => instance.id === spawnedInstanceId),
|
|
1301
|
+
).toBe(true);
|
|
1302
|
+
|
|
1303
|
+
stage = "stop first runtime and inspect saved snapshot";
|
|
1304
|
+
await stopProcessGracefully(firstRuntime.process);
|
|
1305
|
+
firstRuntimeStopped = true;
|
|
1306
|
+
const persistedSnapshot = PersistedStateSchema.parse(
|
|
1307
|
+
await Bun.file(firstRuntime.stateFilePath).json(),
|
|
1308
|
+
);
|
|
1309
|
+
expect(persistedSnapshot.instances).toHaveLength(2);
|
|
1310
|
+
expect(persistedSnapshot.adapters).toEqual(
|
|
1311
|
+
expect.arrayContaining([
|
|
1312
|
+
expect.objectContaining({ name: "mqtt", enabled: true }),
|
|
1313
|
+
expect.objectContaining({ name: "opcua", enabled: true }),
|
|
1314
|
+
]),
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
stage = "restart CLI against persisted snapshot";
|
|
1318
|
+
restartRuntime = await createCliPersistenceRuntime({
|
|
1319
|
+
tmpDir: firstRuntime.tmpDir,
|
|
1320
|
+
homeDir: firstRuntime.homeDir,
|
|
1321
|
+
templatePath: join(firstRuntime.tmpDir, `${simulationName}.miravo.yaml`),
|
|
1322
|
+
modelsDir: join(firstRuntime.tmpDir, "models"),
|
|
1323
|
+
simulationName,
|
|
1324
|
+
adminPort: restartAdminPort,
|
|
1325
|
+
});
|
|
1326
|
+
const restartedRuntime = restartRuntime;
|
|
1327
|
+
|
|
1328
|
+
const restoredState = await readAdminJson(
|
|
1329
|
+
{
|
|
1330
|
+
process: restartedRuntime.process,
|
|
1331
|
+
tmpDir: restartedRuntime.tmpDir,
|
|
1332
|
+
ready: restartedRuntime.ready,
|
|
1333
|
+
},
|
|
1334
|
+
"/state",
|
|
1335
|
+
EngineStateSnapshotSchema,
|
|
1336
|
+
);
|
|
1337
|
+
expect(restoredState.instances).toHaveLength(2);
|
|
1338
|
+
expect(restoredState.instances.map((instance) => instance.id).sort()).toEqual(
|
|
1339
|
+
[originalInstanceId, spawnedInstanceId].sort(),
|
|
1340
|
+
);
|
|
1341
|
+
expect(
|
|
1342
|
+
restoredState.instances.find((instance) => instance.id === originalInstanceId)
|
|
1343
|
+
?.activeFaults,
|
|
1344
|
+
).toEqual(["trip"]);
|
|
1345
|
+
expect(
|
|
1346
|
+
restoredState.availableAdapters?.map((adapter) => ({
|
|
1347
|
+
name: adapter.name,
|
|
1348
|
+
enabled: adapter.enabled,
|
|
1349
|
+
})),
|
|
1350
|
+
).toEqual(
|
|
1351
|
+
expect.arrayContaining([
|
|
1352
|
+
{ name: "mqtt", enabled: true },
|
|
1353
|
+
{ name: "opcua", enabled: true },
|
|
1354
|
+
]),
|
|
1355
|
+
);
|
|
1356
|
+
|
|
1357
|
+
stage = "verify restored OPC UA state";
|
|
1358
|
+
opcuaClient = OPCUAClient.create({
|
|
1359
|
+
securityMode: MessageSecurityMode.None,
|
|
1360
|
+
securityPolicy: SecurityPolicy.None,
|
|
1361
|
+
endpointMustExist: false,
|
|
1362
|
+
});
|
|
1363
|
+
await opcuaClient.connect(`opc.tcp://127.0.0.1:${opcuaPort}`);
|
|
1364
|
+
session = await opcuaClient.createSession();
|
|
1365
|
+
expect(
|
|
1366
|
+
await waitForOPCUAValue<number>(
|
|
1367
|
+
session,
|
|
1368
|
+
`ns=1;s=${originalInstancePath}/parameter_echo`,
|
|
1369
|
+
9.5,
|
|
1370
|
+
),
|
|
1371
|
+
).toBe(9.5);
|
|
1372
|
+
expect(
|
|
1373
|
+
await waitForOPCUAValue<string>(
|
|
1374
|
+
session,
|
|
1375
|
+
`ns=1;s=${originalInstancePath}/active_fault_code`,
|
|
1376
|
+
"trip",
|
|
1377
|
+
),
|
|
1378
|
+
).toBe("trip");
|
|
1379
|
+
|
|
1380
|
+
stage = "verify restored MQTT control plane";
|
|
1381
|
+
mqttClient = connect(`mqtt://127.0.0.1:${mqttPort}`, { reconnectPeriod: 0 });
|
|
1382
|
+
await waitForMQTTConnect(mqttClient);
|
|
1383
|
+
const resultTopic = controlTopicCmdResult(simulationName);
|
|
1384
|
+
await subscribeMQTT(mqttClient, [`${originalInstancePath}/#`, resultTopic]);
|
|
1385
|
+
const recorder = createMQTTRecorder(mqttClient);
|
|
1386
|
+
try {
|
|
1387
|
+
const resetResult = await publishControlCommand(mqttClient, recorder, simulationName, {
|
|
1388
|
+
type: "invokeMethod",
|
|
1389
|
+
instanceId: originalInstanceId,
|
|
1390
|
+
method: "ResetFault",
|
|
1391
|
+
} satisfies InvokeMethodCommandPayload);
|
|
1392
|
+
expect(resetResult.success).toBe(true);
|
|
1393
|
+
expect(
|
|
1394
|
+
await waitForMQTTTopicValue(recorder, `${originalInstancePath}/fault_active`, false),
|
|
1395
|
+
).toBe(false);
|
|
1396
|
+
const clearedState = await waitForAsync("cleared restored fault state", async () => {
|
|
1397
|
+
const state = await readAdminJson(
|
|
1398
|
+
{
|
|
1399
|
+
process: restartedRuntime.process,
|
|
1400
|
+
tmpDir: restartedRuntime.tmpDir,
|
|
1401
|
+
ready: restartedRuntime.ready,
|
|
1402
|
+
},
|
|
1403
|
+
"/state",
|
|
1404
|
+
EngineStateSnapshotSchema,
|
|
1405
|
+
);
|
|
1406
|
+
const original = state.instances.find((instance) => instance.id === originalInstanceId);
|
|
1407
|
+
return original?.activeFaults.length === 0 ? state : undefined;
|
|
1408
|
+
});
|
|
1409
|
+
expect(
|
|
1410
|
+
clearedState.instances.find((instance) => instance.id === originalInstanceId)
|
|
1411
|
+
?.activeFaults,
|
|
1412
|
+
).toEqual([]);
|
|
1413
|
+
} finally {
|
|
1414
|
+
recorder.dispose();
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (session) {
|
|
1418
|
+
await session.close();
|
|
1419
|
+
session = undefined;
|
|
1420
|
+
}
|
|
1421
|
+
await opcuaClient.disconnect();
|
|
1422
|
+
opcuaClient = undefined;
|
|
1423
|
+
await stopMQTTClient(mqttClient);
|
|
1424
|
+
mqttClient = undefined;
|
|
1425
|
+
await stopProcessGracefully(restartRuntime.process);
|
|
1426
|
+
await rm(firstRuntime.tmpDir, { recursive: true, force: true });
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
if (session) {
|
|
1429
|
+
await session.close().catch(() => {});
|
|
1430
|
+
}
|
|
1431
|
+
await opcuaClient?.disconnect().catch(() => {});
|
|
1432
|
+
if (mqttClient) {
|
|
1433
|
+
await stopMQTTClient(mqttClient).catch(() => {});
|
|
1434
|
+
}
|
|
1435
|
+
if (!firstRuntimeStopped) {
|
|
1436
|
+
await stopProcessGracefully(firstRuntime.process).catch(() => ({
|
|
1437
|
+
stdout: "",
|
|
1438
|
+
stderr: "",
|
|
1439
|
+
}));
|
|
1440
|
+
}
|
|
1441
|
+
if (restartRuntime) {
|
|
1442
|
+
const output = await stopProcessGracefully(restartRuntime.process).catch(() => ({
|
|
1443
|
+
stdout: "",
|
|
1444
|
+
stderr: "",
|
|
1445
|
+
}));
|
|
1446
|
+
await rm(restartRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
1447
|
+
throw new Error(
|
|
1448
|
+
[
|
|
1449
|
+
`Persistence restart integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
|
|
1450
|
+
output.stdout.trim().length > 0 ? `restart stdout:\n${output.stdout.trim()}` : "",
|
|
1451
|
+
output.stderr.trim().length > 0 ? `restart stderr:\n${output.stderr.trim()}` : "",
|
|
1452
|
+
]
|
|
1453
|
+
.filter(Boolean)
|
|
1454
|
+
.join("\n\n"),
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
await rm(firstRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
1458
|
+
throw new Error(
|
|
1459
|
+
`Persistence restart integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
}, 45000);
|
|
1463
|
+
|
|
902
1464
|
test("admin HTTP server exposes runtime state, executes core commands, and drives published twin behavior", async () => {
|
|
903
1465
|
const runtime = await createSmokeRuntime();
|
|
904
1466
|
let client: MqttClient | undefined;
|
|
@@ -1415,13 +1977,13 @@ describe("executable methods integration", () => {
|
|
|
1415
1977
|
}, 30000);
|
|
1416
1978
|
|
|
1417
1979
|
test("lifecycle effects apply multiplier-then-offset and fleet metrics track stage transitions", async () => {
|
|
1418
|
-
// time_multiplier=
|
|
1419
|
-
// healthy:
|
|
1420
|
-
// Runner waits ~3 ticks (0.
|
|
1980
|
+
// time_multiplier=1000: each 100ms tick = 0.028 simulated hours.
|
|
1981
|
+
// healthy: 0.3h (~11 ticks), degraded: 0.15h (~5 ticks), critical: terminal.
|
|
1982
|
+
// Runner waits ~3 ticks (0.08h) before ready. Test observes healthy→degraded→critical.
|
|
1421
1983
|
const runtime = await createSmokeRuntime({
|
|
1422
1984
|
model: createLifecycleTestModel(),
|
|
1423
1985
|
namespacePrefix: "lifecycle-test",
|
|
1424
|
-
templateSettings: { tick_interval_ms: 100, time_multiplier:
|
|
1986
|
+
templateSettings: { tick_interval_ms: 100, time_multiplier: 1000, seed: 42 },
|
|
1425
1987
|
});
|
|
1426
1988
|
let client: MqttClient | undefined;
|
|
1427
1989
|
let stage = "connect MQTT observer";
|