@blackbelt-technology/pi-agent-dashboard 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/AGENTS.md +342 -0
  2. package/README.md +619 -0
  3. package/docs/architecture.md +646 -0
  4. package/package.json +92 -0
  5. package/packages/extension/package.json +33 -0
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +85 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +712 -0
  8. package/packages/extension/src/__tests__/connection.test.ts +344 -0
  9. package/packages/extension/src/__tests__/credentials-updated.test.ts +26 -0
  10. package/packages/extension/src/__tests__/dev-build.test.ts +79 -0
  11. package/packages/extension/src/__tests__/event-forwarder.test.ts +89 -0
  12. package/packages/extension/src/__tests__/git-info.test.ts +112 -0
  13. package/packages/extension/src/__tests__/git-link-builder.test.ts +102 -0
  14. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +232 -0
  15. package/packages/extension/src/__tests__/openspec-poller.test.ts +119 -0
  16. package/packages/extension/src/__tests__/process-metrics.test.ts +47 -0
  17. package/packages/extension/src/__tests__/process-scanner.test.ts +202 -0
  18. package/packages/extension/src/__tests__/prompt-expander.test.ts +54 -0
  19. package/packages/extension/src/__tests__/server-auto-start.test.ts +167 -0
  20. package/packages/extension/src/__tests__/server-launcher.test.ts +44 -0
  21. package/packages/extension/src/__tests__/server-probe.test.ts +25 -0
  22. package/packages/extension/src/__tests__/session-switch.test.ts +139 -0
  23. package/packages/extension/src/__tests__/session-sync.test.ts +55 -0
  24. package/packages/extension/src/__tests__/source-detector.test.ts +73 -0
  25. package/packages/extension/src/__tests__/stats-extractor.test.ts +92 -0
  26. package/packages/extension/src/__tests__/ui-proxy.test.ts +583 -0
  27. package/packages/extension/src/__tests__/watchdog.test.ts +161 -0
  28. package/packages/extension/src/ask-user-tool.ts +63 -0
  29. package/packages/extension/src/bridge-context.ts +64 -0
  30. package/packages/extension/src/bridge.ts +926 -0
  31. package/packages/extension/src/command-handler.ts +538 -0
  32. package/packages/extension/src/connection.ts +204 -0
  33. package/packages/extension/src/dev-build.ts +39 -0
  34. package/packages/extension/src/event-forwarder.ts +40 -0
  35. package/packages/extension/src/flow-event-wiring.ts +102 -0
  36. package/packages/extension/src/git-info.ts +65 -0
  37. package/packages/extension/src/git-link-builder.ts +112 -0
  38. package/packages/extension/src/model-tracker.ts +56 -0
  39. package/packages/extension/src/pi-env.d.ts +23 -0
  40. package/packages/extension/src/process-metrics.ts +70 -0
  41. package/packages/extension/src/process-scanner.ts +396 -0
  42. package/packages/extension/src/prompt-expander.ts +87 -0
  43. package/packages/extension/src/provider-register.ts +276 -0
  44. package/packages/extension/src/server-auto-start.ts +87 -0
  45. package/packages/extension/src/server-launcher.ts +82 -0
  46. package/packages/extension/src/server-probe.ts +33 -0
  47. package/packages/extension/src/session-sync.ts +154 -0
  48. package/packages/extension/src/source-detector.ts +26 -0
  49. package/packages/extension/src/ui-proxy.ts +269 -0
  50. package/packages/extension/tsconfig.json +11 -0
  51. package/packages/server/package.json +37 -0
  52. package/packages/server/src/__tests__/auth-plugin.test.ts +117 -0
  53. package/packages/server/src/__tests__/auth.test.ts +224 -0
  54. package/packages/server/src/__tests__/auto-attach.test.ts +246 -0
  55. package/packages/server/src/__tests__/auto-resume.test.ts +135 -0
  56. package/packages/server/src/__tests__/auto-shutdown.test.ts +136 -0
  57. package/packages/server/src/__tests__/browse-endpoint.test.ts +104 -0
  58. package/packages/server/src/__tests__/bulk-archive-handler.test.ts +15 -0
  59. package/packages/server/src/__tests__/cli-parse.test.ts +73 -0
  60. package/packages/server/src/__tests__/client-discovery.test.ts +39 -0
  61. package/packages/server/src/__tests__/config-api.test.ts +104 -0
  62. package/packages/server/src/__tests__/cors.test.ts +48 -0
  63. package/packages/server/src/__tests__/directory-service.test.ts +240 -0
  64. package/packages/server/src/__tests__/editor-detection.test.ts +60 -0
  65. package/packages/server/src/__tests__/editor-endpoints.test.ts +26 -0
  66. package/packages/server/src/__tests__/editor-manager.test.ts +73 -0
  67. package/packages/server/src/__tests__/editor-registry.test.ts +151 -0
  68. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +55 -0
  69. package/packages/server/src/__tests__/event-status-extraction.test.ts +58 -0
  70. package/packages/server/src/__tests__/extension-register.test.ts +61 -0
  71. package/packages/server/src/__tests__/file-endpoint.test.ts +49 -0
  72. package/packages/server/src/__tests__/force-kill-handler.test.ts +109 -0
  73. package/packages/server/src/__tests__/git-operations.test.ts +251 -0
  74. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  75. package/packages/server/src/__tests__/headless-shutdown-fallback.test.ts +109 -0
  76. package/packages/server/src/__tests__/health-endpoint.test.ts +35 -0
  77. package/packages/server/src/__tests__/heartbeat-ack.test.ts +63 -0
  78. package/packages/server/src/__tests__/json-store.test.ts +70 -0
  79. package/packages/server/src/__tests__/localhost-guard.test.ts +149 -0
  80. package/packages/server/src/__tests__/memory-event-store.test.ts +260 -0
  81. package/packages/server/src/__tests__/memory-session-manager.test.ts +80 -0
  82. package/packages/server/src/__tests__/meta-persistence.test.ts +107 -0
  83. package/packages/server/src/__tests__/migrate-persistence.test.ts +180 -0
  84. package/packages/server/src/__tests__/npm-search-proxy.test.ts +153 -0
  85. package/packages/server/src/__tests__/oauth-callback-server.test.ts +165 -0
  86. package/packages/server/src/__tests__/openspec-archive.test.ts +87 -0
  87. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +163 -0
  88. package/packages/server/src/__tests__/package-routes.test.ts +172 -0
  89. package/packages/server/src/__tests__/pending-fork-registry.test.ts +69 -0
  90. package/packages/server/src/__tests__/pending-load-manager.test.ts +144 -0
  91. package/packages/server/src/__tests__/pending-resume-registry.test.ts +130 -0
  92. package/packages/server/src/__tests__/pi-resource-scanner.test.ts +235 -0
  93. package/packages/server/src/__tests__/preferences-store.test.ts +108 -0
  94. package/packages/server/src/__tests__/process-manager.test.ts +184 -0
  95. package/packages/server/src/__tests__/provider-auth-handlers.test.ts +93 -0
  96. package/packages/server/src/__tests__/provider-auth-routes.test.ts +143 -0
  97. package/packages/server/src/__tests__/provider-auth-storage.test.ts +114 -0
  98. package/packages/server/src/__tests__/resolve-path.test.ts +38 -0
  99. package/packages/server/src/__tests__/ring-buffer.test.ts +45 -0
  100. package/packages/server/src/__tests__/server-pid.test.ts +89 -0
  101. package/packages/server/src/__tests__/session-api.test.ts +244 -0
  102. package/packages/server/src/__tests__/session-diff.test.ts +138 -0
  103. package/packages/server/src/__tests__/session-file-dedup.test.ts +102 -0
  104. package/packages/server/src/__tests__/session-file-reader.test.ts +85 -0
  105. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +138 -0
  106. package/packages/server/src/__tests__/session-order-manager.test.ts +135 -0
  107. package/packages/server/src/__tests__/session-ordering-integration.test.ts +102 -0
  108. package/packages/server/src/__tests__/session-scanner.test.ts +199 -0
  109. package/packages/server/src/__tests__/shutdown-endpoint.test.ts +42 -0
  110. package/packages/server/src/__tests__/skip-wipe.test.ts +123 -0
  111. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +126 -0
  112. package/packages/server/src/__tests__/smoke-integration.test.ts +175 -0
  113. package/packages/server/src/__tests__/spa-fallback.test.ts +68 -0
  114. package/packages/server/src/__tests__/subscription-handler.test.ts +155 -0
  115. package/packages/server/src/__tests__/terminal-gateway.test.ts +61 -0
  116. package/packages/server/src/__tests__/terminal-manager.test.ts +257 -0
  117. package/packages/server/src/__tests__/trusted-networks-config.test.ts +84 -0
  118. package/packages/server/src/__tests__/tunnel.test.ts +206 -0
  119. package/packages/server/src/__tests__/ws-ping-pong.test.ts +112 -0
  120. package/packages/server/src/auth-plugin.ts +302 -0
  121. package/packages/server/src/auth.ts +323 -0
  122. package/packages/server/src/browse.ts +55 -0
  123. package/packages/server/src/browser-gateway.ts +495 -0
  124. package/packages/server/src/browser-handlers/directory-handler.ts +137 -0
  125. package/packages/server/src/browser-handlers/handler-context.ts +45 -0
  126. package/packages/server/src/browser-handlers/session-action-handler.ts +271 -0
  127. package/packages/server/src/browser-handlers/session-meta-handler.ts +95 -0
  128. package/packages/server/src/browser-handlers/subscription-handler.ts +154 -0
  129. package/packages/server/src/browser-handlers/terminal-handler.ts +37 -0
  130. package/packages/server/src/cli.ts +347 -0
  131. package/packages/server/src/config-api.ts +130 -0
  132. package/packages/server/src/directory-service.ts +162 -0
  133. package/packages/server/src/editor-detection.ts +60 -0
  134. package/packages/server/src/editor-manager.ts +352 -0
  135. package/packages/server/src/editor-proxy.ts +134 -0
  136. package/packages/server/src/editor-registry.ts +108 -0
  137. package/packages/server/src/event-status-extraction.ts +131 -0
  138. package/packages/server/src/event-wiring.ts +589 -0
  139. package/packages/server/src/extension-register.ts +92 -0
  140. package/packages/server/src/git-operations.ts +200 -0
  141. package/packages/server/src/headless-pid-registry.ts +207 -0
  142. package/packages/server/src/idle-timer.ts +61 -0
  143. package/packages/server/src/json-store.ts +32 -0
  144. package/packages/server/src/localhost-guard.ts +117 -0
  145. package/packages/server/src/memory-event-store.ts +193 -0
  146. package/packages/server/src/memory-session-manager.ts +123 -0
  147. package/packages/server/src/meta-persistence.ts +64 -0
  148. package/packages/server/src/migrate-persistence.ts +195 -0
  149. package/packages/server/src/npm-search-proxy.ts +143 -0
  150. package/packages/server/src/oauth-callback-server.ts +177 -0
  151. package/packages/server/src/openspec-archive.ts +60 -0
  152. package/packages/server/src/package-manager-wrapper.ts +200 -0
  153. package/packages/server/src/pending-fork-registry.ts +53 -0
  154. package/packages/server/src/pending-load-manager.ts +110 -0
  155. package/packages/server/src/pending-resume-registry.ts +69 -0
  156. package/packages/server/src/pi-gateway.ts +419 -0
  157. package/packages/server/src/pi-resource-scanner.ts +369 -0
  158. package/packages/server/src/preferences-store.ts +116 -0
  159. package/packages/server/src/process-manager.ts +311 -0
  160. package/packages/server/src/provider-auth-handlers.ts +438 -0
  161. package/packages/server/src/provider-auth-storage.ts +200 -0
  162. package/packages/server/src/resolve-path.ts +12 -0
  163. package/packages/server/src/routes/editor-routes.ts +86 -0
  164. package/packages/server/src/routes/file-routes.ts +116 -0
  165. package/packages/server/src/routes/git-routes.ts +89 -0
  166. package/packages/server/src/routes/openspec-routes.ts +99 -0
  167. package/packages/server/src/routes/package-routes.ts +172 -0
  168. package/packages/server/src/routes/provider-auth-routes.ts +244 -0
  169. package/packages/server/src/routes/provider-routes.ts +101 -0
  170. package/packages/server/src/routes/route-deps.ts +23 -0
  171. package/packages/server/src/routes/session-routes.ts +91 -0
  172. package/packages/server/src/routes/system-routes.ts +271 -0
  173. package/packages/server/src/server-pid.ts +84 -0
  174. package/packages/server/src/server.ts +554 -0
  175. package/packages/server/src/session-api.ts +330 -0
  176. package/packages/server/src/session-bootstrap.ts +80 -0
  177. package/packages/server/src/session-diff.ts +178 -0
  178. package/packages/server/src/session-discovery.ts +134 -0
  179. package/packages/server/src/session-file-reader.ts +135 -0
  180. package/packages/server/src/session-order-manager.ts +73 -0
  181. package/packages/server/src/session-scanner.ts +233 -0
  182. package/packages/server/src/session-stats-reader.ts +99 -0
  183. package/packages/server/src/terminal-gateway.ts +51 -0
  184. package/packages/server/src/terminal-manager.ts +241 -0
  185. package/packages/server/src/tunnel.ts +329 -0
  186. package/packages/server/tsconfig.json +11 -0
  187. package/packages/shared/package.json +15 -0
  188. package/packages/shared/src/__tests__/config.test.ts +358 -0
  189. package/packages/shared/src/__tests__/deriveChangeState.test.ts +95 -0
  190. package/packages/shared/src/__tests__/mdns-discovery.test.ts +80 -0
  191. package/packages/shared/src/__tests__/protocol.test.ts +243 -0
  192. package/packages/shared/src/__tests__/resolve-jiti.test.ts +17 -0
  193. package/packages/shared/src/__tests__/server-identity.test.ts +73 -0
  194. package/packages/shared/src/__tests__/session-meta.test.ts +125 -0
  195. package/packages/shared/src/archive-types.ts +11 -0
  196. package/packages/shared/src/browser-protocol.ts +534 -0
  197. package/packages/shared/src/config.ts +245 -0
  198. package/packages/shared/src/diff-types.ts +41 -0
  199. package/packages/shared/src/editor-types.ts +18 -0
  200. package/packages/shared/src/mdns-discovery.ts +248 -0
  201. package/packages/shared/src/openspec-activity-detector.ts +109 -0
  202. package/packages/shared/src/openspec-poller.ts +96 -0
  203. package/packages/shared/src/protocol.ts +369 -0
  204. package/packages/shared/src/resolve-jiti.ts +43 -0
  205. package/packages/shared/src/rest-api.ts +255 -0
  206. package/packages/shared/src/server-identity.ts +51 -0
  207. package/packages/shared/src/session-meta.ts +86 -0
  208. package/packages/shared/src/state-replay.ts +174 -0
  209. package/packages/shared/src/stats-extractor.ts +54 -0
  210. package/packages/shared/src/terminal-types.ts +18 -0
  211. package/packages/shared/src/types.ts +351 -0
  212. package/packages/shared/tsconfig.json +8 -0
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { PackageManagerWrapper, PackageOperationBusyError } from "../package-manager-wrapper.js";
3
+
4
+ // Track mock functions
5
+ const installAndPersist = vi.fn().mockResolvedValue(undefined);
6
+ const removeAndPersist = vi.fn().mockResolvedValue(undefined);
7
+ const update = vi.fn().mockResolvedValue(undefined);
8
+ const listConfiguredPackages = vi.fn().mockReturnValue([
9
+ { source: "npm:pi-doom", scope: "user", filtered: false },
10
+ { source: "npm:pi-local", scope: "project", filtered: false },
11
+ ]);
12
+ const checkForAvailableUpdates = vi.fn().mockResolvedValue([
13
+ { source: "npm:pi-doom", displayName: "pi-doom", type: "npm" },
14
+ ]);
15
+ const setProgressCallback = vi.fn();
16
+
17
+ vi.mock("@mariozechner/pi-coding-agent", () => {
18
+ const MockPM = function() {
19
+ return {
20
+ installAndPersist,
21
+ removeAndPersist,
22
+ update,
23
+ listConfiguredPackages,
24
+ checkForAvailableUpdates,
25
+ setProgressCallback,
26
+ };
27
+ };
28
+ return {
29
+ DefaultPackageManager: MockPM,
30
+ SettingsManager: { create: () => ({}) },
31
+ default: undefined,
32
+ };
33
+ });
34
+
35
+ describe("PackageManagerWrapper", () => {
36
+ let wrapper: PackageManagerWrapper;
37
+
38
+ beforeEach(() => {
39
+ installAndPersist.mockReset().mockResolvedValue(undefined);
40
+ removeAndPersist.mockReset().mockResolvedValue(undefined);
41
+ update.mockReset().mockResolvedValue(undefined);
42
+ listConfiguredPackages.mockReset().mockReturnValue([
43
+ { source: "npm:pi-doom", scope: "user", filtered: false },
44
+ { source: "npm:pi-local", scope: "project", filtered: false },
45
+ ]);
46
+ checkForAvailableUpdates.mockReset().mockResolvedValue([
47
+ { source: "npm:pi-doom", displayName: "pi-doom", type: "npm" },
48
+ ]);
49
+ setProgressCallback.mockReset();
50
+ wrapper = new PackageManagerWrapper();
51
+ });
52
+
53
+ it("returns operationId on run", async () => {
54
+ const id = await wrapper.run({ action: "install", source: "npm:test", scope: "global" });
55
+ expect(id).toMatch(/^[0-9a-f-]+$/);
56
+ });
57
+
58
+ it("throws PackageOperationBusyError on concurrent operations", async () => {
59
+ let resolveInstall!: () => void;
60
+ installAndPersist.mockImplementation(() => new Promise<void>((r) => { resolveInstall = r; }));
61
+
62
+ await wrapper.run({ action: "install", source: "npm:a", scope: "global" });
63
+ // Wait for the dynamic import + installAndPersist to be called
64
+ await vi.waitFor(() => expect(installAndPersist).toHaveBeenCalled());
65
+
66
+ await expect(
67
+ wrapper.run({ action: "install", source: "npm:b", scope: "global" }),
68
+ ).rejects.toThrow(PackageOperationBusyError);
69
+
70
+ resolveInstall();
71
+ await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
72
+ });
73
+
74
+ it("forwards progress events via listener", async () => {
75
+ const progressEvents: any[] = [];
76
+
77
+ let capturedCallback: any;
78
+ setProgressCallback.mockImplementation((cb: any) => { capturedCallback = cb; });
79
+ installAndPersist.mockImplementation(async () => {
80
+ capturedCallback?.({ type: "start", action: "install", source: "npm:test" });
81
+ capturedCallback?.({ type: "complete", action: "install", source: "npm:test" });
82
+ });
83
+
84
+ wrapper.setProgressListener((opId, event) => {
85
+ progressEvents.push({ opId, event });
86
+ });
87
+
88
+ const opId = await wrapper.run({ action: "install", source: "npm:test", scope: "global" });
89
+ await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
90
+
91
+ expect(progressEvents.length).toBe(2);
92
+ expect(progressEvents[0].opId).toBe(opId);
93
+ expect(progressEvents[0].event.type).toBe("start");
94
+ expect(progressEvents[1].event.type).toBe("complete");
95
+ });
96
+
97
+ it("calls reloadSessions on success", async () => {
98
+ const reloadFn = vi.fn().mockResolvedValue(3);
99
+ wrapper.setReloadSessions(reloadFn);
100
+
101
+ const completions: any[] = [];
102
+ wrapper.setCompleteListener((result) => completions.push(result));
103
+
104
+ await wrapper.run({ action: "install", source: "npm:test", scope: "global" });
105
+ await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
106
+
107
+ expect(reloadFn).toHaveBeenCalledOnce();
108
+ expect(completions[0].success).toBe(true);
109
+ expect(completions[0].sessionsReloaded).toBe(3);
110
+ });
111
+
112
+ it("does NOT call reloadSessions on failure", async () => {
113
+ installAndPersist.mockRejectedValue(new Error("npm exploded"));
114
+
115
+ const reloadFn = vi.fn().mockResolvedValue(0);
116
+ wrapper.setReloadSessions(reloadFn);
117
+
118
+ const completions: any[] = [];
119
+ wrapper.setCompleteListener((result) => completions.push(result));
120
+
121
+ await wrapper.run({ action: "install", source: "npm:test", scope: "global" });
122
+ await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
123
+
124
+ expect(reloadFn).not.toHaveBeenCalled();
125
+ expect(completions[0].success).toBe(false);
126
+ expect(completions[0].error).toBe("npm exploded");
127
+ });
128
+
129
+ it("listInstalled filters by scope", async () => {
130
+ const global = await wrapper.listInstalled("global");
131
+ expect(global).toEqual([{ source: "npm:pi-doom", scope: "user", filtered: false }]);
132
+
133
+ const local = await wrapper.listInstalled("local");
134
+ expect(local).toEqual([{ source: "npm:pi-local", scope: "project", filtered: false }]);
135
+ });
136
+
137
+ it("checkUpdates delegates to PackageManager", async () => {
138
+ const updates = await wrapper.checkUpdates();
139
+ expect(updates).toEqual([{ source: "npm:pi-doom", displayName: "pi-doom", type: "npm" }]);
140
+ });
141
+
142
+ it("calls remove for remove action", async () => {
143
+ const completions: any[] = [];
144
+ wrapper.setCompleteListener((result) => completions.push(result));
145
+
146
+ await wrapper.run({ action: "remove", source: "npm:test", scope: "local", cwd: "/tmp" });
147
+ await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
148
+
149
+ expect(removeAndPersist).toHaveBeenCalledWith("npm:test", { local: true });
150
+ expect(completions[0].success).toBe(true);
151
+ });
152
+
153
+ it("calls update for update action", async () => {
154
+ const completions: any[] = [];
155
+ wrapper.setCompleteListener((result) => completions.push(result));
156
+
157
+ await wrapper.run({ action: "update", source: "npm:test", scope: "global" });
158
+ await vi.waitFor(() => expect(wrapper.isBusy()).toBe(false));
159
+
160
+ expect(update).toHaveBeenCalledWith("npm:test");
161
+ expect(completions[0].success).toBe(true);
162
+ });
163
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Tests for package management REST routes.
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
+ import Fastify from "fastify";
6
+ import type { FastifyInstance } from "fastify";
7
+ import { registerPackageRoutes } from "../routes/package-routes.js";
8
+ import { PackageOperationBusyError } from "../package-manager-wrapper.js";
9
+
10
+ // Mock pi dependency (pulled transitively by package-manager-wrapper)
11
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
12
+ DefaultPackageManager: function() { return {}; },
13
+ SettingsManager: { create: () => ({}) },
14
+ }));
15
+
16
+ // Mock npm-search-proxy
17
+ vi.mock("../npm-search-proxy.js", () => ({
18
+ searchPackages: vi.fn().mockResolvedValue({ packages: [{ name: "pi-doom", types: ["extension"] }], total: 1 }),
19
+ fetchReadme: vi.fn().mockResolvedValue({ readme: "# Test", name: "pi-doom", version: "1.0.0" }),
20
+ PackageNotFoundError: class PackageNotFoundError extends Error {
21
+ constructor(name: string) { super(`Package not found: ${name}`); this.name = "PackageNotFoundError"; }
22
+ },
23
+ }));
24
+
25
+ import { searchPackages, fetchReadme, PackageNotFoundError } from "../npm-search-proxy.js";
26
+
27
+ function createMockWrapper() {
28
+ return {
29
+ run: vi.fn().mockResolvedValue("op-123"),
30
+ listInstalled: vi.fn().mockReturnValue([{ source: "npm:pi-doom", scope: "user", filtered: false }]),
31
+ checkUpdates: vi.fn().mockResolvedValue([]),
32
+ isBusy: vi.fn().mockReturnValue(false),
33
+ setProgressListener: vi.fn(),
34
+ setCompleteListener: vi.fn(),
35
+ setReloadSessions: vi.fn(),
36
+ } as any;
37
+ }
38
+
39
+ describe("package-routes", () => {
40
+ let app: FastifyInstance;
41
+ let wrapper: ReturnType<typeof createMockWrapper>;
42
+
43
+ beforeEach(async () => {
44
+ vi.clearAllMocks();
45
+ wrapper = createMockWrapper();
46
+ app = Fastify();
47
+ registerPackageRoutes(app, { packageManagerWrapper: wrapper });
48
+ await app.ready();
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await app.close();
53
+ });
54
+
55
+ describe("GET /api/packages/search", () => {
56
+ it("returns search results", async () => {
57
+ const res = await app.inject({ method: "GET", url: "/api/packages/search?q=doom" });
58
+ expect(res.statusCode).toBe(200);
59
+ const body = JSON.parse(res.body);
60
+ expect(body.success).toBe(true);
61
+ expect(body.data.packages[0].name).toBe("pi-doom");
62
+ });
63
+
64
+ it("passes type filter", async () => {
65
+ await app.inject({ method: "GET", url: "/api/packages/search?type=extension" });
66
+ expect(searchPackages).toHaveBeenCalledWith({ query: undefined, type: "extension" });
67
+ });
68
+ });
69
+
70
+ describe("GET /api/packages/readme", () => {
71
+ it("returns readme", async () => {
72
+ const res = await app.inject({ method: "GET", url: "/api/packages/readme?pkg=pi-doom" });
73
+ expect(res.statusCode).toBe(200);
74
+ const body = JSON.parse(res.body);
75
+ expect(body.data.readme).toBe("# Test");
76
+ });
77
+
78
+ it("returns 400 without pkg param", async () => {
79
+ const res = await app.inject({ method: "GET", url: "/api/packages/readme" });
80
+ expect(res.statusCode).toBe(400);
81
+ });
82
+
83
+ it("returns 404 for missing package", async () => {
84
+ vi.mocked(fetchReadme).mockRejectedValueOnce(new PackageNotFoundError("x"));
85
+ const res = await app.inject({ method: "GET", url: "/api/packages/readme?pkg=x" });
86
+ expect(res.statusCode).toBe(404);
87
+ });
88
+ });
89
+
90
+ describe("GET /api/packages/installed", () => {
91
+ it("returns installed packages", async () => {
92
+ const res = await app.inject({ method: "GET", url: "/api/packages/installed?scope=global" });
93
+ expect(res.statusCode).toBe(200);
94
+ const body = JSON.parse(res.body);
95
+ expect(body.data[0].source).toBe("npm:pi-doom");
96
+ });
97
+ });
98
+
99
+ describe("POST /api/packages/install", () => {
100
+ it("returns 202 with operationId", async () => {
101
+ const res = await app.inject({
102
+ method: "POST",
103
+ url: "/api/packages/install",
104
+ payload: { source: "npm:test", scope: "global" },
105
+ });
106
+ expect(res.statusCode).toBe(202);
107
+ const body = JSON.parse(res.body);
108
+ expect(body.data.operationId).toBe("op-123");
109
+ });
110
+
111
+ it("returns 400 without source", async () => {
112
+ const res = await app.inject({
113
+ method: "POST",
114
+ url: "/api/packages/install",
115
+ payload: { scope: "global" },
116
+ });
117
+ expect(res.statusCode).toBe(400);
118
+ });
119
+
120
+ it("returns 409 when busy", async () => {
121
+ wrapper.run.mockRejectedValueOnce(new PackageOperationBusyError());
122
+ const res = await app.inject({
123
+ method: "POST",
124
+ url: "/api/packages/install",
125
+ payload: { source: "npm:test", scope: "global" },
126
+ });
127
+ expect(res.statusCode).toBe(409);
128
+ });
129
+ });
130
+
131
+ describe("POST /api/packages/remove", () => {
132
+ it("returns 202", async () => {
133
+ const res = await app.inject({
134
+ method: "POST",
135
+ url: "/api/packages/remove",
136
+ payload: { source: "npm:test", scope: "local", cwd: "/tmp" },
137
+ });
138
+ expect(res.statusCode).toBe(202);
139
+ expect(wrapper.run).toHaveBeenCalledWith({
140
+ action: "remove",
141
+ source: "npm:test",
142
+ scope: "local",
143
+ cwd: "/tmp",
144
+ });
145
+ });
146
+ });
147
+
148
+ describe("POST /api/packages/update", () => {
149
+ it("returns 202", async () => {
150
+ const res = await app.inject({
151
+ method: "POST",
152
+ url: "/api/packages/update",
153
+ payload: { scope: "global" },
154
+ });
155
+ expect(res.statusCode).toBe(202);
156
+ });
157
+ });
158
+
159
+ describe("POST /api/packages/check-updates", () => {
160
+ it("returns updates list", async () => {
161
+ wrapper.checkUpdates.mockResolvedValueOnce([{ source: "npm:pi-doom", displayName: "pi-doom", type: "npm" }]);
162
+ const res = await app.inject({
163
+ method: "POST",
164
+ url: "/api/packages/check-updates",
165
+ payload: {},
166
+ });
167
+ expect(res.statusCode).toBe(200);
168
+ const body = JSON.parse(res.body);
169
+ expect(body.data[0].source).toBe("npm:pi-doom");
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createPendingForkRegistry } from "../pending-fork-registry.js";
3
+
4
+ describe("PendingForkRegistry", () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ });
8
+
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ it("records and consumes a fork", () => {
14
+ const reg = createPendingForkRegistry();
15
+ reg.recordFork("/project", "parent-1");
16
+ const result = reg.consumeFork("/project");
17
+ expect(result).toBe("parent-1");
18
+ });
19
+
20
+ it("returns undefined when no fork pending", () => {
21
+ const reg = createPendingForkRegistry();
22
+ expect(reg.consumeFork("/project")).toBeUndefined();
23
+ });
24
+
25
+ it("consume clears the entry", () => {
26
+ const reg = createPendingForkRegistry();
27
+ reg.recordFork("/project", "parent-1");
28
+ reg.consumeFork("/project");
29
+ expect(reg.consumeFork("/project")).toBeUndefined();
30
+ });
31
+
32
+ it("expires after 30 seconds", () => {
33
+ const reg = createPendingForkRegistry();
34
+ reg.recordFork("/project", "parent-1");
35
+ vi.advanceTimersByTime(30_001);
36
+ expect(reg.consumeFork("/project")).toBeUndefined();
37
+ });
38
+
39
+ it("does not expire before 30 seconds", () => {
40
+ const reg = createPendingForkRegistry();
41
+ reg.recordFork("/project", "parent-1");
42
+ vi.advanceTimersByTime(29_999);
43
+ expect(reg.consumeFork("/project")).toBe("parent-1");
44
+ });
45
+
46
+ it("latest fork overwrites previous for same cwd", () => {
47
+ const reg = createPendingForkRegistry();
48
+ reg.recordFork("/project", "parent-1");
49
+ reg.recordFork("/project", "parent-2");
50
+ expect(reg.consumeFork("/project")).toBe("parent-2");
51
+ });
52
+
53
+ it("different cwds are independent", () => {
54
+ const reg = createPendingForkRegistry();
55
+ reg.recordFork("/a", "parent-a");
56
+ reg.recordFork("/b", "parent-b");
57
+ expect(reg.consumeFork("/a")).toBe("parent-a");
58
+ expect(reg.consumeFork("/b")).toBe("parent-b");
59
+ });
60
+
61
+ it("dispose clears all timers", () => {
62
+ const reg = createPendingForkRegistry();
63
+ reg.recordFork("/a", "parent-a");
64
+ reg.recordFork("/b", "parent-b");
65
+ reg.dispose();
66
+ expect(reg.consumeFork("/a")).toBeUndefined();
67
+ expect(reg.consumeFork("/b")).toBeUndefined();
68
+ });
69
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createPendingLoadManager } from "../pending-load-manager.js";
3
+
4
+ // Minimal WebSocket mock
5
+ function mockWs(): any {
6
+ return { readyState: 1 };
7
+ }
8
+
9
+ describe("pending-load-manager", () => {
10
+ beforeEach(() => vi.useFakeTimers());
11
+ afterEach(() => vi.useRealTimers());
12
+
13
+ it("starts and completes a load", () => {
14
+ const onTimeout = vi.fn();
15
+ const plm = createPendingLoadManager(onTimeout);
16
+ const ws = mockWs();
17
+
18
+ expect(plm.start("s1", ws, "bridge1")).toBe(true);
19
+ expect(plm.isPending("s1")).toBe(true);
20
+
21
+ const browsers = plm.complete("s1");
22
+ expect(browsers).toBeDefined();
23
+ expect(browsers!.has(ws)).toBe(true);
24
+ expect(plm.isPending("s1")).toBe(false);
25
+ plm.dispose();
26
+ });
27
+
28
+ it("deduplicates — second start returns false", () => {
29
+ const onTimeout = vi.fn();
30
+ const plm = createPendingLoadManager(onTimeout);
31
+
32
+ plm.start("s1", mockWs(), "bridge1");
33
+ expect(plm.start("s1", mockWs(), "bridge1")).toBe(false);
34
+ plm.dispose();
35
+ });
36
+
37
+ it("addBrowser adds to existing pending load", () => {
38
+ const onTimeout = vi.fn();
39
+ const plm = createPendingLoadManager(onTimeout);
40
+ const ws1 = mockWs();
41
+ const ws2 = mockWs();
42
+
43
+ plm.start("s1", ws1, "bridge1");
44
+ expect(plm.addBrowser("s1", ws2)).toBe(true);
45
+
46
+ const browsers = plm.complete("s1");
47
+ expect(browsers!.size).toBe(2);
48
+ expect(browsers!.has(ws1)).toBe(true);
49
+ expect(browsers!.has(ws2)).toBe(true);
50
+ plm.dispose();
51
+ });
52
+
53
+ it("addBrowser returns false when no pending load", () => {
54
+ const onTimeout = vi.fn();
55
+ const plm = createPendingLoadManager(onTimeout);
56
+ expect(plm.addBrowser("s1", mockWs())).toBe(false);
57
+ plm.dispose();
58
+ });
59
+
60
+ it("fires timeout callback after timeout", () => {
61
+ const onTimeout = vi.fn();
62
+ const plm = createPendingLoadManager(onTimeout, 5000);
63
+ const ws = mockWs();
64
+
65
+ plm.start("s1", ws, "bridge1");
66
+ vi.advanceTimersByTime(5100);
67
+
68
+ expect(onTimeout).toHaveBeenCalledWith("s1", expect.any(Set));
69
+ expect(plm.isPending("s1")).toBe(false);
70
+ plm.dispose();
71
+ });
72
+
73
+ it("complete cancels timeout", () => {
74
+ const onTimeout = vi.fn();
75
+ const plm = createPendingLoadManager(onTimeout, 5000);
76
+
77
+ plm.start("s1", mockWs(), "bridge1");
78
+ plm.complete("s1");
79
+ vi.advanceTimersByTime(6000);
80
+
81
+ expect(onTimeout).not.toHaveBeenCalled();
82
+ plm.dispose();
83
+ });
84
+
85
+ it("cancelForBridge cancels all loads for that bridge", () => {
86
+ const onTimeout = vi.fn();
87
+ const plm = createPendingLoadManager(onTimeout);
88
+ const ws1 = mockWs();
89
+ const ws2 = mockWs();
90
+
91
+ plm.start("s1", ws1, "bridge-a");
92
+ plm.start("s2", ws2, "bridge-a");
93
+ plm.start("s3", mockWs(), "bridge-b");
94
+
95
+ const cancelled = plm.cancelForBridge("bridge-a");
96
+ expect(cancelled.size).toBe(2);
97
+ expect(cancelled.has("s1")).toBe(true);
98
+ expect(cancelled.has("s2")).toBe(true);
99
+ expect(plm.isPending("s1")).toBe(false);
100
+ expect(plm.isPending("s2")).toBe(false);
101
+ expect(plm.isPending("s3")).toBe(true);
102
+
103
+ plm.dispose();
104
+ });
105
+
106
+ it("cancel removes a specific pending load", () => {
107
+ const onTimeout = vi.fn();
108
+ const plm = createPendingLoadManager(onTimeout);
109
+ const ws = mockWs();
110
+
111
+ plm.start("s1", ws, "bridge1");
112
+ const browsers = plm.cancel("s1");
113
+ expect(browsers).toBeDefined();
114
+ expect(browsers!.has(ws)).toBe(true);
115
+ expect(plm.isPending("s1")).toBe(false);
116
+ plm.dispose();
117
+ });
118
+
119
+ it("cancel returns null for nonexistent load", () => {
120
+ const onTimeout = vi.fn();
121
+ const plm = createPendingLoadManager(onTimeout);
122
+ expect(plm.cancel("s1")).toBeNull();
123
+ plm.dispose();
124
+ });
125
+
126
+ it("complete returns null for nonexistent load", () => {
127
+ const onTimeout = vi.fn();
128
+ const plm = createPendingLoadManager(onTimeout);
129
+ expect(plm.complete("nonexistent")).toBeNull();
130
+ plm.dispose();
131
+ });
132
+
133
+ it("dispose clears all pending loads and timers", () => {
134
+ const onTimeout = vi.fn();
135
+ const plm = createPendingLoadManager(onTimeout, 5000);
136
+
137
+ plm.start("s1", mockWs(), "bridge1");
138
+ plm.start("s2", mockWs(), "bridge1");
139
+ plm.dispose();
140
+
141
+ vi.advanceTimersByTime(6000);
142
+ expect(onTimeout).not.toHaveBeenCalled();
143
+ });
144
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createPendingResumeRegistry } from "../pending-resume-registry.js";
3
+
4
+ describe("PendingResumeRegistry", () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ });
8
+
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+
13
+ it("records and consumes a pending resume", () => {
14
+ const reg = createPendingResumeRegistry();
15
+ reg.record("/project", {
16
+ text: "fix the bug",
17
+ oldSessionId: "old-1",
18
+ sessionFile: "session.jsonl",
19
+ });
20
+ const result = reg.consume("/project");
21
+ expect(result).toEqual({
22
+ text: "fix the bug",
23
+ oldSessionId: "old-1",
24
+ sessionFile: "session.jsonl",
25
+ });
26
+ });
27
+
28
+ it("returns undefined when no pending resume", () => {
29
+ const reg = createPendingResumeRegistry();
30
+ expect(reg.consume("/project")).toBeUndefined();
31
+ });
32
+
33
+ it("consume clears the entry", () => {
34
+ const reg = createPendingResumeRegistry();
35
+ reg.record("/project", {
36
+ text: "fix it",
37
+ oldSessionId: "old-1",
38
+ sessionFile: "session.jsonl",
39
+ });
40
+ reg.consume("/project");
41
+ expect(reg.consume("/project")).toBeUndefined();
42
+ });
43
+
44
+ it("expires after 30 seconds", () => {
45
+ const onTimeout = vi.fn();
46
+ const reg = createPendingResumeRegistry({ onTimeout });
47
+ reg.record("/project", {
48
+ text: "fix it",
49
+ oldSessionId: "old-1",
50
+ sessionFile: "session.jsonl",
51
+ });
52
+ vi.advanceTimersByTime(30_001);
53
+ expect(reg.consume("/project")).toBeUndefined();
54
+ expect(onTimeout).toHaveBeenCalledWith("old-1");
55
+ });
56
+
57
+ it("does not expire before 30 seconds", () => {
58
+ const reg = createPendingResumeRegistry();
59
+ reg.record("/project", {
60
+ text: "fix it",
61
+ oldSessionId: "old-1",
62
+ sessionFile: "session.jsonl",
63
+ });
64
+ vi.advanceTimersByTime(29_999);
65
+ expect(reg.consume("/project")).toBeDefined();
66
+ });
67
+
68
+ it("latest entry overwrites previous for same cwd", () => {
69
+ const reg = createPendingResumeRegistry();
70
+ reg.record("/project", {
71
+ text: "first prompt",
72
+ oldSessionId: "old-1",
73
+ sessionFile: "session.jsonl",
74
+ });
75
+ reg.record("/project", {
76
+ text: "second prompt",
77
+ oldSessionId: "old-2",
78
+ sessionFile: "session2.jsonl",
79
+ });
80
+ const result = reg.consume("/project");
81
+ expect(result?.text).toBe("second prompt");
82
+ expect(result?.oldSessionId).toBe("old-2");
83
+ });
84
+
85
+ it("different cwds are independent", () => {
86
+ const reg = createPendingResumeRegistry();
87
+ reg.record("/a", { text: "prompt-a", oldSessionId: "old-a", sessionFile: "a.jsonl" });
88
+ reg.record("/b", { text: "prompt-b", oldSessionId: "old-b", sessionFile: "b.jsonl" });
89
+ expect(reg.consume("/a")?.text).toBe("prompt-a");
90
+ expect(reg.consume("/b")?.text).toBe("prompt-b");
91
+ });
92
+
93
+ it("preserves images in pending resume", () => {
94
+ const reg = createPendingResumeRegistry();
95
+ const images = [{ type: "image" as const, data: "base64data", mimeType: "image/png" }];
96
+ reg.record("/project", {
97
+ text: "look at this",
98
+ images,
99
+ oldSessionId: "old-1",
100
+ sessionFile: "session.jsonl",
101
+ });
102
+ const result = reg.consume("/project");
103
+ expect(result?.images).toEqual(images);
104
+ });
105
+
106
+ it("dispose clears all entries and timers", () => {
107
+ const onTimeout = vi.fn();
108
+ const reg = createPendingResumeRegistry({ onTimeout });
109
+ reg.record("/a", { text: "a", oldSessionId: "old-a", sessionFile: "a.jsonl" });
110
+ reg.record("/b", { text: "b", oldSessionId: "old-b", sessionFile: "b.jsonl" });
111
+ reg.dispose();
112
+ expect(reg.consume("/a")).toBeUndefined();
113
+ expect(reg.consume("/b")).toBeUndefined();
114
+ // Timers should not fire after dispose
115
+ vi.advanceTimersByTime(31_000);
116
+ expect(onTimeout).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("overwrite clears previous timer", () => {
120
+ const onTimeout = vi.fn();
121
+ const reg = createPendingResumeRegistry({ onTimeout });
122
+ reg.record("/project", { text: "first", oldSessionId: "old-1", sessionFile: "s.jsonl" });
123
+ vi.advanceTimersByTime(20_000);
124
+ reg.record("/project", { text: "second", oldSessionId: "old-2", sessionFile: "s.jsonl" });
125
+ vi.advanceTimersByTime(15_000); // 35s from first, 15s from second
126
+ // First timer would have fired at 30s, but was cleared by overwrite
127
+ expect(onTimeout).not.toHaveBeenCalled();
128
+ expect(reg.consume("/project")?.text).toBe("second");
129
+ });
130
+ });