@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.1

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 (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Tests for `platform/node-spawn.ts` — the canonical helper for
3
+ * constructing `node --import <loader> <entry>` argv.
4
+ *
5
+ * See change: fix-windows-entry-script-url.
6
+ */
7
+ import { describe, it, expect, vi } from "vitest";
8
+ import { toFileUrl, spawnNodeScript, isTsxLoader, shouldUrlWrapEntry } from "../platform/node-spawn.js";
9
+ import * as execModule from "../platform/exec.js";
10
+
11
+ describe("toFileUrl", () => {
12
+ it("returns a file:// URL input unchanged (idempotent)", () => {
13
+ expect(toFileUrl("file:///C:/foo.ts")).toBe("file:///C:/foo.ts");
14
+ expect(toFileUrl("file:///usr/local/bin/cli.js")).toBe("file:///usr/local/bin/cli.js");
15
+ });
16
+
17
+ it("wraps Windows B:\\ drive-letter paths on any host OS", () => {
18
+ expect(toFileUrl("B:\\Dev\\cli.ts")).toBe("file:///B:/Dev/cli.ts");
19
+ });
20
+
21
+ it("wraps Windows C:\\ drive-letter paths on any host OS", () => {
22
+ expect(toFileUrl("C:\\Users\\x\\cli.ts")).toBe("file:///C:/Users/x/cli.ts");
23
+ });
24
+
25
+ it("wraps forward-slash Windows paths", () => {
26
+ expect(toFileUrl("B:/Dev/cli.ts")).toBe("file:///B:/Dev/cli.ts");
27
+ });
28
+
29
+ it("wraps POSIX absolute paths", () => {
30
+ expect(toFileUrl("/usr/local/bin/cli.js")).toBe("file:///usr/local/bin/cli.js");
31
+ });
32
+
33
+ it("handles uppercase and lowercase drive letters identically", () => {
34
+ expect(toFileUrl("b:\\Dev\\cli.ts")).toBe("file:///b:/Dev/cli.ts");
35
+ expect(toFileUrl("Z:\\foo.ts")).toBe("file:///Z:/foo.ts");
36
+ });
37
+ });
38
+
39
+ describe("isTsxLoader", () => {
40
+ it("returns true for loader URLs containing /tsx/", () => {
41
+ expect(isTsxLoader("file:///home/x/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
42
+ expect(isTsxLoader("file:///C:/project/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
43
+ });
44
+
45
+ it("returns true for raw paths with /tsx/ segment", () => {
46
+ expect(isTsxLoader("/home/x/node_modules/tsx/dist/esm/index.mjs")).toBe(true);
47
+ });
48
+
49
+ it("returns true for Windows raw paths with \\tsx\\ segment", () => {
50
+ expect(isTsxLoader("C:\\project\\node_modules\\tsx\\dist\\esm\\index.mjs")).toBe(true);
51
+ });
52
+
53
+ it("returns false for jiti loader", () => {
54
+ expect(isTsxLoader("file:///home/x/node_modules/@mariozechner/jiti/lib/jiti-register.mjs")).toBe(false);
55
+ expect(isTsxLoader("/home/x/node_modules/@mariozechner/jiti/lib/jiti-register.mjs")).toBe(false);
56
+ });
57
+
58
+ it("returns false for undefined / empty", () => {
59
+ expect(isTsxLoader(undefined)).toBe(false);
60
+ expect(isTsxLoader("")).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("spawnNodeScript", () => {
65
+ it("URL-wraps both loader and entry when loader is NOT tsx (jiti/default)", () => {
66
+ const spawnSpy = vi
67
+ .spyOn(execModule, "spawn")
68
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
69
+
70
+ spawnNodeScript({
71
+ nodeBin: "C:\\Program Files\\nodejs\\node.exe",
72
+ loader: "B:\\jiti\\register.mjs",
73
+ entry: "B:\\Dev\\cli.ts",
74
+ args: ["start", "--dev"],
75
+ });
76
+
77
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
78
+ const [bin, argv] = spawnSpy.mock.calls[0]!;
79
+ expect(bin).toBe("C:\\Program Files\\nodejs\\node.exe");
80
+ // On Linux host: entry stays raw even with a Windows-styled path
81
+ // (shouldUrlWrapEntry consults process.platform, which is Linux).
82
+ // The Windows-wrapped branch is exercised separately via shouldUrlWrapEntry.
83
+ expect(argv).toEqual([
84
+ "--import",
85
+ "file:///B:/jiti/register.mjs",
86
+ "B:\\Dev\\cli.ts",
87
+ "start",
88
+ "--dev",
89
+ ]);
90
+
91
+ spawnSpy.mockRestore();
92
+ });
93
+
94
+ it("URL-wraps loader but passes RAW entry when loader is tsx", () => {
95
+ const spawnSpy = vi
96
+ .spyOn(execModule, "spawn")
97
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
98
+
99
+ spawnNodeScript({
100
+ nodeBin: "/usr/bin/node",
101
+ loader: "/home/u/node_modules/tsx/dist/esm/index.mjs",
102
+ entry: "/home/u/repo/packages/server/src/cli.ts",
103
+ args: ["start"],
104
+ });
105
+
106
+ const [, argv] = spawnSpy.mock.calls[0]!;
107
+ expect(argv).toEqual([
108
+ "--import",
109
+ "file:///home/u/node_modules/tsx/dist/esm/index.mjs",
110
+ "/home/u/repo/packages/server/src/cli.ts", // RAW, not URL
111
+ "start",
112
+ ]);
113
+ spawnSpy.mockRestore();
114
+ });
115
+
116
+ it("defaults nodeBin to process.execPath when omitted", () => {
117
+ const spawnSpy = vi
118
+ .spyOn(execModule, "spawn")
119
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
120
+
121
+ spawnNodeScript({
122
+ entry: "/usr/local/cli.ts",
123
+ });
124
+
125
+ const [bin] = spawnSpy.mock.calls[0]!;
126
+ expect(bin).toBe(process.execPath);
127
+ spawnSpy.mockRestore();
128
+ });
129
+
130
+ it("omits --import when no loader is provided", () => {
131
+ const spawnSpy = vi
132
+ .spyOn(execModule, "spawn")
133
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
134
+
135
+ spawnNodeScript({
136
+ entry: "B:\\Dev\\cli.ts",
137
+ args: ["help"],
138
+ });
139
+
140
+ const [, argv] = spawnSpy.mock.calls[0]!;
141
+ // No loader → shouldUrlWrapEntry returns false on Linux host → raw entry.
142
+ expect(argv).toEqual(["B:\\Dev\\cli.ts", "help"]);
143
+ spawnSpy.mockRestore();
144
+ });
145
+
146
+ it("passes spawnOptions through to exec.spawn unchanged", () => {
147
+ const spawnSpy = vi
148
+ .spyOn(execModule, "spawn")
149
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
150
+
151
+ const opts = { detached: true, stdio: ["ignore", 1, 2] as ("ignore" | number)[], env: { FOO: "bar" } };
152
+ spawnNodeScript({
153
+ entry: "/usr/local/cli.ts",
154
+ spawnOptions: opts,
155
+ });
156
+
157
+ const [, , passedOpts] = spawnSpy.mock.calls[0]!;
158
+ expect(passedOpts).toBe(opts);
159
+ spawnSpy.mockRestore();
160
+ });
161
+
162
+ it("accepts a loader that is already a file:// URL without double-wrapping", () => {
163
+ const spawnSpy = vi
164
+ .spyOn(execModule, "spawn")
165
+ .mockImplementation(() => ({ unref: () => {} } as unknown as ReturnType<typeof execModule.spawn>));
166
+
167
+ spawnNodeScript({
168
+ loader: "file:///C:/jiti/register.mjs",
169
+ entry: "B:\\Dev\\cli.ts",
170
+ });
171
+
172
+ const [, argv] = spawnSpy.mock.calls[0]!;
173
+ // On Linux host with non-tsx loader: entry stays raw.
174
+ expect(argv).toEqual([
175
+ "--import",
176
+ "file:///C:/jiti/register.mjs",
177
+ "B:\\Dev\\cli.ts",
178
+ ]);
179
+ spawnSpy.mockRestore();
180
+ });
181
+ });
182
+
183
+ describe("shouldUrlWrapEntry", () => {
184
+ it("returns false for tsx loader on any platform", () => {
185
+ const tsxLoader = "file:///home/u/node_modules/tsx/dist/esm/index.mjs";
186
+ expect(shouldUrlWrapEntry(tsxLoader, "linux")).toBe(false);
187
+ expect(shouldUrlWrapEntry(tsxLoader, "darwin")).toBe(false);
188
+ expect(shouldUrlWrapEntry(tsxLoader, "win32")).toBe(false);
189
+ });
190
+
191
+ it("returns false for non-tsx loader on POSIX (jiti MUST get raw entry)", () => {
192
+ const jiti = "file:///home/u/node_modules/@mariozechner/jiti/lib/jiti-register.mjs";
193
+ expect(shouldUrlWrapEntry(jiti, "linux")).toBe(false);
194
+ expect(shouldUrlWrapEntry(jiti, "darwin")).toBe(false);
195
+ });
196
+
197
+ it("returns true for non-tsx loader on Windows (drive letters need file://)", () => {
198
+ const jiti = "file:///C:/node_modules/@mariozechner/jiti/lib/jiti-register.mjs";
199
+ expect(shouldUrlWrapEntry(jiti, "win32")).toBe(true);
200
+ });
201
+
202
+ it("returns false when no loader is provided, regardless of platform", () => {
203
+ // Without a loader, Node's default resolver handles the entry; the URL
204
+ // wrap was historically used for Windows drive-letter collision, but
205
+ // if we were to spawn without a loader we'd still default to raw on POSIX.
206
+ // On Windows without a loader, callers should wrap themselves.
207
+ expect(shouldUrlWrapEntry(undefined, "linux")).toBe(false);
208
+ expect(shouldUrlWrapEntry(undefined, "darwin")).toBe(false);
209
+ });
210
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/commands.ts.
3
+ * Platform behavior exercised via injected `platform` + `exec` / `asyncExec`.
4
+ * See change: consolidate-platform-handlers.
5
+ */
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { openBrowser, isVirtualMachine } from "../platform/commands.js";
8
+
9
+ describe("openBrowser", () => {
10
+ it("uses `open` on macOS", () => {
11
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(null));
12
+ openBrowser("https://example.com", { platform: "darwin", asyncExec });
13
+ expect(asyncExec).toHaveBeenCalledOnce();
14
+ expect(asyncExec.mock.calls[0][0]).toMatch(/^open\s+"https:\/\/example\.com"$/);
15
+ });
16
+
17
+ it("uses `start` on Windows", () => {
18
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(null));
19
+ openBrowser("https://example.com", { platform: "win32", asyncExec });
20
+ expect(asyncExec.mock.calls[0][0]).toMatch(/^start\s+""\s+"https:\/\/example\.com"$/);
21
+ });
22
+
23
+ it("uses `xdg-open` on Linux", () => {
24
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(null));
25
+ openBrowser("https://example.com", { platform: "linux", asyncExec });
26
+ expect(asyncExec.mock.calls[0][0]).toMatch(/^xdg-open\s+"https:\/\/example\.com"$/);
27
+ });
28
+
29
+ it("escapes URLs via JSON.stringify (quotes, newlines, backslashes)", () => {
30
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(null));
31
+ openBrowser('https://example.com/?q="escaped"', { platform: "linux", asyncExec });
32
+ // JSON.stringify converts " → \"
33
+ expect(asyncExec.mock.calls[0][0]).toContain('\\"escaped\\"');
34
+ });
35
+
36
+ it("invokes onError callback when async exec fails", () => {
37
+ const err = new Error("nope");
38
+ const onError = vi.fn();
39
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(err));
40
+ openBrowser("https://example.com", { platform: "linux", asyncExec, onError });
41
+ expect(onError).toHaveBeenCalledWith(err);
42
+ });
43
+
44
+ it("does not throw when onError is absent", () => {
45
+ const err = new Error("nope");
46
+ const asyncExec = vi.fn((_cmd, cb: (e: Error | null) => void) => cb(err));
47
+ expect(() =>
48
+ openBrowser("https://example.com", { platform: "linux", asyncExec }),
49
+ ).not.toThrow();
50
+ });
51
+ });
52
+
53
+ describe("isVirtualMachine", () => {
54
+ it("detects VMware via sysctl on macOS", () => {
55
+ const exec = vi.fn().mockReturnValue("VMware7,1\n");
56
+ expect(isVirtualMachine({ platform: "darwin", exec })).toBe(true);
57
+ });
58
+
59
+ it("detects VirtualBox via sysctl on macOS", () => {
60
+ const exec = vi.fn().mockReturnValue("VirtualBox6,0\n");
61
+ expect(isVirtualMachine({ platform: "darwin", exec })).toBe(true);
62
+ });
63
+
64
+ it("returns false on physical macOS hardware", () => {
65
+ const exec = vi.fn().mockReturnValue("MacBookPro18,3\n");
66
+ expect(isVirtualMachine({ platform: "darwin", exec })).toBe(false);
67
+ });
68
+
69
+ it("detects VM via systemd-detect-virt on Linux", () => {
70
+ const exec = vi.fn().mockReturnValue("kvm\n");
71
+ expect(isVirtualMachine({ platform: "linux", exec })).toBe(true);
72
+ });
73
+
74
+ it("returns false on bare-metal Linux", () => {
75
+ const exec = vi.fn().mockReturnValue("none\n");
76
+ expect(isVirtualMachine({ platform: "linux", exec })).toBe(false);
77
+ });
78
+
79
+ it("detects VMware via wmic on Windows", () => {
80
+ const exec = vi.fn().mockImplementation((cmd: string) => {
81
+ if (cmd.includes("bios")) return "SerialNumber\nVMware-42 AA BB\n";
82
+ return "";
83
+ });
84
+ expect(isVirtualMachine({ platform: "win32", exec })).toBe(true);
85
+ });
86
+
87
+ it("detects Hyper-V via wmic computersystem on Windows", () => {
88
+ const exec = vi.fn().mockImplementation((cmd: string) => {
89
+ if (cmd.includes("bios")) throw new Error("no serial");
90
+ if (cmd.includes("computersystem")) return "Manufacturer Model\nMicrosoft Corporation Virtual Machine\n";
91
+ return "";
92
+ });
93
+ expect(isVirtualMachine({ platform: "win32", exec })).toBe(true);
94
+ });
95
+
96
+ it("returns false on physical Windows when no VM markers found", () => {
97
+ const exec = vi.fn().mockReturnValue("SerialNumber\nR90ABCDE\n");
98
+ expect(isVirtualMachine({ platform: "win32", exec })).toBe(false);
99
+ });
100
+
101
+ it("returns false when exec throws unexpectedly", () => {
102
+ const exec = vi.fn().mockImplementation(() => {
103
+ throw new Error("boom");
104
+ });
105
+ expect(isVirtualMachine({ platform: "darwin", exec })).toBe(false);
106
+ expect(isVirtualMachine({ platform: "linux", exec })).toBe(false);
107
+ });
108
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/exec.ts — the thin wrapper over
3
+ * node:child_process that sets `windowsHide: true` by default.
4
+ *
5
+ * These tests assert the *option passthrough* contract; they do not spawn
6
+ * real subprocesses. The wrapper is a few lines per function — its only
7
+ * job is to forward arguments with `windowsHide: true` layered on top.
8
+ *
9
+ * See change: platform-command-executor.
10
+ */
11
+ import { describe, it, expect } from "vitest";
12
+ import { execSync, spawn, spawnSync, exec, execFile } from "../platform/exec.js";
13
+
14
+ describe("platform/exec wrappers", () => {
15
+ // ── execSync ────────────────────────────────────────────────────────────
16
+ // Real invocation: we pick commands that exit 0 on every OS (node itself).
17
+
18
+ it("execSync exits 0 for `node --version`", () => {
19
+ const out = execSync(`"${process.execPath}" --version`, { encoding: "utf-8" });
20
+ expect(String(out).trim()).toMatch(/^v\d+\.\d+\.\d+/);
21
+ });
22
+
23
+ // ── spawnSync ───────────────────────────────────────────────────────────
24
+
25
+ it("spawnSync runs `node --version` and captures stdout", () => {
26
+ const result = spawnSync(process.execPath, ["--version"], { encoding: "utf-8" });
27
+ expect(result.status).toBe(0);
28
+ expect(String(result.stdout).trim()).toMatch(/^v\d+\.\d+\.\d+/);
29
+ });
30
+
31
+ it("spawnSync accepts undefined args and defaults to []", () => {
32
+ // Should not throw — wrapper must coerce undefined args to []
33
+ const result = spawnSync(process.execPath, undefined, {
34
+ encoding: "utf-8",
35
+ input: "process.stdout.write('ok')",
36
+ });
37
+ // May or may not work depending on shell, but the call itself must not throw.
38
+ expect(typeof result).toBe("object");
39
+ });
40
+
41
+ // ── windowsHide default ─────────────────────────────────────────────────
42
+
43
+ // The key invariant: wrappers set windowsHide: true when caller omits it.
44
+ // We verify this by inspecting the spawn metadata (spawnargs / opts).
45
+ // Node doesn't expose the final options object, so we check by spawning
46
+ // with a non-overridden call and verifying it completes successfully
47
+ // (a misconfigured windowsHide would not change functional behavior,
48
+ // so the real assertion is in D10 below via source inspection).
49
+
50
+ it("spawn returns a ChildProcess object", async () => {
51
+ const child = spawn(process.execPath, ["--version"]);
52
+ expect(child.pid).toBeGreaterThan(0);
53
+ await new Promise<void>((resolve) => {
54
+ child.on("exit", () => resolve());
55
+ });
56
+ });
57
+
58
+ // ── exec (callback form) ────────────────────────────────────────────────
59
+
60
+ it("exec(cmd, cb) invokes callback with stdout", async () => {
61
+ const out = await new Promise<string>((resolve, reject) => {
62
+ exec(`"${process.execPath}" --version`, (err, stdout) => {
63
+ if (err) reject(err);
64
+ else resolve(String(stdout));
65
+ });
66
+ });
67
+ expect(out.trim()).toMatch(/^v\d+\.\d+\.\d+/);
68
+ });
69
+
70
+ // ── execFile ────────────────────────────────────────────────────────────
71
+
72
+ it("execFile(file, args, cb) works", async () => {
73
+ const out = await new Promise<string>((resolve, reject) => {
74
+ execFile(process.execPath, ["--version"], (err, stdout) => {
75
+ if (err) reject(err);
76
+ else resolve(String(stdout));
77
+ });
78
+ });
79
+ expect(out.trim()).toMatch(/^v\d+\.\d+\.\d+/);
80
+ });
81
+ });
82
+
83
+ describe("platform/exec — windowsHide default (source-level assertion)", () => {
84
+ // Since Node doesn't expose the spawn options after the call, we verify
85
+ // the windowsHide default by reading the wrapper source and asserting
86
+ // that every public export merges `windowsHide: true` into its options.
87
+ //
88
+ // This catches refactors that accidentally drop the default.
89
+
90
+ it("exec.ts source sets windowsHide: true by default", async () => {
91
+ const fs = await import("node:fs/promises");
92
+ const path = await import("node:path");
93
+ const url = await import("node:url");
94
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
95
+ const src = await fs.readFile(path.resolve(here, "../platform/exec.ts"), "utf-8");
96
+
97
+ // Must define a withHide helper and apply it uniformly.
98
+ expect(src).toMatch(/windowsHide\??:\s*boolean/);
99
+ expect(src).toMatch(/windowsHide:\s*hide/);
100
+ // Default must be true (not false) when caller omits it.
101
+ expect(src).toMatch(/opts\?\.windowsHide\s*\?\?\s*true/);
102
+ });
103
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/git.ts — Recipe argv shapes +
3
+ * light integration tests against the actual repository.
4
+ *
5
+ * The argv-shape tests are pure: they verify the Recipe's `argv()`
6
+ * function produces the expected command without spawning anything.
7
+ *
8
+ * The integration tests use the actual repo as a git fixture; they're
9
+ * smoke tests that the full recipe → runner → git pipeline works.
10
+ *
11
+ * See change: platform-command-executor.
12
+ */
13
+ import { describe, it, expect } from "vitest";
14
+ import path from "node:path";
15
+ import url from "node:url";
16
+ import {
17
+ GIT_DIFF,
18
+ GIT_STATUS_PORCELAIN,
19
+ GIT_IS_REPO,
20
+ GIT_CURRENT_BRANCH,
21
+ GIT_HEAD_SHA,
22
+ GIT_REMOTE_URL,
23
+ GH_PR_NUMBER,
24
+ GIT_RECIPES,
25
+ isGitRepo,
26
+ currentBranch,
27
+ headSha,
28
+ remoteUrl,
29
+ diff,
30
+ statusPorcelain,
31
+ isGitRepoOr,
32
+ currentBranchOr,
33
+ } from "../platform/git.js";
34
+
35
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
36
+ const REPO_ROOT = path.resolve(here, "..", "..", "..", "..");
37
+
38
+ // ── Pure argv tests ─────────────────────────────────────────────────────────
39
+
40
+ describe("GIT_DIFF.argv", () => {
41
+ it("produces `git diff HEAD -- <path>` by default", () => {
42
+ expect(GIT_DIFF.argv({ cwd: "/tmp", path: "src/foo.ts" })).toEqual([
43
+ "git", "diff", "HEAD", "--", "src/foo.ts",
44
+ ]);
45
+ });
46
+
47
+ it("respects an explicit ref", () => {
48
+ expect(GIT_DIFF.argv({ cwd: "/tmp", path: "a.ts", ref: "main" })).toEqual([
49
+ "git", "diff", "main", "--", "a.ts",
50
+ ]);
51
+ });
52
+
53
+ it("does not shell-escape the path (argv array is passed verbatim)", () => {
54
+ // With argv arrays, paths like "a b.ts" flow through as one element.
55
+ // No JSON.stringify, no quoting — safe by construction.
56
+ expect(GIT_DIFF.argv({ cwd: "/tmp", path: "a b.ts" })).toEqual([
57
+ "git", "diff", "HEAD", "--", "a b.ts",
58
+ ]);
59
+ });
60
+
61
+ it("tolerates exit code 1 (no-diff or empty repo)", () => {
62
+ expect(GIT_DIFF.tolerate).toContain(1);
63
+ });
64
+ });
65
+
66
+ describe("GIT_STATUS_PORCELAIN.argv", () => {
67
+ it("omits `--` when no path is given", () => {
68
+ expect(GIT_STATUS_PORCELAIN.argv({ cwd: "/tmp" })).toEqual([
69
+ "git", "status", "--porcelain",
70
+ ]);
71
+ });
72
+
73
+ it("adds `-- <path>` when a path is given", () => {
74
+ expect(GIT_STATUS_PORCELAIN.argv({ cwd: "/tmp", path: "src/foo.ts" })).toEqual([
75
+ "git", "status", "--porcelain", "--", "src/foo.ts",
76
+ ]);
77
+ });
78
+ });
79
+
80
+ describe("other recipe argv shapes", () => {
81
+ it("GIT_IS_REPO", () => {
82
+ expect(GIT_IS_REPO.argv({ cwd: "/tmp" })).toEqual(["git", "rev-parse", "--is-inside-work-tree"]);
83
+ });
84
+
85
+ it("GIT_CURRENT_BRANCH", () => {
86
+ expect(GIT_CURRENT_BRANCH.argv({ cwd: "/tmp" })).toEqual(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
87
+ });
88
+
89
+ it("GIT_HEAD_SHA (full)", () => {
90
+ expect(GIT_HEAD_SHA.argv({ cwd: "/tmp" })).toEqual(["git", "rev-parse", "HEAD"]);
91
+ });
92
+
93
+ it("GIT_HEAD_SHA (short)", () => {
94
+ expect(GIT_HEAD_SHA.argv({ cwd: "/tmp", short: true })).toEqual(["git", "rev-parse", "--short", "HEAD"]);
95
+ });
96
+
97
+ it("GIT_REMOTE_URL (default origin)", () => {
98
+ expect(GIT_REMOTE_URL.argv({ cwd: "/tmp" })).toEqual(["git", "remote", "get-url", "origin"]);
99
+ });
100
+
101
+ it("GIT_REMOTE_URL (custom remote)", () => {
102
+ expect(GIT_REMOTE_URL.argv({ cwd: "/tmp", remote: "upstream" })).toEqual([
103
+ "git", "remote", "get-url", "upstream",
104
+ ]);
105
+ });
106
+
107
+ it("GH_PR_NUMBER tolerates exit 1 (no PR)", () => {
108
+ expect(GH_PR_NUMBER.tolerate).toContain(1);
109
+ expect(GH_PR_NUMBER.argv({ cwd: "/tmp" })).toEqual([
110
+ "gh", "pr", "view", "--json", "number", "-q", ".number",
111
+ ]);
112
+ });
113
+ });
114
+
115
+ describe("GIT_RECIPES registry", () => {
116
+ it("enumerates all exported recipes", () => {
117
+ const keys = Object.keys(GIT_RECIPES).sort();
118
+ expect(keys).toEqual([
119
+ "GH_PR_NUMBER",
120
+ "GIT_CURRENT_BRANCH",
121
+ "GIT_DIFF",
122
+ "GIT_HEAD_SHA",
123
+ "GIT_IS_REPO",
124
+ "GIT_REMOTE_URL",
125
+ "GIT_STATUS_PORCELAIN",
126
+ ]);
127
+ });
128
+
129
+ it("every recipe has argv and parse functions", () => {
130
+ for (const [name, recipe] of Object.entries(GIT_RECIPES)) {
131
+ expect(typeof recipe.argv, `${name}.argv`).toBe("function");
132
+ expect(typeof recipe.parse, `${name}.parse`).toBe("function");
133
+ }
134
+ });
135
+ });
136
+
137
+ // ── Integration tests against the actual repository ────────────────────────
138
+
139
+ describe("git.* integration (runs against this repo)", () => {
140
+ it("isGitRepo returns true for the repo root", () => {
141
+ const result = isGitRepo({ cwd: REPO_ROOT });
142
+ expect(result.ok).toBe(true);
143
+ if (result.ok) expect(result.value).toBe(true);
144
+ });
145
+
146
+ it("isGitRepoOr returns false for a non-repo directory", () => {
147
+ expect(isGitRepoOr({ cwd: require("node:os").tmpdir() })).toBe(false);
148
+ });
149
+
150
+ it("currentBranch returns a string for the repo", () => {
151
+ const result = currentBranch({ cwd: REPO_ROOT });
152
+ expect(result.ok).toBe(true);
153
+ if (result.ok) {
154
+ expect(typeof result.value).toBe("string");
155
+ expect(result.value!.length).toBeGreaterThan(0);
156
+ }
157
+ });
158
+
159
+ it("headSha returns a 40-char SHA for the repo", () => {
160
+ const result = headSha({ cwd: REPO_ROOT });
161
+ expect(result.ok).toBe(true);
162
+ if (result.ok) expect(result.value).toMatch(/^[0-9a-f]{40}$/);
163
+ });
164
+
165
+ it("headSha short returns a 7-char SHA", () => {
166
+ const result = headSha({ cwd: REPO_ROOT, short: true });
167
+ expect(result.ok).toBe(true);
168
+ if (result.ok) expect(result.value).toMatch(/^[0-9a-f]{7}/);
169
+ });
170
+
171
+ it("remoteUrl returns a URL string if origin is set", () => {
172
+ // This repo has an origin remote; the value is non-empty.
173
+ const result = remoteUrl({ cwd: REPO_ROOT });
174
+ expect(result.ok).toBe(true);
175
+ if (result.ok) expect(result.value?.length ?? 0).toBeGreaterThan(0);
176
+ });
177
+
178
+ it("statusPorcelain returns a string (possibly empty)", () => {
179
+ const result = statusPorcelain({ cwd: REPO_ROOT });
180
+ expect(result.ok).toBe(true);
181
+ if (result.ok) expect(typeof result.value).toBe("string");
182
+ });
183
+
184
+ it("diff with exit-1 tolerance does not throw for a clean path", () => {
185
+ // Pick a file guaranteed to exist and typically be unchanged in test runs.
186
+ const result = diff({ cwd: REPO_ROOT, path: "package.json" });
187
+ // tolerate: [1] means no diff is still ok
188
+ expect(result.ok).toBe(true);
189
+ });
190
+
191
+ it("currentBranchOr returns a fallback when cwd is not a repo", () => {
192
+ expect(currentBranchOr({ cwd: require("node:os").tmpdir() }, "no-repo")).toBe("no-repo");
193
+ });
194
+ });