@blackbelt-technology/pi-agent-dashboard 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 (212) hide show
  1. package/AGENTS.md +342 -0
  2. package/README.md +619 -0
  3. package/docs/architecture.md +646 -0
  4. package/package.json +92 -0
  5. package/packages/extension/package.json +33 -0
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
  8. package/packages/extension/src/__tests__/connection.test.ts +344 -0
  9. package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
  10. package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
  11. package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
  12. package/packages/extension/src/__tests__/git-info.test.ts +112 -0
  13. package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
  14. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
  15. package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
  16. package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
  17. package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
  18. package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
  19. package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
  20. package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
  21. package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
  22. package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
  23. package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
  24. package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
  25. package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
  26. package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
  27. package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
  28. package/packages/extension/src/ask-user-tool.ts +63 -0
  29. package/packages/extension/src/bridge-context.ts +64 -0
  30. package/packages/extension/src/bridge.ts +926 -0
  31. package/packages/extension/src/command-handler.ts +538 -0
  32. package/packages/extension/src/connection.ts +204 -0
  33. package/packages/extension/src/dev-build.ts +39 -0
  34. package/packages/extension/src/event-forwarder.ts +40 -0
  35. package/packages/extension/src/flow-event-wiring.ts +102 -0
  36. package/packages/extension/src/git-info.ts +65 -0
  37. package/packages/extension/src/git-link-builder.ts +112 -0
  38. package/packages/extension/src/model-tracker.ts +56 -0
  39. package/packages/extension/src/pi-env.d.ts +23 -0
  40. package/packages/extension/src/process-metrics.ts +70 -0
  41. package/packages/extension/src/process-scanner.ts +396 -0
  42. package/packages/extension/src/prompt-expander.ts +87 -0
  43. package/packages/extension/src/provider-register.ts +276 -0
  44. package/packages/extension/src/server-auto-start.ts +87 -0
  45. package/packages/extension/src/server-launcher.ts +82 -0
  46. package/packages/extension/src/server-probe.ts +33 -0
  47. package/packages/extension/src/session-sync.ts +154 -0
  48. package/packages/extension/src/source-detector.ts +26 -0
  49. package/packages/extension/src/ui-proxy.ts +269 -0
  50. package/packages/extension/tsconfig.json +11 -0
  51. package/packages/server/package.json +37 -0
  52. package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
  53. package/packages/server/src/__tests__/auth.test.ts +224 -0
  54. package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
  55. package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
  56. package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
  57. package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
  58. package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
  59. package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
  60. package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
  61. package/packages/server/src/__tests__/config-api.test.ts +104 -0
  62. package/packages/server/src/__tests__/cors.test.ts +48 -0
  63. package/packages/server/src/__tests__/directory-service.test.ts +240 -0
  64. package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
  65. package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
  66. package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
  67. package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
  68. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
  69. package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
  70. package/packages/server/src/__tests__/extension-register.test.ts +61 -0
  71. package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
  72. package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
  73. package/packages/server/src/__tests__/git-operations.test.ts +251 -0
  74. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  75. package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
  76. package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
  77. package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
  78. package/packages/server/src/__tests__/json-store.test.ts +70 -0
  79. package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
  80. package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
  81. package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
  82. package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
  83. package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
  84. package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
  85. package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
  86. package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
  87. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
  88. package/packages/server/src/__tests__/package-routes.test.ts +172 -0
  89. package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
  90. package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
  91. package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
  92. package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
  93. package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
  94. package/packages/server/src/__tests__/process-manager.test.ts +184 -0
  95. package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
  96. package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
  97. package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
  98. package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
  99. package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
  100. package/packages/server/src/__tests__/server-pid.test.ts +89 -0
  101. package/packages/server/src/__tests__/session-api.test.ts +244 -0
  102. package/packages/server/src/__tests__/session-diff.test.ts +138 -0
  103. package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
  104. package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
  105. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
  106. package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
  107. package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
  108. package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
  109. package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
  110. package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
  111. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
  112. package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
  113. package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
  114. package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
  115. package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
  116. package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
  117. package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
  118. package/packages/server/src/__tests__/tunnel.test.ts +206 -0
  119. package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
  120. package/packages/server/src/auth-plugin.ts +302 -0
  121. package/packages/server/src/auth.ts +323 -0
  122. package/packages/server/src/browse.ts +55 -0
  123. package/packages/server/src/browser-gateway.ts +495 -0
  124. package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
  125. package/packages/server/src/browser-handlers/handler-context.ts +45 -0
  126. package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
  127. package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
  128. package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
  129. package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
  130. package/packages/server/src/cli.ts +347 -0
  131. package/packages/server/src/config-api.ts +130 -0
  132. package/packages/server/src/directory-service.ts +162 -0
  133. package/packages/server/src/editor-detection.ts +60 -0
  134. package/packages/server/src/editor-manager.ts +352 -0
  135. package/packages/server/src/editor-proxy.ts +134 -0
  136. package/packages/server/src/editor-registry.ts +108 -0
  137. package/packages/server/src/event-status-extraction.ts +131 -0
  138. package/packages/server/src/event-wiring.ts +589 -0
  139. package/packages/server/src/extension-register.ts +92 -0
  140. package/packages/server/src/git-operations.ts +200 -0
  141. package/packages/server/src/headless-pid-registry.ts +207 -0
  142. package/packages/server/src/idle-timer.ts +61 -0
  143. package/packages/server/src/json-store.ts +32 -0
  144. package/packages/server/src/localhost-guard.ts +117 -0
  145. package/packages/server/src/memory-event-store.ts +193 -0
  146. package/packages/server/src/memory-session-manager.ts +123 -0
  147. package/packages/server/src/meta-persistence.ts +64 -0
  148. package/packages/server/src/migrate-persistence.ts +195 -0
  149. package/packages/server/src/npm-search-proxy.ts +143 -0
  150. package/packages/server/src/oauth-callback-server.ts +177 -0
  151. package/packages/server/src/openspec-archive.ts +60 -0
  152. package/packages/server/src/package-manager-wrapper.ts +200 -0
  153. package/packages/server/src/pending-fork-registry.ts +53 -0
  154. package/packages/server/src/pending-load-manager.ts +110 -0
  155. package/packages/server/src/pending-resume-registry.ts +69 -0
  156. package/packages/server/src/pi-gateway.ts +419 -0
  157. package/packages/server/src/pi-resource-scanner.ts +369 -0
  158. package/packages/server/src/preferences-store.ts +116 -0
  159. package/packages/server/src/process-manager.ts +311 -0
  160. package/packages/server/src/provider-auth-handlers.ts +438 -0
  161. package/packages/server/src/provider-auth-storage.ts +200 -0
  162. package/packages/server/src/resolve-path.ts +12 -0
  163. package/packages/server/src/routes/editor-routes.ts +86 -0
  164. package/packages/server/src/routes/file-routes.ts +116 -0
  165. package/packages/server/src/routes/git-routes.ts +89 -0
  166. package/packages/server/src/routes/openspec-routes.ts +99 -0
  167. package/packages/server/src/routes/package-routes.ts +172 -0
  168. package/packages/server/src/routes/provider-auth-routes.ts +244 -0
  169. package/packages/server/src/routes/provider-routes.ts +101 -0
  170. package/packages/server/src/routes/route-deps.ts +23 -0
  171. package/packages/server/src/routes/session-routes.ts +91 -0
  172. package/packages/server/src/routes/system-routes.ts +271 -0
  173. package/packages/server/src/server-pid.ts +84 -0
  174. package/packages/server/src/server.ts +554 -0
  175. package/packages/server/src/session-api.ts +330 -0
  176. package/packages/server/src/session-bootstrap.ts +80 -0
  177. package/packages/server/src/session-diff.ts +178 -0
  178. package/packages/server/src/session-discovery.ts +134 -0
  179. package/packages/server/src/session-file-reader.ts +135 -0
  180. package/packages/server/src/session-order-manager.ts +73 -0
  181. package/packages/server/src/session-scanner.ts +233 -0
  182. package/packages/server/src/session-stats-reader.ts +99 -0
  183. package/packages/server/src/terminal-gateway.ts +51 -0
  184. package/packages/server/src/terminal-manager.ts +241 -0
  185. package/packages/server/src/tunnel.ts +329 -0
  186. package/packages/server/tsconfig.json +11 -0
  187. package/packages/shared/package.json +15 -0
  188. package/packages/shared/src/__tests__/config.test.ts +358 -0
  189. package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
  190. package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
  191. package/packages/shared/src/__tests__/protocol.test.ts +243 -0
  192. package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
  193. package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
  194. package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
  195. package/packages/shared/src/archive-types.ts +11 -0
  196. package/packages/shared/src/browser-protocol.ts +534 -0
  197. package/packages/shared/src/config.ts +245 -0
  198. package/packages/shared/src/diff-types.ts +41 -0
  199. package/packages/shared/src/editor-types.ts +18 -0
  200. package/packages/shared/src/mdns-discovery.ts +248 -0
  201. package/packages/shared/src/openspec-activity-detector.ts +109 -0
  202. package/packages/shared/src/openspec-poller.ts +96 -0
  203. package/packages/shared/src/protocol.ts +369 -0
  204. package/packages/shared/src/resolve-jiti.ts +43 -0
  205. package/packages/shared/src/rest-api.ts +255 -0
  206. package/packages/shared/src/server-identity.ts +51 -0
  207. package/packages/shared/src/session-meta.ts +86 -0
  208. package/packages/shared/src/state-replay.ts +174 -0
  209. package/packages/shared/src/stats-extractor.ts +54 -0
  210. package/packages/shared/src/terminal-types.ts +18 -0
  211. package/packages/shared/src/types.ts +351 -0
  212. package/packages/shared/tsconfig.json +8 -0
