@hoox-sh/hoox-tui 0.2.0 → 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 +50 -31
- package/dist/main.js +4238 -1711
- package/package.json +2 -2
- package/src/app.tsx +45 -30
- package/src/cli-bridge-test-double.ts +48 -1
- package/src/components/layout/sidebar.tsx +27 -11
- package/src/components/layout/statusbar.test.tsx +19 -5
- package/src/components/layout/statusbar.tsx +34 -18
- package/src/components/shared/crash-screen.tsx +24 -2
- package/src/components/shared/error-boundary.tsx +56 -33
- package/src/components/ui/connection-toasts.test.ts +58 -1
- package/src/components/views/config-editor.test.tsx +4 -2
- package/src/components/views/config-editor.tsx +31 -3
- package/src/components/views/dashboard/alerts-panel.tsx +36 -8
- package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
- package/src/components/views/dashboard.test.tsx +2 -1
- package/src/components/views/dashboard.tsx +126 -41
- package/src/components/views/db-query.test.tsx +7 -0
- package/src/components/views/db-query.tsx +5 -4
- package/src/components/views/edge-topology.tsx +3 -2
- package/src/components/views/kv-viewer.test.tsx +28 -0
- package/src/components/views/kv-viewer.tsx +4 -3
- package/src/components/views/logs-viewer.tsx +46 -19
- package/src/components/views/queue-depth.test.tsx +39 -0
- package/src/components/views/queue-depth.tsx +3 -2
- package/src/components/views/secrets-viewer.test.tsx +2 -2
- package/src/components/views/secrets-viewer.tsx +4 -3
- package/src/components/views/service-manager.tsx +147 -53
- package/src/components/views/settings.tsx +50 -10
- package/src/components/views/setup-wizard.test.tsx +13 -0
- package/src/components/views/setup-wizard.tsx +53 -22
- package/src/components/views/trade-monitor.tsx +20 -13
- package/src/components/views/worker-detail.tsx +27 -12
- package/src/components/views/worker-settings.test.tsx +5 -6
- package/src/components/views/worker-settings.tsx +13 -11
- package/src/components/views/workers-overview.tsx +27 -6
- package/src/hooks/hooks.test.ts +303 -9
- package/src/hooks/index.ts +7 -1
- package/src/hooks/shell-overlay.ts +41 -0
- package/src/hooks/use-polling.ts +91 -35
- package/src/hooks/use-service-data.ts +21 -4
- package/src/main.tsx +15 -0
- package/src/services/cli-bridge/index.ts +28 -22
- package/src/services/cli-bridge/standalone.ts +29 -2
- package/src/services/cli-bridge.test.ts +848 -1
- package/src/services/clipboard.test.ts +217 -3
- package/src/services/dashboard-settings-loader.test.ts +50 -0
- package/src/services/tui-connection.test.ts +21 -0
- package/src/stores/service-store.test.ts +89 -3
- package/src/stores/ui-store.test.ts +23 -0
- package/src/test-setup.ts +11 -2
- package/src/types.ts +1 -0
- package/src/view-registry.test.ts +3 -1
- package/src/view-registry.tsx +14 -1
|
@@ -3,14 +3,35 @@
|
|
|
3
3
|
* SPDX-License-Identifier: Apache-2.0
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { describe, it, expect } from "bun:test";
|
|
7
|
-
import {
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
copyToClipboard,
|
|
9
|
+
copyViaSystemClipboard,
|
|
10
|
+
enableAutoCopyOnSelection,
|
|
11
|
+
type ClipboardRenderer,
|
|
12
|
+
} from "./clipboard";
|
|
8
13
|
import { messageCopiedToClipboard } from "../components/ui/toast";
|
|
14
|
+
import { setRendererRef } from "../hooks";
|
|
9
15
|
|
|
10
16
|
describe("clipboard", () => {
|
|
17
|
+
const originalWhich = Bun.which;
|
|
18
|
+
const originalSpawn = Bun.spawn;
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
(Bun as { which: typeof Bun.which }).which = originalWhich;
|
|
22
|
+
(Bun as { spawn: typeof Bun.spawn }).spawn = originalSpawn;
|
|
23
|
+
setRendererRef(null);
|
|
24
|
+
});
|
|
25
|
+
|
|
11
26
|
it("treats empty text as success without throwing", async () => {
|
|
12
27
|
const result = await copyToClipboard(" ", null);
|
|
13
28
|
expect(result.ok).toBe(true);
|
|
29
|
+
if (result.ok) expect(result.method).toBe("osc52");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("treats empty string as success", async () => {
|
|
33
|
+
const result = await copyToClipboard("", null);
|
|
34
|
+
expect(result.ok).toBe(true);
|
|
14
35
|
});
|
|
15
36
|
|
|
16
37
|
it("copyViaSystemClipboard returns a tool name or null", async () => {
|
|
@@ -19,9 +40,139 @@ describe("clipboard", () => {
|
|
|
19
40
|
expect(tool === null || typeof tool === "string").toBe(true);
|
|
20
41
|
});
|
|
21
42
|
|
|
43
|
+
it("copyViaSystemClipboard returns null when no tools exist", async () => {
|
|
44
|
+
(Bun as { which: typeof Bun.which }).which = (() =>
|
|
45
|
+
null) as typeof Bun.which;
|
|
46
|
+
const tool = await copyViaSystemClipboard("x");
|
|
47
|
+
expect(tool).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("copyViaSystemClipboard returns tool name on successful spawn", async () => {
|
|
51
|
+
(Bun as { which: typeof Bun.which }).which = ((bin: string) =>
|
|
52
|
+
bin === "wl-copy" ? "/usr/bin/wl-copy" : null) as typeof Bun.which;
|
|
53
|
+
|
|
54
|
+
(Bun as { spawn: typeof Bun.spawn }).spawn = (() => {
|
|
55
|
+
const writes: string[] = [];
|
|
56
|
+
return {
|
|
57
|
+
stdin: {
|
|
58
|
+
write: (t: string | Uint8Array) => {
|
|
59
|
+
writes.push(
|
|
60
|
+
typeof t === "string" ? t : new TextDecoder().decode(t)
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
end: () => {},
|
|
64
|
+
},
|
|
65
|
+
stdout: null,
|
|
66
|
+
stderr: null,
|
|
67
|
+
killed: false,
|
|
68
|
+
kill: () => {},
|
|
69
|
+
exited: Promise.resolve(0),
|
|
70
|
+
};
|
|
71
|
+
}) as unknown as typeof Bun.spawn;
|
|
72
|
+
|
|
73
|
+
const tool = await copyViaSystemClipboard("secret-text");
|
|
74
|
+
expect(tool).toBe("wl-copy");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("copyViaSystemClipboard skips tools that exit non-zero", async () => {
|
|
78
|
+
let whichCalls = 0;
|
|
79
|
+
(Bun as { which: typeof Bun.which }).which = ((bin: string) => {
|
|
80
|
+
whichCalls++;
|
|
81
|
+
if (bin === "wl-copy") return "/usr/bin/wl-copy";
|
|
82
|
+
if (bin === "xclip") return "/usr/bin/xclip";
|
|
83
|
+
return null;
|
|
84
|
+
}) as typeof Bun.which;
|
|
85
|
+
|
|
86
|
+
let spawnN = 0;
|
|
87
|
+
(Bun as { spawn: typeof Bun.spawn }).spawn = ((cmd: string[]) => {
|
|
88
|
+
spawnN++;
|
|
89
|
+
const bin = cmd[0];
|
|
90
|
+
return {
|
|
91
|
+
stdin: { write: () => {}, end: () => {} },
|
|
92
|
+
killed: false,
|
|
93
|
+
kill: () => {},
|
|
94
|
+
exited: Promise.resolve(bin === "wl-copy" ? 1 : 0),
|
|
95
|
+
};
|
|
96
|
+
}) as unknown as typeof Bun.spawn;
|
|
97
|
+
|
|
98
|
+
const tool = await copyViaSystemClipboard("payload");
|
|
99
|
+
expect(tool).toBe("xclip");
|
|
100
|
+
expect(spawnN).toBe(2);
|
|
101
|
+
expect(whichCalls).toBeGreaterThan(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("copyViaSystemClipboard continues after spawn throw", async () => {
|
|
105
|
+
(Bun as { which: typeof Bun.which }).which = ((bin: string) =>
|
|
106
|
+
bin === "wl-copy" || bin === "pbcopy"
|
|
107
|
+
? `/bin/${bin}`
|
|
108
|
+
: null) as typeof Bun.which;
|
|
109
|
+
|
|
110
|
+
(Bun as { spawn: typeof Bun.spawn }).spawn = ((cmd: string[]) => {
|
|
111
|
+
if (cmd[0] === "wl-copy") throw new Error("spawn failed");
|
|
112
|
+
return {
|
|
113
|
+
stdin: { write: () => {}, end: () => {} },
|
|
114
|
+
killed: false,
|
|
115
|
+
kill: () => {},
|
|
116
|
+
exited: Promise.resolve(0),
|
|
117
|
+
};
|
|
118
|
+
}) as unknown as typeof Bun.spawn;
|
|
119
|
+
|
|
120
|
+
const tool = await copyViaSystemClipboard("x");
|
|
121
|
+
expect(tool).toBe("pbcopy");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("copyToClipboard uses OSC52 when renderer succeeds", async () => {
|
|
125
|
+
(Bun as { which: typeof Bun.which }).which = (() =>
|
|
126
|
+
null) as typeof Bun.which;
|
|
127
|
+
const renderer: ClipboardRenderer = {
|
|
128
|
+
copyToClipboardOSC52: () => true,
|
|
129
|
+
on: () => renderer,
|
|
130
|
+
off: () => renderer,
|
|
131
|
+
};
|
|
132
|
+
const result = await copyToClipboard("hello", renderer);
|
|
133
|
+
expect(result).toEqual({ ok: true, method: "osc52" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("copyToClipboard falls through when OSC52 returns false", async () => {
|
|
137
|
+
(Bun as { which: typeof Bun.which }).which = ((bin: string) =>
|
|
138
|
+
bin === "xsel" ? "/usr/bin/xsel" : null) as typeof Bun.which;
|
|
139
|
+
(Bun as { spawn: typeof Bun.spawn }).spawn = (() => ({
|
|
140
|
+
stdin: { write: () => {}, end: () => {} },
|
|
141
|
+
killed: false,
|
|
142
|
+
kill: () => {},
|
|
143
|
+
exited: Promise.resolve(0),
|
|
144
|
+
})) as unknown as typeof Bun.spawn;
|
|
145
|
+
|
|
146
|
+
const renderer: ClipboardRenderer = {
|
|
147
|
+
copyToClipboardOSC52: () => false,
|
|
148
|
+
on: () => renderer,
|
|
149
|
+
off: () => renderer,
|
|
150
|
+
};
|
|
151
|
+
const result = await copyToClipboard("hello", renderer);
|
|
152
|
+
expect(result.ok).toBe(true);
|
|
153
|
+
if (result.ok) {
|
|
154
|
+
expect(result.method).toBe("system");
|
|
155
|
+
expect(result.tool).toBe("xsel");
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("copyToClipboard falls through when OSC52 throws", async () => {
|
|
160
|
+
(Bun as { which: typeof Bun.which }).which = (() =>
|
|
161
|
+
null) as typeof Bun.which;
|
|
162
|
+
const renderer: ClipboardRenderer = {
|
|
163
|
+
copyToClipboardOSC52: () => {
|
|
164
|
+
throw new Error("no osc");
|
|
165
|
+
},
|
|
166
|
+
on: () => renderer,
|
|
167
|
+
off: () => renderer,
|
|
168
|
+
};
|
|
169
|
+
const result = await copyToClipboard("hello", renderer);
|
|
170
|
+
expect(result.ok).toBe(false);
|
|
171
|
+
if (!result.ok) expect(result.error).toMatch(/clipboard/i);
|
|
172
|
+
});
|
|
173
|
+
|
|
22
174
|
it("copyToClipboard falls back when no renderer is available", async () => {
|
|
23
175
|
const result = await copyToClipboard("hello from hoox tui", null);
|
|
24
|
-
// Success if system tool exists; otherwise structured failure
|
|
25
176
|
if (result.ok) {
|
|
26
177
|
expect(result.method === "osc52" || result.method === "system").toBe(
|
|
27
178
|
true
|
|
@@ -36,6 +187,65 @@ describe("clipboard", () => {
|
|
|
36
187
|
const result = await copyToClipboard("notify me", null, { notify: true });
|
|
37
188
|
expect(typeof result.ok).toBe("boolean");
|
|
38
189
|
});
|
|
190
|
+
|
|
191
|
+
it("uses getRendererRef when renderer arg omitted", async () => {
|
|
192
|
+
const renderer: ClipboardRenderer = {
|
|
193
|
+
copyToClipboardOSC52: () => true,
|
|
194
|
+
on: () => renderer,
|
|
195
|
+
off: () => renderer,
|
|
196
|
+
};
|
|
197
|
+
setRendererRef(renderer as unknown as import("@opentui/core").CliRenderer);
|
|
198
|
+
(Bun as { which: typeof Bun.which }).which = (() =>
|
|
199
|
+
null) as typeof Bun.which;
|
|
200
|
+
const result = await copyToClipboard("via-ref");
|
|
201
|
+
expect(result).toEqual({ ok: true, method: "osc52" });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("enableAutoCopyOnSelection", () => {
|
|
206
|
+
it("registers selection listener and ignores drag/empty/debounced", async () => {
|
|
207
|
+
const handlers = new Map<string, (...args: unknown[]) => void>();
|
|
208
|
+
const osc = mock(() => true);
|
|
209
|
+
const renderer: ClipboardRenderer = {
|
|
210
|
+
copyToClipboardOSC52: osc,
|
|
211
|
+
on: (event, listener) => {
|
|
212
|
+
handlers.set(event, listener as (...args: unknown[]) => void);
|
|
213
|
+
return renderer;
|
|
214
|
+
},
|
|
215
|
+
off: (event) => {
|
|
216
|
+
handlers.delete(event);
|
|
217
|
+
return renderer;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const unsub = enableAutoCopyOnSelection(renderer);
|
|
222
|
+
expect(handlers.has("selection")).toBe(true);
|
|
223
|
+
const onSelection = handlers.get("selection")!;
|
|
224
|
+
|
|
225
|
+
// dragging → ignore
|
|
226
|
+
onSelection({ isDragging: true, getSelectedText: () => "nope" });
|
|
227
|
+
expect(osc).not.toHaveBeenCalled();
|
|
228
|
+
|
|
229
|
+
// empty → ignore
|
|
230
|
+
onSelection({ isDragging: false, getSelectedText: () => " " });
|
|
231
|
+
expect(osc).not.toHaveBeenCalled();
|
|
232
|
+
|
|
233
|
+
// valid selection
|
|
234
|
+
onSelection({ isDragging: false, getSelectedText: () => "selected" });
|
|
235
|
+
await Promise.resolve();
|
|
236
|
+
expect(osc).toHaveBeenCalled();
|
|
237
|
+
|
|
238
|
+
// debounce identical text within 400ms
|
|
239
|
+
const callsBefore = osc.mock.calls.length;
|
|
240
|
+
onSelection({ isDragging: false, getSelectedText: () => "selected" });
|
|
241
|
+
await Promise.resolve();
|
|
242
|
+
expect(osc.mock.calls.length).toBe(callsBefore);
|
|
243
|
+
|
|
244
|
+
// missing selection object is safe
|
|
245
|
+
onSelection(undefined);
|
|
246
|
+
unsub();
|
|
247
|
+
expect(handlers.has("selection")).toBe(false);
|
|
248
|
+
});
|
|
39
249
|
});
|
|
40
250
|
|
|
41
251
|
describe("messageCopiedToClipboard", () => {
|
|
@@ -54,4 +264,8 @@ describe("messageCopiedToClipboard", () => {
|
|
|
54
264
|
it("collapses whitespace", () => {
|
|
55
265
|
expect(messageCopiedToClipboard("a\n\nb")).toBe("Copied: “a b”");
|
|
56
266
|
});
|
|
267
|
+
|
|
268
|
+
it("handles empty after trim", () => {
|
|
269
|
+
expect(messageCopiedToClipboard(" ")).toBe("Copied to clipboard");
|
|
270
|
+
});
|
|
57
271
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for loadDashboardSettingsManifests — thin runtime-root wrapper.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "bun:test";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { loadDashboardSettingsManifests } from "./dashboard-settings-loader";
|
|
14
|
+
|
|
15
|
+
describe("loadDashboardSettingsManifests", () => {
|
|
16
|
+
it("loads manifests from an explicit monorepo root", () => {
|
|
17
|
+
// packages/tui → monorepo root (…/hoox)
|
|
18
|
+
const monorepoRoot = join(import.meta.dir, "..", "..", "..", "..");
|
|
19
|
+
const manifests = loadDashboardSettingsManifests(monorepoRoot);
|
|
20
|
+
expect(Array.isArray(manifests)).toBe(true);
|
|
21
|
+
// Real checkout has worker dashboard.jsonc files
|
|
22
|
+
expect(manifests.length).toBeGreaterThan(0);
|
|
23
|
+
for (const m of manifests) {
|
|
24
|
+
expect(typeof m.worker).toBe("string");
|
|
25
|
+
expect(m.worker.length).toBeGreaterThan(0);
|
|
26
|
+
expect(Array.isArray(m.sections)).toBe(true);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns empty list when workers dir is missing", () => {
|
|
31
|
+
const emptyRoot = mkdtempSync(join(tmpdir(), "hoox-dash-loader-"));
|
|
32
|
+
try {
|
|
33
|
+
const manifests = loadDashboardSettingsManifests(emptyRoot);
|
|
34
|
+
expect(manifests).toEqual([]);
|
|
35
|
+
} finally {
|
|
36
|
+
rmSync(emptyRoot, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("accepts null runtimeRoot and still resolves via fallback root", () => {
|
|
41
|
+
// null → resolveHooxRuntimeRoot or packages/tui walk-up fallback
|
|
42
|
+
const manifests = loadDashboardSettingsManifests(null);
|
|
43
|
+
expect(Array.isArray(manifests)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("accepts undefined runtimeRoot (default argument path)", () => {
|
|
47
|
+
const manifests = loadDashboardSettingsManifests();
|
|
48
|
+
expect(Array.isArray(manifests)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isRemoteAuthReady,
|
|
17
17
|
resolveTuiConnectionEnv,
|
|
18
18
|
shouldUseCliFallback,
|
|
19
|
+
remoteAuthMissingHint,
|
|
19
20
|
} from "./tui-connection";
|
|
20
21
|
|
|
21
22
|
describe("tui-connection", () => {
|
|
@@ -38,6 +39,18 @@ describe("tui-connection", () => {
|
|
|
38
39
|
"https://gw.example.com"
|
|
39
40
|
);
|
|
40
41
|
});
|
|
42
|
+
it("falls back when URL is invalid", () => {
|
|
43
|
+
expect(getApiHost("not a url")).toBe("not a url");
|
|
44
|
+
expect(getApiHost("://bad")).toBe("://bad");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("remoteAuthMissingHint", () => {
|
|
49
|
+
it("mentions token and Access credentials", () => {
|
|
50
|
+
const hint = remoteAuthMissingHint();
|
|
51
|
+
expect(hint).toContain("HOOX_API_TOKEN");
|
|
52
|
+
expect(hint).toContain("CF_ACCESS_CLIENT_ID");
|
|
53
|
+
});
|
|
41
54
|
});
|
|
42
55
|
|
|
43
56
|
describe("hasApiToken", () => {
|
|
@@ -166,6 +179,14 @@ describe("tui-connection", () => {
|
|
|
166
179
|
it("mentions Access when only Access is set", () => {
|
|
167
180
|
expect(formatAuthBanner(false, "remote", true)).toContain("Access");
|
|
168
181
|
});
|
|
182
|
+
it("mentions Bearer + Access when both present", () => {
|
|
183
|
+
expect(formatAuthBanner(true, "remote", true)).toContain(
|
|
184
|
+
"Bearer + Access"
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
it("notes optional token for local mode", () => {
|
|
188
|
+
expect(formatAuthBanner(false, "local")).toContain("optional");
|
|
189
|
+
});
|
|
169
190
|
});
|
|
170
191
|
|
|
171
192
|
describe("getSettingsConnectionSnapshot", () => {
|
|
@@ -41,8 +41,9 @@ import {
|
|
|
41
41
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
42
42
|
|
|
43
43
|
function resetStore() {
|
|
44
|
-
// Tear down
|
|
44
|
+
// Tear down SSE + reconnect timers before clearing state
|
|
45
45
|
useServiceStore.getState().stopStreams();
|
|
46
|
+
useServiceStore.getState().resetRetries();
|
|
46
47
|
useServiceStore.setState({
|
|
47
48
|
workers: [],
|
|
48
49
|
tradeStream: [],
|
|
@@ -262,12 +263,60 @@ describe("useServiceStore", () => {
|
|
|
262
263
|
|
|
263
264
|
// retryCount goes to 5, which is >= MAX_RETRIES (5)
|
|
264
265
|
expect(useServiceStore.getState().connectionStatus).toBe("offline");
|
|
266
|
+
// Root-cause message is preserved with a retry exhaustion note
|
|
265
267
|
expect(useServiceStore.getState().lastError).toContain(
|
|
266
|
-
"
|
|
268
|
+
"gave up after 5 retries"
|
|
267
269
|
);
|
|
268
270
|
});
|
|
269
271
|
});
|
|
270
272
|
|
|
273
|
+
// ── Alert triage ──────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
describe("alert acknowledge / dismiss", () => {
|
|
276
|
+
it("acknowledgeAlert marks the matching alert", () => {
|
|
277
|
+
useServiceStore.getState().addAlert({
|
|
278
|
+
id: "a1",
|
|
279
|
+
type: "system",
|
|
280
|
+
severity: "warning",
|
|
281
|
+
message: "hello",
|
|
282
|
+
timestamp: Date.now(),
|
|
283
|
+
acknowledged: false,
|
|
284
|
+
});
|
|
285
|
+
useServiceStore.getState().acknowledgeAlert("a1");
|
|
286
|
+
expect(
|
|
287
|
+
useServiceStore.getState().alerts.find((a) => a.id === "a1")
|
|
288
|
+
?.acknowledged
|
|
289
|
+
).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("dismissAlert removes the matching alert", () => {
|
|
293
|
+
useServiceStore.getState().addAlert({
|
|
294
|
+
id: "a2",
|
|
295
|
+
type: "system",
|
|
296
|
+
severity: "info",
|
|
297
|
+
message: "bye",
|
|
298
|
+
timestamp: Date.now(),
|
|
299
|
+
acknowledged: false,
|
|
300
|
+
});
|
|
301
|
+
useServiceStore.getState().dismissAlert("a2");
|
|
302
|
+
expect(
|
|
303
|
+
useServiceStore.getState().alerts.find((a) => a.id === "a2")
|
|
304
|
+
).toBeUndefined();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("clearLogs empties the log ring", () => {
|
|
308
|
+
useServiceStore.getState().pushLog({
|
|
309
|
+
id: "l1",
|
|
310
|
+
level: "info",
|
|
311
|
+
message: "x",
|
|
312
|
+
timestamp: Date.now(),
|
|
313
|
+
});
|
|
314
|
+
expect(useServiceStore.getState().logs.length).toBeGreaterThan(0);
|
|
315
|
+
useServiceStore.getState().clearLogs();
|
|
316
|
+
expect(useServiceStore.getState().logs).toHaveLength(0);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
271
320
|
// ── Connection State Machine ──────────────────────────────────────────────
|
|
272
321
|
|
|
273
322
|
describe("connection state machine", () => {
|
|
@@ -329,8 +378,9 @@ describe("useServiceStore", () => {
|
|
|
329
378
|
useServiceStore.getState().handleConnectionFailure("Final fail");
|
|
330
379
|
|
|
331
380
|
expect(useServiceStore.getState().connectionStatus).toBe("offline");
|
|
381
|
+
expect(useServiceStore.getState().lastError).toContain("Final fail");
|
|
332
382
|
expect(useServiceStore.getState().lastError).toContain(
|
|
333
|
-
"
|
|
383
|
+
"gave up after 5 retries"
|
|
334
384
|
);
|
|
335
385
|
});
|
|
336
386
|
|
|
@@ -361,11 +411,47 @@ describe("useServiceStore", () => {
|
|
|
361
411
|
});
|
|
362
412
|
useServiceStore.getState().forceRetry();
|
|
363
413
|
|
|
414
|
+
// Immediate transition is polling; deferred fetch may later connect
|
|
364
415
|
expect(useServiceStore.getState().connectionStatus).toBe("polling");
|
|
365
416
|
expect(useServiceStore.getState().retryCount).toBe(0);
|
|
366
417
|
expect(useServiceStore.getState().lastError).toBeNull();
|
|
367
418
|
});
|
|
368
419
|
|
|
420
|
+
it("schedules a real reconnect fetch after handleConnectionFailure", async () => {
|
|
421
|
+
setMockApiData([
|
|
422
|
+
{
|
|
423
|
+
id: "w1",
|
|
424
|
+
name: "worker-1",
|
|
425
|
+
status: "operational",
|
|
426
|
+
uptime: 1,
|
|
427
|
+
cpu: 0,
|
|
428
|
+
memory: 0,
|
|
429
|
+
requests: 0,
|
|
430
|
+
durableObjectCount: 0,
|
|
431
|
+
edgeCount: 0,
|
|
432
|
+
version: "1",
|
|
433
|
+
lastDeployed: 0,
|
|
434
|
+
},
|
|
435
|
+
]);
|
|
436
|
+
useServiceStore.setState({
|
|
437
|
+
connectionStatus: "connected",
|
|
438
|
+
retryCount: 0,
|
|
439
|
+
});
|
|
440
|
+
// Fail once → reconnecting with delay
|
|
441
|
+
setMockApiFailure(true, "blip");
|
|
442
|
+
await useServiceStore.getState().fetchWorkers();
|
|
443
|
+
expect(useServiceStore.getState().connectionStatus).toBe("reconnecting");
|
|
444
|
+
expect(useServiceStore.getState().reconnectDelay).toBeGreaterThan(0);
|
|
445
|
+
|
|
446
|
+
// Allow success on the scheduled retry
|
|
447
|
+
setMockApiFailure(false);
|
|
448
|
+
// Wait slightly longer than first backoff (1s) for the timer
|
|
449
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
450
|
+
// If network double is wired, we should be connected; if not, still reconnecting
|
|
451
|
+
const status = useServiceStore.getState().connectionStatus;
|
|
452
|
+
expect(["connected", "reconnecting", "polling"]).toContain(status);
|
|
453
|
+
});
|
|
454
|
+
|
|
369
455
|
it("forceRetry does nothing when not offline", () => {
|
|
370
456
|
useServiceStore.setState({
|
|
371
457
|
connectionStatus: "connected",
|
|
@@ -24,6 +24,7 @@ function resetStore() {
|
|
|
24
24
|
modal: null,
|
|
25
25
|
commandPaletteOpen: false,
|
|
26
26
|
previousView: null,
|
|
27
|
+
statusErrorExpanded: false,
|
|
27
28
|
});
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -208,6 +209,28 @@ describe("useUIStore", () => {
|
|
|
208
209
|
});
|
|
209
210
|
});
|
|
210
211
|
|
|
212
|
+
// ── Status error diagnostics panel ──────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
describe("statusErrorExpanded", () => {
|
|
215
|
+
it("starts collapsed", () => {
|
|
216
|
+
expect(useUIStore.getState().statusErrorExpanded).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("toggleStatusErrorExpanded flips the flag", () => {
|
|
220
|
+
useUIStore.getState().toggleStatusErrorExpanded();
|
|
221
|
+
expect(useUIStore.getState().statusErrorExpanded).toBe(true);
|
|
222
|
+
useUIStore.getState().toggleStatusErrorExpanded();
|
|
223
|
+
expect(useUIStore.getState().statusErrorExpanded).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("setStatusErrorExpanded sets absolute value", () => {
|
|
227
|
+
useUIStore.getState().setStatusErrorExpanded(true);
|
|
228
|
+
expect(useUIStore.getState().statusErrorExpanded).toBe(true);
|
|
229
|
+
useUIStore.getState().setStatusErrorExpanded(false);
|
|
230
|
+
expect(useUIStore.getState().statusErrorExpanded).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
211
234
|
// ── goBack ────────────────────────────────────────────────────────────────
|
|
212
235
|
|
|
213
236
|
describe("goBack", () => {
|
package/src/test-setup.ts
CHANGED
|
@@ -227,23 +227,32 @@ async function installNetworkDoubles(): Promise<void> {
|
|
|
227
227
|
subscribeSSE: subscribeSSEMock,
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
-
// From packages/tui/src → ../../shared
|
|
230
|
+
// From packages/tui/src → ../../shared (sibling package). Cover both source
|
|
231
|
+
// and dist (package exports point at dist/stores/* for store imports).
|
|
231
232
|
const apiPaths = [
|
|
232
233
|
new URL("../../shared/src/api-client.ts", import.meta.url).href,
|
|
233
234
|
new URL("../../shared/src/api-client.ts", import.meta.url).pathname,
|
|
235
|
+
new URL("../../shared/dist/api-client.js", import.meta.url).href,
|
|
236
|
+
new URL("../../shared/dist/api-client.js", import.meta.url).pathname,
|
|
234
237
|
"@hoox-sh/hoox-shared/api-client",
|
|
235
|
-
// Relative as resolved from packages/shared/src/stores/service-store
|
|
238
|
+
// Relative as resolved from packages/shared/src|dist/stores/service-store
|
|
236
239
|
"../api-client",
|
|
237
240
|
"../api-client.ts",
|
|
241
|
+
"../api-client.js",
|
|
238
242
|
"../../../../packages/shared/src/api-client",
|
|
243
|
+
"../../../../packages/shared/dist/api-client.js",
|
|
239
244
|
];
|
|
240
245
|
const ssePaths = [
|
|
241
246
|
new URL("../../shared/src/sse.ts", import.meta.url).href,
|
|
242
247
|
new URL("../../shared/src/sse.ts", import.meta.url).pathname,
|
|
248
|
+
new URL("../../shared/dist/sse.js", import.meta.url).href,
|
|
249
|
+
new URL("../../shared/dist/sse.js", import.meta.url).pathname,
|
|
243
250
|
"@hoox-sh/hoox-shared/sse",
|
|
244
251
|
"../sse",
|
|
245
252
|
"../sse.ts",
|
|
253
|
+
"../sse.js",
|
|
246
254
|
"../../../../packages/shared/src/sse",
|
|
255
|
+
"../../../../packages/shared/dist/sse.js",
|
|
247
256
|
];
|
|
248
257
|
|
|
249
258
|
for (const p of apiPaths) {
|
package/src/types.ts
CHANGED
|
@@ -112,6 +112,7 @@ export const DEFAULT_SHORTCUTS: ShortcutMap = {
|
|
|
112
112
|
"Ctrl+B": "TOGGLE SIDEBAR",
|
|
113
113
|
"Ctrl+R": "REFRESH DATA",
|
|
114
114
|
"Ctrl+Q": "QUIT",
|
|
115
|
+
"Ctrl+Shift+D": "ERROR DIAGNOSTICS",
|
|
115
116
|
Esc: "BACK / CLOSE",
|
|
116
117
|
Tab: "NEXT FOCUS",
|
|
117
118
|
"Shift+Tab": "PREVIOUS FOCUS",
|
|
@@ -107,9 +107,11 @@ describe("view-registry", () => {
|
|
|
107
107
|
expect(isRegisteredViewId("")).toBe(false);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
it("action commands include refresh,
|
|
110
|
+
it("action commands include refresh, reconnect, diagnostics, sidebar, quit", () => {
|
|
111
111
|
const ids = ACTION_COMMANDS.map((c) => c.id);
|
|
112
112
|
expect(ids).toContain("refresh");
|
|
113
|
+
expect(ids).toContain("force-retry");
|
|
114
|
+
expect(ids).toContain("expand-error");
|
|
113
115
|
expect(ids).toContain("toggle-sidebar");
|
|
114
116
|
expect(ids).toContain("quit");
|
|
115
117
|
});
|
package/src/view-registry.tsx
CHANGED
|
@@ -125,7 +125,7 @@ export const VIEW_REGISTRY: ViewRegistryEntry[] = [
|
|
|
125
125
|
key: "7",
|
|
126
126
|
keyMod: "ctrl",
|
|
127
127
|
paletteShortcut: "^7",
|
|
128
|
-
aliases: ["edit", "
|
|
128
|
+
aliases: ["edit", "config", "toml", "json"],
|
|
129
129
|
factory: () => <ConfigEditor />,
|
|
130
130
|
},
|
|
131
131
|
{
|
|
@@ -346,6 +346,19 @@ export const ACTION_COMMANDS: CommandEntry[] = [
|
|
|
346
346
|
shortcut: "^R",
|
|
347
347
|
aliases: ["reload"],
|
|
348
348
|
},
|
|
349
|
+
{
|
|
350
|
+
id: "force-retry",
|
|
351
|
+
name: "FORCE RECONNECT",
|
|
352
|
+
category: "action",
|
|
353
|
+
aliases: ["retry", "reconnect", "online"],
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: "expand-error",
|
|
357
|
+
name: "TOGGLE ERROR DIAGNOSTICS",
|
|
358
|
+
category: "action",
|
|
359
|
+
shortcut: "^⇧D",
|
|
360
|
+
aliases: ["diagnostics", "stderr", "cli-error", "expand-error"],
|
|
361
|
+
},
|
|
349
362
|
{
|
|
350
363
|
id: "toggle-sidebar",
|
|
351
364
|
name: "TOGGLE SIDEBAR",
|