@hoox-sh/hoox-tui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,1026 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Service Store Tests — Worker data, trades, alerts, logs, connection state.
8
+ *
9
+ * Tests Zustand store actions including:
10
+ * - fetchWorkers (mocked API via shared hooxFetch double)
11
+ * - streamTrades (mocked SSE subscription)
12
+ * - Connection state machine transitions
13
+ * - Ring buffer limits (MAX_TRADES=500, MAX_ALERTS=100, MAX_LOGS=1000)
14
+ * - addAlert, pushTrade, pushLog, setMetrics
15
+ *
16
+ * Network doubles are process-wide from test-setup.ts (no per-file mock.module).
17
+ */
18
+ import { describe, it, expect, beforeEach, afterAll } from "bun:test";
19
+ import { useServiceStore } from "@hoox-sh/hoox-shared/stores/service-store";
20
+ import type {
21
+ WorkerInfo,
22
+ Trade,
23
+ Alert,
24
+ LogEntry,
25
+ SystemMetrics,
26
+ ConnectionStatus,
27
+ CliErrorDetails,
28
+ CliErrorType,
29
+ } from "@hoox-sh/hoox-shared";
30
+ import {
31
+ hooxFetchMock,
32
+ subscribeSSEMock,
33
+ resetNetworkDoubles,
34
+ setMockApiData,
35
+ setMockApiFailure,
36
+ setMockApiDelay,
37
+ emitSseEvent,
38
+ sseSubscriptions,
39
+ } from "../network-test-double";
40
+
41
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
42
+
43
+ function resetStore() {
44
+ // Tear down any SSE handles before clearing state (avoids cross-test leaks)
45
+ useServiceStore.getState().stopStreams();
46
+ useServiceStore.setState({
47
+ workers: [],
48
+ tradeStream: [],
49
+ alerts: [],
50
+ logs: [],
51
+ metrics: null,
52
+ connectionStatus: "offline",
53
+ lastUpdated: 0,
54
+ selectedWorkerId: null,
55
+ retryCount: 0,
56
+ lastError: null,
57
+ lastErrorDetails: null,
58
+ lastSuccessfulFetch: 0,
59
+ reconnectDelay: 0,
60
+ disconnectedAt: null,
61
+ });
62
+ resetNetworkDoubles();
63
+ }
64
+
65
+ function makeTrade(overrides: Partial<Trade> = {}): Trade {
66
+ return {
67
+ id: `trade-${Math.random().toString(36).slice(2, 8)}`,
68
+ symbol: "BTC",
69
+ side: "buy",
70
+ price: 50000,
71
+ quantity: 0.1,
72
+ timestamp: Date.now(),
73
+ exchange: "binance",
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ function makeAlert(overrides: Partial<Alert> = {}): Alert {
79
+ return {
80
+ id: `alert-${Math.random().toString(36).slice(2, 8)}`,
81
+ type: "system",
82
+ severity: "info",
83
+ message: "Test alert",
84
+ timestamp: Date.now(),
85
+ acknowledged: false,
86
+ ...overrides,
87
+ };
88
+ }
89
+
90
+ function makeLog(overrides: Partial<LogEntry> = {}): LogEntry {
91
+ return {
92
+ id: `log-${Math.random().toString(36).slice(2, 8)}`,
93
+ level: "info",
94
+ message: "Test log entry",
95
+ timestamp: Date.now(),
96
+ ...overrides,
97
+ };
98
+ }
99
+
100
+ function makeWorker(overrides: Partial<WorkerInfo> = {}): WorkerInfo {
101
+ return {
102
+ id: `worker-${Math.random().toString(36).slice(2, 8)}`,
103
+ name: "test-worker",
104
+ status: "operational",
105
+ uptime: 3600,
106
+ cpu: 25,
107
+ memory: 64,
108
+ requests: 1000,
109
+ durableObjectCount: 2,
110
+ edgeCount: 5,
111
+ ...overrides,
112
+ };
113
+ }
114
+
115
+ // ─── Test Suite ───────────────────────────────────────────────────────────────
116
+
117
+ describe("useServiceStore", () => {
118
+ beforeEach(() => {
119
+ resetStore();
120
+ });
121
+
122
+ // ── fetchWorkers ──────────────────────────────────────────────────────────
123
+
124
+ describe("fetchWorkers", () => {
125
+ it("fetches workers and updates store on success", async () => {
126
+ const workers = [
127
+ makeWorker({ name: "alpha" }),
128
+ makeWorker({ name: "beta" }),
129
+ ];
130
+ setMockApiData(workers);
131
+
132
+ await useServiceStore.getState().fetchWorkers();
133
+
134
+ const state = useServiceStore.getState();
135
+ expect(state.workers).toHaveLength(2);
136
+ expect(state.workers[0].name).toBe("alpha");
137
+ expect(state.workers[1].name).toBe("beta");
138
+ expect(state.lastUpdated).toBeGreaterThan(0);
139
+ });
140
+
141
+ it("transitions from offline to connected on successful fetch", async () => {
142
+ setMockApiData([makeWorker()]);
143
+ useServiceStore.setState({ connectionStatus: "offline" });
144
+
145
+ await useServiceStore.getState().fetchWorkers();
146
+
147
+ expect(useServiceStore.getState().connectionStatus).toBe("connected");
148
+ });
149
+
150
+ it("transitions from polling to connected on successful fetch", async () => {
151
+ setMockApiData([makeWorker()]);
152
+ useServiceStore.setState({ connectionStatus: "polling" });
153
+
154
+ await useServiceStore.getState().fetchWorkers();
155
+
156
+ expect(useServiceStore.getState().connectionStatus).toBe("connected");
157
+ });
158
+
159
+ it("resets retry state on successful fetch", async () => {
160
+ setMockApiData([makeWorker()]);
161
+ useServiceStore.setState({
162
+ connectionStatus: "reconnecting",
163
+ retryCount: 3,
164
+ lastError: "Previous error",
165
+ reconnectDelay: 4000,
166
+ });
167
+
168
+ await useServiceStore.getState().fetchWorkers();
169
+
170
+ const state = useServiceStore.getState();
171
+ expect(state.retryCount).toBe(0);
172
+ expect(state.lastError).toBeNull();
173
+ expect(state.reconnectDelay).toBe(0);
174
+ });
175
+
176
+ it("updates lastSuccessfulFetch on success", async () => {
177
+ setMockApiData([makeWorker()]);
178
+ await useServiceStore.getState().fetchWorkers();
179
+ expect(useServiceStore.getState().lastSuccessfulFetch).toBeGreaterThan(0);
180
+ });
181
+ });
182
+
183
+ // ── fetchWorkers (error path) ─────────────────────────────────────────────
184
+
185
+ describe("fetchWorkers error handling", () => {
186
+ it("transitions from connected to reconnecting on failure", async () => {
187
+ setMockApiFailure(true, "Connection refused");
188
+ useServiceStore.setState({ connectionStatus: "connected" });
189
+
190
+ await useServiceStore.getState().fetchWorkers();
191
+
192
+ const state = useServiceStore.getState();
193
+ expect(state.connectionStatus).toBe("reconnecting");
194
+ expect(state.retryCount).toBe(1);
195
+ expect(state.lastError).toBe("Connection refused");
196
+ expect(state.reconnectDelay).toBe(1000); // first backoff: 1s
197
+ });
198
+
199
+ it("transitions from polling to reconnecting on failure", async () => {
200
+ setMockApiFailure(true);
201
+ useServiceStore.setState({ connectionStatus: "polling" });
202
+
203
+ await useServiceStore.getState().fetchWorkers();
204
+
205
+ expect(useServiceStore.getState().connectionStatus).toBe("reconnecting");
206
+ });
207
+
208
+ it("tracks retry count incrementally", async () => {
209
+ setMockApiFailure(true);
210
+ useServiceStore.setState({ connectionStatus: "connected" });
211
+
212
+ // First failure
213
+ await useServiceStore.getState().fetchWorkers();
214
+ expect(useServiceStore.getState().retryCount).toBe(1);
215
+
216
+ // Second failure
217
+ useServiceStore.setState({ connectionStatus: "reconnecting" }); // simulate reconnecting
218
+ await useServiceStore.getState().fetchWorkers();
219
+ // retryCount should be 1 (not incrementing since fetchWorkers only increments on connected/polling → reconnecting)
220
+ // handleConnectionFailure actually increments. fetchWorkers only increments for the first transition.
221
+ // fetchWorkers sets retryCount +1 when going connected→reconnecting. On subsequent calls while reconnecting,
222
+ // it updates lastError but doesn't modify retryCount further.
223
+ });
224
+
225
+ it("sets disconnectedAt on first failure", async () => {
226
+ setMockApiFailure(true);
227
+ useServiceStore.setState({
228
+ connectionStatus: "connected",
229
+ disconnectedAt: null,
230
+ });
231
+
232
+ await useServiceStore.getState().fetchWorkers();
233
+
234
+ expect(useServiceStore.getState().disconnectedAt).toBeGreaterThan(0);
235
+ });
236
+
237
+ it("preserves existing disconnectedAt on repeated failures", async () => {
238
+ setMockApiFailure(true);
239
+ const before = Date.now();
240
+ useServiceStore.setState({
241
+ connectionStatus: "connected",
242
+ disconnectedAt: null,
243
+ });
244
+
245
+ await useServiceStore.getState().fetchWorkers();
246
+ const firstDisconnect = useServiceStore.getState().disconnectedAt;
247
+
248
+ useServiceStore.setState({ connectionStatus: "reconnecting" });
249
+ await useServiceStore.getState().fetchWorkers();
250
+
251
+ expect(useServiceStore.getState().disconnectedAt).toBe(firstDisconnect);
252
+ });
253
+
254
+ it("transitions to offline after 5 retries", async () => {
255
+ setMockApiFailure(true);
256
+ useServiceStore.setState({
257
+ connectionStatus: "connected",
258
+ retryCount: 4,
259
+ });
260
+
261
+ await useServiceStore.getState().fetchWorkers();
262
+
263
+ // retryCount goes to 5, which is >= MAX_RETRIES (5)
264
+ expect(useServiceStore.getState().connectionStatus).toBe("offline");
265
+ expect(useServiceStore.getState().lastError).toContain(
266
+ "Connection lost after 5 retries"
267
+ );
268
+ });
269
+ });
270
+
271
+ // ── Connection State Machine ──────────────────────────────────────────────
272
+
273
+ describe("connection state machine", () => {
274
+ it("handleConnectionSuccess resets all error state", () => {
275
+ useServiceStore.setState({
276
+ connectionStatus: "reconnecting",
277
+ retryCount: 3,
278
+ lastError: "Some error",
279
+ reconnectDelay: 4000,
280
+ disconnectedAt: Date.now(),
281
+ });
282
+
283
+ useServiceStore.getState().handleConnectionSuccess();
284
+
285
+ const state = useServiceStore.getState();
286
+ expect(state.connectionStatus).toBe("connected");
287
+ expect(state.retryCount).toBe(0);
288
+ expect(state.lastError).toBeNull();
289
+ expect(state.reconnectDelay).toBe(0);
290
+ expect(state.disconnectedAt).toBeNull();
291
+ });
292
+
293
+ it("handleConnectionSuccess from polling goes to connected", () => {
294
+ useServiceStore.setState({ connectionStatus: "polling" });
295
+ useServiceStore.getState().handleConnectionSuccess();
296
+ expect(useServiceStore.getState().connectionStatus).toBe("connected");
297
+ });
298
+
299
+ it("handleConnectionFailure from connected transitions to reconnecting", () => {
300
+ useServiceStore.setState({ connectionStatus: "connected" });
301
+ useServiceStore.getState().handleConnectionFailure("API down");
302
+
303
+ const state = useServiceStore.getState();
304
+ expect(state.connectionStatus).toBe("reconnecting");
305
+ expect(state.retryCount).toBe(1);
306
+ expect(state.lastError).toBe("API down");
307
+ });
308
+
309
+ it("handleConnectionFailure increments retry count when reconnecting", () => {
310
+ useServiceStore.setState({
311
+ connectionStatus: "reconnecting",
312
+ retryCount: 2,
313
+ });
314
+ useServiceStore.getState().handleConnectionFailure("Still down");
315
+
316
+ expect(useServiceStore.getState().retryCount).toBe(3);
317
+ expect(useServiceStore.getState().reconnectDelay).toBe(4000); // retryCount=3 → index=Math.min(3-1,4)=2 → BACKOFF[2]=4000
318
+
319
+ // Actually retryCount starts at 1 for handleConnectionFailure, so after first failure retryCount=1.
320
+ // When called again with retryCount=2, it becomes 3 → backoff[2]=4000. Let me verify the backoff.
321
+ // getBackoffDelay(3): index = Math.min(3-1, 4) = 2 → BACKOFF[2] = 4000. Correct.
322
+ });
323
+
324
+ it("handleConnectionFailure transitions to offline after 5 retries", () => {
325
+ useServiceStore.setState({
326
+ connectionStatus: "reconnecting",
327
+ retryCount: 4,
328
+ });
329
+ useServiceStore.getState().handleConnectionFailure("Final fail");
330
+
331
+ expect(useServiceStore.getState().connectionStatus).toBe("offline");
332
+ expect(useServiceStore.getState().lastError).toContain(
333
+ "Connection lost after 5 retries"
334
+ );
335
+ });
336
+
337
+ it("setConnectionStatus resets retries when going to connected", () => {
338
+ useServiceStore.setState({
339
+ retryCount: 3,
340
+ lastError: "old error",
341
+ reconnectDelay: 4000,
342
+ });
343
+ useServiceStore.getState().setConnectionStatus("connected");
344
+
345
+ expect(useServiceStore.getState().retryCount).toBe(0);
346
+ expect(useServiceStore.getState().lastError).toBeNull();
347
+ expect(useServiceStore.getState().reconnectDelay).toBe(0);
348
+ });
349
+
350
+ it("setConnectionStatus tracks disconnectedAt for offline/reconnecting", () => {
351
+ useServiceStore.setState({ disconnectedAt: null });
352
+ useServiceStore.getState().setConnectionStatus("offline");
353
+ expect(useServiceStore.getState().disconnectedAt).toBeGreaterThan(0);
354
+ });
355
+
356
+ it("forceRetry from offline goes to polling", () => {
357
+ useServiceStore.setState({
358
+ connectionStatus: "offline",
359
+ retryCount: 5,
360
+ lastError: "Lost connection",
361
+ });
362
+ useServiceStore.getState().forceRetry();
363
+
364
+ expect(useServiceStore.getState().connectionStatus).toBe("polling");
365
+ expect(useServiceStore.getState().retryCount).toBe(0);
366
+ expect(useServiceStore.getState().lastError).toBeNull();
367
+ });
368
+
369
+ it("forceRetry does nothing when not offline", () => {
370
+ useServiceStore.setState({
371
+ connectionStatus: "connected",
372
+ retryCount: 0,
373
+ });
374
+ useServiceStore.getState().forceRetry();
375
+ expect(useServiceStore.getState().connectionStatus).toBe("connected");
376
+ });
377
+
378
+ it("resetRetries clears all retry state", () => {
379
+ useServiceStore.setState({
380
+ retryCount: 4,
381
+ lastError: "error",
382
+ reconnectDelay: 16000,
383
+ disconnectedAt: Date.now(),
384
+ });
385
+ useServiceStore.getState().resetRetries();
386
+
387
+ expect(useServiceStore.getState().retryCount).toBe(0);
388
+ expect(useServiceStore.getState().lastError).toBeNull();
389
+ expect(useServiceStore.getState().reconnectDelay).toBe(0);
390
+ expect(useServiceStore.getState().disconnectedAt).toBeNull();
391
+ });
392
+
393
+ // Backoff sequence tests
394
+ it("calculates correct backoff delays", () => {
395
+ // These verify the internal BACKOFF_SEQUENCE logic through the store
396
+ // After 1st failure (connected → reconnecting): retryCount=1 → reconnectDelay=1000
397
+ useServiceStore.setState({ connectionStatus: "connected" });
398
+ useServiceStore.getState().handleConnectionFailure("e1");
399
+ expect(useServiceStore.getState().reconnectDelay).toBe(1000);
400
+
401
+ // After 2nd failure: retryCount=2 → reconnectDelay=2000
402
+ useServiceStore.getState().handleConnectionFailure("e2");
403
+ expect(useServiceStore.getState().reconnectDelay).toBe(2000);
404
+
405
+ // After 3rd failure: retryCount=3 → reconnectDelay=4000
406
+ useServiceStore.getState().handleConnectionFailure("e3");
407
+ expect(useServiceStore.getState().reconnectDelay).toBe(4000);
408
+
409
+ // After 4th failure: retryCount=4 → reconnectDelay=8000
410
+ useServiceStore.getState().handleConnectionFailure("e4");
411
+ expect(useServiceStore.getState().reconnectDelay).toBe(8000);
412
+
413
+ // After 5th failure: retryCount=5 → offline (reconnectDelay=0)
414
+ useServiceStore.getState().handleConnectionFailure("e5");
415
+ expect(useServiceStore.getState().connectionStatus).toBe("offline");
416
+ expect(useServiceStore.getState().reconnectDelay).toBe(0);
417
+ });
418
+ });
419
+
420
+ // ── Structured CLI Error Details ─────────────────────────────────────────
421
+
422
+ describe("setLastErrorDetails", () => {
423
+ const sampleDetails: CliErrorDetails = {
424
+ command: "/usr/local/bin/hoox check health",
425
+ exitCode: 1,
426
+ stderr:
427
+ "Error: cloudflare credentials missing\nRun: hoox config env init",
428
+ stdout: "",
429
+ errorType: "non-zero-exit",
430
+ timestamp: 1717000000000,
431
+ duration: 1234,
432
+ };
433
+
434
+ it("stores full structured error in lastErrorDetails", () => {
435
+ useServiceStore.getState().setLastErrorDetails(sampleDetails);
436
+ const state = useServiceStore.getState();
437
+ expect(state.lastErrorDetails).not.toBeNull();
438
+ expect(state.lastErrorDetails?.command).toBe(sampleDetails.command);
439
+ expect(state.lastErrorDetails?.exitCode).toBe(1);
440
+ expect(state.lastErrorDetails?.errorType).toBe("non-zero-exit");
441
+ });
442
+
443
+ it("mirrors a short summary into lastError for one-line display", () => {
444
+ useServiceStore.getState().setLastErrorDetails(sampleDetails);
445
+ const state = useServiceStore.getState();
446
+ expect(state.lastError).toContain("cloudflare credentials missing");
447
+ });
448
+
449
+ it("falls back to stdout when stderr is empty", () => {
450
+ useServiceStore.getState().setLastErrorDetails({
451
+ ...sampleDetails,
452
+ stderr: "",
453
+ stdout: '{"ok":false,"reason":"auth failed"}',
454
+ });
455
+ expect(useServiceStore.getState().lastError).toContain("auth failed");
456
+ });
457
+
458
+ it("falls back to command when both stderr and stdout are empty", () => {
459
+ useServiceStore.getState().setLastErrorDetails({
460
+ ...sampleDetails,
461
+ stderr: "",
462
+ stdout: "",
463
+ });
464
+ expect(useServiceStore.getState().lastError).toBe(sampleDetails.command);
465
+ });
466
+
467
+ it("clears both lastError and lastErrorDetails when set to null", () => {
468
+ useServiceStore.getState().setLastErrorDetails(sampleDetails);
469
+ expect(useServiceStore.getState().lastErrorDetails).not.toBeNull();
470
+
471
+ useServiceStore.getState().setLastErrorDetails(null);
472
+ const state = useServiceStore.getState();
473
+ expect(state.lastErrorDetails).toBeNull();
474
+ expect(state.lastError).toBeNull();
475
+ });
476
+
477
+ it("truncates very long error messages to keep the one-line display tidy", () => {
478
+ const huge = "x".repeat(500);
479
+ useServiceStore.getState().setLastErrorDetails({
480
+ ...sampleDetails,
481
+ stderr: huge,
482
+ });
483
+ const lastError = useServiceStore.getState().lastError;
484
+ expect(lastError).not.toBeNull();
485
+ expect(lastError?.length).toBeLessThanOrEqual(120);
486
+ });
487
+
488
+ it("handleConnectionSuccess clears lastErrorDetails along with lastError", async () => {
489
+ useServiceStore.getState().setLastErrorDetails(sampleDetails);
490
+ useServiceStore.getState().handleConnectionSuccess();
491
+ const state = useServiceStore.getState();
492
+ expect(state.lastErrorDetails).toBeNull();
493
+ expect(state.lastError).toBeNull();
494
+ });
495
+
496
+ it("resetRetries clears lastErrorDetails", () => {
497
+ useServiceStore.getState().setLastErrorDetails(sampleDetails);
498
+ useServiceStore.getState().resetRetries();
499
+ expect(useServiceStore.getState().lastErrorDetails).toBeNull();
500
+ });
501
+
502
+ it("forceRetry clears lastErrorDetails when leaving offline state", () => {
503
+ useServiceStore.setState({
504
+ connectionStatus: "offline",
505
+ lastErrorDetails: sampleDetails,
506
+ });
507
+ useServiceStore.getState().forceRetry();
508
+ expect(useServiceStore.getState().lastErrorDetails).toBeNull();
509
+ });
510
+
511
+ it("preserves classification — accepts all CliErrorType variants", () => {
512
+ const variants: CliErrorType[] = [
513
+ "binary-not-found",
514
+ "timeout",
515
+ "aborted",
516
+ "non-zero-exit",
517
+ "spawn-error",
518
+ ];
519
+ for (const errorType of variants) {
520
+ useServiceStore.getState().setLastErrorDetails({
521
+ ...sampleDetails,
522
+ errorType,
523
+ });
524
+ expect(useServiceStore.getState().lastErrorDetails?.errorType).toBe(
525
+ errorType
526
+ );
527
+ }
528
+ });
529
+ });
530
+
531
+ // ── clearError (acknowledgement alias) ─────────────────────────────────
532
+
533
+ describe("clearError", () => {
534
+ it("clears both lastError and lastErrorDetails", () => {
535
+ useServiceStore.setState({
536
+ lastError: "stale summary",
537
+ lastErrorDetails: {
538
+ command: "hoox check health",
539
+ exitCode: 1,
540
+ stderr: "fail",
541
+ stdout: "",
542
+ errorType: "non-zero-exit",
543
+ timestamp: Date.now(),
544
+ duration: 100,
545
+ },
546
+ });
547
+
548
+ useServiceStore.getState().clearError();
549
+
550
+ const state = useServiceStore.getState();
551
+ expect(state.lastError).toBeNull();
552
+ expect(state.lastErrorDetails).toBeNull();
553
+ });
554
+
555
+ it("is a no-op when there is no error state", () => {
556
+ // Should not throw on an already-clean store
557
+ useServiceStore.setState({
558
+ lastError: null,
559
+ lastErrorDetails: null,
560
+ });
561
+ expect(() => useServiceStore.getState().clearError()).not.toThrow();
562
+ const state = useServiceStore.getState();
563
+ expect(state.lastError).toBeNull();
564
+ expect(state.lastErrorDetails).toBeNull();
565
+ });
566
+
567
+ it("does not touch connection state (unlike handleConnectionSuccess)", () => {
568
+ useServiceStore.setState({
569
+ connectionStatus: "reconnecting",
570
+ retryCount: 2,
571
+ reconnectDelay: 4000,
572
+ lastError: "old",
573
+ lastErrorDetails: {
574
+ command: "hoox check health",
575
+ exitCode: 1,
576
+ stderr: "fail",
577
+ stdout: "",
578
+ errorType: "non-zero-exit",
579
+ timestamp: Date.now(),
580
+ duration: 100,
581
+ },
582
+ });
583
+
584
+ useServiceStore.getState().clearError();
585
+
586
+ const state = useServiceStore.getState();
587
+ // Error fields cleared
588
+ expect(state.lastError).toBeNull();
589
+ expect(state.lastErrorDetails).toBeNull();
590
+ // Connection state machine untouched
591
+ expect(state.connectionStatus).toBe("reconnecting");
592
+ expect(state.retryCount).toBe(2);
593
+ expect(state.reconnectDelay).toBe(4000);
594
+ });
595
+ });
596
+
597
+ // ── addCliErrorAlert (status bar + alerts panel propagation) ───────────
598
+
599
+ describe("addCliErrorAlert", () => {
600
+ const cliFailure: CliErrorDetails = {
601
+ command: "hoox deploy all",
602
+ exitCode: 1,
603
+ stderr: "Build failed: missing import",
604
+ stdout: "",
605
+ errorType: "non-zero-exit",
606
+ timestamp: Date.now(),
607
+ duration: 2500,
608
+ };
609
+
610
+ it("stores the structured error in lastErrorDetails", () => {
611
+ useServiceStore.getState().addCliErrorAlert(cliFailure);
612
+ const state = useServiceStore.getState();
613
+ expect(state.lastErrorDetails).not.toBeNull();
614
+ expect(state.lastErrorDetails?.command).toBe(cliFailure.command);
615
+ expect(state.lastErrorDetails?.errorType).toBe(cliFailure.errorType);
616
+ });
617
+
618
+ it("mirrors a short summary into lastError", () => {
619
+ useServiceStore.getState().addCliErrorAlert(cliFailure);
620
+ expect(useServiceStore.getState().lastError).toContain(
621
+ "Build failed: missing import"
622
+ );
623
+ });
624
+
625
+ it("appends a high-severity alert to the alerts ring buffer", () => {
626
+ expect(useServiceStore.getState().alerts).toHaveLength(0);
627
+ useServiceStore.getState().addCliErrorAlert(cliFailure);
628
+
629
+ const alerts = useServiceStore.getState().alerts;
630
+ expect(alerts).toHaveLength(1);
631
+ expect(alerts[0].severity).toBe("error");
632
+ expect(alerts[0].type).toBe("connection");
633
+ expect(alerts[0].message).toContain("CLI failure");
634
+ expect(alerts[0].message).toContain("Build failed: missing import");
635
+ expect(alerts[0].acknowledged).toBe(false);
636
+ expect(alerts[0].id).toBeTruthy();
637
+ expect(alerts[0].timestamp).toBeGreaterThan(0);
638
+ });
639
+
640
+ it("appends multiple alerts without dropping older ones under the cap", () => {
641
+ useServiceStore.getState().addCliErrorAlert(cliFailure);
642
+ useServiceStore.getState().addCliErrorAlert({
643
+ ...cliFailure,
644
+ command: "hox workers deploy",
645
+ stderr: "second failure",
646
+ });
647
+
648
+ const alerts = useServiceStore.getState().alerts;
649
+ expect(alerts).toHaveLength(2);
650
+ expect(alerts[0].message).toContain("Build failed: missing import");
651
+ expect(alerts[1].message).toContain("second failure");
652
+ });
653
+
654
+ it("respects the MAX_ALERTS cap of 100 entries", () => {
655
+ for (let i = 0; i < 110; i++) {
656
+ useServiceStore.getState().addCliErrorAlert({
657
+ ...cliFailure,
658
+ stderr: `failure ${i}`,
659
+ });
660
+ }
661
+ expect(useServiceStore.getState().alerts).toHaveLength(100);
662
+ // Newest entries survive
663
+ const alerts = useServiceStore.getState().alerts;
664
+ expect(alerts[alerts.length - 1].message).toContain("failure 109");
665
+ });
666
+
667
+ it("also works when stderr is empty (falls back to command summary)", () => {
668
+ useServiceStore.getState().addCliErrorAlert({
669
+ ...cliFailure,
670
+ stderr: "",
671
+ stdout: "",
672
+ });
673
+ const lastError = useServiceStore.getState().lastError;
674
+ // summarizeCliError falls back to the command itself
675
+ expect(lastError).toBe(cliFailure.command);
676
+ // And the alert still contains the command
677
+ const alerts = useServiceStore.getState().alerts;
678
+ expect(alerts[0].message).toContain("CLI failure");
679
+ expect(alerts[0].message).toContain("hoox deploy all");
680
+ });
681
+ });
682
+
683
+ // ── Ring Buffer Limits ────────────────────────────────────────────────────
684
+
685
+ describe("ring buffer limits", () => {
686
+ it("caps trade stream at 500 entries", () => {
687
+ const store = useServiceStore.getState();
688
+ for (let i = 0; i < 600; i++) {
689
+ store.pushTrade(makeTrade({ id: `t${i}` }));
690
+ }
691
+ expect(useServiceStore.getState().tradeStream).toHaveLength(500);
692
+ });
693
+
694
+ it("trade stream keeps newest entries when exceeding cap", () => {
695
+ const store = useServiceStore.getState();
696
+ // Push 501 trades, verify first one is dropped
697
+ store.pushTrade(makeTrade({ id: "first" }));
698
+ for (let i = 0; i < 500; i++) {
699
+ store.pushTrade(makeTrade({ id: `fill-${i}` }));
700
+ }
701
+ const stream = useServiceStore.getState().tradeStream;
702
+ expect(stream).toHaveLength(500);
703
+ // "first" should be dropped
704
+ expect(stream.find((t) => t.id === "first")).toBeUndefined();
705
+ });
706
+
707
+ it("caps alerts at 100 entries", () => {
708
+ for (let i = 0; i < 150; i++) {
709
+ useServiceStore
710
+ .getState()
711
+ .addAlert(makeAlert({ id: `a${i}`, timestamp: i }));
712
+ }
713
+ expect(useServiceStore.getState().alerts).toHaveLength(100);
714
+ });
715
+
716
+ it("alerts buffer keeps newest entries", () => {
717
+ useServiceStore
718
+ .getState()
719
+ .addAlert(makeAlert({ id: "first-old", timestamp: 1 }));
720
+ for (let i = 0; i < 100; i++) {
721
+ useServiceStore
722
+ .getState()
723
+ .addAlert(makeAlert({ id: `a${i}`, timestamp: i + 100 }));
724
+ }
725
+ expect(
726
+ useServiceStore.getState().alerts.find((a) => a.id === "first-old")
727
+ ).toBeUndefined();
728
+ });
729
+
730
+ it("caps logs at 1000 entries", () => {
731
+ for (let i = 0; i < 1200; i++) {
732
+ useServiceStore.getState().pushLog(makeLog({ id: `l${i}` }));
733
+ }
734
+ expect(useServiceStore.getState().logs).toHaveLength(1000);
735
+ });
736
+
737
+ it("logs buffer keeps newest entries when exceeding cap", () => {
738
+ useServiceStore.getState().pushLog(makeLog({ id: "first-log" }));
739
+ for (let i = 0; i < 1000; i++) {
740
+ useServiceStore.getState().pushLog(makeLog({ id: `fill-${i}` }));
741
+ }
742
+ expect(
743
+ useServiceStore.getState().logs.find((l) => l.id === "first-log")
744
+ ).toBeUndefined();
745
+ });
746
+
747
+ it("trade stream preserves order (FIFO)", () => {
748
+ const store = useServiceStore.getState();
749
+ store.pushTrade(makeTrade({ id: "a", timestamp: 1 }));
750
+ store.pushTrade(makeTrade({ id: "b", timestamp: 2 }));
751
+ store.pushTrade(makeTrade({ id: "c", timestamp: 3 }));
752
+
753
+ const stream = useServiceStore.getState().tradeStream;
754
+ expect(stream[0].id).toBe("a");
755
+ expect(stream[1].id).toBe("b");
756
+ expect(stream[2].id).toBe("c");
757
+ });
758
+ });
759
+
760
+ // ── addAlert ──────────────────────────────────────────────────────────────
761
+
762
+ describe("addAlert", () => {
763
+ it("adds a single alert", () => {
764
+ const alert = makeAlert({
765
+ message: "CPU threshold exceeded",
766
+ severity: "warning",
767
+ });
768
+ useServiceStore.getState().addAlert(alert);
769
+
770
+ expect(useServiceStore.getState().alerts).toHaveLength(1);
771
+ expect(useServiceStore.getState().alerts[0].message).toBe(
772
+ "CPU threshold exceeded"
773
+ );
774
+ });
775
+
776
+ it("adds multiple alerts in order", () => {
777
+ useServiceStore
778
+ .getState()
779
+ .addAlert(makeAlert({ id: "a1", timestamp: 100 }));
780
+ useServiceStore
781
+ .getState()
782
+ .addAlert(makeAlert({ id: "a2", timestamp: 200 }));
783
+ useServiceStore
784
+ .getState()
785
+ .addAlert(makeAlert({ id: "a3", timestamp: 300 }));
786
+
787
+ expect(useServiceStore.getState().alerts).toHaveLength(3);
788
+ });
789
+
790
+ it("addAlert respects MAX_ALERTS cap", () => {
791
+ for (let i = 0; i < 110; i++) {
792
+ useServiceStore.getState().addAlert(makeAlert({ id: `a${i}` }));
793
+ }
794
+ expect(useServiceStore.getState().alerts).toHaveLength(100);
795
+ });
796
+ });
797
+
798
+ // ── addAlerts (bulk) ──────────────────────────────────────────────────────
799
+
800
+ describe("addAlerts (bulk)", () => {
801
+ it("adds multiple alerts at once", () => {
802
+ const alerts = [
803
+ makeAlert({ id: "b1", message: "Bulk 1" }),
804
+ makeAlert({ id: "b2", message: "Bulk 2" }),
805
+ ];
806
+ useServiceStore.getState().addAlerts(alerts);
807
+ expect(useServiceStore.getState().alerts).toHaveLength(2);
808
+ });
809
+
810
+ it("caps bulk adds at 100", () => {
811
+ const alerts = Array.from({ length: 150 }, (_, i) =>
812
+ makeAlert({ id: `bulk-${i}`, timestamp: i })
813
+ );
814
+ useServiceStore.getState().addAlerts(alerts);
815
+ expect(useServiceStore.getState().alerts).toHaveLength(100);
816
+ });
817
+ });
818
+
819
+ // ── selectWorker / setWorkers / setMetrics ────────────────────────────────
820
+
821
+ describe("worker selection and metrics", () => {
822
+ it("selectWorker sets selectedWorkerId", () => {
823
+ useServiceStore.getState().selectWorker("worker-123");
824
+ expect(useServiceStore.getState().selectedWorkerId).toBe("worker-123");
825
+ });
826
+
827
+ it("selectWorker clears selection with null", () => {
828
+ useServiceStore.getState().selectWorker("worker-123");
829
+ useServiceStore.getState().selectWorker(null);
830
+ expect(useServiceStore.getState().selectedWorkerId).toBeNull();
831
+ });
832
+
833
+ it("setWorkers replaces worker list and updates timestamp", () => {
834
+ const workers = [makeWorker(), makeWorker()];
835
+ useServiceStore.getState().setWorkers(workers);
836
+
837
+ expect(useServiceStore.getState().workers).toHaveLength(2);
838
+ expect(useServiceStore.getState().lastUpdated).toBeGreaterThan(0);
839
+ });
840
+
841
+ it("setMetrics updates metrics and timestamp", () => {
842
+ const metrics: SystemMetrics = {
843
+ totalWorkers: 10,
844
+ onlineWorkers: 8,
845
+ totalPnl: 15000,
846
+ activeStrategies: 3,
847
+ dailyTrades: 500,
848
+ aiCalls: 50,
849
+ uptime: 360000,
850
+ lastUpdated: Date.now(),
851
+ };
852
+ useServiceStore.getState().setMetrics(metrics);
853
+ expect(useServiceStore.getState().metrics?.totalPnl).toBe(15000);
854
+ expect(useServiceStore.getState().lastUpdated).toBe(metrics.lastUpdated);
855
+ });
856
+ });
857
+
858
+ // ── streamTrades / streamLogs / SSE lifecycle ────────────────────────────
859
+
860
+ describe("streamTrades", () => {
861
+ it("subscribes to SSE trade stream", async () => {
862
+ await useServiceStore.getState().streamTrades();
863
+ expect(subscribeSSEMock).toHaveBeenCalledTimes(1);
864
+ expect(subscribeSSEMock.mock.calls[0][0]).toBe("/v1/trades/stream");
865
+ });
866
+
867
+ it("streamTrades does not throw on SSE connection failure", async () => {
868
+ // override the mock to simulate failure
869
+ const origMock = subscribeSSEMock.getMockImplementation();
870
+ subscribeSSEMock.mockImplementation(async () => {
871
+ throw new Error("SSE failed");
872
+ });
873
+
874
+ // Should not throw
875
+ await expect(
876
+ useServiceStore.getState().streamTrades()
877
+ ).resolves.toBeUndefined();
878
+
879
+ // Restore
880
+ subscribeSSEMock.mockImplementation(origMock as any);
881
+ });
882
+
883
+ it("pushes trades from SSE events into the ring buffer", async () => {
884
+ await useServiceStore.getState().streamTrades();
885
+ const trade = makeTrade({ id: "sse-t1", symbol: "ETH" });
886
+ emitSseEvent(trade, "/v1/trades");
887
+ expect(useServiceStore.getState().tradeStream).toHaveLength(1);
888
+ expect(useServiceStore.getState().tradeStream[0].id).toBe("sse-t1");
889
+ });
890
+ });
891
+
892
+ describe("streamLogs", () => {
893
+ it("subscribes to SSE log stream", async () => {
894
+ await useServiceStore.getState().streamLogs();
895
+ expect(subscribeSSEMock).toHaveBeenCalledTimes(1);
896
+ expect(subscribeSSEMock.mock.calls[0][0]).toBe("/v1/logs/stream");
897
+ });
898
+
899
+ it("pushes logs from SSE events into the ring buffer", async () => {
900
+ await useServiceStore.getState().streamLogs();
901
+ const log = makeLog({ id: "sse-l1", message: "from-sse" });
902
+ emitSseEvent(log, "/v1/logs");
903
+ expect(useServiceStore.getState().logs).toHaveLength(1);
904
+ expect(useServiceStore.getState().logs[0].message).toBe("from-sse");
905
+ });
906
+ });
907
+
908
+ describe("SSE subscription cleanup", () => {
909
+ it("stopStreams aborts active subscriptions so further events are ignored", async () => {
910
+ await useServiceStore.getState().streamTrades();
911
+ await useServiceStore.getState().streamLogs();
912
+ expect(sseSubscriptions.length).toBe(2);
913
+
914
+ useServiceStore.getState().stopStreams();
915
+ expect(sseSubscriptions.length).toBe(0);
916
+
917
+ emitSseEvent(makeTrade({ id: "after-stop" }), "/v1/trades");
918
+ emitSseEvent(makeLog({ id: "after-stop-log" }), "/v1/logs");
919
+ expect(useServiceStore.getState().tradeStream).toHaveLength(0);
920
+ expect(useServiceStore.getState().logs).toHaveLength(0);
921
+ });
922
+
923
+ it("re-subscribing trades aborts the previous trade subscription", async () => {
924
+ await useServiceStore.getState().streamTrades();
925
+ expect(
926
+ sseSubscriptions.filter((s) => s.path.includes("trades"))
927
+ ).toHaveLength(1);
928
+
929
+ await useServiceStore.getState().streamTrades();
930
+ // Only one active trade subscription after replace
931
+ expect(
932
+ sseSubscriptions.filter((s) => s.path.includes("trades"))
933
+ ).toHaveLength(1);
934
+
935
+ emitSseEvent(makeTrade({ id: "only-once" }), "/v1/trades");
936
+ // Single push — not duplicated from two live callbacks
937
+ expect(
938
+ useServiceStore
939
+ .getState()
940
+ .tradeStream.filter((t) => t.id === "only-once")
941
+ ).toHaveLength(1);
942
+ });
943
+
944
+ it("stopStreams is idempotent when no streams are open", () => {
945
+ expect(() => useServiceStore.getState().stopStreams()).not.toThrow();
946
+ useServiceStore.getState().stopStreams();
947
+ expect(sseSubscriptions.length).toBe(0);
948
+ });
949
+ });
950
+
951
+ // ── Concurrent fetch race ────────────────────────────────────────────────
952
+
953
+ describe("fetchWorkers race", () => {
954
+ it("newer fetch wins when an older in-flight request resolves later", async () => {
955
+ const slow = [makeWorker({ name: "stale-alpha" })];
956
+ const fast = [
957
+ makeWorker({ name: "fresh-beta" }),
958
+ makeWorker({ name: "fresh-gamma" }),
959
+ ];
960
+
961
+ // First call: delayed stale payload
962
+ setMockApiDelay(40);
963
+ setMockApiData(slow);
964
+ const p1 = useServiceStore.getState().fetchWorkers();
965
+
966
+ // Second call: immediate fresh payload (starts while p1 still pending)
967
+ await new Promise((r) => setTimeout(r, 5));
968
+ setMockApiDelay(0);
969
+ setMockApiData(fast);
970
+ const p2 = useServiceStore.getState().fetchWorkers();
971
+
972
+ await Promise.all([p1, p2]);
973
+
974
+ const workers = useServiceStore.getState().workers;
975
+ expect(workers).toHaveLength(2);
976
+ expect(workers.map((w) => w.name)).toEqual(["fresh-beta", "fresh-gamma"]);
977
+ expect(useServiceStore.getState().connectionStatus).toBe("connected");
978
+ });
979
+
980
+ it("stale failure does not clobber a newer successful fetch", async () => {
981
+ setMockApiDelay(40);
982
+ setMockApiFailure(true, "slow fail");
983
+ const p1 = useServiceStore.getState().fetchWorkers();
984
+
985
+ await new Promise((r) => setTimeout(r, 5));
986
+ setMockApiDelay(0);
987
+ setMockApiFailure(false);
988
+ setMockApiData([makeWorker({ name: "winner" })]);
989
+ const p2 = useServiceStore.getState().fetchWorkers();
990
+
991
+ await Promise.all([p1, p2]);
992
+
993
+ expect(useServiceStore.getState().workers[0]?.name).toBe("winner");
994
+ expect(useServiceStore.getState().connectionStatus).toBe("connected");
995
+ });
996
+ });
997
+
998
+ // ── Initial State ─────────────────────────────────────────────────────────
999
+
1000
+ describe("initial state", () => {
1001
+ it("starts with empty workers array", () => {
1002
+ resetStore();
1003
+ expect(useServiceStore.getState().workers).toEqual([]);
1004
+ });
1005
+
1006
+ it("starts with empty trade stream", () => {
1007
+ resetStore();
1008
+ expect(useServiceStore.getState().tradeStream).toEqual([]);
1009
+ });
1010
+
1011
+ it("starts with offline connection status", () => {
1012
+ resetStore();
1013
+ expect(useServiceStore.getState().connectionStatus).toBe("offline");
1014
+ });
1015
+
1016
+ it("starts with null metrics", () => {
1017
+ resetStore();
1018
+ expect(useServiceStore.getState().metrics).toBeNull();
1019
+ });
1020
+
1021
+ it("starts with no selected worker", () => {
1022
+ resetStore();
1023
+ expect(useServiceStore.getState().selectedWorkerId).toBeNull();
1024
+ });
1025
+ });
1026
+ });