@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,110 @@
1
+ /**
2
+ * Integration test — concurrent launches.
3
+ *
4
+ * Simulates two dashboard startups racing for the same per-HOME lock.
5
+ * Asserts that exactly one wins (`acquired`) and the other falls back
6
+ * cleanly (`attach` OR `InstanceLockMismatchError`, depending on liveness
7
+ * of the winner's probe).
8
+ *
9
+ * Uses real tmp dirs (not memfs) because proper-lockfile requires real
10
+ * filesystem semantics.
11
+ *
12
+ * See change: single-dashboard-per-home, task 12.1.
13
+ */
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { acquireOrAttach, InstanceLockMismatchError } from "../home-lock.js";
19
+
20
+ let tmpHome: string;
21
+ let lockPath: string;
22
+ let metaPath: string;
23
+
24
+ beforeEach(() => {
25
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-concurrent-"));
26
+ lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
27
+ metaPath = `${lockPath}.meta.json`;
28
+ });
29
+
30
+ afterEach(() => {
31
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
32
+ });
33
+
34
+ describe("concurrent launch", () => {
35
+ it("exactly one of two parallel acquireOrAttach calls wins the lock", async () => {
36
+ // Both attempts race. Whichever wins first gets `acquired`. The loser
37
+ // sees ELOCKED; because our probe says the winner is "not alive" (we
38
+ // intentionally return dead to avoid racing the probe), the loser
39
+ // steals the stale lock and also acquires. That's not right for
40
+ // same-HOME same-instant races — we need the loser to SEE the winner.
41
+ //
42
+ // To mimic reality: make isProcessAlive true (process IS alive) and
43
+ // have the probe treat the metadata's identity as authoritative.
44
+ const hookFactory = () => ({
45
+ lockPath, metaPath, staleMs: 5_000,
46
+ isProcessAlive: () => true,
47
+ probeHealth: async () => {
48
+ // Read the live metadata file and echo back its identity — this
49
+ // models a working /api/health from the winner.
50
+ try {
51
+ const raw = fs.readFileSync(metaPath, "utf-8");
52
+ const m = JSON.parse(raw) as { identity?: string; pid?: number };
53
+ if (m && typeof m.identity === "string") {
54
+ return { running: true, identity: m.identity, pid: m.pid };
55
+ }
56
+ } catch { /* metadata not yet written */ }
57
+ return { running: true, pid: process.pid };
58
+ },
59
+ });
60
+
61
+ const cfg = (id: string) => ({
62
+ httpPort: 8000, piPort: 9999, version: "t",
63
+ identity: id,
64
+ hooks: hookFactory(),
65
+ });
66
+
67
+ const [a, b] = await Promise.allSettled([
68
+ acquireOrAttach(cfg("racer-A")),
69
+ acquireOrAttach(cfg("racer-B")),
70
+ ]);
71
+
72
+ // Count outcomes.
73
+ const outcomes = [a, b].map(r => {
74
+ if (r.status === "rejected") return "error";
75
+ return r.value.mode;
76
+ });
77
+
78
+ // Exactly one winner, and the loser is either "attach" or "error"
79
+ // (identity mismatch if the winner's identity appears in metadata
80
+ // before the loser reads it).
81
+ const winners = outcomes.filter(o => o === "acquired");
82
+ expect(winners).toHaveLength(1);
83
+
84
+ const losers = outcomes.filter(o => o !== "acquired");
85
+ expect(losers).toHaveLength(1);
86
+ expect(["attach", "error"]).toContain(losers[0]);
87
+
88
+ // Cleanup: release whichever won.
89
+ for (const r of [a, b]) {
90
+ if (r.status === "fulfilled" && r.value.mode === "acquired") {
91
+ await r.value.release();
92
+ }
93
+ }
94
+ });
95
+
96
+ it("the winning identity is persisted to metadata", async () => {
97
+ const first = await acquireOrAttach({
98
+ httpPort: 8000, piPort: 9999, version: "t",
99
+ identity: "winner",
100
+ hooks: { lockPath, metaPath, staleMs: 5_000 },
101
+ });
102
+ expect(first.mode).toBe("acquired");
103
+
104
+ const raw = fs.readFileSync(metaPath, "utf-8");
105
+ const meta = JSON.parse(raw) as { identity: string };
106
+ expect(meta.identity).toBe("winner");
107
+
108
+ if (first.mode === "acquired") await first.release();
109
+ });
110
+ });
@@ -100,5 +100,73 @@ describe("config-api", () => {
100
100
  // providers preserved
101
101
  expect(written.auth.providers.github.clientId).toBe("x");
102
102
  });