@@ -0,0 +1,257 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createTerminalManager, detectShell, type TerminalManager } from "../terminal-manager.js";
3
+
4
+ // Mock node-pty
5
+ const mockPtyWrite = vi.fn();
6
+ const mockPtyResize = vi.fn();
7
+ const mockPtyKill = vi.fn();
8
+ let mockOnData: ((data: string) => void) | null = null;
9
+ let mockOnExit: ((e: { exitCode: number; signal?: number }) => void) | null = null;
10
+
11
+ vi.mock("node-pty", () => ({
12
+ spawn: vi.fn(() => ({
13
+ write: mockPtyWrite,
14
+ resize: mockPtyResize,
15
+ kill: mockPtyKill,
16
+ onData: (cb: (data: string) => void) => {
17
+ mockOnData = cb;
18
+ return { dispose: vi.fn() };
19
+ },
20
+ onExit: (cb: (e: { exitCode: number; signal?: number }) => void) => {
21
+ mockOnExit = cb;
22
+ return { dispose: vi.fn() };
23
+ },
24
+ pid: 12345,
25
+ })),
26
+ }));
27
+
28
+ describe("TerminalManager", () => {
29
+ let manager: TerminalManager;
30
+ let exitCallbacks: Array<(termId: string) => void>;
31
+
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ mockOnData = null;
35
+ mockOnExit = null;
36
+ exitCallbacks = [];
37
+ manager = createTerminalManager({
38
+ onExit: (termId) => exitCallbacks.forEach((cb) => cb(termId)),
39
+ });
40
+ });
41
+
42
+ afterEach(() => {
43
+ // Kill all terminals to clean up
44
+ for (const t of manager.list()) {
45
+ try { manager.kill(t.id); } catch {}
46
+ }
47
+ });
48
+
49
+ describe("spawn", () => {
50
+ it("creates a terminal with term- prefix ID", () => {
51
+ const session = manager.spawn("/tmp");
52
+ expect(session.id).toMatch(/^term-/);
53
+ expect(session.cwd).toBe("/tmp");
54
+ expect(session.status).toBe("active");
55
+ expect(session.shell).toBeDefined();
56
+ });
57
+
58
+ it("detects shell from env", () => {
59
+ const original = process.env.SHELL;
60
+ process.env.SHELL = "/bin/zsh";
61
+ const session = manager.spawn("/tmp");
62
+ expect(session.shell).toBe("/bin/zsh");
63
+ process.env.SHELL = original;
64
+ });
65
+
66
+ it("falls back to /bin/bash when SHELL not set", () => {
67
+ const original = process.env.SHELL;
68
+ delete process.env.SHELL;
69
+ const session = manager.spawn("/tmp");
70
+ expect(session.shell).toBe("/bin/bash");
71
+ process.env.SHELL = original;
72
+ });
73
+
74
+ it("spawns node-pty with correct args", async () => {
75
+ const pty = await import("node-pty");
76
+ manager.spawn("/home/user");
77
+ expect(pty.spawn).toHaveBeenCalledWith(
78
+ expect.any(String),
79
+ [],
80
+ expect.objectContaining({
81
+ cwd: "/home/user",
82
+ cols: 80,
83
+ rows: 24,
84
+ }),
85
+ );
86
+ });
87
+ });
88
+
89
+ describe("list and get", () => {
90
+ it("lists all active terminals", () => {
91
+ manager.spawn("/tmp/a");
92
+ manager.spawn("/tmp/b");
93
+ expect(manager.list()).toHaveLength(2);
94
+ });
95
+
96
+ it("gets a terminal by ID", () => {
97
+ const session = manager.spawn("/tmp");
98
+ expect(manager.get(session.id)).toEqual(session);
99
+ });
100
+
101
+ it("returns undefined for unknown ID", () => {
102
+ expect(manager.get("term-nonexistent")).toBeUndefined();
103
+ });
104
+ });
105
+
106
+ describe("updateTitle", () => {
107
+ it("updates the title", () => {
108
+ const session = manager.spawn("/tmp");
109
+ manager.updateTitle(session.id, "my title");
110
+ expect(manager.get(session.id)?.title).toBe("my title");
111
+ });
112
+ });
113
+
114
+ describe("kill", () => {
115
+ it("sends SIGHUP to PTY (bash on Linux ignores SIGTERM)", () => {
116
+ const session = manager.spawn("/tmp");
117
+ manager.kill(session.id);
118
+ expect(mockPtyKill).toHaveBeenCalledWith("SIGHUP");
119
+ });
120
+
121
+ it("throws for unknown ID", () => {
122
+ expect(() => manager.kill("term-unknown")).toThrow();
123
+ });
124
+ });
125
+
126
+ describe("attach", () => {
127
+ it("replays buffer contents on attach", () => {
128
+ const session = manager.spawn("/tmp");
129
+ // Simulate PTY output
130
+ mockOnData?.("hello world");
131
+
132
+ const mockWs = {
133
+ send: vi.fn(),
134
+ on: vi.fn(),
135
+ readyState: 1,
136
+ OPEN: 1,
137
+ } as any;
138
+
139
+ manager.attach(session.id, mockWs);
140
+
141
+ // First call should be the replay
142
+ expect(mockWs.send).toHaveBeenCalledWith(
143
+ expect.any(Buffer),
144
+ );
145
+ const sentData = mockWs.send.mock.calls[0][0];
146
+ expect(sentData.toString()).toBe("hello world");
147
+ });
148
+
149
+ it("routes binary frames to pty.write", () => {
150
+ const session = manager.spawn("/tmp");
151
+ const handlers: Record<string, Function> = {};
152
+
153
+ const mockWs = {
154
+ send: vi.fn(),
155
+ on: vi.fn((event: string, cb: any) => { handlers[event] = cb; }),
156
+ readyState: 1,
157
+ OPEN: 1,
158
+ } as any;
159
+
160
+ manager.attach(session.id, mockWs);
161
+
162
+ // Simulate binary input from browser
163
+ const input = Buffer.from("ls\n");
164
+ handlers.message(input, true);
165
+ expect(mockPtyWrite).toHaveBeenCalledWith(input.toString());
166
+ });
167
+
168
+ it("routes non-JSON text frames to pty.write (AttachAddon sends text)", () => {
169
+ const session = manager.spawn("/tmp");
170
+ const handlers: Record<string, Function> = {};
171
+
172
+ const mockWs = {
173
+ send: vi.fn(),
174
+ on: vi.fn((event: string, cb: any) => { handlers[event] = cb; }),
175
+ readyState: 1,
176
+ OPEN: 1,
177
+ } as any;
178
+
179
+ manager.attach(session.id, mockWs);
180
+
181
+ // AttachAddon sends keystrokes as text frames
182
+ const input = Buffer.from("ls\n");
183
+ handlers.message(input, false);
184
+ expect(mockPtyWrite).toHaveBeenCalledWith("ls\n");
185
+ });
186
+
187
+ it("handles resize control message", () => {
188
+ const session = manager.spawn("/tmp");
189
+ const handlers: Record<string, Function> = {};
190
+
191
+ const mockWs = {
192
+ send: vi.fn(),
193
+ on: vi.fn((event: string, cb: any) => { handlers[event] = cb; }),
194
+ readyState: 1,
195
+ OPEN: 1,
196
+ } as any;
197
+
198
+ manager.attach(session.id, mockWs);
199
+
200
+ // Simulate resize control message (text frame)
201
+ const resizeMsg = Buffer.from(JSON.stringify({ type: "resize", cols: 120, rows: 40 }));
202
+ handlers.message(resizeMsg, false);
203
+ expect(mockPtyResize).toHaveBeenCalledWith(120, 40);
204
+ });
205
+ });
206
+
207
+ describe("PTY exit", () => {
208
+ it("calls onExit callback and removes terminal", () => {
209
+ const cb = vi.fn();
210
+ exitCallbacks.push(cb);
211
+
212
+ const session = manager.spawn("/tmp");
213
+ // Simulate PTY exit
214
+ mockOnExit?.({ exitCode: 0 });
215
+
216
+ expect(cb).toHaveBeenCalledWith(session.id);
217
+ expect(manager.get(session.id)).toBeUndefined();
218
+ });
219
+ });
220
+ });
221
+
222
+ describe("detectShell", () => {
223
+ const origShell = process.env.SHELL;
224
+ const origComspec = process.env.COMSPEC;
225
+
226
+ afterEach(() => {
227
+ if (origShell !== undefined) process.env.SHELL = origShell;
228
+ else delete process.env.SHELL;
229
+ if (origComspec !== undefined) process.env.COMSPEC = origComspec;
230
+ else delete process.env.COMSPEC;
231
+ });
232
+
233
+ it("should use SHELL on macOS", () => {
234
+ process.env.SHELL = "/bin/zsh";
235
+ expect(detectShell("darwin")).toBe("/bin/zsh");
236
+ });
237
+
238
+ it("should use SHELL on Linux", () => {
239
+ process.env.SHELL = "/usr/bin/fish";
240
+ expect(detectShell("linux")).toBe("/usr/bin/fish");
241
+ });
242
+
243
+ it("should fall back to /bin/bash on Unix when SHELL unset", () => {
244
+ delete process.env.SHELL;
245
+ expect(detectShell("linux")).toBe("/bin/bash");
246
+ });
247
+
248
+ it("should use COMSPEC on Windows", () => {
249
+ process.env.COMSPEC = "C:\\Windows\\system32\\cmd.exe";
250
+ expect(detectShell("win32")).toBe("C:\\Windows\\system32\\cmd.exe");
251
+ });
252
+
253
+ it("should fall back to powershell.exe on Windows when COMSPEC unset", () => {
254
+ delete process.env.COMSPEC;
255
+ expect(detectShell("win32")).toBe("powershell.exe");
256
+ });
257
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+
7
+ describe("trustedNetworks config", () => {
8
+ let testDir: string;
9
+ let configFile: string;
10
+ let origHome: string;
11
+
12
+ beforeEach(() => {
13
+ testDir = path.join(os.tmpdir(), `test-trusted-nets-${Date.now()}`);
14
+ fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
15
+ configFile = path.join(testDir, ".pi", "dashboard", "config.json");
16
+ origHome = process.env.HOME!;
17
+ process.env.HOME = testDir;
18
+ });
19
+
20
+ afterEach(() => {
21
+ process.env.HOME = origHome;
22
+ fs.rmSync(testDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it("should default to empty arrays when not configured", () => {
26
+ fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
27
+ const config = loadConfig();
28
+ expect(config.trustedNetworks).toEqual([]);
29
+ expect(config.resolvedTrustedNetworks).toEqual([]);
30
+ });
31
+
32
+ it("should parse trustedNetworks", () => {
33
+ fs.writeFileSync(configFile, JSON.stringify({
34
+ trustedNetworks: ["192.168.1.0/24", "10.0.0.*"],
35
+ }));
36
+ const config = loadConfig();
37
+ expect(config.trustedNetworks).toEqual(["192.168.1.0/24", "10.0.0.*"]);
38
+ expect(config.resolvedTrustedNetworks).toEqual(["192.168.1.0/24", "10.0.0.*"]);
39
+ });
40
+
41
+ it("should merge trustedNetworks with auth.bypassHosts", () => {
42
+ fs.writeFileSync(configFile, JSON.stringify({
43
+ trustedNetworks: ["192.168.1.0/24"],
44
+ auth: {
45
+ secret: "s",
46
+ providers: { github: { clientId: "a", clientSecret: "b" } },
47
+ bypassHosts: ["10.0.0.0/8"],
48
+ },
49
+ }));
50
+ const config = loadConfig();
51
+ expect(config.resolvedTrustedNetworks).toContain("192.168.1.0/24");
52
+ expect(config.resolvedTrustedNetworks).toContain("10.0.0.0/8");
53
+ });
54
+
55
+ it("should deduplicate entries", () => {
56
+ fs.writeFileSync(configFile, JSON.stringify({
57
+ trustedNetworks: ["192.168.1.0/24"],
58
+ auth: {
59
+ secret: "s",
60
+ providers: { github: { clientId: "a", clientSecret: "b" } },
61
+ bypassHosts: ["192.168.1.0/24"],
62
+ },
63
+ }));
64
+ const config = loadConfig();
65
+ expect(config.resolvedTrustedNetworks).toEqual(["192.168.1.0/24"]);
66
+ });
67
+
68
+ it("should filter non-string entries", () => {
69
+ fs.writeFileSync(configFile, JSON.stringify({
70
+ trustedNetworks: ["192.168.1.0/24", 123, null, ""],
71
+ }));
72
+ const config = loadConfig();
73
+ expect(config.trustedNetworks).toEqual(["192.168.1.0/24"]);
74
+ });
75
+
76
+ it("should handle trustedNetworks without auth", () => {
77
+ fs.writeFileSync(configFile, JSON.stringify({
78
+ trustedNetworks: ["10.0.0.0/8"],
79
+ }));
80
+ const config = loadConfig();
81
+ expect(config.resolvedTrustedNetworks).toEqual(["10.0.0.0/8"]);
82
+ expect(config.auth).toBeUndefined();
83
+ });
84
+ });
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+
5
+ vi.mock("node:fs", async (importOriginal) => {
6
+ const actual = await importOriginal<typeof import("node:fs")>();
7
+ return {
8
+ ...actual,
9
+ default: {
10
+ ...actual.default,
11
+ existsSync: vi.fn(),
12
+ readFileSync: vi.fn(),
13
+ writeFileSync: vi.fn(),
14
+ mkdirSync: vi.fn(),
15
+ unlinkSync: vi.fn(),
16
+ },
17
+ existsSync: vi.fn(),
18
+ readFileSync: vi.fn(),
19
+ writeFileSync: vi.fn(),
20
+ mkdirSync: vi.fn(),
21
+ unlinkSync: vi.fn(),
22
+ };
23
+ });
24
+ vi.mock("node:os", async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import("node:os")>();
26
+ return {
27
+ ...actual,
28
+ default: { ...actual.default, homedir: vi.fn(() => "/home/testuser") },
29
+ homedir: vi.fn(() => "/home/testuser"),
30
+ };
31
+ });
32
+
33
+ import {
34
+ loadZrokEnv,
35
+ detectZrokBinary,
36
+ writeZrokPid,
37
+ readZrokPid,
38
+ removeZrokPid,
39
+ cleanupStaleZrok,
40
+ getTunnelStatus,
41
+ _resetBinaryCache,
42
+ _setBinaryAvailable,
43
+ } from "../tunnel.js";
44
+
45
+ beforeEach(() => {
46
+ vi.mocked(os.homedir).mockReturnValue("/home/testuser");
47
+ _resetBinaryCache();
48
+ });
49
+
50
+ afterEach(() => {
51
+ vi.clearAllMocks();
52
+ _resetBinaryCache();
53
+ });
54
+
55
+ describe("loadZrokEnv", () => {
56
+ it("should return zrok env when enrolled", () => {
57
+ vi.mocked(fs.existsSync).mockReturnValue(true);
58
+ vi.mocked(fs.readFileSync).mockReturnValue(
59
+ JSON.stringify({
60
+ zrok_token: "tok_secret",
61
+ ziti_identity: "env-abc123",
62
+ api_endpoint: "https://api.zrok.io",
63
+ })
64
+ );
65
+
66
+ const env = loadZrokEnv();
67
+ expect(env).not.toBeNull();
68
+ expect(env!.apiEndpoint).toBe("https://api.zrok.io");
69
+ expect(env!.envZId).toBe("env-abc123");
70
+ expect(env!.token).toBe("tok_secret");
71
+ });
72
+
73
+ it("should return null when not enrolled", () => {
74
+ vi.mocked(fs.existsSync).mockReturnValue(false);
75
+ expect(loadZrokEnv()).toBeNull();
76
+ });
77
+
78
+ it("should return null on malformed JSON", () => {
79
+ vi.mocked(fs.existsSync).mockReturnValue(true);
80
+ vi.mocked(fs.readFileSync).mockReturnValue("not valid json{{{");
81
+ expect(loadZrokEnv()).toBeNull();
82
+ });
83
+
84
+ it("should return null when required fields are missing", () => {
85
+ vi.mocked(fs.existsSync).mockReturnValue(true);
86
+ vi.mocked(fs.readFileSync).mockReturnValue(
87
+ JSON.stringify({ ziti_identity: "test" })
88
+ );
89
+ expect(loadZrokEnv()).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe("detectZrokBinary", () => {
94
+ it("should return true when set available", () => {
95
+ _setBinaryAvailable(true);
96
+ expect(detectZrokBinary()).toBe(true);
97
+ });
98
+
99
+ it("should return false when set unavailable", () => {
100
+ _setBinaryAvailable(false);
101
+ expect(detectZrokBinary()).toBe(false);
102
+ });
103
+
104
+ it("should cache the result across calls", () => {
105
+ _setBinaryAvailable(true);
106
+ expect(detectZrokBinary()).toBe(true);
107
+ // Value is cached — stays true
108
+ expect(detectZrokBinary()).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe("PID file helpers", () => {
113
+ it("writeZrokPid should write PID to file", () => {
114
+ vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
115
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
116
+
117
+ writeZrokPid(12345);
118
+
119
+ expect(fs.mkdirSync).toHaveBeenCalled();
120
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
121
+ expect.stringContaining("zrok.pid"),
122
+ "12345\n"
123
+ );
124
+ });
125
+
126
+ it("readZrokPid should return PID from file", () => {
127
+ vi.mocked(fs.readFileSync).mockReturnValue("12345\n");
128
+ expect(readZrokPid()).toBe(12345);
129
+ });
130
+
131
+ it("readZrokPid should return null when file does not exist", () => {
132
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
133
+ throw new Error("ENOENT");
134
+ });
135
+ expect(readZrokPid()).toBeNull();
136
+ });
137
+
138
+ it("readZrokPid should return null for invalid content", () => {
139
+ vi.mocked(fs.readFileSync).mockReturnValue("not-a-number\n");
140
+ expect(readZrokPid()).toBeNull();
141
+ });
142
+
143
+ it("removeZrokPid should not throw if file does not exist", () => {
144
+ vi.mocked(fs.unlinkSync).mockImplementation(() => {
145
+ throw new Error("ENOENT");
146
+ });
147
+ expect(() => removeZrokPid()).not.toThrow();
148
+ });
149
+ });
150
+
151
+ describe("cleanupStaleZrok", () => {
152
+ it("should do nothing when no PID file exists", () => {
153
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
154
+ throw new Error("ENOENT");
155
+ });
156
+ const killSpy = vi.spyOn(process, "kill");
157
+
158
+ cleanupStaleZrok();
159
+
160
+ expect(killSpy).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it("should kill running stale process and remove PID file", () => {
164
+ vi.mocked(fs.readFileSync).mockReturnValue("99999\n");
165
+ const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
166
+ vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
167
+
168
+ cleanupStaleZrok();
169
+
170
+ expect(killSpy).toHaveBeenCalledWith(99999, 0);
171
+ expect(killSpy).toHaveBeenCalledWith(99999, "SIGTERM");
172
+ expect(fs.unlinkSync).toHaveBeenCalled();
173
+ });
174
+
175
+ it("should just remove PID file if process is not running", () => {
176
+ vi.mocked(fs.readFileSync).mockReturnValue("99999\n");
177
+ const killSpy = vi.spyOn(process, "kill").mockImplementation((_pid: number, signal?: string | number) => {
178
+ if (signal === 0) throw new Error("ESRCH");
179
+ return true;
180
+ });
181
+ vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
182
+
183
+ cleanupStaleZrok();
184
+
185
+ expect(killSpy).toHaveBeenCalledTimes(1);
186
+ expect(fs.unlinkSync).toHaveBeenCalled();
187
+ });
188
+ });
189
+
190
+ describe("getTunnelStatus", () => {
191
+ it("should return unavailable when binary not available", () => {
192
+ _setBinaryAvailable(false);
193
+
194
+ const status = getTunnelStatus();
195
+ expect(status.status).toBe("unavailable");
196
+ expect(status.serverOs).toBe(process.platform);
197
+ });
198
+
199
+ it("should return inactive when binary available but no tunnel", () => {
200
+ _setBinaryAvailable(true);
201
+
202
+ const status = getTunnelStatus();
203
+ expect(status.status).toBe("inactive");
204
+ expect(status.serverOs).toBe(process.platform);
205
+ });
206
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Tests for WS-level ping/pong dead connection detection in pi-gateway.
3
+ */
4
+ import { describe, it, expect, afterEach } from "vitest";
5
+ import { createPiGateway } from "../pi-gateway.js";
6
+ import { createMemorySessionManager } from "../memory-session-manager.js";
7
+ import { WebSocket } from "ws";
8
+
9
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
10
+
11
+ function waitForOpen(ws: WebSocket): Promise<void> {
12
+ return new Promise((resolve, reject) => {
13
+ if (ws.readyState === WebSocket.OPEN) return resolve();
14
+ ws.on("open", resolve);
15
+ ws.on("error", reject);
16
+ setTimeout(() => reject(new Error("open timeout")), 3000);
17
+ });
18
+ }
19
+
20
+ // Short intervals for fast tests
21
+ const SHORT_PING = 200; // 200ms ping interval
22
+ const SHORT_HB = 5000; // long heartbeat so it doesn't interfere
23
+ let portCounter = 19500;
24
+
25
+ describe("WS ping/pong", () => {
26
+ let gateway: ReturnType<typeof createPiGateway>;
27
+
28
+ afterEach(() => {
29
+ gateway?.stop();
30
+ });
31
+
32
+ it("should keep session alive when client responds to pings", async () => {
33
+ const sessionManager = createMemorySessionManager();
34
+ gateway = createPiGateway(sessionManager, {
35
+ heartbeatTimeout: SHORT_HB,
36
+ pingInterval: SHORT_PING,
37
+ });
38
+ const port = portCounter++;
39
+ gateway.start(port);
40
+
41
+ // ws library auto-responds to pings with pong
42
+ const ws = new WebSocket(`ws://localhost:${port}`);
43
+ await waitForOpen(ws);
44
+ ws.send(JSON.stringify({
45
+ type: "session_register", sessionId: "ping-alive", cwd: "/tmp", source: "tui",
46
+ }));
47
+ await delay(100);
48
+
49
+ // Wait for several ping cycles — session should stay alive
50
+ await delay(SHORT_PING * 4);
51
+
52
+ expect(sessionManager.get("ping-alive")!.status).toBe("active");
53
+ ws.close();
54
+ }, 10000);
55
+
56
+ it("should terminate connection when client stops responding to pings", async () => {
57
+ const sessionManager = createMemorySessionManager();
58
+ gateway = createPiGateway(sessionManager, {
59
+ heartbeatTimeout: SHORT_HB,
60
+ pingInterval: SHORT_PING,
61
+ });
62
+ const port = portCounter++;
63
+ gateway.start(port);
64
+
65
+ const ws = new WebSocket(`ws://localhost:${port}`);
66
+ await waitForOpen(ws);
67
+ ws.send(JSON.stringify({
68
+ type: "session_register", sessionId: "ping-dead", cwd: "/tmp", source: "tui",
69
+ }));
70
+ await delay(100);
71
+ expect(sessionManager.get("ping-dead")!.status).toBe("active");
72
+
73
+ // Disable pong responses by removing the pong handler and overriding
74
+ // The ws library auto-responds at the protocol level, so we need to
75
+ // break the connection at a lower level — pause the socket
76
+ (ws as any)._socket?.pause();
77
+
78
+ // Wait for ping cycle to detect the dead connection
79
+ // First ping sets isAlive=false, second ping sees isAlive=false → terminate
80
+ await delay(SHORT_PING * 3);
81
+
82
+ expect(sessionManager.get("ping-dead")!.status).toBe("ended");
83
+ }, 10000);
84
+
85
+ it("should call onEmpty after ping timeout terminates last connection", async () => {
86
+ const sessionManager = createMemorySessionManager();
87
+ gateway = createPiGateway(sessionManager, {
88
+ heartbeatTimeout: SHORT_HB,
89
+ pingInterval: SHORT_PING,
90
+ });
91
+ const port = portCounter++;
92
+ gateway.start(port);
93
+
94
+ let emptyCalled = false;
95
+ gateway.onEmpty = () => { emptyCalled = true; };
96
+
97
+ const ws = new WebSocket(`ws://localhost:${port}`);
98
+ await waitForOpen(ws);
99
+ ws.send(JSON.stringify({
100
+ type: "session_register", sessionId: "ping-empty", cwd: "/tmp", source: "tui",
101
+ }));
102
+ await delay(100);
103
+
104
+ // Pause socket to prevent pong responses
105
+ (ws as any)._socket?.pause();
106
+
107
+ // Wait for ping timeout
108
+ await delay(SHORT_PING * 3);
109
+
110
+ expect(emptyCalled).toBe(true);
111
+ }, 10000);
112
+ });