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.
Files changed (61) hide show
  1. package/README.md +116 -30
  2. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  3. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  4. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  5. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  6. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  7. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  8. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  9. package/console-dist/assets/index-BXtW73cF.css +2 -0
  10. package/console-dist/assets/index-ChSocZFW.js +119 -0
  11. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  12. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  13. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  14. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  15. package/console-dist/favicon.svg +20 -0
  16. package/console-dist/index.html +14 -0
  17. package/console-dist/miravo-logo.png +0 -0
  18. package/package.json +10 -7
  19. package/src/__tests__/integration/executable-methods-runner.ts +52 -45
  20. package/src/__tests__/integration/executable-methods.integration.ts +609 -47
  21. package/src/commands/adapter.ts +76 -0
  22. package/src/commands/cli-test-setup.ts +1 -1
  23. package/src/commands/fork.ts +14 -0
  24. package/src/commands/inject.test.ts +1 -1
  25. package/src/commands/inject.ts +16 -8
  26. package/src/commands/load.ts +167 -0
  27. package/src/commands/logs.ts +175 -0
  28. package/src/commands/models.test.ts +22 -1
  29. package/src/commands/models.ts +14 -0
  30. package/src/commands/pause.ts +67 -0
  31. package/src/commands/ps.ts +116 -0
  32. package/src/commands/remove.ts +38 -0
  33. package/src/commands/reset.ts +44 -0
  34. package/src/commands/rm.ts +97 -0
  35. package/src/commands/spawn.ts +45 -0
  36. package/src/commands/speed.ts +40 -0
  37. package/src/commands/start.test.ts +93 -0
  38. package/src/commands/start.ts +974 -192
  39. package/src/commands/status.test.ts +3 -1
  40. package/src/commands/status.ts +12 -6
  41. package/src/commands/stop.test.ts +84 -0
  42. package/src/commands/stop.ts +158 -0
  43. package/src/commands/templates.test.ts +1 -1
  44. package/src/commands/templates.ts +13 -0
  45. package/src/index.ts +49 -7
  46. package/src/lib/admin-client.test.ts +35 -0
  47. package/src/lib/admin-client.ts +13 -1
  48. package/src/lib/admin-server.test.ts +339 -53
  49. package/src/lib/admin-server.ts +413 -62
  50. package/src/lib/daemon-registry.test.ts +429 -0
  51. package/src/lib/daemon-registry.ts +345 -0
  52. package/src/lib/mqtt-client.ts +1 -1
  53. package/src/lib/parse.ts +18 -0
  54. package/src/lib/resolve-simulation.ts +1 -1
  55. package/src/lib/shutdown.test.ts +16 -4
  56. package/src/lib/shutdown.ts +3 -3
  57. package/src/lib/template-category.test.ts +1 -0
  58. package/src/lib/template-utils.ts +33 -2
  59. package/src/lib/ws-hub.ts +255 -0
  60. package/src/logger.test.ts +19 -19
  61. 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 createMockAdminEngine(initialState: EngineCoreMetrics["state"], tickIntervalMs: number) {
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: () => snapshot,
52
- execute: (command: EngineCommand): CommandResult => {
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 mqttEnabled: boolean;
89
- readonly isMqttConnected: () => boolean;
90
- readonly opcuaEnabled?: boolean;
91
- readonly isOpcuaConnected?: () => boolean;
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 mock = createMockAdminEngine(options.state, options.tickIntervalMs ?? 1000);
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
- host: "127.0.0.1",
99
- port: 0,
100
- tickIntervalMs: options.tickIntervalMs ?? 1000,
101
- mqttEnabled: options.mqttEnabled,
102
- isMqttConnected: options.isMqttConnected,
103
- opcuaEnabled: options.opcuaEnabled ?? false,
104
- isOpcuaConnected: options.isOpcuaConnected ?? (() => false),
105
- getLastTickProgressAt: () => mock.getLastTickProgressAt(),
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
- describe("admin server", () => {
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
- { state, mqttEnabled: true, isMqttConnected: () => true },
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", mqttEnabled: true, isMqttConnected: () => false },
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
- mqttEnabled: false,
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
  });