103
+
104
+ // ── fix-trusted-networks-no-oauth regression tests ─────────────────
105
+ // These assert that auth.bypassHosts and auth.bypassUrls are persisted
106
+ // through PUT /api/config. Before the fix, the auth-merge block only
107
+ // copied secret / providers / allowedUsers, silently dropping bypass*
108
+ // on every save.
109
+
110
+ it("should persist auth.bypassHosts with no pre-existing auth (task 1.5)", () => {
111
+ fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
112
+ const result = writeConfigPartial({
113
+ auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
114
+ });
115
+ expect(result.success).toBe(true);
116
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
117
+ expect(written.auth.bypassHosts).toEqual(["192.168.1.0/24"]);
118
+ });
119
+
120
+ it("should persist auth.bypassHosts alongside existing providers (task 1.6)", () => {
121
+ fs.writeFileSync(configFile, JSON.stringify({
122
+ auth: {
123
+ secret: "s",
124
+ providers: { github: { clientId: "abc", clientSecret: "xyz" } },
125
+ },
126
+ }));
127
+ const result = writeConfigPartial({
128
+ auth: { bypassHosts: ["10.0.0.0/8"] },
129
+ });
130
+ expect(result.success).toBe(true);
131
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
132
+ expect(written.auth.providers.github.clientId).toBe("abc");
133
+ expect(written.auth.providers.github.clientSecret).toBe("xyz");
134
+ expect(written.auth.bypassHosts).toEqual(["10.0.0.0/8"]);
135
+ });
136
+
137
+ it("should clear auth.bypassHosts via empty array (task 1.7)", () => {
138
+ fs.writeFileSync(configFile, JSON.stringify({
139
+ auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
140
+ }));
141
+ const result = writeConfigPartial({
142
+ auth: { bypassHosts: [] },
143
+ });
144
+ expect(result.success).toBe(true);
145
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
146
+ expect(written.auth.bypassHosts).toEqual([]);
147
+ });
148
+
149
+ it("should preserve existing auth.bypassHosts when partial omits the key (task 1.8)", () => {
150
+ fs.writeFileSync(configFile, JSON.stringify({
151
+ auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
152
+ }));
153
+ const result = writeConfigPartial({
154
+ auth: { allowedUsers: ["alice"] },
155
+ });
156
+ expect(result.success).toBe(true);
157
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
158
+ expect(written.auth.bypassHosts).toEqual(["192.168.1.0/24"]);
159
+ expect(written.auth.allowedUsers).toEqual(["alice"]);
160
+ });
161
+
162
+ it("should persist auth.bypassUrls symmetrically (task 1.9)", () => {
163
+ fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
164
+ const result = writeConfigPartial({
165
+ auth: { providers: {}, bypassUrls: ["/webhooks/", "/metrics"] },
166
+ });
167
+ expect(result.success).toBe(true);
168
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
169
+ expect(written.auth.bypassUrls).toEqual(["/webhooks/", "/metrics"]);
170
+ });
103
171
  });
104
172
  });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Integration test — crash recovery.
