@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,251 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { execSync } from "node:child_process";
3
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import {
7
+ isGitRepo,
8
+ getDirtyFiles,
9
+ listBranches,
10
+ checkoutBranch,
11
+ gitInit,
12
+ stashPop,
13
+ } from "../git-operations.js";
14
+
15
+ function git(cmd: string, cwd: string) {
16
+ execSync(`git ${cmd}`, { cwd, stdio: "pipe" });
17
+ }
18
+
19
+ function makeRepo(): string {
20
+ const dir = mkdtempSync(join(tmpdir(), "git-ops-test-"));
21
+ git("init", dir);
22
+ git("config user.email test@test.com", dir);
23
+ git("config user.name Test", dir);
24
+ // Initial commit so we have a branch
25
+ writeFileSync(join(dir, "README.md"), "init");
26
+ git("add .", dir);
27
+ git("commit -m init", dir);
28
+ return dir;
29
+ }
30
+
31
+ describe("git-operations", () => {
32
+ let repo: string;
33
+
34
+ beforeEach(() => {
35
+ repo = makeRepo();
36
+ });
37
+
38
+ afterEach(() => {
39
+ rmSync(repo, { recursive: true, force: true });
40
+ });
41
+
42
+ describe("isGitRepo", () => {
43
+ it("returns true for a git repo", () => {
44
+ expect(isGitRepo(repo)).toBe(true);
45
+ });
46
+
47
+ it("returns false for a non-git directory", () => {
48
+ const plain = mkdtempSync(join(tmpdir(), "no-git-"));
49
+ try {
50
+ expect(isGitRepo(plain)).toBe(false);
51
+ } finally {
52
+ rmSync(plain, { recursive: true, force: true });
53
+ }
54
+ });
55
+ });
56
+
57
+ describe("getDirtyFiles", () => {
58
+ it("returns empty for a clean repo", () => {
59
+ expect(getDirtyFiles(repo)).toEqual([]);
60
+ });
61
+
62
+ it("returns modified files", () => {
63
+ writeFileSync(join(repo, "README.md"), "changed");
64
+ const files = getDirtyFiles(repo);
65
+ expect(files).toContain("README.md");
66
+ });
67
+
68
+ it("returns untracked files", () => {
69
+ writeFileSync(join(repo, "new.txt"), "hello");
70
+ const files = getDirtyFiles(repo);
71
+ expect(files).toContain("new.txt");
72
+ });
73
+ });
74
+
75
+ describe("listBranches", () => {
76
+ it("lists the current branch", () => {
77
+ const info = listBranches(repo);
78
+ expect(info.detached).toBe(false);
79
+ expect(info.branches.length).toBeGreaterThanOrEqual(1);
80
+ const current = info.branches.find((b) => b.isCurrent);
81
+ expect(current).toBeDefined();
82
+ expect(current!.name).toBe(info.current);
83
+ });
84
+
85
+ it("lists multiple local branches", () => {
86
+ git("checkout -b feature-a", repo);
87
+ git("checkout -b feature-b", repo);
88
+ const info = listBranches(repo);
89
+ const names = info.branches.map((b) => b.name);
90
+ expect(names).toContain("feature-a");
91
+ expect(names).toContain("feature-b");
92
+ });
93
+
94
+ it("handles empty repo (no commits)", () => {
95
+ const emptyRepo = mkdtempSync(join(tmpdir(), "git-empty-"));
96
+ try {
97
+ git("init", emptyRepo);
98
+ const info = listBranches(emptyRepo);
99
+ expect(info.detached).toBe(false);
100
+ expect(info.branches).toEqual([]);
101
+ expect(info.current).toBeTruthy(); // default branch name
102
+ } finally {
103
+ rmSync(emptyRepo, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ it("detects detached HEAD", () => {
108
+ const sha = execSync("git rev-parse HEAD", { cwd: repo, encoding: "utf-8" }).trim();
109
+ git(`checkout ${sha}`, repo);
110
+ const info = listBranches(repo);
111
+ expect(info.detached).toBe(true);
112
+ expect(info.current).toMatch(/^[0-9a-f]+$/);
113
+ });
114
+
115
+ it("lists remote branches", () => {
116
+ // Create a "remote" by cloning
117
+ const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
118
+ try {
119
+ execSync(`git clone ${repo} ${clone}`, { stdio: "pipe" });
120
+ // Create a branch in origin that doesn't exist locally
121
+ git("checkout -b remote-only", repo);
122
+ writeFileSync(join(repo, "remote.txt"), "data");
123
+ git("add .", repo);
124
+ git("commit -m remote-only", repo);
125
+ git("checkout master", repo);
126
+
127
+ // Fetch in clone
128
+ git("fetch origin", clone);
129
+ const info = listBranches(clone);
130
+ const remotes = info.branches.filter((b) => b.isRemote);
131
+ const remoteNames = remotes.map((b) => b.name);
132
+ expect(remoteNames.some((n) => n.includes("remote-only"))).toBe(true);
133
+ } finally {
134
+ rmSync(clone, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ it("excludes origin/HEAD from remote branches", () => {
139
+ const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
140
+ try {
141
+ execSync(`git clone ${repo} ${clone}`, { stdio: "pipe" });
142
+ const info = listBranches(clone);
143
+ const remoteNames = info.branches.filter((b) => b.isRemote).map((b) => b.name);
144
+ expect(remoteNames.every((n) => !n.endsWith("/HEAD"))).toBe(true);
145
+ } finally {
146
+ rmSync(clone, { recursive: true, force: true });
147
+ }
148
+ });
149
+ });
150
+
151
+ describe("checkoutBranch", () => {
152
+ it("checks out a local branch on clean repo", () => {
153
+ git("checkout -b feature-x", repo);
154
+ git("checkout master", repo);
155
+ const result = checkoutBranch(repo, "feature-x", false);
156
+ expect(result.success).toBe(true);
157
+ const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repo, encoding: "utf-8" }).trim();
158
+ expect(head).toBe("feature-x");
159
+ });
160
+
161
+ it("returns dirty when working tree is dirty and stash=false", () => {
162
+ git("checkout -b feature-y", repo);
163
+ git("checkout master", repo);
164
+ writeFileSync(join(repo, "README.md"), "dirty");
165
+ const result = checkoutBranch(repo, "feature-y", false);
166
+ expect(result.success).toBe(false);
167
+ if (!result.success) {
168
+ expect(result.dirty).toBe(true);
169
+ expect(result.files.length).toBeGreaterThan(0);
170
+ }
171
+ });
172
+
173
+ it("stashes and checks out when stash=true", () => {
174
+ git("checkout -b feature-z", repo);
175
+ git("checkout master", repo);
176
+ writeFileSync(join(repo, "README.md"), "dirty");
177
+ const result = checkoutBranch(repo, "feature-z", true);
178
+ expect(result.success).toBe(true);
179
+ if (result.success) {
180
+ expect(result.stashed).toBe(true);
181
+ }
182
+ const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repo, encoding: "utf-8" }).trim();
183
+ expect(head).toBe("feature-z");
184
+ });
185
+
186
+ it("returns success when already on target branch", () => {
187
+ const result = checkoutBranch(repo, "master", false);
188
+ expect(result.success).toBe(true);
189
+ });
190
+
191
+ it("creates local tracking branch for remote branch", () => {
192
+ // Create remote-only branch
193
+ git("checkout -b only-remote", repo);
194
+ writeFileSync(join(repo, "r.txt"), "data");
195
+ git("add .", repo);
196
+ git("commit -m r", repo);
197
+ git("checkout master", repo);
198
+
199
+ const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
200
+ try {
201
+ execSync(`git clone ${repo} ${clone}`, { stdio: "pipe" });
202
+ const result = checkoutBranch(clone, "origin/only-remote", false);
203
+ expect(result.success).toBe(true);
204
+ const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: clone, encoding: "utf-8" }).trim();
205
+ expect(head).toBe("only-remote");
206
+ } finally {
207
+ rmSync(clone, { recursive: true, force: true });
208
+ }
209
+ });
210
+ });
211
+
212
+ describe("gitInit", () => {
213
+ it("initializes a new git repo", () => {
214
+ const dir = mkdtempSync(join(tmpdir(), "git-init-"));
215
+ try {
216
+ gitInit(dir);
217
+ expect(isGitRepo(dir)).toBe(true);
218
+ } finally {
219
+ rmSync(dir, { recursive: true, force: true });
220
+ }
221
+ });
222
+
223
+ it("throws if already a git repo", () => {
224
+ expect(() => gitInit(repo)).toThrow("already a git repository");
225
+ });
226
+ });
227
+
228
+ describe("stashPop", () => {
229
+ it("pops stash cleanly", () => {
230
+ writeFileSync(join(repo, "README.md"), "stashed-content");
231
+ git("stash push -u", repo);
232
+ const result = stashPop(repo);
233
+ expect(result.conflicts).toBe(false);
234
+ });
235
+
236
+ it("throws when no stash entries", () => {
237
+ expect(() => stashPop(repo)).toThrow("no stash entries");
238
+ });
239
+
240
+ it("detects conflicts on stash pop", () => {
241
+ // Create a stash, then modify same file on current branch
242
+ writeFileSync(join(repo, "README.md"), "stash-version");
243
+ git("stash push -u", repo);
244
+ writeFileSync(join(repo, "README.md"), "branch-version");
245
+ git("add .", repo);
246
+ git("commit -m conflict", repo);
247
+ const result = stashPop(repo);
248
+ expect(result.conflicts).toBe(true);
249
+ });
250
+ });
251
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createHeadlessPidRegistry } from "../headless-pid-registry.js";
3
+ import { EventEmitter } from "node:events";
4
+ import { join } from "node:path";
5
+ import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import type { ChildProcess } from "node:child_process";
8
+
9
+ function mockProcess(): ChildProcess {
10
+ return new EventEmitter() as any;
11
+ }
12
+
13
+ function makeTempDir() {
14
+ return mkdtempSync(join(tmpdir(), "pid-reg-test-"));
15
+ }
16
+
17
+ describe("HeadlessPidRegistry", () => {
18
+ it("should register and track a process", () => {
19
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
20
+ const proc = mockProcess();
21
+ registry.register(100, "/projects/app", proc);
22
+ expect(registry.size()).toBe(1);
23
+ });
24
+
25
+ it("should remove entry on process exit", () => {
26
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
27
+ const proc = mockProcess();
28
+ registry.register(100, "/projects/app", proc);
29
+ expect(registry.size()).toBe(1);
30
+ proc.emit("exit");
31
+ expect(registry.size()).toBe(0);
32
+ });
33
+
34
+ it("should link session ID by cwd", () => {
35
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
36
+ const proc = mockProcess();
37
+ registry.register(100, "/projects/app", proc);
38
+ const linked = registry.linkSession("session-1", "/projects/app");
39
+ expect(linked).toBe(true);
40
+ expect(registry.getPid("session-1")).toBe(100);
41
+ });
42
+
43
+ it("should return false when linking unknown cwd", () => {
44
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
45
+ const linked = registry.linkSession("session-1", "/unknown");
46
+ expect(linked).toBe(false);
47
+ });
48
+
49
+ it("should use FIFO matching for same cwd", () => {
50
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
51
+ const proc1 = mockProcess();
52
+ const proc2 = mockProcess();
53
+ registry.register(100, "/projects/app", proc1);
54
+ registry.register(200, "/projects/app", proc2);
55
+
56
+ registry.linkSession("session-1", "/projects/app");
57
+ expect(registry.getPid("session-1")).toBe(100);
58
+
59
+ registry.linkSession("session-2", "/projects/app");
60
+ expect(registry.getPid("session-2")).toBe(200);
61
+ });
62
+
63
+ it("should not link to already-linked entries", () => {
64
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
65
+ const proc = mockProcess();
66
+ registry.register(100, "/projects/app", proc);
67
+ registry.linkSession("session-1", "/projects/app");
68
+
69
+ const linked = registry.linkSession("session-2", "/projects/app");
70
+ expect(linked).toBe(false);
71
+ });
72
+
73
+ it("should return undefined for unknown session ID", () => {
74
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
75
+ expect(registry.getPid("unknown")).toBeUndefined();
76
+ });
77
+
78
+ it("should kill process by session ID", () => {
79
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
80
+ const proc = mockProcess();
81
+ registry.register(process.pid, "/projects/app", proc);
82
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
83
+
84
+ registry.linkSession("session-1", "/projects/app");
85
+ const killed = registry.killBySessionId("session-1");
86
+ expect(killed).toBe(true);
87
+ expect(killSpy).toHaveBeenCalledWith(-process.pid, "SIGTERM");
88
+ expect(registry.size()).toBe(0);
89
+
90
+ killSpy.mockRestore();
91
+ });
92
+
93
+ it("should return false when killing unknown session", () => {
94
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
95
+ const killed = registry.killBySessionId("unknown");
96
+ expect(killed).toBe(false);
97
+ });
98
+
99
+ it("should handle kill failure gracefully", () => {
100
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
101
+ const proc = mockProcess();
102
+ registry.register(999999, "/projects/app", proc);
103
+ registry.linkSession("session-1", "/projects/app");
104
+
105
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
106
+ throw new Error("ESRCH");
107
+ });
108
+
109
+ const killed = registry.killBySessionId("session-1");
110
+ expect(killed).toBe(false);
111
+ expect(registry.size()).toBe(0);
112
+
113
+ killSpy.mockRestore();
114
+ });
115
+
116
+ it("should remove by PID", () => {
117
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
118
+ const proc = mockProcess();
119
+ registry.register(100, "/projects/app", proc);
120
+ registry.remove(100);
121
+ expect(registry.size()).toBe(0);
122
+ });
123
+
124
+ it("should kill all tracked processes", () => {
125
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
126
+ const proc1 = mockProcess();
127
+ const proc2 = mockProcess();
128
+ registry.register(100, "/a", proc1);
129
+ registry.register(200, "/b", proc2);
130
+
131
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
132
+ registry.killAll();
133
+ expect(killSpy).toHaveBeenCalledTimes(2);
134
+ expect(registry.size()).toBe(0);
135
+ killSpy.mockRestore();
136
+ });
137
+ });
138
+
139
+ describe("HeadlessPidRegistry persistence", () => {
140
+ it("should persist entries to disk on register", () => {
141
+ const dir = makeTempDir();
142
+ const pidFile = join(dir, "pids.json");
143
+ const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
144
+ const proc = mockProcess();
145
+ registry.register(100, "/projects/app", proc);
146
+
147
+ const data = JSON.parse(readFileSync(pidFile, "utf-8"));
148
+ expect(data.entries).toHaveLength(1);
149
+ expect(data.entries[0].pid).toBe(100);
150
+ expect(data.entries[0].cwd).toBe("/projects/app");
151
+ expect(data.entries[0].spawnedAt).toBeDefined();
152
+ });
153
+
154
+ it("should remove entry from disk on process exit", () => {
155
+ const dir = makeTempDir();
156
+ const pidFile = join(dir, "pids.json");
157
+ const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
158
+ const proc = mockProcess();
159
+ registry.register(100, "/projects/app", proc);
160
+ proc.emit("exit");
161
+
162
+ const data = JSON.parse(readFileSync(pidFile, "utf-8"));
163
+ expect(data.entries).toHaveLength(0);
164
+ });
165
+
166
+ it("should remove entry from disk on remove()", () => {
167
+ const dir = makeTempDir();
168
+ const pidFile = join(dir, "pids.json");
169
+ const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
170
+ const proc = mockProcess();
171
+ registry.register(100, "/projects/app", proc);
172
+ registry.remove(100);
173
+
174
+ const data = JSON.parse(readFileSync(pidFile, "utf-8"));
175
+ expect(data.entries).toHaveLength(0);
176
+ });
177
+ });
178
+
179
+ describe("HeadlessPidRegistry orphan cleanup", () => {
180
+ it("should reclaim alive processes from disk", () => {
181
+ const dir = makeTempDir();
182
+ const pidFile = join(dir, "pids.json");
183
+
184
+ // Pre-populate the PID file with current process PID (guaranteed alive)
185
+ writeFileSync(pidFile, JSON.stringify({
186
+ entries: [{ pid: process.pid, cwd: "/projects/app", spawnedAt: new Date().toISOString() }],
187
+ }));
188
+
189
+ const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
190
+ registry.cleanupOrphans();
191
+
192
+ expect(registry.size()).toBe(1);
193
+ expect(registry.getPid("any")).toBeUndefined(); // not linked yet
194
+ });
195
+
196
+ it("should remove dead processes from disk", () => {
197
+ const dir = makeTempDir();
198
+ const pidFile = join(dir, "pids.json");
199
+
200
+ // Use a PID that's almost certainly dead
201
+ writeFileSync(pidFile, JSON.stringify({
202
+ entries: [{ pid: 999999, cwd: "/projects/app", spawnedAt: new Date().toISOString() }],
203
+ }));
204
+
205
+ const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
206
+ registry.cleanupOrphans();
207
+
208
+ expect(registry.size()).toBe(0);
209
+ const data = JSON.parse(readFileSync(pidFile, "utf-8"));
210
+ expect(data.entries).toHaveLength(0);
211
+ });
212
+
213
+ it("should kill very old alive orphans (>7 days)", () => {
214
+ const dir = makeTempDir();
215
+ const pidFile = join(dir, "pids.json");
216
+
217
+ const oldDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); // 8 days ago
218
+ writeFileSync(pidFile, JSON.stringify({
219
+ entries: [{ pid: process.pid, cwd: "/projects/app", spawnedAt: oldDate }],
220
+ }));
221
+
222
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
223
+ const registry = createHeadlessPidRegistry({ pidFilePath: pidFile });
224
+ registry.cleanupOrphans();
225
+
226
+ // Should have tried to kill the process group
227
+ expect(killSpy).toHaveBeenCalledWith(-process.pid, "SIGTERM");
228
+ // Should NOT be reclaimed
229
+ expect(registry.size()).toBe(0);
230
+
231
+ killSpy.mockRestore();
232
+ });
233
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Integration test: shutdown fallback kills headless process when bridge is disconnected.
3
+ */
4
+ import { describe, it, expect, afterAll } from "vitest";
5
+ import { createServer, type DashboardServer } from "../server.js";
6
+ import { WebSocket } from "ws";
7
+ import { spawn } from "node:child_process";
8
+
9
+ function waitForOpen(ws: WebSocket): Promise<void> {
10
+ return new Promise((resolve, reject) => {
11
+ if (ws.readyState === WebSocket.OPEN) return resolve();
12
+ ws.on("open", resolve);
13
+ ws.on("error", reject);
14
+ setTimeout(() => reject(new Error("open timeout")), 3000);
15
+ });
16
+ }
17
+
18
+ function collectMsgs(ws: WebSocket, ms: number): Promise<any[]> {
19
+ return new Promise((resolve) => {
20
+ const arr: any[] = [];
21
+ const h = (raw: any) => arr.push(JSON.parse(raw.toString()));
22
+ ws.on("message", h);
23
+ setTimeout(() => { ws.off("message", h); resolve(arr); }, ms);
24
+ });
25
+ }
26
+
27
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
28
+ const httpPort = 19190;
29
+ const piPort = 19191;
30
+ let server: DashboardServer;
31
+
32
+ describe("Headless shutdown fallback", () => {
33
+ afterAll(async () => {
34
+ if (server) await server.stop();
35
+ });
36
+
37
+ it("should kill headless process via SIGTERM when bridge is disconnected", async () => {
38
+ server = await createServer({
39
+ port: httpPort, piPort, dev: true,
40
+ autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
41
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
42
+ });
43
+ await server.start();
44
+
45
+ // Spawn a real dummy process (sleep) to act as the headless pi session
46
+ const dummy = spawn("sleep", ["60"], { detached: true, stdio: "ignore" });
47
+ dummy.unref();
48
+ const pid = dummy.pid!;
49
+
50
+ // Register it in the headless registry with a known cwd
51
+ const registry = server.browserGateway.headlessPidRegistry;
52
+ registry.register(pid, "/test/cwd", dummy);
53
+
54
+ // Simulate bridge connecting and registering with that cwd
55
+ const bridge = new WebSocket(`ws://localhost:${piPort}`);
56
+ await waitForOpen(bridge);
57
+ bridge.send(JSON.stringify({
58
+ type: "session_register", sessionId: "headless-1", cwd: "/test/cwd", source: "tui",
59
+ }));
60
+ // Wait for session_register to be processed (may need longer under load)
61
+ for (let i = 0; i < 20; i++) {
62
+ if (registry.getPid("headless-1") !== undefined) break;
63
+ await delay(50);
64
+ }
65
+
66
+ // Verify the session got linked
67
+ expect(registry.getPid("headless-1")).toBe(pid);
68
+
69
+ // Now disconnect the bridge (simulating bridge gone)
70
+ bridge.close();
71
+ await delay(200);
72
+
73
+ // Browser sends shutdown — bridge is disconnected, should fallback to kill
74
+ const browser = new WebSocket(`ws://localhost:${httpPort}/ws`);
75
+ await waitForOpen(browser);
76
+ await delay(100);
77
+
78
+ browser.send(JSON.stringify({ type: "shutdown", sessionId: "headless-1" }));
79
+ await delay(300);
80
+
81
+ // Verify process was killed
82
+ let alive = true;
83
+ try {
84
+ process.kill(pid, 0); // signal 0 = check if alive
85
+ } catch {
86
+ alive = false;
87
+ }
88
+ expect(alive).toBe(false);
89
+
90
+ browser.close();
91
+ await delay(50);
92
+ }, 15000);
93
+
94
+ it("should not crash when no PID is linked for shutdown", async () => {
95
+ const browser = new WebSocket(`ws://localhost:${httpPort}/ws`);
96
+ await waitForOpen(browser);
97
+ await delay(100);
98
+
99
+ // Send shutdown for an unknown session — should not crash
100
+ browser.send(JSON.stringify({ type: "shutdown", sessionId: "nonexistent" }));
101
+ await delay(200);
102
+
103
+ // If we get here without crashing, it's a pass
104
+ expect(browser.readyState).toBe(WebSocket.OPEN);
105
+
106
+ browser.close();
107
+ await delay(50);
108
+ }, 10000);
109
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tests for GET /api/health endpoint.
3
+ */
4
+ import { describe, it, expect, afterEach } from "vitest";
5
+ import { createServer, type DashboardServer } from "../server.js";
6
+
7
+ const httpPort = 19090;
8
+ const piPort = 19091;
9
+ let server: DashboardServer;
10
+
11
+ describe("GET /api/health", () => {
12
+ afterEach(async () => {
13
+ if (server) {
14
+ try { await server.stop(); } catch { /* already stopped */ }
15
+ }
16
+ });
17
+
18
+ it("should return ok, pid, and uptime", async () => {
19
+ server = await createServer({
20
+ port: httpPort, piPort, dev: true,
21
+ autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
22
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
23
+ });
24
+ await server.start();
25
+
26
+ const res = await fetch(`http://localhost:${httpPort}/api/health`);
27
+ expect(res.status).toBe(200);
28
+
29
+ const body = await res.json();
30
+ expect(body.ok).toBe(true);
31
+ expect(body.pid).toBe(process.pid);
32
+ expect(typeof body.uptime).toBe("number");
33
+ expect(body.uptime).toBeGreaterThanOrEqual(0);
34
+ });
35
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Tests for heartbeat_ack response 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
+ let portCounter = 19550;
21
+
22
+ describe("heartbeat_ack", () => {
23
+ let gateway: ReturnType<typeof createPiGateway>;
24
+
25
+ afterEach(() => {
26
+ gateway?.stop();
27
+ });
28
+
29
+ it("should respond with heartbeat_ack when receiving session_heartbeat", async () => {
30
+ const sessionManager = createMemorySessionManager();
31
+ gateway = createPiGateway(sessionManager, { heartbeatTimeout: 5000 });
32
+ const port = portCounter++;
33
+ gateway.start(port);
34
+
35
+ const ws = new WebSocket(`ws://localhost:${port}`);
36
+ await waitForOpen(ws);
37
+
38
+ // Register session first
39
+ ws.send(JSON.stringify({
40
+ type: "session_register", sessionId: "ack-test", cwd: "/tmp", source: "tui",
41
+ }));
42
+ await delay(100);
43
+
44
+ // Collect messages
45
+ const messages: any[] = [];
46
+ ws.on("message", (raw) => {
47
+ messages.push(JSON.parse(raw.toString()));
48
+ });
49
+
50
+ // Send heartbeat
51
+ ws.send(JSON.stringify({
52
+ type: "session_heartbeat", sessionId: "ack-test",
53
+ }));
54
+ await delay(100);
55
+
56
+ // Should have received heartbeat_ack
57
+ const ack = messages.find((m) => m.type === "heartbeat_ack");
58
+ expect(ack).toBeDefined();
59
+ expect(ack.type).toBe("heartbeat_ack");
60
+
61
+ ws.close();
62
+ }, 10000);
63
+ });