@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,175 @@
1
+ /**
2
+ * Smoke integration tests — validates end-to-end flows without SQLite.
3
+ */
4
+ import { describe, it, expect, afterAll } from "vitest";
5
+ import { createServer, type DashboardServer, type ServerConfig } from "../server.js";
6
+ import { WebSocket } from "ws";
7
+
8
+ function waitForOpen(ws: WebSocket): Promise<void> {
9
+ return new Promise((resolve, reject) => {
10
+ if (ws.readyState === WebSocket.OPEN) return resolve();
11
+ ws.on("open", resolve);
12
+ ws.on("error", reject);
13
+ setTimeout(() => reject(new Error("open timeout")), 3000);
14
+ });
15
+ }
16
+
17
+ function collectMsgs(ws: WebSocket, ms: number): Promise<any[]> {
18
+ return new Promise((resolve) => {
19
+ const arr: any[] = [];
20
+ const h = (raw: any) => arr.push(JSON.parse(raw.toString()));
21
+ ws.on("message", h);
22
+ setTimeout(() => { ws.off("message", h); resolve(arr); }, ms);
23
+ });
24
+ }
25
+
26
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
27
+ const httpPort = 19070;
28
+ const piPort = 19071;
29
+ let server: DashboardServer;
30
+
31
+ describe("Smoke integration", () => {
32
+ afterAll(async () => {
33
+ if (server) await server.stop();
34
+ });
35
+
36
+ it("9.2 — events flow and replay from memory on reconnect", async () => {
37
+ server = await createServer({
38
+ port: httpPort, piPort, dev: true,
39
+ autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
40
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
41
+ });
42
+ await server.start();
43
+
44
+ // Bridge connects and registers
45
+ const bridge = new WebSocket(`ws://localhost:${piPort}`);
46
+ await waitForOpen(bridge);
47
+ bridge.send(JSON.stringify({
48
+ type: "session_register", sessionId: "s1", cwd: "/tmp", source: "tui", name: "Test",
49
+ }));
50
+ bridge.send(JSON.stringify({ type: "replay_complete", sessionId: "s1" }));
51
+ await delay(150);
52
+
53
+ // Browser connects, subscribes, receives event
54
+ const b1 = new WebSocket(`ws://localhost:${httpPort}/ws`);
55
+ await waitForOpen(b1);
56
+ await delay(100); // drain session_added
57
+ b1.send(JSON.stringify({ type: "subscribe", sessionId: "s1", lastSeq: 0 }));
58
+ await delay(50);
59
+
60
+ bridge.send(JSON.stringify({
61
+ type: "event_forward", sessionId: "s1",
62
+ event: { eventType: "message_start", timestamp: Date.now(), data: { role: "user" } },
63
+ }));
64
+
65
+ const msgs1 = await collectMsgs(b1, 300);
66
+ const liveEvent = msgs1.find((m) => m.type === "event" && m.sessionId === "s1");
67
+ expect(liveEvent).toBeDefined();
68
+ expect(liveEvent.seq).toBe(1);
69
+
70
+ // Reconnect browser — should replay from memory
71
+ b1.close();
72
+ await delay(100);
73
+
74
+ const b2 = new WebSocket(`ws://localhost:${httpPort}/ws`);
75
+ await waitForOpen(b2);
76
+ await delay(100); // drain session_added
77
+ b2.send(JSON.stringify({ type: "subscribe", sessionId: "s1", lastSeq: 0 }));
78
+
79
+ const msgs2 = await collectMsgs(b2, 300);
80
+ const replay = msgs2.find((m) => m.type === "event_replay" && m.sessionId === "s1");
81
+ expect(replay).toBeDefined();
82
+ expect(replay.events.length).toBeGreaterThanOrEqual(1);
83
+ expect(replay.events[0].event.eventType).toBe("message_start");
84
+
85
+ b2.close();
86
+ bridge.close();
87
+ await delay(100);
88
+ }, 15000);
89
+
90
+ it("9.3 — hide session updates session manager", async () => {
91
+ const bridge = new WebSocket(`ws://localhost:${piPort}`);
92
+ await waitForOpen(bridge);
93
+ bridge.send(JSON.stringify({
94
+ type: "session_register", sessionId: "s3", cwd: "/tmp", source: "tui",
95
+ }));
96
+ await delay(150);
97
+
98
+ const browser = new WebSocket(`ws://localhost:${httpPort}/ws`);
99
+ await waitForOpen(browser);
100
+ await delay(100); // drain session_added
101
+
102
+ browser.send(JSON.stringify({ type: "hide_session", sessionId: "s3" }));
103
+
104
+ const msgs = await collectMsgs(browser, 300);
105
+ const hideUpdate = msgs.find((m) =>
106
+ m.type === "session_updated" && m.sessionId === "s3" && m.updates?.hidden === true
107
+ );
108
+ expect(hideUpdate).toBeDefined();
109
+ expect(server.sessionManager.get("s3")?.hidden).toBe(true);
110
+
111
+ browser.close();
112
+ bridge.close();
113
+ await delay(100);
114
+ }, 10000);
115
+
116
+ it.skip("9.5 — old session with no bridge shows dataUnavailable", async () => {
117
+ // Use a unique cwd that won't match other sessions
118
+ const bridge = new WebSocket(`ws://localhost:${piPort}`);
119
+ await waitForOpen(bridge);
120
+ bridge.send(JSON.stringify({
121
+ type: "session_register", sessionId: "s5", cwd: "/unique/isolated/path",
122
+ source: "tui", sessionFile: "/unique/old.json",
123
+ }));
124
+ await delay(100);
125
+ bridge.send(JSON.stringify({
126
+ type: "event_forward", sessionId: "s5",
127
+ event: { eventType: "agent_start", timestamp: Date.now(), data: {} },
128
+ }));
129
+ await delay(50);
130
+ bridge.send(JSON.stringify({ type: "session_unregister", sessionId: "s5" }));
131
+ await delay(50);
132
+ bridge.close();
133
+ await delay(200);
134
+
135
+ // Simulate eviction
136
+ server.eventStore.deleteEventsForSession("s5");
137
+
138
+ // Browser subscribes — should get dataUnavailable
139
+ const browser = new WebSocket(`ws://localhost:${httpPort}/ws`);
140
+ await waitForOpen(browser);
141
+ await delay(200); // drain session_added
142
+
143
+ browser.send(JSON.stringify({ type: "subscribe", sessionId: "s5", lastSeq: 0 }));
144
+
145
+ // Collect messages over time — the async loadSessionEvents may take a while
146
+ // due to dynamic imports in the test environment
147
+ const allMsgs: any[] = [];
148
+ const handler = (raw: any) => allMsgs.push(JSON.parse(raw.toString()));
149
+ browser.on("message", handler);
150
+
151
+ // Poll until we see dataUnavailable or timeout
152
+ const deadline = Date.now() + 8000;
153
+ while (Date.now() < deadline) {
154
+ await delay(200);
155
+ const unavail = allMsgs.find((m: any) =>
156
+ m.type === "session_updated" && m.sessionId === "s5" && m.updates?.dataUnavailable === true
157
+ );
158
+ if (unavail) break;
159
+ }
160
+ browser.off("message", handler);
161
+
162
+ // Verify we got at least one empty replay
163
+ const replays = allMsgs.filter((m: any) => m.type === "event_replay" && m.sessionId === "s5");
164
+ expect(replays.length).toBeGreaterThan(0);
165
+
166
+ // Verify dataUnavailable was set
167
+ const unavail = allMsgs.find((m: any) =>
168
+ m.type === "session_updated" && m.sessionId === "s5" && m.updates?.dataUnavailable === true
169
+ );
170
+ expect(unavail).toBeDefined();
171
+
172
+ browser.close();
173
+ await delay(50);
174
+ }, 15000);
175
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * SPA fallback tests — validates that client-side routes return index.html.
3
+ */
4
+ import { describe, it, expect, afterAll, beforeAll } from "vitest";
5
+ import { createServer, type DashboardServer } from "../server.js";
6
+ import path from "node:path";
7
+ import fs from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const httpPort = 19100;
11
+ const piPort = 19101;
12
+ let server: DashboardServer;
13
+
14
+ // Ensure dist/client/index.html exists for the test
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const clientDir = path.join(__dirname, "../../../dist/client");
17
+ const indexPath = path.join(clientDir, "index.html");
18
+ let createdDir = false;
19
+
20
+ describe("SPA fallback", () => {
21
+ beforeAll(async () => {
22
+ // Create minimal dist/client/index.html if it doesn't exist
23
+ if (!fs.existsSync(indexPath)) {
24
+ fs.mkdirSync(clientDir, { recursive: true });
25
+ fs.writeFileSync(indexPath, "<!doctype html><html><body>SPA</body></html>");
26
+ createdDir = true;
27
+ }
28
+
29
+ server = await createServer({
30
+ port: httpPort,
31
+ piPort,
32
+ dev: false, // production mode enables static serving + SPA fallback
33
+ autoShutdown: false,
34
+ shutdownIdleSeconds: 999,
35
+ tunnel: false,
36
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
37
+ });
38
+ await server.start();
39
+ });
40
+
41
+ afterAll(async () => {
42
+ if (server) await server.stop();
43
+ if (createdDir) {
44
+ fs.rmSync(indexPath);
45
+ }
46
+ });
47
+
48
+ it("returns index.html for /session/:id route", async () => {
49
+ const res = await fetch(`http://localhost:${httpPort}/session/abc-123`);
50
+ expect(res.status).toBe(200);
51
+ const body = await res.text();
52
+ expect(body).toContain("html");
53
+ });
54
+
55
+ it("returns index.html for unknown client routes", async () => {
56
+ const res = await fetch(`http://localhost:${httpPort}/some/unknown/path`);
57
+ expect(res.status).toBe(200);
58
+ const body = await res.text();
59
+ expect(body).toContain("html");
60
+ });
61
+
62
+ it("still serves API routes normally", async () => {
63
+ const res = await fetch(`http://localhost:${httpPort}/api/sessions`);
64
+ expect(res.status).toBe(200);
65
+ const json = await res.json();
66
+ expect(json.success).toBe(true);
67
+ });
68
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { handleSubscribe } from "../browser-handlers/subscription-handler.js";
3
+ import { createMemoryEventStore } from "../memory-event-store.js";
4
+ import { createMemorySessionManager } from "../memory-session-manager.js";
5
+ import type { BrowserHandlerContext } from "../browser-handlers/handler-context.js";
6
+ import type { ServerToBrowserMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
7
+ import type { DashboardEvent } from "@blackbelt-technology/pi-dashboard-shared/types.js";
8
+
9
+ function makeEvent(type: string = "test"): DashboardEvent {
10
+ return { eventType: type, timestamp: Date.now(), data: {} };
11
+ }
12
+
13
+ function createMockContext(overrides: Partial<BrowserHandlerContext> = {}): BrowserHandlerContext {
14
+ return {
15
+ ws: { readyState: 1, OPEN: 1, bufferedAmount: 0 } as any,
16
+ sessionManager: createMemorySessionManager(),
17
+ eventStore: createMemoryEventStore(() => false),
18
+ piGateway: { sendToSession: vi.fn() } as any,
19
+ headlessPidRegistry: {} as any,
20
+ pendingResumeRegistry: {} as any,
21
+ sendTo: vi.fn(),
22
+ broadcast: vi.fn(),
23
+ getSubscribers: () => [],
24
+ trackUiRequest: vi.fn(),
25
+ replayPendingUiRequests: vi.fn(),
26
+ markReplaying: vi.fn(),
27
+ clearReplaying: vi.fn(),
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ describe("handleSubscribe — metadata requests on subscribe", () => {
33
+ it("sends request_commands, request_models, and request_roles to piGateway", () => {
34
+ const ctx = createMockContext();
35
+ const subs = new Set<string>();
36
+ handleSubscribe({ type: "subscribe", sessionId: "s1" }, subs, ctx);
37
+
38
+ const calls = (ctx.piGateway.sendToSession as any).mock.calls;
39
+ expect(calls).toHaveLength(3);
40
+ expect(calls[0]).toEqual(["s1", { type: "request_commands", sessionId: "s1" }]);
41
+ expect(calls[1]).toEqual(["s1", { type: "request_models", sessionId: "s1" }]);
42
+ expect(calls[2]).toEqual(["s1", { type: "request_roles", sessionId: "s1" }]);
43
+ });
44
+ });
45
+
46
+ describe("handleSubscribe — stale lastSeq detection", () => {
47
+ it("replays delta when lastSeq is within server range", async () => {
48
+ const ctx = createMockContext();
49
+ // Insert 5 events
50
+ for (let i = 0; i < 5; i++) ctx.eventStore.insertEvent("s1", makeEvent(`e${i}`));
51
+
52
+ const subs = new Set<string>();
53
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 3 }, subs, ctx);
54
+
55
+ // Wait for async replay
56
+ await new Promise((r) => setTimeout(r, 50));
57
+
58
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
59
+ // Should NOT have sent session_state_reset
60
+ const resets = calls.filter(([, msg]) => msg.type === "session_state_reset");
61
+ expect(resets).toHaveLength(0);
62
+
63
+ // Should have replayed only events 4 and 5
64
+ const replays = calls.filter(([, msg]) => msg.type === "event_replay");
65
+ expect(replays.length).toBeGreaterThanOrEqual(1);
66
+ const allEvents = replays.flatMap(([, msg]: any) => msg.events);
67
+ expect(allEvents).toHaveLength(2);
68
+ expect(allEvents[0].seq).toBe(4);
69
+ expect(allEvents[1].seq).toBe(5);
70
+ });
71
+
72
+ it("sends session_state_reset and full replay when lastSeq > server maxSeq", async () => {
73
+ const ctx = createMockContext();
74
+ // Insert 3 events (maxSeq = 3)
75
+ for (let i = 0; i < 3; i++) ctx.eventStore.insertEvent("s1", makeEvent(`e${i}`));
76
+
77
+ const subs = new Set<string>();
78
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 100 }, subs, ctx);
79
+
80
+ // Wait for async replay
81
+ await new Promise((r) => setTimeout(r, 50));
82
+
83
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
84
+ // Should have sent session_state_reset first
85
+ const resets = calls.filter(([, msg]) => msg.type === "session_state_reset");
86
+ expect(resets).toHaveLength(1);
87
+
88
+ // Should have replayed ALL events from seq 1
89
+ const replays = calls.filter(([, msg]) => msg.type === "event_replay");
90
+ const allEvents = replays.flatMap(([, msg]: any) => msg.events);
91
+ expect(allEvents).toHaveLength(3);
92
+ expect(allEvents[0].seq).toBe(1);
93
+ });
94
+
95
+ it("marks replaying during delta replay and clears after", async () => {
96
+ const markReplaying = vi.fn();
97
+ const clearReplaying = vi.fn();
98
+ const ctx = createMockContext({ markReplaying, clearReplaying });
99
+ for (let i = 0; i < 5; i++) ctx.eventStore.insertEvent("s1", makeEvent());
100
+
101
+ const subs = new Set<string>();
102
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 3 }, subs, ctx);
103
+
104
+ await new Promise((r) => setTimeout(r, 50));
105
+
106
+ expect(markReplaying).toHaveBeenCalledWith(ctx.ws, "s1");
107
+ expect(clearReplaying).toHaveBeenCalledWith(ctx.ws, "s1", 5); // lastSent = 5
108
+ });
109
+
110
+ it("does not mark replaying for fresh subscribe (lastSeq: 0)", async () => {
111
+ const markReplaying = vi.fn();
112
+ const ctx = createMockContext({ markReplaying });
113
+ for (let i = 0; i < 3; i++) ctx.eventStore.insertEvent("s1", makeEvent());
114
+
115
+ const subs = new Set<string>();
116
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 0 }, subs, ctx);
117
+
118
+ await new Promise((r) => setTimeout(r, 50));
119
+
120
+ expect(markReplaying).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("marks replaying during stale lastSeq full replay", async () => {
124
+ const markReplaying = vi.fn();
125
+ const clearReplaying = vi.fn();
126
+ const ctx = createMockContext({ markReplaying, clearReplaying });
127
+ for (let i = 0; i < 3; i++) ctx.eventStore.insertEvent("s1", makeEvent());
128
+
129
+ const subs = new Set<string>();
130
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 100 }, subs, ctx);
131
+
132
+ await new Promise((r) => setTimeout(r, 50));
133
+
134
+ expect(markReplaying).toHaveBeenCalledWith(ctx.ws, "s1");
135
+ expect(clearReplaying).toHaveBeenCalledWith(ctx.ws, "s1", 3);
136
+ });
137
+
138
+ it("does full replay when lastSeq is 0", async () => {
139
+ const ctx = createMockContext();
140
+ for (let i = 0; i < 3; i++) ctx.eventStore.insertEvent("s1", makeEvent());
141
+
142
+ const subs = new Set<string>();
143
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 0 }, subs, ctx);
144
+
145
+ await new Promise((r) => setTimeout(r, 50));
146
+
147
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
148
+ const resets = calls.filter(([, msg]) => msg.type === "session_state_reset");
149
+ expect(resets).toHaveLength(0); // No reset needed for fresh subscribe
150
+
151
+ const replays = calls.filter(([, msg]) => msg.type === "event_replay");
152
+ const allEvents = replays.flatMap(([, msg]: any) => msg.events);
153
+ expect(allEvents).toHaveLength(3);
154
+ });
155
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createTerminalGateway } from "../terminal-gateway.js";
3
+ import type { TerminalManager } from "../terminal-manager.js";
4
+
5
+ describe("TerminalGateway", () => {
6
+ let mockManager: TerminalManager;
7
+
8
+ beforeEach(() => {
9
+ mockManager = {
10
+ spawn: vi.fn(),
11
+ attach: vi.fn(),
12
+ detach: vi.fn(),
13
+ kill: vi.fn(),
14
+ get: vi.fn(),
15
+ list: vi.fn(() => []),
16
+ updateTitle: vi.fn(),
17
+ };
18
+ });
19
+
20
+ it("parses terminal ID from URL path", () => {
21
+ const gateway = createTerminalGateway(mockManager);
22
+ expect(gateway.parseTerminalId("/ws/terminal/term-abc123")).toBe("term-abc123");
23
+ expect(gateway.parseTerminalId("/ws/terminal/")).toBeNull();
24
+ expect(gateway.parseTerminalId("/ws")).toBeNull();
25
+ expect(gateway.parseTerminalId("/ws/terminal")).toBeNull();
26
+ });
27
+
28
+ it("rejects upgrade for non-existent terminal", () => {
29
+ const gateway = createTerminalGateway(mockManager);
30
+ (mockManager.get as any).mockReturnValue(undefined);
31
+
32
+ const socket = { destroy: vi.fn() } as any;
33
+ gateway.handleUpgrade(
34
+ { url: "/ws/terminal/term-nonexistent" } as any,
35
+ socket,
36
+ Buffer.alloc(0),
37
+ );
38
+
39
+ expect(socket.destroy).toHaveBeenCalled();
40
+ expect(mockManager.attach).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it("accepts upgrade for existing terminal", () => {
44
+ const gateway = createTerminalGateway(mockManager);
45
+ (mockManager.get as any).mockReturnValue({ id: "term-abc", status: "active" });
46
+
47
+ const mockWs = { on: vi.fn() };
48
+ const handleUpgradeMock = vi.fn((_req: any, _socket: any, _head: any, cb: any) => {
49
+ cb(mockWs);
50
+ });
51
+ (gateway.wss as any).handleUpgrade = handleUpgradeMock;
52
+
53
+ const socket = { destroy: vi.fn() } as any;
54
+ const request = { url: "/ws/terminal/term-abc" } as any;
55
+
56
+ gateway.handleUpgrade(request, socket, Buffer.alloc(0));
57
+
58
+ expect(handleUpgradeMock).toHaveBeenCalled();
59
+ expect(mockManager.attach).toHaveBeenCalledWith("term-abc", mockWs);
60
+ });
61
+ });