3
+ *
4
+ * Simulates the "SIGKILL, no cleanup" case:
5
+ * 1. Acquire lock
6
+ * 2. Skip release() (simulated crash)
7
+ * 3. Attempt to acquire again from a different caller
8
+ * 4. Assert: stale detection fires, new caller acquires cleanly
9
+ *
10
+ * See change: single-dashboard-per-home, task 12.2.
11
+ */
12
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { acquireOrAttach } from "../home-lock.js";
17
+
18
+ let tmpHome: string;
19
+ let lockPath: string;
20
+ let metaPath: string;
21
+
22
+ beforeEach(() => {
23
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crash-"));
24
+ lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
25
+ metaPath = `${lockPath}.meta.json`;
26
+ });
27
+
28
+ afterEach(() => {
29
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
30
+ });
31
+
32
+ describe("crash recovery", () => {
33
+ it("steals a stale lock when the previous holder's process is dead", async () => {
34
+ // First acquire — simulate a dead process.
35
+ const first = await acquireOrAttach({
36
+ httpPort: 8000, piPort: 9999, version: "t",
37
+ identity: "dead-holder",
38
+ hooks: { lockPath, metaPath, staleMs: 1 },
39
+ });
40
+ expect(first.mode).toBe("acquired");
41
+ // INTENTIONALLY don't release.
42
+
43
+ // Allow stale threshold to elapse.
44
+ await new Promise(r => setTimeout(r, 50));
45
+
46
+ const second = await acquireOrAttach({
47
+ httpPort: 8000, piPort: 9999, version: "t",
48
+ identity: "recovery",
49
+ hooks: {
50
+ lockPath, metaPath, staleMs: 1,
51
+ isProcessAlive: () => false, // previous holder is dead
52
+ probeHealth: async () => ({ running: false }),
53
+ },
54
+ });
55
+ expect(second.mode).toBe("acquired");
56
+
57
+ // Metadata now reflects the new holder.
58
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")) as { identity: string };
59
+ expect(meta.identity).toBe("recovery");
60
+
61
+ if (second.mode === "acquired") await second.release();
62
+ });
63
+
64
+ it("cleans up the metadata sidecar on graceful release", async () => {
65
+ const r = await acquireOrAttach({
66
+ httpPort: 8000, piPort: 9999, version: "t",
67
+ hooks: { lockPath, metaPath, staleMs: 1_000 },
68
+ });
69
+ expect(r.mode).toBe("acquired");
70
+ expect(fs.existsSync(metaPath)).toBe(true);
71
+
72
+ if (r.mode === "acquired") {
73
+ await r.release();
74
+ expect(fs.existsSync(metaPath)).toBe(false);
75
+ }
76
+ });
77
+
78
+ it("leaves metadata in place on crash (no release called)", async () => {
79
+ const r = await acquireOrAttach({
80
+ httpPort: 8000, piPort: 9999, version: "t",
81
+ hooks: { lockPath, metaPath, staleMs: 1_000 },
82
+ });
83
+ expect(r.mode).toBe("acquired");
84
+ // Don't release; metadata should persist until the next successful
85
+ // acquire clears it as part of steal.
86
+ expect(fs.existsSync(metaPath)).toBe(true);
87
+ });
88
+ });
@@ -7,9 +7,23 @@ import type { PreferencesStore } from "../preferences-store.js";
7
7
  import type { SessionManager } from "../memory-session-manager.js";
8
8
  import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
9
9
 
10
- // Mock the shared openspec poller
11
- vi.mock("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js", () => ({
12
- pollOpenSpecAsync: vi.fn(async () => ({ initialized: false, changes: [] })),
10
+ // Mock the shared openspec poller. We expose three entry points now:
11
+ // - pollOpenSpecAsync: legacy monolithic (still used as fallback where no mtime gate applies)
12
+ // - runOpenSpecList: new granular list call
13
+ // - runOpenSpecStatus: new granular status call
14
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js")>();
16
+ return {
17
+ ...actual,
18
+ pollOpenSpecAsync: vi.fn(async () => ({ initialized: false, changes: [] })),
19
+ runOpenSpecList: vi.fn(async () => null),
20
+ runOpenSpecStatus: vi.fn(async () => null),
21
+ };
22
+ });
23
+
24
+ // Mock pi-resource-scanner so polling ticks don't hit the filesystem.
25
+ vi.mock("../pi-resource-scanner.js", () => ({
26
+ scanPiResources: vi.fn(async () => ({ local: { extensions: [], skills: [], prompts: [] }, global: { extensions: [], skills: [], prompts: [] }, packages: [] })),
13
27
  }));
14
28
 
15
29
  // Mock the shared state replay
@@ -184,20 +198,33 @@ describe("DirectoryService", () => {
184
198
 
185
199
  describe("getOpenSpecData / refreshOpenSpec", () => {
186
200
  it("returns cached data after polling", async () => {
187
- const { pollOpenSpecAsync } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
188
- (pollOpenSpecAsync as any).mockResolvedValue({ initialized: true, changes: [{ name: "change-1" }] });
201
+ // Use the granular mocks; fs.stat on the bogus path will return undefined so
202
+ // the new gated poll short-circuits. To exercise the happy path we set up a
203
+ // real tmp dir with an openspec/changes folder.
204
+ const fs = await import("node:fs");
205
+ const os = await import("node:os");
206
+ const path = await import("node:path");
207
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ds-cache-"));
208
+ fs.mkdirSync(path.join(tmp, "openspec", "changes", "change-1"), { recursive: true });
209
+
210
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
211
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
212
+ { name: "change-1", status: "in-progress", completedTasks: 0, totalTasks: 1 },
213
+ ] });
214
+ (runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
189
215
 
190
216
  const stateStore = createMockPreferencesStore();
191
217
  const sessionManager = createMockSessionManager();
192
218
  service = createDirectoryService(stateStore, sessionManager);
193
219
 
194
- const data = await service.refreshOpenSpec("/project");
220
+ const data = await service.refreshOpenSpec(tmp);
195
221
  expect(data.initialized).toBe(true);
196
222
  expect(data.changes[0].name).toBe("change-1");
197
223
 
198
- // getOpenSpecData returns cached value
199
- const cached = service.getOpenSpecData("/project");
224
+ const cached = service.getOpenSpecData(tmp);
200
225
  expect(cached).toEqual(data);
226
+
227
+ fs.rmSync(tmp, { recursive: true, force: true });
201
228
  });
202
229
  });
203
230
 
@@ -237,4 +264,203 @@ describe("DirectoryService", () => {
237
264
  vi.useRealTimers();
238
265
  });
239
266
  });
