@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,134 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Session restore/save tests — validates that all 16 ViewIds round-trip
8
+ * through `$HOME/.hoox/.tui-state/session.json` and invalid views fall back
9
+ * to dashboard (fail-closed for unknown IDs).
10
+ *
11
+ * Uses a temp HOOX_HOME so production ~/.hoox is never touched.
12
+ */
13
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
14
+ import { mkdir, rm, writeFile, readFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { saveSession, restoreSession } from "@hoox-sh/hoox-shared";
18
+ import type { ViewId } from "@hoox-sh/hoox-shared";
19
+ import { ALL_VIEW_IDS } from "../test-utils";
20
+
21
+ const PREV_HOOX_HOME = process.env.HOOX_HOME;
22
+ const PREV_HOME = process.env.HOME;
23
+
24
+ let tempRoot: string;
25
+
26
+ async function setupTempHome(): Promise<void> {
27
+ tempRoot = join(
28
+ tmpdir(),
29
+ `hoox-tui-session-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
30
+ );
31
+ await mkdir(tempRoot, { recursive: true });
32
+ // path-utils / session prefer HOOX_HOME when set
33
+ process.env.HOOX_HOME = tempRoot;
34
+ process.env.HOME = tempRoot;
35
+ }
36
+
37
+ async function teardownTempHome(): Promise<void> {
38
+ if (PREV_HOOX_HOME === undefined) delete process.env.HOOX_HOME;
39
+ else process.env.HOOX_HOME = PREV_HOOX_HOME;
40
+ if (PREV_HOME === undefined) delete process.env.HOME;
41
+ else process.env.HOME = PREV_HOME;
42
+ if (tempRoot) {
43
+ await rm(tempRoot, { recursive: true, force: true }).catch(() => {});
44
+ }
45
+ }
46
+
47
+ /** session.json lives under HOOX_HOME/.tui-state (getHooxHome() + .tui-state). */
48
+ function sessionPath(): string {
49
+ return join(tempRoot, ".tui-state", "session.json");
50
+ }
51
+
52
+ describe("session restore / save", () => {
53
+ beforeEach(async () => {
54
+ await setupTempHome();
55
+ });
56
+
57
+ afterEach(async () => {
58
+ await teardownTempHome();
59
+ });
60
+
61
+ it("returns defaults when no session file exists", async () => {
62
+ const session = await restoreSession();
63
+ expect(session.activeView).toBe("dashboard");
64
+ expect(session.sidebarExpanded).toBe(true);
65
+ expect(session.lastData).toBe(0);
66
+ });
67
+
68
+ it("round-trips every ViewId (all 16)", async () => {
69
+ expect(ALL_VIEW_IDS).toHaveLength(16);
70
+ for (const view of ALL_VIEW_IDS) {
71
+ await saveSession(view, true, { cols: 100, rows: 40 }, 12345);
72
+ const restored = await restoreSession();
73
+ expect(restored.activeView).toBe(view);
74
+ expect(restored.sidebarExpanded).toBe(true);
75
+ expect(restored.windowSize).toEqual({ cols: 100, rows: 40 });
76
+ expect(restored.lastData).toBe(12345);
77
+ }
78
+ });
79
+
80
+ it("preserves sidebarExpanded=false", async () => {
81
+ await saveSession("workers", false, { cols: 80, rows: 24 }, 0);
82
+ const restored = await restoreSession();
83
+ expect(restored.sidebarExpanded).toBe(false);
84
+ expect(restored.activeView).toBe("workers");
85
+ });
86
+
87
+ it("falls back to dashboard for unknown view IDs", async () => {
88
+ await mkdir(join(tempRoot, ".tui-state"), { recursive: true });
89
+ await writeFile(
90
+ sessionPath(),
91
+ JSON.stringify({
92
+ activeView: "not-a-real-view",
93
+ sidebarExpanded: true,
94
+ windowSize: { cols: 80, rows: 24 },
95
+ lastData: 0,
96
+ savedAt: new Date().toISOString(),
97
+ }),
98
+ "utf8"
99
+ );
100
+ const restored = await restoreSession();
101
+ expect(restored.activeView).toBe("dashboard");
102
+ });
103
+
104
+ it("restores chord views (kv, secrets, ai-chat, db-query, edge, worker-settings)", async () => {
105
+ const chordViews: ViewId[] = [
106
+ "kv-viewer",
107
+ "secrets-viewer",
108
+ "ai-chat",
109
+ "db-query",
110
+ "edge-topology",
111
+ "worker-settings",
112
+ ];
113
+ for (const view of chordViews) {
114
+ await saveSession(view, true, { cols: 80, rows: 24 }, 1);
115
+ expect((await restoreSession()).activeView).toBe(view);
116
+ }
117
+ });
118
+
119
+ it("returns defaults on corrupt JSON", async () => {
120
+ await mkdir(join(tempRoot, ".tui-state"), { recursive: true });
121
+ await writeFile(sessionPath(), "{not-json!!!", "utf8");
122
+ const restored = await restoreSession();
123
+ expect(restored.activeView).toBe("dashboard");
124
+ expect(restored.sidebarExpanded).toBe(true);
125
+ });
126
+
127
+ it("saveSession writes readable session.json under .tui-state", async () => {
128
+ await saveSession("queue-depth", true, { cols: 120, rows: 36 }, 99);
129
+ const raw = await readFile(sessionPath(), "utf8");
130
+ const parsed = JSON.parse(raw) as { activeView: string; lastData: number };
131
+ expect(parsed.activeView).toBe("queue-depth");
132
+ expect(parsed.lastData).toBe(99);
133
+ });
134
+ });
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * UI Store Tests — Navigation, sidebar, modals, command palette.
8
+ *
9
+ * Tests Zustand store actions in isolation (no rendering).
10
+ * Uses Bun test runner.
11
+ */
12
+ import { describe, it, expect, beforeEach } from "bun:test";
13
+ import { useUIStore } from "@hoox-sh/hoox-shared/stores/ui-store";
14
+ import type { ViewId, ModalState } from "@hoox-sh/hoox-shared";
15
+ import { ALL_VIEW_IDS } from "../test-utils";
16
+
17
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ /** Reset store to initial state before each test */
20
+ function resetStore() {
21
+ useUIStore.setState({
22
+ activeView: "dashboard",
23
+ sidebarExpanded: true,
24
+ modal: null,
25
+ commandPaletteOpen: false,
26
+ previousView: null,
27
+ });
28
+ }
29
+
30
+ // ─── Test Suite ───────────────────────────────────────────────────────────────
31
+
32
+ describe("useUIStore", () => {
33
+ beforeEach(() => {
34
+ resetStore();
35
+ });
36
+
37
+ // ── setView ──────────────────────────────────────────────────────────────
38
+
39
+ describe("setView", () => {
40
+ it("sets the active view", () => {
41
+ useUIStore.getState().setView("workers");
42
+ expect(useUIStore.getState().activeView).toBe("workers");
43
+ });
44
+
45
+ it("accepts all valid view IDs (16)", () => {
46
+ expect(ALL_VIEW_IDS).toHaveLength(16);
47
+ for (const view of ALL_VIEW_IDS) {
48
+ useUIStore.getState().setView(view);
49
+ expect(useUIStore.getState().activeView).toBe(view);
50
+ }
51
+ });
52
+
53
+ it("tracks previous view when navigating to a different view", () => {
54
+ useUIStore.getState().setView("workers");
55
+ expect(useUIStore.getState().previousView).toBe("dashboard");
56
+ });
57
+
58
+ it("does not overwrite previousView when setting same view", () => {
59
+ useUIStore.getState().setView("dashboard"); // already on dashboard
60
+ expect(useUIStore.getState().previousView).toBeNull();
61
+ });
62
+
63
+ it("closes command palette when switching views", () => {
64
+ useUIStore.getState().openPalette();
65
+ expect(useUIStore.getState().commandPaletteOpen).toBe(true);
66
+
67
+ useUIStore.getState().setView("settings");
68
+ expect(useUIStore.getState().commandPaletteOpen).toBe(false);
69
+ });
70
+
71
+ it("preserves previousView through multiple navigations", () => {
72
+ useUIStore.getState().setView("workers");
73
+ useUIStore.getState().setView("trade-monitor");
74
+ expect(useUIStore.getState().previousView).toBe("workers");
75
+ expect(useUIStore.getState().activeView).toBe("trade-monitor");
76
+ });
77
+ });
78
+
79
+ // ── toggleSidebar ─────────────────────────────────────────────────────────
80
+
81
+ describe("toggleSidebar", () => {
82
+ it("toggles sidebar from expanded to collapsed", () => {
83
+ expect(useUIStore.getState().sidebarExpanded).toBe(true);
84
+ useUIStore.getState().toggleSidebar();
85
+ expect(useUIStore.getState().sidebarExpanded).toBe(false);
86
+ });
87
+
88
+ it("toggles sidebar from collapsed to expanded", () => {
89
+ useUIStore.getState().toggleSidebar(); // collapse
90
+ useUIStore.getState().toggleSidebar(); // expand
91
+ expect(useUIStore.getState().sidebarExpanded).toBe(true);
92
+ });
93
+
94
+ it("handles multiple toggles correctly", () => {
95
+ for (let i = 0; i < 5; i++) {
96
+ useUIStore.getState().toggleSidebar();
97
+ }
98
+ expect(useUIStore.getState().sidebarExpanded).toBe(false); // 5 toggles from true = false
99
+ });
100
+
101
+ it("does not affect other UI state", () => {
102
+ const before = { ...useUIStore.getState() };
103
+ useUIStore.getState().toggleSidebar();
104
+ const after = useUIStore.getState();
105
+ expect(after.activeView).toBe(before.activeView);
106
+ expect(after.commandPaletteOpen).toBe(before.commandPaletteOpen);
107
+ expect(after.modal).toBe(before.modal);
108
+ });
109
+ });
110
+
111
+ // ── openPalette / closePalette ────────────────────────────────────────────
112
+
113
+ describe("command palette", () => {
114
+ it("opens the command palette", () => {
115
+ useUIStore.getState().openPalette();
116
+ expect(useUIStore.getState().commandPaletteOpen).toBe(true);
117
+ });
118
+
119
+ it("closes the command palette", () => {
120
+ useUIStore.getState().openPalette();
121
+ useUIStore.getState().closePalette();
122
+ expect(useUIStore.getState().commandPaletteOpen).toBe(false);
123
+ });
124
+
125
+ it("closePalette is idempotent when already closed", () => {
126
+ useUIStore.getState().closePalette();
127
+ expect(useUIStore.getState().commandPaletteOpen).toBe(false);
128
+ });
129
+
130
+ it("openPalette is idempotent when already open", () => {
131
+ useUIStore.getState().openPalette();
132
+ useUIStore.getState().openPalette();
133
+ expect(useUIStore.getState().commandPaletteOpen).toBe(true);
134
+ });
135
+
136
+ it("palette state is independent of sidebar state", () => {
137
+ useUIStore.getState().openPalette();
138
+ useUIStore.getState().toggleSidebar();
139
+ expect(useUIStore.getState().commandPaletteOpen).toBe(true);
140
+ expect(useUIStore.getState().sidebarExpanded).toBe(false);
141
+ });
142
+ });
143
+
144
+ // ── showModal / dismissModal ──────────────────────────────────────────────
145
+
146
+ describe("modal lifecycle", () => {
147
+ it("sets modal state when showing a modal", () => {
148
+ const modal: ModalState = {
149
+ type: "confirm",
150
+ title: "Delete Worker",
151
+ message: "Are you sure?",
152
+ };
153
+ useUIStore.getState().showModal(modal);
154
+ expect(useUIStore.getState().modal).toEqual(modal);
155
+ });
156
+
157
+ it("dismisses modal by setting it to null", () => {
158
+ const modal: ModalState = {
159
+ type: "alert",
160
+ title: "Error",
161
+ message: "Something went wrong",
162
+ };
163
+ useUIStore.getState().showModal(modal);
164
+ useUIStore.getState().dismissModal();
165
+ expect(useUIStore.getState().modal).toBeNull();
166
+ });
167
+
168
+ it("dismissModal is idempotent when no modal is shown", () => {
169
+ useUIStore.getState().dismissModal();
170
+ expect(useUIStore.getState().modal).toBeNull();
171
+ });
172
+
173
+ it("shows alert type modals", () => {
174
+ const modal: ModalState = {
175
+ type: "alert",
176
+ title: "Warning",
177
+ message: "Disk space low",
178
+ };
179
+ useUIStore.getState().showModal(modal);
180
+ expect(useUIStore.getState().modal?.type).toBe("alert");
181
+ });
182
+
183
+ it("shows prompt type modals", () => {
184
+ const modal: ModalState = {
185
+ type: "prompt",
186
+ title: "Rename",
187
+ message: "Enter new name",
188
+ };
189
+ useUIStore.getState().showModal(modal);
190
+ expect(useUIStore.getState().modal?.type).toBe("prompt");
191
+ });
192
+
193
+ it("shows custom type modals with data", () => {
194
+ const modal: ModalState = {
195
+ type: "custom",
196
+ title: "Advanced",
197
+ message: "Custom dialog",
198
+ data: { key: "value" },
199
+ };
200
+ useUIStore.getState().showModal(modal);
201
+ expect(useUIStore.getState().modal?.data).toEqual({ key: "value" });
202
+ });
203
+
204
+ it("replaces existing modal when showing a new one", () => {
205
+ useUIStore.getState().showModal({ type: "alert", title: "First" });
206
+ useUIStore.getState().showModal({ type: "confirm", title: "Second" });
207
+ expect(useUIStore.getState().modal?.title).toBe("Second");
208
+ });
209
+ });
210
+
211
+ // ── goBack ────────────────────────────────────────────────────────────────
212
+
213
+ describe("goBack", () => {
214
+ it("navigates back to the previous view", () => {
215
+ useUIStore.getState().setView("workers");
216
+ useUIStore.getState().setView("trade-monitor");
217
+ // Now on trade-monitor, previousView = workers
218
+
219
+ useUIStore.getState().goBack();
220
+ expect(useUIStore.getState().activeView).toBe("workers");
221
+ expect(useUIStore.getState().previousView).toBeNull();
222
+ });
223
+
224
+ it("does nothing when there is no previous view", () => {
225
+ expect(useUIStore.getState().previousView).toBeNull();
226
+ useUIStore.getState().goBack();
227
+ expect(useUIStore.getState().activeView).toBe("dashboard");
228
+ });
229
+
230
+ it("only goes back one level", () => {
231
+ useUIStore.getState().setView("workers"); // prev = dashboard
232
+ useUIStore.getState().setView("trade-monitor"); // prev = workers
233
+ useUIStore.getState().setView("settings"); // prev = trade-monitor
234
+
235
+ useUIStore.getState().goBack();
236
+ expect(useUIStore.getState().activeView).toBe("trade-monitor");
237
+ });
238
+
239
+ it("clears previousView after going back", () => {
240
+ useUIStore.getState().setView("settings");
241
+ useUIStore.getState().goBack();
242
+ useUIStore.getState().goBack();
243
+ // Second goBack should be a no-op since previousView is null
244
+ expect(useUIStore.getState().activeView).toBe("dashboard");
245
+ });
246
+ });
247
+
248
+ // ── Initial State ─────────────────────────────────────────────────────────
249
+
250
+ describe("initial state", () => {
251
+ it("starts on dashboard view", () => {
252
+ resetStore();
253
+ expect(useUIStore.getState().activeView).toBe("dashboard");
254
+ });
255
+
256
+ it("starts with sidebar expanded", () => {
257
+ resetStore();
258
+ expect(useUIStore.getState().sidebarExpanded).toBe(true);
259
+ });
260
+
261
+ it("starts with no modal", () => {
262
+ resetStore();
263
+ expect(useUIStore.getState().modal).toBeNull();
264
+ });
265
+
266
+ it("starts with command palette closed", () => {
267
+ resetStore();
268
+ expect(useUIStore.getState().commandPaletteOpen).toBe(false);
269
+ });
270
+
271
+ it("starts with no previous view", () => {
272
+ resetStore();
273
+ expect(useUIStore.getState().previousView).toBeNull();
274
+ });
275
+ });
276
+ });
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Test setup / preload for TUI package.
8
+ *
9
+ * Pre-loads @opentui/core so its top-level await resolves BEFORE any test
10
+ * code runs. This prevents the Temporal Dead Zone (TDZ) race condition
11
+ * that causes:
12
+ * "Cannot access 'FFIRenderLib' before initialization"
13
+ *
14
+ * OpenTUI's bundled index-ysvpktsp.js has:
15
+ * line 12404: await import(`@opentui/core-${platform}-${arch}`)
16
+ * line 13725: class FFIRenderLib { ... } // AFTER the await
17
+ * line 15202: function resolveRenderLib() { ... }
18
+ *
19
+ * If resolveRenderLib() is called while the module is still suspended by
20
+ * the top-level await, FFIRenderLib is in the TDZ → ReferenceError.
21
+ *
22
+ * This preload ensures the module finishes evaluating before any test
23
+ * imports trigger resolveRenderLib. It also wraps createTestRenderer with
24
+ * error handling for environments where the native library is absent.
25
+ */
26
+
27
+ // yoga-layout (dep of @opentui/core) uses fetch() to load its WASM binary.
28
+ // Must be set BEFORE @opentui/core is imported below.
29
+ global.fetch = fetch;
30
+
31
+ import { mock } from "bun:test";
32
+
33
+ // ── Module-level state ────────────────────────────────────────────────────────
34
+
35
+ type CoreTestingModule = typeof import("@opentui/core/testing");
36
+ type CreateTestRendererFn = CoreTestingModule["createTestRenderer"];
37
+ let realCreateTestRenderer: CreateTestRendererFn | null = null;
38
+ let coreAvailable = false;
39
+
40
+ // ── Load real module (pre-resolves the top-level await) ────────────────────────
41
+
42
+ try {
43
+ // 1. Load @opentui/core first — this triggers its top-level await
44
+ // which loads the platform-specific native library package.
45
+ // If the native library is present, the await resolves quickly and
46
+ // the module finishes evaluating (FFIRenderLib becomes defined).
47
+ await import("@opentui/core");
48
+
49
+ // 2. Now load the testing module and capture its createTestRenderer
50
+ const coreTesting = await import("@opentui/core/testing");
51
+ realCreateTestRenderer = coreTesting.createTestRenderer;
52
+ coreAvailable = true;
53
+ } catch {
54
+ // Core not available — tests that require rendering will get a clear error
55
+ coreAvailable = false;
56
+ }
57
+
58
+ // ── Mock @opentui/core/testing ─────────────────────────────────────────────────
59
+ //
60
+ // This mock intercepts all imports of @opentui/core/testing so that:
61
+ // - If the real module loaded successfully → uses real createTestRenderer
62
+ // (with error-wrapping for defensive handling)
63
+ // - If the real module failed → provides a mock that throws a clear
64
+ // error message about the missing native library
65
+ // - The Temporal Dead Zone race condition is avoided because we've
66
+ // already pre-loaded @opentui/core above
67
+
68
+ mock.module("@opentui/core/testing", () => {
69
+ if (coreAvailable && realCreateTestRenderer) {
70
+ return {
71
+ // Spread any other exports from the real module (setRendererCapabilities,
72
+ // createTerminalCapabilities, MockTreeSitterClient, etc.)
73
+ ...shareTestingExports(),
74
+
75
+ // Wrap createTestRenderer with defensive error handling
76
+ createTestRenderer: async (...args: Parameters<CreateTestRendererFn>) => {
77
+ try {
78
+ return await realCreateTestRenderer!(...args);
79
+ } catch (cause) {
80
+ throw new Error(
81
+ `OpenTUI render library initialization failed: ${
82
+ cause instanceof Error ? cause.message : String(cause)
83
+ }. ` +
84
+ "Ensure @opentui/core native library is installed for your platform.",
85
+ { cause }
86
+ );
87
+ }
88
+ },
89
+ };
90
+ }
91
+
92
+ // Fallback: native library not available → provide a mock that throws
93
+ // a clear, actionable error (instead of the confusing TDZ ReferenceError)
94
+ return {
95
+ createTestRenderer: async (..._args: Parameters<CreateTestRendererFn>) => {
96
+ throw new Error(
97
+ "OpenTUI native render library is not available in this environment. " +
98
+ "Render-dependent tests cannot run. " +
99
+ "Install @opentui/core native library for your platform, " +
100
+ "or run tests on a supported platform (linux-x64, darwin-arm64, etc.)."
101
+ );
102
+ },
103
+
104
+ setRendererCapabilities: () => {},
105
+ createTerminalCapabilities: (overrides = {}) => overrides,
106
+ createSpy: () => ({
107
+ calls: [],
108
+ callCount: () => 0,
109
+ calledWith: () => false,
110
+ reset: () => {},
111
+ }),
112
+ MockTreeSitterClient: class {},
113
+ KeyCodes: {},
114
+ MouseButtons: {},
115
+ pasteBytes: (_s: string) => new Uint8Array(),
116
+ createMockKeys: () => ({}),
117
+ createMockMouse: () => ({}),
118
+ TestRecorder: class {},
119
+ };
120
+ });
121
+
122
+ // ── Mock helper exports ─────────────────────────────────────────────────────────
123
+
124
+ /** Re-export testing utilities from the real module for convenience. */
125
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
+ function shareTestingExports(): Record<string, any> {
127
+ return {
128
+ setRendererCapabilities: () => {},
129
+ createTerminalCapabilities: (o: Record<string, unknown> = {}) => o,
130
+ createSpy: () => ({
131
+ calls: [],
132
+ callCount: () => 0,
133
+ calledWith: () => false,
134
+ reset: () => {},
135
+ }),
136
+ MockTreeSitterClient: class {},
137
+ KeyCodes: {},
138
+ MouseButtons: {},
139
+ pasteBytes: (_s: string) => new Uint8Array(),
140
+ createMockKeys: () => ({}),
141
+ createMockMouse: () => ({}),
142
+ TestRecorder: class {},
143
+ };
144
+ }
145
+
146
+ // Prevent tests from accidentally spawning real wrangler, hoox CLI, or
147
+ // other heavy processes during unit/integration runs. Set
148
+ // HOOX_TEST_ALLOW_WRANGLER=1 to allow the real spawn during live testing.
149
+ import { installSpawnShim } from "@hoox/test-utils/spawn-shim";
150
+ installSpawnShim();
151
+
152
+ // ── Shared CLI bridge test double (process-wide, full surface) ───────────────
153
+ //
154
+ // Install ONE complete mock of `cli-bridge` here so individual test files do
155
+ // not each call mock.module with partial stubs (which strip methods other
156
+ // suites need under Bun's process-wide module mock).
157
+ //
158
+ // Per-test overrides: import { cliBridgeDouble, resetCliBridgeDouble } from
159
+ // "./cli-bridge-test-double" and use mockResolvedValue / mockImplementation.
160
+ import {
161
+ cliBridgeDouble,
162
+ createCliBridgeModuleMock,
163
+ } from "./cli-bridge-test-double";
164
+
165
+ const cliBridgeModule = await createCliBridgeModuleMock();
166
+
167
+ /** Register under every import specifier used across the suite. */
168
+ function installCliBridgeDouble(): void {
169
+ const paths = [
170
+ // Absolute file URL (Bun resolves relative imports to this)
171
+ new URL("./services/cli-bridge/index.ts", import.meta.url).href,
172
+ new URL("./services/cli-bridge/", import.meta.url).href,
173
+ // Relative from this preload file
174
+ "./services/cli-bridge",
175
+ "./services/cli-bridge/index.ts",
176
+ // Relative paths used by view tests under components/views/
177
+ "../../services/cli-bridge",
178
+ // Relative paths used by store tests
179
+ "../services/cli-bridge",
180
+ ];
181
+ for (const p of paths) {
182
+ try {
183
+ mock.module(p, () => cliBridgeModule);
184
+ } catch {
185
+ // Specifier may be invalid in some environments — ignore
186
+ }
187
+ }
188
+ }
189
+
190
+ installCliBridgeDouble();
191
+
192
+ // Expose for tests that need the double without importing the helper path
193
+ (
194
+ globalThis as unknown as { __hooxCliBridgeDouble?: typeof cliBridgeDouble }
195
+ ).__hooxCliBridgeDouble = cliBridgeDouble;
196
+
197
+ // ── Shared network doubles (api-client + SSE) ────────────────────────────────
198
+ //
199
+ // service-store (and views that call fetchWorkers / stream*) must not hit a
200
+ // real HTTP API in unit tests. One full mock here replaces per-file
201
+ // mock.module of api-client / sse.
202
+ import {
203
+ hooxFetchMock,
204
+ subscribeSSEMock,
205
+ resetNetworkDoubles,
206
+ } from "./network-test-double";
207
+
208
+ resetNetworkDoubles();
209
+
210
+ async function installNetworkDoubles(): Promise<void> {
211
+ let realApi: Record<string, unknown> = {};
212
+ try {
213
+ realApi = (await import("@hoox-sh/hoox-shared/api-client")) as Record<
214
+ string,
215
+ unknown
216
+ >;
217
+ } catch {
218
+ realApi = {};
219
+ }
220
+
221
+ const apiFactory = () => ({
222
+ ...realApi,
223
+ hooxFetch: hooxFetchMock,
224
+ });
225
+
226
+ const sseFactory = () => ({
227
+ subscribeSSE: subscribeSSEMock,
228
+ });
229
+
230
+ // From packages/tui/src → ../../shared/src (sibling package)
231
+ const apiPaths = [
232
+ new URL("../../shared/src/api-client.ts", import.meta.url).href,
233
+ new URL("../../shared/src/api-client.ts", import.meta.url).pathname,
234
+ "@hoox-sh/hoox-shared/api-client",
235
+ // Relative as resolved from packages/shared/src/stores/service-store.ts
236
+ "../api-client",
237
+ "../api-client.ts",
238
+ "../../../../packages/shared/src/api-client",
239
+ ];
240
+ const ssePaths = [
241
+ new URL("../../shared/src/sse.ts", import.meta.url).href,
242
+ new URL("../../shared/src/sse.ts", import.meta.url).pathname,
243
+ "@hoox-sh/hoox-shared/sse",
244
+ "../sse",
245
+ "../sse.ts",
246
+ "../../../../packages/shared/src/sse",
247
+ ];
248
+
249
+ for (const p of apiPaths) {
250
+ try {
251
+ mock.module(p, apiFactory);
252
+ } catch {
253
+ // ignore bad specifier
254
+ }
255
+ }
256
+ for (const p of ssePaths) {
257
+ try {
258
+ mock.module(p, sseFactory);
259
+ } catch {
260
+ // ignore
261
+ }
262
+ }
263
+ }
264
+
265
+ await installNetworkDoubles();