@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.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 (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/paths.ts.
3
+ *
4
+ * Every test explicitly passes a `platform: NodeJS.Platform` argument so
5
+ * both Windows and Unix branches run on every CI host. No mutation of
6
+ * `process.platform`, no `vi.mock`.
7
+ *
8
+ * See change: platform-path-normalization.
9
+ */
10
+ import { describe, it, expect } from "vitest";
11
+ import {
12
+ normalizePath,
13
+ samePath,
14
+ parsePathInput,
15
+ withTrailingSep,
16
+ joinForDisplay,
17
+ isFilesystemRoot,
18
+ } from "../platform/paths.js";
19
+
20
+ // ── normalizePath ───────────────────────────────────────────────────────────
21
+
22
+ describe("normalizePath — Windows", () => {
23
+ it("strips trailing separator from non-root path", () => {
24
+ expect(normalizePath("C:\\Dev\\BB\\pi-agent-dashboard\\", "win32"))
25
+ .toBe("C:\\Dev\\BB\\pi-agent-dashboard");
26
+ });
27
+
28
+ it("canonicalizes mixed separators to backslash", () => {
29
+ expect(normalizePath("C:/Dev\\BB/pi-agent-dashboard", "win32"))
30
+ .toBe("C:\\Dev\\BB\\pi-agent-dashboard");
31
+ });
32
+
33
+ it("preserves drive root trailing separator", () => {
34
+ expect(normalizePath("C:\\", "win32")).toBe("C:\\");
35
+ });
36
+
37
+ it("preserves UNC root", () => {
38
+ expect(normalizePath("\\\\server\\share\\path\\", "win32"))
39
+ .toBe("\\\\server\\share\\path");
40
+ });
41
+
42
+ it("resolves .. and . segments within a drive", () => {
43
+ expect(normalizePath("C:\\Dev\\BB\\..\\.\\pi-agent-dashboard", "win32"))
44
+ .toBe("C:\\Dev\\pi-agent-dashboard");
45
+ });
46
+
47
+ it("preserves case (no lowercasing)", () => {
48
+ expect(normalizePath("C:\\Dev\\BB", "win32")).toBe("C:\\Dev\\BB");
49
+ expect(normalizePath("b:\\Dev\\BB", "win32")).toBe("b:\\Dev\\BB");
50
+ });
51
+
52
+ it("preserves different drive letters independently", () => {
53
+ expect(normalizePath("A:\\Foo\\Bar", "win32")).toBe("A:\\Foo\\Bar");
54
+ expect(normalizePath("B:\\Foo\\Bar", "win32")).toBe("B:\\Foo\\Bar");
55
+ expect(normalizePath("Z:\\Something", "win32")).toBe("Z:\\Something");
56
+ });
57
+
58
+ it("treats bare drive letter as drive root, not cwd-relative", () => {
59
+ // Must NOT fall through to path.win32.resolve which would return
60
+ // <cwd-on-B-drive>, leaking process.cwd() into the result.
61
+ expect(normalizePath("B:", "win32")).toBe("B:\\");
62
+ expect(normalizePath("Z:", "win32")).toBe("Z:\\");
63
+ });
64
+
65
+ it("treats drive-relative typed form as drive-rooted", () => {
66
+ // "B:Dev" → treat as "B:\Dev", NOT as <B-drive-cwd>\Dev
67
+ expect(normalizePath("B:Dev", "win32")).toBe("B:\\Dev");
68
+ expect(normalizePath("C:Users\\me", "win32")).toBe("C:\\Users\\me");
69
+ });
70
+
71
+ it("drops duplicate separators", () => {
72
+ expect(normalizePath("D:\\\\", "win32")).toBe("D:\\");
73
+ expect(normalizePath("C:\\\\Users\\\\me", "win32")).toBe("C:\\Users\\me");
74
+ });
75
+ });
76
+
77
+ describe("normalizePath — POSIX", () => {
78
+ it("strips trailing separator from non-root path", () => {
79
+ expect(normalizePath("/Users/me/Projects/", "linux"))
80
+ .toBe("/Users/me/Projects");
81
+ });
82
+
83
+ it("preserves root", () => {
84
+ expect(normalizePath("/", "linux")).toBe("/");
85
+ expect(normalizePath("/", "darwin")).toBe("/");
86
+ });
87
+
88
+ it("resolves .. and . segments", () => {
89
+ expect(normalizePath("/Users/me/Dev/../Projects", "linux"))
90
+ .toBe("/Users/me/Projects");
91
+ });
92
+
93
+ it("collapses duplicate slashes", () => {
94
+ expect(normalizePath("/Users//me///Projects", "linux"))
95
+ .toBe("/Users/me/Projects");
96
+ });
97
+
98
+ it("preserves case", () => {
99
+ expect(normalizePath("/Users/Robson/Dev", "linux")).toBe("/Users/Robson/Dev");
100
+ });
101
+ });
102
+
103
+ // ── samePath ────────────────────────────────────────────────────────────────
104
+
105
+ describe("samePath — Windows (case-insensitive)", () => {
106
+ it("matches identical paths", () => {
107
+ expect(samePath("C:\\Dev", "C:\\Dev", "win32")).toBe(true);
108
+ });
109
+
110
+ it("matches with different case", () => {
111
+ expect(samePath("C:\\Dev\\BB", "c:\\dev\\bb", "win32")).toBe(true);
112
+ });
113
+
114
+ it("matches with different separator style", () => {
115
+ expect(samePath("C:\\Dev\\BB", "C:/Dev/BB", "win32")).toBe(true);
116
+ });
117
+
118
+ it("matches with trailing-separator drift", () => {
119
+ expect(samePath("C:\\Dev\\BB", "C:\\Dev\\BB\\", "win32")).toBe(true);
120
+ });
121
+
122
+ it("matches drive-letter case drift alone", () => {
123
+ expect(samePath("B:\\Dev\\BB", "b:\\Dev\\BB", "win32")).toBe(true);
124
+ });
125
+
126
+ it("DOES NOT merge different drive letters", () => {
127
+ expect(samePath("A:\\Foo", "B:\\Foo", "win32")).toBe(false);
128
+ expect(samePath("C:\\Users\\me\\Dev", "D:\\Users\\me\\Dev", "win32")).toBe(false);
129
+ });
130
+
131
+ it("DOES NOT merge UNC path with drive-letter path", () => {
132
+ expect(samePath("\\\\server\\share\\x", "B:\\x", "win32")).toBe(false);
133
+ });
134
+
135
+ it("returns false for genuinely different paths", () => {
136
+ expect(samePath("C:\\a", "C:\\b", "win32")).toBe(false);
137
+ });
138
+ });
139
+
140
+ describe("samePath — macOS (case-insensitive, HFS+ default)", () => {
141
+ it("matches with different case", () => {
142
+ expect(samePath("/Users/me/Dev", "/Users/me/dev", "darwin")).toBe(true);
143
+ });
144
+
145
+ it("matches with trailing-separator drift", () => {
146
+ expect(samePath("/Users/me/Dev", "/Users/me/Dev/", "darwin")).toBe(true);
147
+ });
148
+ });
149
+
150
+ describe("samePath — Linux (case-sensitive)", () => {
151
+ it("does NOT match on case drift", () => {
152
+ expect(samePath("/Users/me/Dev", "/users/me/dev", "linux")).toBe(false);
153
+ });
154
+
155
+ it("matches identical paths", () => {
156
+ expect(samePath("/Users/me/Dev", "/Users/me/Dev", "linux")).toBe(true);
157
+ });
158
+
159
+ it("matches with trailing-separator drift", () => {
160
+ expect(samePath("/Users/me/Dev", "/Users/me/Dev/", "linux")).toBe(true);
161
+ });
162
+
163
+ it("returns false for different paths", () => {
164
+ expect(samePath("/a/b", "/a/c", "linux")).toBe(false);
165
+ });
166
+ });
167
+
168
+ // ── parsePathInput ──────────────────────────────────────────────────────────
169
+
170
+ describe("parsePathInput — Windows", () => {
171
+ it("splits path ending in separator", () => {
172
+ expect(parsePathInput("C:\\Users\\mboto\\", "win32"))
173
+ .toEqual({ parent: "C:\\Users\\mboto", partial: "" });
174
+ });
175
+
176
+ it("splits path with partial trailing segment", () => {
177
+ expect(parsePathInput("C:\\Users\\mboto\\Dev", "win32"))
178
+ .toEqual({ parent: "C:\\Users\\mboto", partial: "Dev" });
179
+ });
180
+
181
+ it("treats drive root with trailing separator as root", () => {
182
+ expect(parsePathInput("C:\\", "win32"))
183
+ .toEqual({ parent: "C:\\", partial: "" });
184
+ });
185
+
186
+ it("treats bare drive letter as drive root", () => {
187
+ // Critical: must NOT leak cwd via path.win32.resolve fallback.
188
+ expect(parsePathInput("B:", "win32"))
189
+ .toEqual({ parent: "B:\\", partial: "" });
190
+ expect(parsePathInput("Z:", "win32"))
191
+ .toEqual({ parent: "Z:\\", partial: "" });
192
+ });
193
+
194
+ it("treats drive-relative typed form as drive root + partial", () => {
195
+ expect(parsePathInput("B:Dev", "win32"))
196
+ .toEqual({ parent: "B:\\", partial: "Dev" });
197
+ expect(parsePathInput("C:Users", "win32"))
198
+ .toEqual({ parent: "C:\\", partial: "Users" });
199
+ });
200
+
201
+ it("splits drive root + partial when separator present", () => {
202
+ expect(parsePathInput("C:\\Us", "win32"))
203
+ .toEqual({ parent: "C:\\", partial: "Us" });
204
+ });
205
+
206
+ it("handles UNC path with trailing separator", () => {
207
+ expect(parsePathInput("\\\\server\\share\\dir\\", "win32"))
208
+ .toEqual({ parent: "\\\\server\\share\\dir", partial: "" });
209
+ });
210
+
211
+ it("tolerates mixed separators", () => {
212
+ expect(parsePathInput("C:\\Users\\mboto/Dev", "win32"))
213
+ .toEqual({ parent: "C:\\Users\\mboto", partial: "Dev" });
214
+ });
215
+
216
+ it("is drive-letter symmetric", () => {
217
+ // Same shape regardless of drive letter.
218
+ for (const d of ["A", "B", "C", "D", "Z"]) {
219
+ expect(parsePathInput(`${d}:\\Foo\\B`, "win32"))
220
+ .toEqual({ parent: `${d}:\\Foo`, partial: "B" });
221
+ }
222
+ });
223
+ });
224
+
225
+ describe("parsePathInput — POSIX", () => {
226
+ it("splits absolute path with trailing separator", () => {
227
+ expect(parsePathInput("/Users/me/", "linux"))
228
+ .toEqual({ parent: "/Users/me", partial: "" });
229
+ });
230
+
231
+ it("splits absolute path with partial", () => {
232
+ expect(parsePathInput("/Users/me/Dev", "linux"))
233
+ .toEqual({ parent: "/Users/me", partial: "Dev" });
234
+ });
235
+
236
+ it("treats root alone as root", () => {
237
+ expect(parsePathInput("/", "linux"))
238
+ .toEqual({ parent: "/", partial: "" });
239
+ });
240
+
241
+ it("treats partial-under-root as such", () => {
242
+ expect(parsePathInput("/U", "linux"))
243
+ .toEqual({ parent: "/", partial: "U" });
244
+ });
245
+ });
246
+
247
+ // ── withTrailingSep & joinForDisplay ────────────────────────────────────────
248
+
249
+ describe("withTrailingSep", () => {
250
+ it("appends \\ on Windows", () => {
251
+ expect(withTrailingSep("C:\\Users\\me", "win32")).toBe("C:\\Users\\me\\");
252
+ });
253
+ it("appends / on POSIX", () => {
254
+ expect(withTrailingSep("/Users/me", "linux")).toBe("/Users/me/");
255
+ });
256
+ it("does not double-append when already terminated", () => {
257
+ expect(withTrailingSep("C:\\Users\\me\\", "win32")).toBe("C:\\Users\\me\\");
258
+ expect(withTrailingSep("/Users/me/", "linux")).toBe("/Users/me/");
259
+ });
260
+ });
261
+
262
+ describe("joinForDisplay", () => {
263
+ it("joins Windows paths with backslash", () => {
264
+ expect(joinForDisplay("C:\\Users", "me", "win32")).toBe("C:\\Users\\me");
265
+ });
266
+ it("joins POSIX paths with forward slash", () => {
267
+ expect(joinForDisplay("/Users", "me", "linux")).toBe("/Users/me");
268
+ });
269
+ });
270
+
271
+ // ── isFilesystemRoot ────────────────────────────────────────────────────────
272
+
273
+ describe("isFilesystemRoot", () => {
274
+ it("recognises Windows drive roots", () => {
275
+ expect(isFilesystemRoot("C:\\", "win32")).toBe(true);
276
+ expect(isFilesystemRoot("B:\\", "win32")).toBe(true);
277
+ expect(isFilesystemRoot("C:\\Users", "win32")).toBe(false);
278
+ });
279
+
280
+ it("recognises Unix root", () => {
281
+ expect(isFilesystemRoot("/", "linux")).toBe(true);
282
+ expect(isFilesystemRoot("/Users", "linux")).toBe(false);
283
+ });
284
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/process-scan.ts.
3
+ * Platform behavior is exercised via injected `platform` + `exec`.
4
+ * See change: consolidate-platform-handlers.
5
+ */
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { parseEtime, isProcessRunning } from "../platform/process-scan.js";
8
+
9
+ describe("parseEtime", () => {
10
+ it("parses mm:ss format", () => expect(parseEtime("02:15")).toBe(135_000));
11
+ it("parses hh:mm:ss format", () => expect(parseEtime("01:30:00")).toBe(5_400_000));
12
+ it("parses dd-hh:mm:ss format", () => expect(parseEtime("2-03:00:00")).toBe(183_600_000));
13
+ it("parses 1-00:00:00 as 1 day", () => expect(parseEtime("1-00:00:00")).toBe(86_400_000));
14
+ it("parses 00:05 as 5 seconds", () => expect(parseEtime("00:05")).toBe(5_000));
15
+ it("returns 0 for empty", () => expect(parseEtime("")).toBe(0));
16
+ it("returns 0 for whitespace", () => expect(parseEtime(" ")).toBe(0));
17
+ it("returns 0 for garbage", () => expect(parseEtime("not-a-time")).toBe(0));
18
+ it("returns 0 for single number (not a time)", () => expect(parseEtime("42")).toBe(0));
19
+ });
20
+
21
+ describe("isProcessRunning", () => {
22
+ it("uses tasklist on Windows and matches image name", () => {
23
+ const exec = vi.fn().mockReturnValue(
24
+ "Code.exe 12345 Console 1 50,000 K\n",
25
+ );
26
+ expect(isProcessRunning("Code.exe", { platform: "win32", exec })).toBe(true);
27
+ expect(exec.mock.calls[0][0]).toMatch(/tasklist\s+\/FI\s+"IMAGENAME eq Code\.exe"/);
28
+ });
29
+
30
+ it("returns false on Windows when image name is missing from output", () => {
31
+ const exec = vi.fn().mockReturnValue("INFO: No tasks are running.\n");
32
+ expect(isProcessRunning("Missing.exe", { platform: "win32", exec })).toBe(false);
33
+ });
34
+
35
+ it("uses pgrep on Unix and returns true when exit code is 0", () => {
36
+ const exec = vi.fn().mockReturnValue("12345\n");
37
+ expect(isProcessRunning("/Applications/Zed.app", { platform: "darwin", exec })).toBe(true);
38
+ expect(exec.mock.calls[0][0]).toMatch(/pgrep\s+-f\s+"\/Applications\/Zed\.app"/);
39
+ });
40
+
41
+ it("returns false on Unix when pgrep throws (no match)", () => {
42
+ const exec = vi.fn().mockImplementation(() => {
43
+ throw new Error("exit code 1");
44
+ });
45
+ expect(isProcessRunning("nothing", { platform: "linux", exec })).toBe(false);
46
+ });
47
+
48
+ it("returns false on any platform when exec throws unexpectedly", () => {
49
+ const exec = vi.fn().mockImplementation(() => {
50
+ throw new Error("boom");
51
+ });
52
+ expect(isProcessRunning("Code.exe", { platform: "win32", exec })).toBe(false);
53
+ expect(isProcessRunning("zed", { platform: "linux", exec })).toBe(false);
54
+ });
55
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/process.ts.
3
+ *
4
+ * Every helper accepts injectable `platform`, `exec`, and `kill` parameters,
5
+ * so no `Object.defineProperty(process, "platform", ...)` mutation is needed.
6
+ * See change: consolidate-platform-handlers.
7
+ */
8
+ import { describe, it, expect, vi } from "vitest";
9
+ import {
10
+ findPortHolders,
11
+ parseNetstatListeners,
12
+ isProcessAlive,
13
+ killProcess,
14
+ killPidWithGroup,
15
+ } from "../platform/process.js";
16
+
17
+ describe("parseNetstatListeners", () => {
18
+ const selfPid = 99999;
19
+
20
+ it("parses a Windows netstat listener", () => {
21
+ const output = [
22
+ " Proto Local Address Foreign Address State PID",
23
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345",
24
+ ].join("\r\n");
25
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([12345]);
26
+ });
27
+
28
+ it("excludes non-LISTENING rows", () => {
29
+ const output = " TCP 0.0.0.0:8000 0.0.0.0:0 ESTABLISHED 1111";
30
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([]);
31
+ });
32
+
33
+ it("excludes current process PID", () => {
34
+ const output = ` TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING ${selfPid}`;
35
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([]);
36
+ });
37
+
38
+ it("only matches the requested port", () => {
39
+ const output = [
40
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 1111",
41
+ " TCP 0.0.0.0:18000 0.0.0.0:0 LISTENING 2222",
42
+ ].join("\n");
43
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([1111]);
44
+ });
45
+
46
+ it("handles IPv6 addresses", () => {
47
+ const output = " TCP [::]:8000 [::]:0 LISTENING 7777";
48
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([7777]);
49
+ });
50
+ });
51
+
52
+ describe("findPortHolders", () => {
53
+ it("uses netstat when platform=win32 is injected", () => {
54
+ const exec = vi.fn().mockReturnValue(
55
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345\n",
56
+ );
57
+ const result = findPortHolders(8000, { platform: "win32", exec });
58
+ expect(exec).toHaveBeenCalledOnce();
59
+ expect(exec.mock.calls[0][0]).toMatch(/netstat/i);
60
+ expect(result).toEqual([12345]);
61
+ });
62
+
63
+ it("uses lsof when platform=linux is injected", () => {
64
+ const exec = vi.fn().mockReturnValue("12345\n67890\n");
65
+ const result = findPortHolders(8000, { platform: "linux", exec });
66
+ expect(exec).toHaveBeenCalledOnce();
67
+ expect(exec.mock.calls[0][0]).toMatch(/lsof.*:8000/);
68
+ expect(result.sort()).toEqual([12345, 67890]);
69
+ });
70
+
71
+ it("returns [] on exec failure (best-effort)", () => {
72
+ const exec = vi.fn().mockImplementation(() => {
73
+ throw new Error("boom");
74
+ });
75
+ expect(findPortHolders(8000, { platform: "win32", exec })).toEqual([]);
76
+ });
77
+ });
78
+
79
+ describe("isProcessAlive", () => {
80
+ it("returns true when kill(pid, 0) succeeds", () => {
81
+ const kill = vi.fn().mockReturnValue(undefined);
82
+ expect(isProcessAlive(12345, { kill })).toBe(true);
83
+ expect(kill).toHaveBeenCalledWith(12345, 0);
84
+ });
85
+
86
+ it("returns false when kill(pid, 0) throws", () => {
87
+ const kill = vi.fn().mockImplementation(() => {
88
+ throw new Error("ESRCH");
89
+ });
90
+ expect(isProcessAlive(12345, { kill })).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("killProcess", () => {
95
+ it("uses taskkill on Windows", async () => {
96
+ const exec = vi.fn().mockReturnValue("");
97
+ const kill = vi.fn().mockReturnValue(undefined); // isProcessAlive → true
98
+ const result = await killProcess(12345, { platform: "win32", exec, kill });
99
+ expect(exec).toHaveBeenCalledWith(
100
+ expect.stringMatching(/taskkill\s+\/F\s+\/T\s+\/PID\s+12345/),
101
+ expect.any(Object),
102
+ );
103
+ expect(result).toEqual({ ok: true, forced: false });
104
+ });
105
+
106
+ it("returns { ok: false } when pid already dead", async () => {
107
+ const kill = vi.fn().mockImplementation(() => {
108
+ throw new Error("ESRCH");
109
+ });
110
+ const result = await killProcess(12345, { platform: "linux", kill });
111
+ expect(result).toEqual({ ok: false, forced: false });
112
+ });
113
+
114
+ it("sends SIGTERM on Unix and reports clean stop when process dies", async () => {
115
+ let aliveCount = 0;
116
+ const kill = vi.fn().mockImplementation((_pid, sig) => {
117
+ // isProcessAlive pre-check (signal 0) must succeed once to enter the branch
118
+ if (sig === 0) {
119
+ aliveCount++;
120
+ if (aliveCount === 1) return; // alive
121
+ throw new Error("ESRCH"); // dead after SIGTERM
122
+ }
123
+ if (sig === "SIGTERM") return;
124
+ throw new Error("unexpected signal");
125
+ });
126
+ const result = await killProcess(12345, { platform: "linux", kill, timeoutMs: 500 });
127
+ expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
128
+ expect(result).toEqual({ ok: true, forced: false });
129
+ });
130
+
131
+ it("forces SIGKILL when process survives SIGTERM", async () => {
132
+ const kill = vi.fn().mockImplementation((_pid, sig) => {
133
+ if (sig === 0) return; // always alive during polling
134
+ if (sig === "SIGTERM" || sig === "SIGKILL") return;
135
+ });
136
+ const result = await killProcess(12345, { platform: "linux", kill, timeoutMs: 300 });
137
+ expect(kill).toHaveBeenCalledWith(12345, "SIGKILL");
138
+ expect(result).toEqual({ ok: true, forced: true });
139
+ });
140
+ });
141
+
142
+ describe("killPidWithGroup", () => {
143
+ it("signals -pid on Unix (process group)", () => {
144
+ const kill = vi.fn();
145
+ killPidWithGroup(12345, "SIGTERM", { platform: "linux", kill });
146
+ expect(kill).toHaveBeenCalledWith(-12345, "SIGTERM");
147
+ });
148
+
149
+ it("signals +pid on Windows (no process groups)", () => {
150
+ const kill = vi.fn();
151
+ killPidWithGroup(12345, "SIGTERM", { platform: "win32", kill });
152
+ expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
153
+ });
154
+
155
+ it("signals -pid on macOS", () => {
156
+ const kill = vi.fn();
157
+ killPidWithGroup(99999, "SIGKILL", { platform: "darwin", kill });
158
+ expect(kill).toHaveBeenCalledWith(-99999, "SIGKILL");
159
+ });
160
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/runner.ts — the Recipe engine.
3
+ * Uses real subprocess execution against node itself (always available)
4
+ * so we test the full pipeline: resolve → spawn → parse → Result.
5
+ *
6
+ * See change: platform-command-executor.
7
+ */
8
+ import { describe, it, expect, beforeEach } from "vitest";
9
+ import { run, unwrap, resetResolverCache, type Recipe } from "../platform/runner.js";
10
+
11
+ // A trivial Recipe that runs `node --version` and returns the version string.
12
+ const NODE_VERSION: Recipe<Record<string, never>, string> = {
13
+ argv: () => [process.execPath, "--version"],
14
+ parse: (stdout) => stdout.trim(),
15
+ };
16
+
17
+ // A Recipe that runs `node -e "process.exit(N)"` where N comes from input.
18
+ const NODE_EXIT: Recipe<{ code: number }, string> = {
19
+ argv: ({ code }) => [process.execPath, "-e", `process.exit(${code})`],
20
+ parse: (stdout) => stdout.trim(),
21
+ };
22
+
23
+ // A Recipe that uses tolerate to accept exit 1.
24
+ const NODE_EXIT_1_TOLERATED: Recipe<Record<string, never>, string> = {
25
+ argv: () => [process.execPath, "-e", "process.exit(1)"],
26
+ parse: (stdout) => stdout.trim() || "exited-1-but-ok",
27
+ tolerate: [1],
28
+ };
29
+
30
+ // A Recipe that times out (sleeps 10s, we allow 100ms).
31
+ const NODE_SLEEP_LONG: Recipe<Record<string, never>, string> = {
32
+ argv: () => [process.execPath, "-e", "setTimeout(() => {}, 10000)"],
33
+ parse: (stdout) => stdout,
34
+ timeout: 100,
35
+ };
36
+
37
+ // A Recipe pointing at a binary that cannot be on PATH.
38
+ const NONEXISTENT_BINARY: Recipe<Record<string, never>, string> = {
39
+ argv: () => ["this-binary-does-not-exist-12345abcde", "--help"],
40
+ parse: (stdout) => stdout,
41
+ };
42
+
43
+ describe("run()", () => {
44
+ beforeEach(() => {
45
+ resetResolverCache();
46
+ });
47
+
48
+ it("executes a successful recipe and returns parsed value", () => {
49
+ const result = run(NODE_VERSION, {});
50
+ expect(result.ok).toBe(true);
51
+ if (result.ok) {
52
+ expect(result.value).toMatch(/^v\d+\.\d+\.\d+/);
53
+ }
54
+ });
55
+
56
+ it("returns { ok: false, error: not-found } when binary is missing", () => {
57
+ const result = run(NONEXISTENT_BINARY, {});
58
+ expect(result.ok).toBe(false);
59
+ if (!result.ok) {
60
+ expect(result.error.kind).toBe("not-found");
61
+ if (result.error.kind === "not-found") {
62
+ expect(result.error.binary).toBe("this-binary-does-not-exist-12345abcde");
63
+ }
64
+ }
65
+ });
66
+
67
+ it("returns { ok: false, error: exit } when subprocess exits non-zero (not tolerated)", () => {
68
+ const result = run(NODE_EXIT, { code: 42 });
69
+ expect(result.ok).toBe(false);
70
+ if (!result.ok) {
71
+ expect(result.error.kind).toBe("exit");
72
+ if (result.error.kind === "exit") {
73
+ expect(result.error.code).toBe(42);
74
+ }
75
+ }
76
+ });
77
+
78
+ it("returns { ok: true } when non-zero exit code is in recipe.tolerate", () => {
79
+ const result = run(NODE_EXIT_1_TOLERATED, {});
80
+ expect(result.ok).toBe(true);
81
+ if (result.ok) {
82
+ expect(result.value).toBe("exited-1-but-ok");
83
+ }
84
+ });
85
+
86
+ it("returns { ok: false, error: timeout } when subprocess exceeds timeout", () => {
87
+ const result = run(NODE_SLEEP_LONG, {});
88
+ expect(result.ok).toBe(false);
89
+ if (!result.ok) {
90
+ expect(result.error.kind).toBe("timeout");
91
+ if (result.error.kind === "timeout") {
92
+ expect(result.error.timeoutMs).toBe(100);
93
+ }
94
+ }
95
+ }, 2000);
96
+
97
+ it("ctx.timeout overrides recipe.timeout", () => {
98
+ // Recipe says 100ms, context says 10s — a 500ms subprocess should succeed.
99
+ const FAST: Recipe<Record<string, never>, string> = {
100
+ argv: () => [process.execPath, "-e", "setTimeout(() => process.exit(0), 200)"],
101
+ parse: () => "ok",
102
+ timeout: 50, // would cause timeout without override
103
+ };
104
+ const result = run(FAST, {}, { timeout: 5000 });
105
+ expect(result.ok).toBe(true);
106
+ }, 10000);
107
+
108
+ it("caches binary resolution across calls", () => {
109
+ // First call resolves + caches
110
+ const a = run(NODE_VERSION, {});
111
+ expect(a.ok).toBe(true);
112
+ // Second call reuses cache — behavior identical
113
+ const b = run(NODE_VERSION, {});
114
+ expect(b.ok).toBe(true);
115
+ });
116
+
117
+ it("resetResolverCache forces re-resolution", () => {
118
+ const a = run(NODE_VERSION, {});
119
+ expect(a.ok).toBe(true);
120
+ resetResolverCache();
121
+ const b = run(NODE_VERSION, {});
122
+ expect(b.ok).toBe(true);
123
+ });
124
+
125
+ it("passes cwd from ctx to the subprocess", () => {
126
+ const PWD: Recipe<Record<string, never>, string> = {
127
+ argv: () => [process.execPath, "-e", "process.stdout.write(process.cwd())"],
128
+ parse: (out) => out.trim(),
129
+ };
130
+ const result = run(PWD, {}, { cwd: process.cwd() });
131
+ expect(result.ok).toBe(true);
132
+ if (result.ok) {
133
+ // Normalize separators for cross-platform comparison
134
+ const normalizedOut = result.value.replace(/\\/g, "/").toLowerCase();
135
+ const normalizedCwd = process.cwd().replace(/\\/g, "/").toLowerCase();
136
+ expect(normalizedOut).toBe(normalizedCwd);
137
+ }
138
+ });
139
+
140
+ it("passes env from ctx to the subprocess (merged over process.env)", () => {
141
+ const PRINT_ENV: Recipe<Record<string, never>, string> = {
142
+ argv: () => [process.execPath, "-e", "process.stdout.write(process.env.TEST_VAR_RUNNER || 'unset')"],
143
+ parse: (out) => out.trim(),
144
+ };
145
+ const result = run(PRINT_ENV, {}, { env: { TEST_VAR_RUNNER: "hello-from-ctx" } });
146
+ expect(result.ok).toBe(true);
147
+ if (result.ok) {
148
+ expect(result.value).toBe("hello-from-ctx");
149
+ }
150
+ });
151
+
152
+ it("rejects recipes with empty argv", () => {
153
+ const EMPTY: Recipe<Record<string, never>, string> = {
154
+ argv: () => [],
155
+ parse: () => "",
156
+ };
157
+ const result = run(EMPTY, {});
158
+ expect(result.ok).toBe(false);
159
+ if (!result.ok) {
160
+ expect(result.error.kind).toBe("spawn-failure");
161
+ }
162
+ });
163
+ });
164
+
165
+ describe("unwrap()", () => {
166
+ it("returns value on success", () => {
167
+ expect(unwrap({ ok: true, value: 42 }, 0)).toBe(42);
168
+ });
169
+
170
+ it("returns fallback on error", () => {
171
+ expect(unwrap({ ok: false, error: { kind: "not-found", binary: "x" } }, 99)).toBe(99);
172
+ });
173
+ });