267
+
268
+ describe("mtime gate", () => {
269
+ const fs = require("node:fs") as typeof import("node:fs");
270
+ const os = require("node:os") as typeof import("node:os");
271
+ const path = require("node:path") as typeof import("node:path");
272
+
273
+ let tmpDir: string;
274
+ let cwd: string;
275
+ let changesDir: string;
276
+
277
+ beforeEach(async () => {
278
+ vi.clearAllMocks();
279
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ds-mtime-"));
280
+ cwd = tmpDir;
281
+ changesDir = path.join(cwd, "openspec", "changes");
282
+ fs.mkdirSync(changesDir, { recursive: true });
283
+ fs.mkdirSync(path.join(changesDir, "change-a"));
284
+ fs.mkdirSync(path.join(changesDir, "change-b"));
285
+ });
286
+
287
+ afterEach(() => {
288
+ if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
289
+ });
290
+
291
+ it("first poll invokes list and status for each change", async () => {
292
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
293
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
294
+ { name: "change-a", status: "in-progress", completedTasks: 1, totalTasks: 2 },
295
+ { name: "change-b", status: "in-progress", completedTasks: 0, totalTasks: 3 },
296
+ ] });
297
+ (runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
298
+
299
+ const stateStore = createMockPreferencesStore();
300
+ const sessionManager = createMockSessionManager();
301
+ service = createDirectoryService(stateStore, sessionManager);
302
+
303
+ const data = await service.refreshOpenSpec(cwd);
304
+ expect(data.initialized).toBe(true);
305
+ expect(data.changes).toHaveLength(2);
306
+ expect(runOpenSpecList).toHaveBeenCalledTimes(1);
307
+ expect(runOpenSpecStatus).toHaveBeenCalledTimes(2);
308
+ });
309
+
310
+ it("second poll with unchanged mtimes makes zero CLI calls", async () => {
311
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
312
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
313
+ { name: "change-a", status: "in-progress", completedTasks: 1, totalTasks: 2 },
314
+ ] });
315
+ (runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
316
+
317
+ const stateStore = createMockPreferencesStore();
318
+ const sessionManager = createMockSessionManager();
319
+ service = createDirectoryService(stateStore, sessionManager);
320
+
321
+ // First poll (force = true bypasses gate, populates cache).
322
+ await service.refreshOpenSpec(cwd);
323
+ (runOpenSpecList as any).mockClear();
324
+ (runOpenSpecStatus as any).mockClear();
325
+
326
+ // Second poll via the internal gated path.
327
+ await service.pollDirectoryGated(cwd);
328
+ expect(runOpenSpecList).not.toHaveBeenCalled();
329
+ expect(runOpenSpecStatus).not.toHaveBeenCalled();
330
+ });
331
+
332
+ it("mtime advance on one change runs status only for that change", async () => {
333
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
334
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
335
+ { name: "change-a", status: "in-progress", completedTasks: 1, totalTasks: 2 },
336
+ { name: "change-b", status: "in-progress", completedTasks: 0, totalTasks: 3 },
337
+ ] });
338
+ (runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
339
+
340
+ const stateStore = createMockPreferencesStore();
341
+ const sessionManager = createMockSessionManager();
342
+ service = createDirectoryService(stateStore, sessionManager);
343
+ await service.refreshOpenSpec(cwd);
344
+ (runOpenSpecList as any).mockClear();
345
+ (runOpenSpecStatus as any).mockClear();
346
+
347
+ // Bump mtime of change-a only by touching a file inside it.
348
+ const future = new Date(Date.now() + 10_000);
349
+ fs.utimesSync(path.join(changesDir, "change-a"), future, future);
350
+
351
+ await service.pollDirectoryGated(cwd);
352
+ // List is gated by top-level mtime (unchanged) so it's skipped.
353
+ expect(runOpenSpecList).not.toHaveBeenCalled();
354
+ // Status re-run exactly once, for change-a.
355
+ expect(runOpenSpecStatus).toHaveBeenCalledTimes(1);
356
+ expect((runOpenSpecStatus as any).mock.calls[0][1]).toBe("change-a");
357
+ });
358
+
359
+ it("changeDetection: 'always' bypasses the gate", async () => {
360
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
361
+ (runOpenSpecList as any).mockResolvedValue({ changes: [
362
+ { name: "change-a", status: "in-progress", completedTasks: 0, totalTasks: 1 },
363
+ ] });
364
+ (runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
365
+
366
+ const stateStore = createMockPreferencesStore();
367
+ const sessionManager = createMockSessionManager();
368
+ service = createDirectoryService(stateStore, sessionManager, { changeDetection: "always" });
369
+ await service.refreshOpenSpec(cwd);
370
+ (runOpenSpecList as any).mockClear();
371
+ (runOpenSpecStatus as any).mockClear();
372
+
373
+ await service.pollDirectoryGated(cwd);
374
+ expect(runOpenSpecList).toHaveBeenCalledTimes(1);
375
+ expect(runOpenSpecStatus).toHaveBeenCalledTimes(1);
376
+ });
377
+
378
+ it("removed change is pruned from cache", async () => {
379
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
380
+ (runOpenSpecList as any).mockResolvedValueOnce({ changes: [
381
+ { name: "change-a", status: "in-progress", completedTasks: 0, totalTasks: 1 },
382
+ { name: "change-b", status: "in-progress", completedTasks: 0, totalTasks: 1 },
383
+ ] });
384
+ (runOpenSpecStatus as any).mockResolvedValue({ artifacts: [], isComplete: false });
385
+
386
+ const stateStore = createMockPreferencesStore();
387
+ const sessionManager = createMockSessionManager();
388
+ service = createDirectoryService(stateStore, sessionManager);
389
+ await service.refreshOpenSpec(cwd);
390
+
391
+ // Remove change-b and bump top-level mtime.
392
+ fs.rmSync(path.join(changesDir, "change-b"), { recursive: true });
393
+ const future = new Date(Date.now() + 20_000);
394
+ fs.utimesSync(changesDir, future, future);
395
+ (runOpenSpecList as any).mockResolvedValueOnce({ changes: [
396
+ { name: "change-a", status: "in-progress", completedTasks: 0, totalTasks: 1 },
397
+ ] });
398
+ (runOpenSpecStatus as any).mockClear();
399
+
400
+ await service.pollDirectoryGated(cwd);
401
+ const data = service.getOpenSpecData(cwd);
402
+ expect(data?.changes).toHaveLength(1);
403
+ expect(data?.changes[0].name).toBe("change-a");
404
+ });
405
+ });
406
+
407
+ describe("semaphore + refresh", () => {
408
+ it("caps concurrent CLI spawns during refresh storms", async () => {
409
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
410
+ (runOpenSpecList as any).mockImplementation(async () => ({ changes: [
411
+ { name: "c1", status: "in-progress", completedTasks: 0, totalTasks: 1 },
412
+ ] }));
413
+
414
+ let active = 0;
415
+ let peak = 0;
416
+ (runOpenSpecStatus as any).mockImplementation(async () => {
417
+ active++;
418
+ peak = Math.max(peak, active);
419
+ await new Promise((r) => setTimeout(r, 10));
420
+ active--;
421
+ return { artifacts: [], isComplete: false };
422
+ });
423
+
424
+ const stateStore = createMockPreferencesStore();
425
+ const sessionManager = createMockSessionManager();
426
+ service = createDirectoryService(stateStore, sessionManager, { maxConcurrentSpawns: 2, changeDetection: "always" });
427
+
428
+ // 10 concurrent refreshes across 5 dirs with list returning 1 change each.
429
+ await Promise.all(Array.from({ length: 10 }, (_, i) => service.refreshOpenSpec(`/project-${i}`)));
430
+ expect(peak).toBeLessThanOrEqual(2);
431
+ });
432
+ });
433
+
434
+ describe("jitter", () => {
435
+ it("produces deterministic per-cwd offsets within jitterSeconds", async () => {
436
+ const { phaseOffsetMs } = await import("../directory-service.js");
437
+ const a1 = phaseOffsetMs("/project/a", 5);
438
+ const a2 = phaseOffsetMs("/project/a", 5);
439
+ const b = phaseOffsetMs("/project/b", 5);
440
+ expect(a1).toBe(a2); // stable
441
+ expect(a1).toBeLessThan(5000);
442
+ expect(a1).toBeGreaterThanOrEqual(0);
443
+ expect(b).toBeLessThan(5000);
444
+ });
445
+
446
+ it("returns 0 when jitterSeconds is 0", async () => {
447
+ const { phaseOffsetMs } = await import("../directory-service.js");
448
+ expect(phaseOffsetMs("/any", 0)).toBe(0);
449
+ });
450
+ });
451
+
452
+ describe("reconfigurePolling", () => {
453
+ it("accepts a new interval without losing cached data", async () => {
454
+ const { runOpenSpecList, runOpenSpecStatus } = await import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js");
455
+ (runOpenSpecList as any).mockResolvedValue({ changes: [] });
456
+ (runOpenSpecStatus as any).mockResolvedValue(null);
457
+
458
+ const stateStore = createMockPreferencesStore();
459
+ const sessionManager = createMockSessionManager();
460
+ service = createDirectoryService(stateStore, sessionManager);
461
+ await service.refreshOpenSpec("/x");
462
+ service.reconfigurePolling({ pollIntervalSeconds: 60, maxConcurrentSpawns: 5, changeDetection: "mtime", jitterSeconds: 0 });
463
+ expect(service.getOpenSpecData("/x")).toBeDefined();
464
+ });
465
+ });
240
466
  });
