miravo 0.2.3 → 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 +974 -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,14 +1,85 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EventEmitter } from "node:events";
|
|
3
|
+
import { createServer } from "node:net";
|
|
2
4
|
import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
|
|
3
5
|
import type { CommandResult, EngineCommand } from "@miravo/shared";
|
|
4
6
|
import { startAdminServer } from "./admin-server.ts";
|
|
5
7
|
|
|
6
|
-
function
|
|
8
|
+
async function canBindLocalTcp(): Promise<boolean> {
|
|
9
|
+
const probe = createServer();
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const cleanup = () => {
|
|
12
|
+
try {
|
|
13
|
+
probe.close();
|
|
14
|
+
} catch {
|
|
15
|
+
// Ignore close races in test cleanup.
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
(probe as unknown as EventEmitter).once("error", () => {
|
|
20
|
+
cleanup();
|
|
21
|
+
resolve(false);
|
|
22
|
+
});
|
|
23
|
+
(probe as unknown as EventEmitter).once("listening", () => {
|
|
24
|
+
cleanup();
|
|
25
|
+
resolve(true);
|
|
26
|
+
});
|
|
27
|
+
probe.listen(0, "127.0.0.1");
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function reservePort(): Promise<number> {
|
|
32
|
+
const probe = createServer();
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const cleanup = () => {
|
|
35
|
+
try {
|
|
36
|
+
probe.close();
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore close races in test cleanup.
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
(probe as unknown as EventEmitter).once("error", (error: Error) => {
|
|
43
|
+
cleanup();
|
|
44
|
+
reject(error);
|
|
45
|
+
});
|
|
46
|
+
(probe as unknown as EventEmitter).once("listening", () => {
|
|
47
|
+
const address = probe.address();
|
|
48
|
+
if (!address || typeof address === "string") {
|
|
49
|
+
cleanup();
|
|
50
|
+
reject(new Error("Failed to reserve a local TCP port for the admin server test."));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { port } = address;
|
|
55
|
+
probe.close((error) => {
|
|
56
|
+
if (error) {
|
|
57
|
+
reject(error);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
resolve(port);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
probe.listen(0, "127.0.0.1");
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TCP_AVAILABLE = await canBindLocalTcp();
|
|
68
|
+
const describeIfTcp = TCP_AVAILABLE ? describe : describe.skip;
|
|
69
|
+
|
|
70
|
+
function createMockAdminEngine(
|
|
71
|
+
initialState: EngineCoreMetrics["state"],
|
|
72
|
+
tickIntervalMs: number,
|
|
73
|
+
connectedAdapters: string[] = [],
|
|
74
|
+
enabledAdapters: string[] = [],
|
|
75
|
+
) {
|
|
7
76
|
const runtime = {
|
|
8
77
|
state: initialState as EngineCoreMetrics["state"],
|
|
9
78
|
tickCount: 0,
|
|
10
79
|
lastTickProgressAt: performance.now(),
|
|
11
80
|
executedCommands: [] as EngineCommand[],
|
|
81
|
+
connectedAdapters: [...connectedAdapters],
|
|
82
|
+
enabledAdapters: [...enabledAdapters],
|
|
12
83
|
};
|
|
13
84
|
|
|
14
85
|
const snapshot: EngineStateSnapshot = {
|
|
@@ -46,10 +117,30 @@ function createMockAdminEngine(initialState: EngineCoreMetrics["state"], tickInt
|
|
|
46
117
|
activeFaultCount: 0,
|
|
47
118
|
faultBreakdown: [],
|
|
48
119
|
},
|
|
49
|
-
adapters:
|
|
120
|
+
adapters: runtime.connectedAdapters.map((name) => ({
|
|
121
|
+
name,
|
|
122
|
+
metrics: { connected: true },
|
|
123
|
+
})),
|
|
50
124
|
}),
|
|
51
|
-
getState: () =>
|
|
52
|
-
|
|
125
|
+
getState: () => ({
|
|
126
|
+
...snapshot,
|
|
127
|
+
availableAdapters: runtime.enabledAdapters.map((name) => ({
|
|
128
|
+
name,
|
|
129
|
+
enabled: true,
|
|
130
|
+
pending: false,
|
|
131
|
+
config: {},
|
|
132
|
+
})),
|
|
133
|
+
}),
|
|
134
|
+
getAvailableAdapters: () =>
|
|
135
|
+
runtime.enabledAdapters.map((name) => ({
|
|
136
|
+
name,
|
|
137
|
+
enabled: true,
|
|
138
|
+
})),
|
|
139
|
+
getInstanceDetail: (_id: string) => undefined,
|
|
140
|
+
getModelSchema: (_name: string) => undefined,
|
|
141
|
+
getEventLog: () => ({ events: [], nextCursor: null, hasMore: false }),
|
|
142
|
+
getRunningTemplates: () => [],
|
|
143
|
+
execute: async (command: EngineCommand): Promise<CommandResult> => {
|
|
53
144
|
runtime.executedCommands.push(command);
|
|
54
145
|
return {
|
|
55
146
|
success: true,
|
|
@@ -74,6 +165,12 @@ function createMockAdminEngine(initialState: EngineCoreMetrics["state"], tickInt
|
|
|
74
165
|
getExecutedCommands() {
|
|
75
166
|
return [...runtime.executedCommands];
|
|
76
167
|
},
|
|
168
|
+
setConnectedAdapters(adapterNames: string[]) {
|
|
169
|
+
runtime.connectedAdapters = [...adapterNames];
|
|
170
|
+
},
|
|
171
|
+
setEnabledAdapters(adapterNames: string[]) {
|
|
172
|
+
runtime.enabledAdapters = [...adapterNames];
|
|
173
|
+
},
|
|
77
174
|
};
|
|
78
175
|
}
|
|
79
176
|
|
|
@@ -85,31 +182,57 @@ function serverBaseUrl(endpoint: string): string {
|
|
|
85
182
|
interface StartServerOptions {
|
|
86
183
|
readonly state: EngineCoreMetrics["state"];
|
|
87
184
|
readonly tickIntervalMs?: number;
|
|
88
|
-
readonly
|
|
89
|
-
|
|
90
|
-
readonly
|
|
91
|
-
|
|
185
|
+
readonly host?: string;
|
|
186
|
+
/** Adapter names that should report as connected in mock metrics. */
|
|
187
|
+
readonly connectedAdapters?: string[];
|
|
188
|
+
/** Adapter names that should report as enabled in the runtime state. */
|
|
189
|
+
readonly enabledAdapters?: string[];
|
|
92
190
|
}
|
|
93
191
|
|
|
94
|
-
function startTestServer(options: StartServerOptions, handles: Array<{ stop: () => void }>) {
|
|
95
|
-
const
|
|
192
|
+
async function startTestServer(options: StartServerOptions, handles: Array<{ stop: () => void }>) {
|
|
193
|
+
const port = await reservePort();
|
|
194
|
+
const mock = createMockAdminEngine(
|
|
195
|
+
options.state,
|
|
196
|
+
options.tickIntervalMs ?? 1000,
|
|
197
|
+
options.connectedAdapters,
|
|
198
|
+
options.enabledAdapters,
|
|
199
|
+
);
|
|
96
200
|
const handle = startAdminServer({
|
|
97
201
|
engine: mock.engine,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
202
|
+
network: {
|
|
203
|
+
host: options.host ?? "127.0.0.1",
|
|
204
|
+
port,
|
|
205
|
+
tickIntervalMs: options.tickIntervalMs ?? 1000,
|
|
206
|
+
},
|
|
207
|
+
protocols: {
|
|
208
|
+
getLastTickProgressAt: () => mock.getLastTickProgressAt(),
|
|
209
|
+
},
|
|
106
210
|
});
|
|
107
211
|
handles.push(handle);
|
|
108
212
|
const baseUrl = serverBaseUrl(handle.endpoint);
|
|
109
213
|
return { mock, baseUrl };
|
|
110
214
|
}
|
|
111
215
|
|
|
112
|
-
|
|
216
|
+
async function startShutdownTestServer(handles: Array<{ stop: () => void }>): Promise<{
|
|
217
|
+
baseUrl: string;
|
|
218
|
+
getShutdownReason: () => string;
|
|
219
|
+
}> {
|
|
220
|
+
const port = await reservePort();
|
|
221
|
+
const mock = createMockAdminEngine("running", 1000);
|
|
222
|
+
let shutdownReason = "";
|
|
223
|
+
const handle = startAdminServer({
|
|
224
|
+
engine: mock.engine,
|
|
225
|
+
network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
|
|
226
|
+
protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
|
|
227
|
+
requestShutdown: (reason) => {
|
|
228
|
+
shutdownReason = reason;
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
handles.push(handle);
|
|
232
|
+
return { baseUrl: serverBaseUrl(handle.endpoint), getShutdownReason: () => shutdownReason };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
describeIfTcp("admin server", () => {
|
|
113
236
|
const handles: Array<{ stop: () => void }> = [];
|
|
114
237
|
|
|
115
238
|
afterEach(() => {
|
|
@@ -125,11 +248,16 @@ describe("admin server", () => {
|
|
|
125
248
|
state: "running" as const,
|
|
126
249
|
healthStatus: 200,
|
|
127
250
|
readyStatus: 200,
|
|
251
|
+
connectedAdapters: ["mqtt"] as string[],
|
|
128
252
|
},
|
|
129
253
|
{ label: "503 when paused", state: "paused" as const, healthStatus: 200, readyStatus: 503 },
|
|
130
|
-
])("/readyz returns $label", async ({ state, healthStatus, readyStatus }) => {
|
|
131
|
-
const { baseUrl } = startTestServer(
|
|
132
|
-
{
|
|
254
|
+
])("/readyz returns $label", async ({ state, healthStatus, readyStatus, connectedAdapters }) => {
|
|
255
|
+
const { baseUrl } = await startTestServer(
|
|
256
|
+
{
|
|
257
|
+
state,
|
|
258
|
+
enabledAdapters: ["mqtt"],
|
|
259
|
+
connectedAdapters: connectedAdapters ?? [],
|
|
260
|
+
},
|
|
133
261
|
handles,
|
|
134
262
|
);
|
|
135
263
|
|
|
@@ -141,8 +269,8 @@ describe("admin server", () => {
|
|
|
141
269
|
});
|
|
142
270
|
|
|
143
271
|
test("/readyz returns 503 when mqtt is enabled but disconnected", async () => {
|
|
144
|
-
const { baseUrl } = startTestServer(
|
|
145
|
-
{ state: "running",
|
|
272
|
+
const { baseUrl } = await startTestServer(
|
|
273
|
+
{ state: "running", enabledAdapters: ["mqtt"] },
|
|
146
274
|
handles,
|
|
147
275
|
);
|
|
148
276
|
|
|
@@ -151,13 +279,10 @@ describe("admin server", () => {
|
|
|
151
279
|
});
|
|
152
280
|
|
|
153
281
|
test("/readyz returns 503 when opcua is enabled but disconnected", async () => {
|
|
154
|
-
const { baseUrl } = startTestServer(
|
|
282
|
+
const { baseUrl } = await startTestServer(
|
|
155
283
|
{
|
|
156
284
|
state: "running",
|
|
157
|
-
|
|
158
|
-
isMqttConnected: () => false,
|
|
159
|
-
opcuaEnabled: true,
|
|
160
|
-
isOpcuaConnected: () => false,
|
|
285
|
+
enabledAdapters: ["opcua"],
|
|
161
286
|
},
|
|
162
287
|
handles,
|
|
163
288
|
);
|
|
@@ -168,11 +293,28 @@ describe("admin server", () => {
|
|
|
168
293
|
expect(body.reason).toBe("opcua disconnected");
|
|
169
294
|
});
|
|
170
295
|
|
|
296
|
+
test("/readyz follows live adapter enablement instead of boot-time flags", async () => {
|
|
297
|
+
const { mock, baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
298
|
+
|
|
299
|
+
const initiallyReady = await fetch(`${baseUrl}/readyz`);
|
|
300
|
+
expect(initiallyReady.status).toBe(200);
|
|
301
|
+
|
|
302
|
+
mock.setEnabledAdapters(["mqtt"]);
|
|
303
|
+
const disconnected = await fetch(`${baseUrl}/readyz`);
|
|
304
|
+
expect(disconnected.status).toBe(503);
|
|
305
|
+
|
|
306
|
+
mock.setConnectedAdapters(["mqtt"]);
|
|
307
|
+
const connected = await fetch(`${baseUrl}/readyz`);
|
|
308
|
+
expect(connected.status).toBe(200);
|
|
309
|
+
|
|
310
|
+
mock.setEnabledAdapters([]);
|
|
311
|
+
mock.setConnectedAdapters([]);
|
|
312
|
+
const disabledAgain = await fetch(`${baseUrl}/readyz`);
|
|
313
|
+
expect(disabledAgain.status).toBe(200);
|
|
314
|
+
});
|
|
315
|
+
|
|
171
316
|
test("/healthz returns 503 on the first probe after running tick loop stalls", async () => {
|
|
172
|
-
const { mock, baseUrl } = startTestServer(
|
|
173
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
174
|
-
handles,
|
|
175
|
-
);
|
|
317
|
+
const { mock, baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
176
318
|
mock.setLastTickProgressAt(performance.now() - 3_500);
|
|
177
319
|
|
|
178
320
|
const stalled = await fetch(`${baseUrl}/healthz`);
|
|
@@ -185,10 +327,7 @@ describe("admin server", () => {
|
|
|
185
327
|
});
|
|
186
328
|
|
|
187
329
|
test("GET /metrics returns the engine metrics snapshot", async () => {
|
|
188
|
-
const { mock, baseUrl } = startTestServer(
|
|
189
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
190
|
-
handles,
|
|
191
|
-
);
|
|
330
|
+
const { mock, baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
192
331
|
mock.setTickCount(12);
|
|
193
332
|
|
|
194
333
|
const response = await fetch(`${baseUrl}/metrics`);
|
|
@@ -204,10 +343,7 @@ describe("admin server", () => {
|
|
|
204
343
|
});
|
|
205
344
|
|
|
206
345
|
test("GET /state returns the engine state snapshot", async () => {
|
|
207
|
-
const { baseUrl } = startTestServer(
|
|
208
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
209
|
-
handles,
|
|
210
|
-
);
|
|
346
|
+
const { baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
211
347
|
|
|
212
348
|
const response = await fetch(`${baseUrl}/state`);
|
|
213
349
|
const body = (await response.json()) as EngineStateSnapshot;
|
|
@@ -218,10 +354,7 @@ describe("admin server", () => {
|
|
|
218
354
|
});
|
|
219
355
|
|
|
220
356
|
test("POST /commands validates and dispatches engine commands", async () => {
|
|
221
|
-
const { mock, baseUrl } = startTestServer(
|
|
222
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
223
|
-
handles,
|
|
224
|
-
);
|
|
357
|
+
const { mock, baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
225
358
|
|
|
226
359
|
const response = await fetch(`${baseUrl}/commands`, {
|
|
227
360
|
method: "POST",
|
|
@@ -239,11 +372,26 @@ describe("admin server", () => {
|
|
|
239
372
|
expect(mock.getExecutedCommands()).toEqual([{ type: "pause", correlationId: "req-1" }]);
|
|
240
373
|
});
|
|
241
374
|
|
|
375
|
+
test("POST /commands requires auth on non-loopback binds", async () => {
|
|
376
|
+
const { baseUrl } = await startTestServer({ state: "running", host: "0.0.0.0" }, handles);
|
|
377
|
+
|
|
378
|
+
const response = await fetch(`${baseUrl}/commands`, {
|
|
379
|
+
method: "POST",
|
|
380
|
+
headers: { "content-type": "application/json" },
|
|
381
|
+
body: JSON.stringify({
|
|
382
|
+
type: "pause",
|
|
383
|
+
correlationId: "req-1",
|
|
384
|
+
}),
|
|
385
|
+
});
|
|
386
|
+
const body = (await response.json()) as { message: string; error: string };
|
|
387
|
+
|
|
388
|
+
expect(response.status).toBe(403);
|
|
389
|
+
expect(body.message).toBe("Mutation endpoints require authentication.");
|
|
390
|
+
expect(body.error).toBe("Set MIRAVO_ADMIN_TOKEN when binding to a non-loopback address.");
|
|
391
|
+
});
|
|
392
|
+
|
|
242
393
|
test("POST /commands returns 400 for invalid JSON", async () => {
|
|
243
|
-
const { baseUrl } = startTestServer(
|
|
244
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
245
|
-
handles,
|
|
246
|
-
);
|
|
394
|
+
const { baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
247
395
|
|
|
248
396
|
const response = await fetch(`${baseUrl}/commands`, {
|
|
249
397
|
method: "POST",
|
|
@@ -257,10 +405,7 @@ describe("admin server", () => {
|
|
|
257
405
|
});
|
|
258
406
|
|
|
259
407
|
test("POST /commands returns 400 for invalid command shape", async () => {
|
|
260
|
-
const { baseUrl } = startTestServer(
|
|
261
|
-
{ state: "running", mqttEnabled: false, isMqttConnected: () => false },
|
|
262
|
-
handles,
|
|
263
|
-
);
|
|
408
|
+
const { baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
264
409
|
|
|
265
410
|
const response = await fetch(`${baseUrl}/commands`, {
|
|
266
411
|
method: "POST",
|
|
@@ -273,4 +418,145 @@ describe("admin server", () => {
|
|
|
273
418
|
expect(body.success).toBe(false);
|
|
274
419
|
expect(body.command).toBe("setSpeed");
|
|
275
420
|
});
|
|
421
|
+
|
|
422
|
+
// --- Daemon mode endpoints ---
|
|
423
|
+
|
|
424
|
+
test("/readyz includes simulationName and runId when provided", async () => {
|
|
425
|
+
const port = await reservePort();
|
|
426
|
+
const mock = createMockAdminEngine("running", 1000);
|
|
427
|
+
const handle = startAdminServer({
|
|
428
|
+
engine: mock.engine,
|
|
429
|
+
network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
|
|
430
|
+
protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
|
|
431
|
+
simulationName: "test-daemon",
|
|
432
|
+
runId: "run-abc-123",
|
|
433
|
+
});
|
|
434
|
+
handles.push(handle);
|
|
435
|
+
const baseUrl = serverBaseUrl(handle.endpoint);
|
|
436
|
+
|
|
437
|
+
const response = await fetch(`${baseUrl}/readyz`);
|
|
438
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
439
|
+
|
|
440
|
+
expect(response.status).toBe(200);
|
|
441
|
+
expect(body["simulationName"]).toBe("test-daemon");
|
|
442
|
+
expect(body["runId"]).toBe("run-abc-123");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("/readyz omits simulationName and runId when not provided", async () => {
|
|
446
|
+
const { baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
447
|
+
|
|
448
|
+
const response = await fetch(`${baseUrl}/readyz`);
|
|
449
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
450
|
+
|
|
451
|
+
expect(response.status).toBe(200);
|
|
452
|
+
expect(body["simulationName"]).toBeUndefined();
|
|
453
|
+
expect(body["runId"]).toBeUndefined();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("POST /shutdown calls requestShutdown callback and returns 200", async () => {
|
|
457
|
+
const { baseUrl, getShutdownReason } = await startShutdownTestServer(handles);
|
|
458
|
+
|
|
459
|
+
const response = await fetch(`${baseUrl}/shutdown`, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { "content-type": "application/json" },
|
|
462
|
+
body: JSON.stringify({ reason: "miravo stop" }),
|
|
463
|
+
});
|
|
464
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
465
|
+
|
|
466
|
+
expect(response.status).toBe(200);
|
|
467
|
+
expect(body["status"]).toBe("shutting_down");
|
|
468
|
+
expect(getShutdownReason()).toBe("miravo stop");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("POST /shutdown uses default reason when body is empty", async () => {
|
|
472
|
+
const { baseUrl, getShutdownReason } = await startShutdownTestServer(handles);
|
|
473
|
+
|
|
474
|
+
const response = await fetch(`${baseUrl}/shutdown`, {
|
|
475
|
+
method: "POST",
|
|
476
|
+
headers: { "content-type": "application/json" },
|
|
477
|
+
});
|
|
478
|
+
expect(response.status).toBe(200);
|
|
479
|
+
expect(getShutdownReason()).toBe("remote shutdown request");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("GET /shutdown returns 405", async () => {
|
|
483
|
+
const port = await reservePort();
|
|
484
|
+
const mock = createMockAdminEngine("running", 1000);
|
|
485
|
+
const handle = startAdminServer({
|
|
486
|
+
engine: mock.engine,
|
|
487
|
+
network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
|
|
488
|
+
protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
|
|
489
|
+
requestShutdown: () => {},
|
|
490
|
+
});
|
|
491
|
+
handles.push(handle);
|
|
492
|
+
const baseUrl = serverBaseUrl(handle.endpoint);
|
|
493
|
+
|
|
494
|
+
const response = await fetch(`${baseUrl}/shutdown`);
|
|
495
|
+
expect(response.status).toBe(405);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("POST /shutdown returns 501 when requestShutdown not provided", async () => {
|
|
499
|
+
const { baseUrl } = await startTestServer({ state: "running" }, handles);
|
|
500
|
+
|
|
501
|
+
const response = await fetch(`${baseUrl}/shutdown`, { method: "POST" });
|
|
502
|
+
expect(response.status).toBe(501);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("POST /shutdown requires auth when adminToken is set", async () => {
|
|
506
|
+
const port = await reservePort();
|
|
507
|
+
const mock = createMockAdminEngine("running", 1000);
|
|
508
|
+
const handle = startAdminServer({
|
|
509
|
+
engine: mock.engine,
|
|
510
|
+
network: { host: "127.0.0.1", port, tickIntervalMs: 1000 },
|
|
511
|
+
protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
|
|
512
|
+
requestShutdown: () => {},
|
|
513
|
+
adminToken: "secret-token",
|
|
514
|
+
});
|
|
515
|
+
handles.push(handle);
|
|
516
|
+
const baseUrl = serverBaseUrl(handle.endpoint);
|
|
517
|
+
|
|
518
|
+
// Without token
|
|
519
|
+
const noAuth = await fetch(`${baseUrl}/shutdown`, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
headers: { "content-type": "application/json" },
|
|
522
|
+
});
|
|
523
|
+
expect(noAuth.status).toBe(401);
|
|
524
|
+
|
|
525
|
+
// With token
|
|
526
|
+
const withAuth = await fetch(`${baseUrl}/shutdown`, {
|
|
527
|
+
method: "POST",
|
|
528
|
+
headers: { "content-type": "application/json", authorization: "Bearer secret-token" },
|
|
529
|
+
});
|
|
530
|
+
expect(withAuth.status).toBe(200);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("POST /commands accepts bearer auth on non-loopback binds", async () => {
|
|
534
|
+
const port = await reservePort();
|
|
535
|
+
const mock = createMockAdminEngine("running", 1000);
|
|
536
|
+
const handle = startAdminServer({
|
|
537
|
+
engine: mock.engine,
|
|
538
|
+
network: { host: "0.0.0.0", port, tickIntervalMs: 1000 },
|
|
539
|
+
protocols: { getLastTickProgressAt: () => mock.getLastTickProgressAt() },
|
|
540
|
+
adminToken: "secret-token",
|
|
541
|
+
});
|
|
542
|
+
handles.push(handle);
|
|
543
|
+
const baseUrl = serverBaseUrl(handle.endpoint);
|
|
544
|
+
|
|
545
|
+
const response = await fetch(`${baseUrl}/commands`, {
|
|
546
|
+
method: "POST",
|
|
547
|
+
headers: {
|
|
548
|
+
"content-type": "application/json",
|
|
549
|
+
authorization: "Bearer secret-token",
|
|
550
|
+
},
|
|
551
|
+
body: JSON.stringify({
|
|
552
|
+
type: "pause",
|
|
553
|
+
correlationId: "req-2",
|
|
554
|
+
}),
|
|
555
|
+
});
|
|
556
|
+
const body = (await response.json()) as CommandResult;
|
|
557
|
+
|
|
558
|
+
expect(response.status).toBe(200);
|
|
559
|
+
expect(body.success).toBe(true);
|
|
560
|
+
expect(mock.getExecutedCommands()).toEqual([{ type: "pause", correlationId: "req-2" }]);
|
|
561
|
+
});
|
|
276
562
|
});
|