@@ -1,12 +1,18 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
 
3
- const { mockedExecSync } = vi.hoisted(() => ({
3
+ const { mockedExecSync, mockedSpawnSync } = vi.hoisted(() => ({
4
4
  mockedExecSync: vi.fn(),
5
+ mockedSpawnSync: vi.fn(),
5
6
  }));
6
7
 
8
+ // platform/process.ts and platform/tools.ts now use spawnSync via whereAllLines
9
+ // and isProcessRunning; both must be mocked to exercise the detection path in
10
+ // isolation. Default return is status:1 (not found) so each test explicitly
11
+ // overrides what it needs.
7
12
  vi.mock("node:child_process", () => ({
8
- default: { execSync: mockedExecSync },
13
+ default: { execSync: mockedExecSync, spawnSync: mockedSpawnSync },
9
14
  execSync: mockedExecSync,
15
+ spawnSync: mockedSpawnSync,
10
16
  }));
11
17
 
12
18
  import { detectEditors, isProcessRunning, isProcessRunningWin32, EDITORS, type DetectedEditor } from "../editor-registry.js";
@@ -14,6 +20,9 @@ import { detectEditors, isProcessRunning, isProcessRunningWin32, EDITORS, type D
14
20
  describe("editor-registry", () => {
15
21
  beforeEach(() => {
16
22
  vi.resetAllMocks();
23
+ // Default: spawnSync reports not-found so any un-overridden test still
24
+ // sees clean state rather than stale returns from previous tests.
25
+ mockedSpawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
17
26
  });
18
27
 
19
28
  describe("EDITORS", () => {
@@ -74,20 +83,22 @@ describe("editor-registry", () => {
74
83
 
75
84
  describe("detectEditors", () => {
76
85
  it("should return editor when process is running AND CLI is available", () => {
86
+ // platform/process.ts isProcessRunning uses execSync (pgrep) internally.
77
87
  mockedExecSync.mockImplementation((cmd) => {
78
88
  const s = String(cmd);
79
89
  if (s.includes("pgrep")) {
80
- // Only Zed is running — match on both the macOS pattern
81
- // ("/Applications/Zed.app") and the Linux pattern ("zed").
82
90
  if (s.includes("Zed") || s.includes("zed")) return Buffer.from("12345\n");
83
91
  throw new Error("not found");
84
92
  }
85
- if (s.includes("which")) {
86
- if (s.includes("zed")) return Buffer.from("/usr/local/bin/zed\n");
87
- throw new Error("not found");
88
- }
89
93
  throw new Error("unexpected command");
90
94
  });
95
+ // ToolResolver.which uses spawnSync for `which`/`where` lookup.
96
+ mockedSpawnSync.mockImplementation((cmd, args) => {
97
+ if ((cmd === "which" || cmd === "where") && args?.[0] === "zed") {
98
+ return { status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" };
99
+ }
100
+ return { status: 1, stdout: "", stderr: "" };
101
+ });
91
102
 
92
103
  const result = detectEditors("/some/project");
93
104
  expect(result).toEqual([{ id: "zed", name: "Zed" }]);
@@ -97,9 +108,9 @@ describe("editor-registry", () => {
97
108
  mockedExecSync.mockImplementation((cmd) => {
98
109
  const s = String(cmd);
99
110
  if (s.includes("pgrep")) throw new Error("not found");
100
- if (s.includes("which")) return Buffer.from("/usr/local/bin/zed\n");
101
111
  throw new Error("unexpected command");
102
112
  });
113
+ mockedSpawnSync.mockImplementation(() => ({ status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" }));
103
114
 
104
115
  const result = detectEditors("/some/project");
105
116
  expect(result).toEqual([]);
@@ -109,9 +120,9 @@ describe("editor-registry", () => {
109
120
  mockedExecSync.mockImplementation((cmd) => {
110
121
  const s = String(cmd);
111
122
  if (s.includes("pgrep")) return Buffer.from("12345\n");
112
- if (s.includes("which")) throw new Error("not found");
113
123
  throw new Error("unexpected command");
114
124
  });
125
+ mockedSpawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
115
126
 
116
127
  const result = detectEditors("/some/project");
117
128
  expect(result).toEqual([]);
@@ -125,13 +136,15 @@ describe("editor-registry", () => {
125
136
  if (s.includes("Visual Studio Code") || s.includes("code")) return Buffer.from("67890\n");
126
137
  throw new Error("not found");
127
138
  }
128
- if (s.includes("which")) {
129
- if (s.includes("zed")) return Buffer.from("/usr/local/bin/zed\n");
130
- if (s.includes("code")) return Buffer.from("/usr/local/bin/code\n");
131
- throw new Error("not found");
132
- }
133
139
  throw new Error("unexpected command");
134
140
  });
141
+ mockedSpawnSync.mockImplementation((cmd, args) => {
142
+ if (cmd === "which" || cmd === "where") {
143
+ if (args?.[0] === "zed") return { status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" };
144
+ if (args?.[0] === "code") return { status: 0, stdout: "/usr/local/bin/code\n", stderr: "" };
145
+ }
146
+ return { status: 1, stdout: "", stderr: "" };
147
+ });
135
148
 
136
149
  const result = detectEditors("/some/project");
137
150
  expect(result).toEqual([
@@ -30,7 +30,11 @@ describe("findBundledExtension - AppImage guard", () => {
30
30
  });
31
31
 
32
32
  it("returns null when extension does not exist", () => {
33
- expect(findBundledExtension(tmpDir)).toBeNull();
33
+ // Disable Strategy 2 (node-resolver fallback) so this test exercises
34
+ // the AppImage guard path in isolation.
35
+ expect(
36
+ findBundledExtension(tmpDir, { resolvePackage: () => null }),
37
+ ).toBeNull();
34
38
  });
35
39
 
36
40
  // Note: We can't easily test the /tmp/.mount_ guard with real paths
@@ -26,7 +26,9 @@ describe("bridge extension registration (server context)", () => {
26
26
  });
27
27
 
28
28
  it("findBundledExtension returns null when extension dir does not exist", () => {
29
- const result = findBundledExtension(tmpDir);
29
+ // Strategy 2 (require.resolve) would find the monorepo extension;
30
+ // disable it for this test so we exercise Strategy 1 in isolation.
31
+ const result = findBundledExtension(tmpDir, { resolvePackage: () => null });
30
32
  expect(result).toBeNull();
31
33
  });
32
34