@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Tests for `changelog-remote.ts`.
3
+ *
4
+ * All tests use mocked fetch — never hit live raw.githubusercontent.com.
5
+ *
6
+ * See change: read-changelog-from-github.
7
+ */
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
9
+ import {
10
+ deriveChangelogRawUrl,
11
+ fetchRemoteChangelog,
12
+ } from "../changelog-remote.js";
13
+
14
+ describe("deriveChangelogRawUrl", () => {
15
+ it("derives raw URL from string repository", () => {
16
+ expect(deriveChangelogRawUrl("https://github.com/badlogic/pi-mono.git")).toBe(
17
+ "https://raw.githubusercontent.com/badlogic/pi-mono/main/CHANGELOG.md",
18
+ );
19
+ });
20
+
21
+ it("honours monorepo `directory` subfield", () => {
22
+ expect(
23
+ deriveChangelogRawUrl({
24
+ type: "git",
25
+ url: "git+https://github.com/badlogic/pi-mono.git",
26
+ directory: "packages/coding-agent",
27
+ }),
28
+ ).toBe(
29
+ "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/CHANGELOG.md",
30
+ );
31
+ });
32
+
33
+ it("strips leading/trailing slashes from directory", () => {
34
+ expect(
35
+ deriveChangelogRawUrl({
36
+ url: "https://github.com/org/repo.git",
37
+ directory: "/packages/foo/",
38
+ }),
39
+ ).toBe("https://raw.githubusercontent.com/org/repo/main/packages/foo/CHANGELOG.md");
40
+ });
41
+
42
+ it("supports github:org/repo shorthand", () => {
43
+ expect(deriveChangelogRawUrl("github:org/repo")).toBe(
44
+ "https://raw.githubusercontent.com/org/repo/main/CHANGELOG.md",
45
+ );
46
+ });
47
+
48
+ it("supports ssh form git@github.com:org/repo.git", () => {
49
+ expect(deriveChangelogRawUrl("git@github.com:org/repo.git")).toBe(
50
+ "https://raw.githubusercontent.com/org/repo/main/CHANGELOG.md",
51
+ );
52
+ });
53
+
54
+ it("returns null for non-GitHub repos", () => {
55
+ expect(deriveChangelogRawUrl("https://gitlab.com/org/repo.git")).toBeNull();
56
+ expect(deriveChangelogRawUrl({ url: "https://bitbucket.org/x/y" })).toBeNull();
57
+ });
58
+
59
+ it("returns null for missing/malformed input", () => {
60
+ expect(deriveChangelogRawUrl(undefined)).toBeNull();
61
+ expect(deriveChangelogRawUrl(null)).toBeNull();
62
+ expect(deriveChangelogRawUrl({})).toBeNull();
63
+ expect(deriveChangelogRawUrl({ url: "" })).toBeNull();
64
+ expect(deriveChangelogRawUrl(42)).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe("fetchRemoteChangelog", () => {
69
+ let originalOffline: string | undefined;
70
+
71
+ beforeEach(() => {
72
+ originalOffline = process.env.PI_OFFLINE;
73
+ delete process.env.PI_OFFLINE;
74
+ });
75
+ afterEach(() => {
76
+ if (originalOffline !== undefined) process.env.PI_OFFLINE = originalOffline;
77
+ else delete process.env.PI_OFFLINE;
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ it("returns ok with text + etag on 200", async () => {
82
+ const fetchImpl = vi.fn().mockResolvedValue({
83
+ ok: true,
84
+ status: 200,
85
+ headers: new Headers({ etag: '"abc123"' }),
86
+ text: async () => "# Changelog\n\n## [1.0.0] - 2026-01-01\n",
87
+ });
88
+ const out = await fetchRemoteChangelog(
89
+ "https://raw.githubusercontent.com/x/y/main/CHANGELOG.md",
90
+ { fetchImpl },
91
+ );
92
+ expect(out).toEqual({
93
+ status: "ok",
94
+ text: expect.stringContaining("[1.0.0]"),
95
+ etag: '"abc123"',
96
+ });
97
+ });
98
+
99
+ it("returns not-modified on 304", async () => {
100
+ const fetchImpl = vi.fn().mockResolvedValue({
101
+ ok: false,
102
+ status: 304,
103
+ headers: new Headers(),
104
+ text: async () => "",
105
+ });
106
+ const out = await fetchRemoteChangelog(
107
+ "https://raw.githubusercontent.com/x/y/main/CHANGELOG.md",
108
+ { fetchImpl, etag: '"abc123"' },
109
+ );
110
+ expect(out).toEqual({ status: "not-modified" });
111
+ });
112
+
113
+ it("sends If-None-Match when etag provided", async () => {
114
+ let captured: Record<string, string> | undefined;
115
+ const fetchImpl = vi.fn().mockImplementation((_url: string, init: any) => {
116
+ captured = init.headers as Record<string, string>;
117
+ return Promise.resolve({
118
+ ok: true,
119
+ status: 200,
120
+ headers: new Headers(),
121
+ text: async () => "ok",
122
+ });
123
+ });
124
+ await fetchRemoteChangelog("https://raw.githubusercontent.com/x/y/main/CHANGELOG.md", {
125
+ fetchImpl,
126
+ etag: '"abc123"',
127
+ });
128
+ expect(captured?.["If-None-Match"]).toBe('"abc123"');
129
+ });
130
+
131
+ it("returns null on non-2xx (excluding 304)", async () => {
132
+ const fetchImpl = vi.fn().mockResolvedValue({
133
+ ok: false,
134
+ status: 404,
135
+ headers: new Headers(),
136
+ text: async () => "Not Found",
137
+ });
138
+ const out = await fetchRemoteChangelog(
139
+ "https://raw.githubusercontent.com/x/y/main/CHANGELOG.md",
140
+ { fetchImpl },
141
+ );
142
+ expect(out).toBeNull();
143
+ });
144
+
145
+ it("returns null on network error", async () => {
146
+ const fetchImpl = vi.fn().mockRejectedValue(new Error("ECONNRESET"));
147
+ const out = await fetchRemoteChangelog(
148
+ "https://raw.githubusercontent.com/x/y/main/CHANGELOG.md",
149
+ { fetchImpl },
150
+ );
151
+ expect(out).toBeNull();
152
+ });
153
+
154
+ it("returns null on empty text body", async () => {
155
+ const fetchImpl = vi.fn().mockResolvedValue({
156
+ ok: true,
157
+ status: 200,
158
+ headers: new Headers(),
159
+ text: async () => "",
160
+ });
161
+ expect(
162
+ await fetchRemoteChangelog(
163
+ "https://raw.githubusercontent.com/x/y/main/CHANGELOG.md",
164
+ { fetchImpl },
165
+ ),
166
+ ).toBeNull();
167
+ });
168
+
169
+ it("skips fetch entirely when PI_OFFLINE is set", async () => {
170
+ process.env.PI_OFFLINE = "1";
171
+ const fetchImpl = vi.fn();
172
+ const out = await fetchRemoteChangelog(
173
+ "https://raw.githubusercontent.com/x/y/main/CHANGELOG.md",
174
+ { fetchImpl },
175
+ );
176
+ expect(out).toBeNull();
177
+ expect(fetchImpl).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("propagates etag null when response has no ETag header", async () => {
181
+ const fetchImpl = vi.fn().mockResolvedValue({
182
+ ok: true,
183
+ status: 200,
184
+ headers: new Headers(),
185
+ text: async () => "ok",
186
+ });
187
+ const out = await fetchRemoteChangelog(
188
+ "https://raw.githubusercontent.com/x/y/main/CHANGELOG.md",
189
+ { fetchImpl },
190
+ );
191
+ expect(out).toEqual({ status: "ok", text: "ok", etag: null });
192
+ });
193
+ });
@@ -76,9 +76,27 @@ describe("parseArgs", () => {
76
76
  });
77
77
 
78
78
  describe("daemon spawn jiti resolution", () => {
79
- it("resolveJitiImport throws outside of pi context", async () => {
80
- // In test context (no pi/jiti loader), peer deps aren't resolvable
81
- const { resolveJitiImport } = await import("@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js");
82
- expect(() => resolveJitiImport()).toThrow("Cannot find pi's TypeScript loader");
79
+ it("resolveJitiImport either returns a file:// URL or throws the documented error", async () => {
80
+ // After change `support-upstream-jiti-resolution`, the resolver
81
+ // also looks for upstream `jiti` (bare name). Vitest itself depends
82
+ // transitively on `jiti`, so when the test runner is the anchor,
83
+ // resolution may succeed. Either outcome is valid — we just assert
84
+ // the contract: success returns a `file://` URL, failure throws the
85
+ // documented error.
86
+ const { resolveJitiImport } = await import(
87
+ "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js"
88
+ );
89
+ let url: string | undefined;
90
+ let err: Error | undefined;
91
+ try {
92
+ url = resolveJitiImport();
93
+ } catch (e) {
94
+ err = e as Error;
95
+ }
96
+ if (url !== undefined) {
97
+ expect(url.startsWith("file://")).toBe(true);
98
+ } else {
99
+ expect(err?.message).toContain("Cannot find pi's TypeScript loader");
100
+ }
83
101
  });
84
102
  });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Cold-boot OpenSpec broadcast — bootstrap initial poll must broadcast
3
+ * `openspec_update` to connected browsers when the prior cache was
4
+ * empty/undefined or the polled data differs from prior.
5
+ *
6
+ * Mirrors `post-install-openspec-refresh.test.ts` contract for the
7
+ * bootstrap path. See change: fix-cold-boot-openspec-protocol.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+ import { discoverAndBroadcastSessions } from "../session-bootstrap.js";
11
+
12
+ interface SpyDirSvc {
13
+ knownDirectories: ReturnType<typeof vi.fn>;
14
+ discoverSessions: ReturnType<typeof vi.fn>;
15
+ getOpenSpecData: ReturnType<typeof vi.fn>;
16
+ refreshOpenSpec: ReturnType<typeof vi.fn>;
17
+ startPolling: ReturnType<typeof vi.fn>;
18
+ }
19
+
20
+ function stubPolling(): { startPolling: ReturnType<typeof vi.fn> } {
21
+ return { startPolling: vi.fn() };
22
+ }
23
+ interface SpySessionMgr {
24
+ get: ReturnType<typeof vi.fn>;
25
+ restore: ReturnType<typeof vi.fn>;
26
+ }
27
+ interface SpyGateway {
28
+ broadcastToAll: ReturnType<typeof vi.fn>;
29
+ broadcastSessionAdded: ReturnType<typeof vi.fn>;
30
+ }
31
+
32
+ function makeSessionMgr(): SpySessionMgr {
33
+ return { get: vi.fn(() => undefined), restore: vi.fn() };
34
+ }
35
+ function makeGateway(): SpyGateway {
36
+ return { broadcastToAll: vi.fn(), broadcastSessionAdded: vi.fn() };
37
+ }
38
+
39
+ /**
40
+ * `discoverAndBroadcastSessions` fires the openspec poll fire-and-forget
41
+ * (`void Promise.all(...)`). We need to await the microtask queue to let
42
+ * those promises resolve before asserting on broadcasts. A few
43
+ * `setImmediate` cycles is enough since the test mocks resolve synchronously.
44
+ */
45
+ async function flush() {
46
+ await new Promise((resolve) => setImmediate(resolve));
47
+ await new Promise((resolve) => setImmediate(resolve));
48
+ }
49
+
50
+ describe("discoverAndBroadcastSessions: cold-boot openspec broadcast", () => {
51
+ beforeEach(() => {
52
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
53
+ });
54
+
55
+ it("broadcasts openspec_update for each cwd whose prior cache was empty/undefined", async () => {
56
+ const cwds = ["/a", "/b"];
57
+ const fresh = { initialized: true, changes: [{ name: "c1" } as never] };
58
+ const directoryService: SpyDirSvc = {
59
+ knownDirectories: vi.fn(() => cwds),
60
+ discoverSessions: vi.fn(() => []),
61
+ getOpenSpecData: vi.fn((cwd: string) =>
62
+ cwd === "/a" ? undefined : { initialized: false, changes: [] },
63
+ ),
64
+ refreshOpenSpec: vi.fn(async () => fresh),
65
+ ...stubPolling(),
66
+ };
67
+ const browserGateway = makeGateway();
68
+
69
+ await discoverAndBroadcastSessions({
70
+ sessionManager: makeSessionMgr() as never,
71
+ browserGateway: browserGateway as never,
72
+ directoryService: directoryService as never,
73
+ });
74
+ await flush();
75
+
76
+ const broadcasts = browserGateway.broadcastToAll.mock.calls
77
+ .map((c: unknown[]) => c[0])
78
+ .filter((m: any) => m?.type === "openspec_update");
79
+ expect(broadcasts).toHaveLength(2);
80
+ expect(broadcasts).toContainEqual({ type: "openspec_update", cwd: "/a", data: fresh });
81
+ expect(broadcasts).toContainEqual({ type: "openspec_update", cwd: "/b", data: fresh });
82
+ });
83
+
84
+ it("does not broadcast openspec_update when refreshed data equals prior data (warm-restart idempotency)", async () => {
85
+ const same = { initialized: true, changes: [{ name: "stable" } as never] };
86
+ const directoryService: SpyDirSvc = {
87
+ knownDirectories: vi.fn(() => ["/p"]),
88
+ discoverSessions: vi.fn(() => []),
89
+ getOpenSpecData: vi.fn(() => same),
90
+ refreshOpenSpec: vi.fn(async () => same),
91
+ ...stubPolling(),
92
+ };
93
+ const browserGateway = makeGateway();
94
+
95
+ await discoverAndBroadcastSessions({
96
+ sessionManager: makeSessionMgr() as never,
97
+ browserGateway: browserGateway as never,
98
+ directoryService: directoryService as never,
99
+ });
100
+ await flush();
101
+
102
+ const broadcasts = browserGateway.broadcastToAll.mock.calls
103
+ .map((c: unknown[]) => c[0])
104
+ .filter((m: any) => m?.type === "openspec_update");
105
+ expect(broadcasts).toHaveLength(0);
106
+ });
107
+
108
+ it("broadcasts when prior is populated and fresh differs", async () => {
109
+ const prior = { initialized: true, changes: [{ name: "old" } as never] };
110
+ const fresh = { initialized: true, changes: [{ name: "new" } as never] };
111
+ const directoryService: SpyDirSvc = {
112
+ knownDirectories: vi.fn(() => ["/p"]),
113
+ discoverSessions: vi.fn(() => []),
114
+ getOpenSpecData: vi.fn(() => prior),
115
+ refreshOpenSpec: vi.fn(async () => fresh),
116
+ ...stubPolling(),
117
+ };
118
+ const browserGateway = makeGateway();
119
+
120
+ await discoverAndBroadcastSessions({
121
+ sessionManager: makeSessionMgr() as never,
122
+ browserGateway: browserGateway as never,
123
+ directoryService: directoryService as never,
124
+ });
125
+ await flush();
126
+
127
+ const broadcasts = browserGateway.broadcastToAll.mock.calls
128
+ .map((c: unknown[]) => c[0])
129
+ .filter((m: any) => m?.type === "openspec_update");
130
+ expect(broadcasts).toHaveLength(1);
131
+ expect(broadcasts[0]).toEqual({ type: "openspec_update", cwd: "/p", data: fresh });
132
+ });
133
+
134
+ it("does not block on refreshOpenSpec failure; logs error and skips broadcast for that cwd", async () => {
135
+ const fresh = { initialized: true, changes: [{ name: "ok" } as never] };
136
+ const directoryService: SpyDirSvc = {
137
+ knownDirectories: vi.fn(() => ["/bad", "/good"]),
138
+ discoverSessions: vi.fn(() => []),
139
+ getOpenSpecData: vi.fn(() => undefined),
140
+ refreshOpenSpec: vi.fn(async (cwd: string) => {
141
+ if (cwd === "/bad") throw new Error("boom");
142
+ return fresh;
143
+ }),
144
+ ...stubPolling(),
145
+ };
146
+ const browserGateway = makeGateway();
147
+
148
+ await discoverAndBroadcastSessions({
149
+ sessionManager: makeSessionMgr() as never,
150
+ browserGateway: browserGateway as never,
151
+ directoryService: directoryService as never,
152
+ });
153
+ await flush();
154
+
155
+ const broadcasts = browserGateway.broadcastToAll.mock.calls
156
+ .map((c: unknown[]) => c[0])
157
+ .filter((m: any) => m?.type === "openspec_update");
158
+ expect(broadcasts).toHaveLength(1);
159
+ expect(broadcasts[0]).toEqual({ type: "openspec_update", cwd: "/good", data: fresh });
160
+ });
161
+ });
@@ -39,7 +39,7 @@ vi.mock("../session-file-reader.js", () => ({
39
39
  loadSessionEntries: vi.fn(() => []),
40
40
  }));
41
41
 
42
- vi.mock("@mariozechner/pi-coding-agent", () => ({
42
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
43
43
  SessionManager: {
44
44
  list: vi.fn(async () => []),
45
45
  open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
@@ -47,7 +47,7 @@ vi.mock("../session-file-reader.js", () => ({
47
47
  loadSessionEntries: vi.fn(() => []),
48
48
  }));
49
49
 
50
- vi.mock("@mariozechner/pi-coding-agent", () => ({
50
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
51
51
  SessionManager: {
52
52
  list: vi.fn(async () => []),
53
53
  open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
@@ -46,7 +46,7 @@ vi.mock("../session-file-reader.js", () => ({
46
46
  loadSessionEntries: vi.fn(() => []),
47
47
  }));
48
48
 
49
- vi.mock("@mariozechner/pi-coding-agent", () => ({
49
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
50
50
  SessionManager: {
51
51
  list: vi.fn(async () => []),
52
52
  open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
@@ -42,7 +42,7 @@ vi.mock("../session-file-reader.js", () => ({
42
42
  }));
43
43
 
44
44
  // Mock the pi-coding-agent SessionManager (legacy, kept for compatibility)
45
- vi.mock("@mariozechner/pi-coding-agent", () => ({
45
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
46
46
  SessionManager: {
47
47
  list: vi.fn(async () => []),
48
48
  open: vi.fn(() => ({
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Route tests for `GET /api/doctor`.
3
+ *
4
+ * Asserts:
5
+ * - JSON shape contract (every check has `section`; non-ok has message+detail+suggestion)
6
+ * - summary counts match
7
+ * - fault-tolerance arm: a deps function that throws → 200 with fallback row
8
+ * - no Electron-only rows (4.5)
9
+ *
10
+ * See change: doctor-rich-output (tasks 4.4–4.5).
11
+ */
12
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
13
+ import Fastify, { type FastifyInstance } from "fastify";
14
+ import { registerDoctorRoutes } from "../routes/doctor-routes.js";
15
+ import type {
16
+ DoctorReport,
17
+ SharedChecksDeps,
18
+ } from "@blackbelt-technology/pi-dashboard-shared/doctor-core.js";
19
+
20
+ const ELECTRON_ONLY_NAMES = new Set([
21
+ "Electron",
22
+ "Bundled Node.js",
23
+ "Bundled npm",
24
+ "Offline packages bundle",
25
+ "Dashboard server code",
26
+ "Server launch test",
27
+ ]);
28
+
29
+ async function makeApp(buildDeps?: () => SharedChecksDeps): Promise<FastifyInstance> {
30
+ const app = Fastify({ logger: false });
31
+ registerDoctorRoutes(app, buildDeps ? { buildDeps } : {});
32
+ await app.ready();
33
+ return app;
34
+ }
35
+
36
+ function fakeDeps(overrides: Partial<SharedChecksDeps> = {}): SharedChecksDeps {
37
+ return {
38
+ managedDir: "/tmp/doctor-route-test-managed",
39
+ detectSystemNode: () => ({ found: true, path: "/usr/bin/node" }),
40
+ detectPi: () => ({ found: true, path: "/usr/local/bin/pi", source: "system" }),
41
+ detectOpenSpec: () => ({ found: false }),
42
+ isApiKeyConfigured: () => true,
43
+ probeServer: async () => ({ running: true, version: "0.4.6", mode: "production" }),
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe("/api/doctor", () => {
49
+ let app: FastifyInstance;
50
+ afterEach(async () => {
51
+ await app?.close();
52
+ });
53
+
54
+ it("returns 200 with a DoctorReport envelope", async () => {
55
+ app = await makeApp(() => fakeDeps());
56
+ const res = await app.inject({ method: "GET", url: "/api/doctor" });
57
+ expect(res.statusCode).toBe(200);
58
+ const body = res.json() as DoctorReport;
59
+ expect(Array.isArray(body.checks)).toBe(true);
60
+ expect(body.summary).toBeDefined();
61
+ expect(typeof body.generatedAt).toBe("number");
62
+ });
63
+
64
+ it("every check has a section", async () => {
65
+ app = await makeApp(() => fakeDeps());
66
+ const res = await app.inject({ method: "GET", url: "/api/doctor" });
67
+ const body = res.json() as DoctorReport;
68
+ for (const c of body.checks) {
69
+ expect(c.section).toBeDefined();
70
+ expect(["runtime", "pi-tooling", "server", "setup", "diagnostics"]).toContain(c.section);
71
+ }
72
+ });
73
+
74
+ it("every non-ok row carries non-empty message + detail + suggestion (Decision 8 lint)", async () => {
75
+ app = await makeApp(() =>
76
+ fakeDeps({
77
+ detectPi: () => ({ found: false }),
78
+ detectOpenSpec: () => ({ found: false }),
79
+ probeServer: async () => ({ running: false }),
80
+ }),
81
+ );
82
+ const res = await app.inject({ method: "GET", url: "/api/doctor" });
83
+ const body = res.json() as DoctorReport;
84
+ const nonOk = body.checks.filter((c) => c.status !== "ok");
85
+ expect(nonOk.length).toBeGreaterThan(0);
86
+ for (const c of nonOk) {
87
+ expect(c.message.length).toBeGreaterThan(0);
88
+ expect((c.detail ?? "").length).toBeGreaterThan(0);
89
+ expect((c.suggestion ?? "").length).toBeGreaterThan(0);
90
+ }
91
+ });
92
+
93
+ it("summary counts match the rows", async () => {
94
+ app = await makeApp(() =>
95
+ fakeDeps({
96
+ detectPi: () => ({ found: false }),
97
+ }),
98
+ );
99
+ const res = await app.inject({ method: "GET", url: "/api/doctor" });
100
+ const body = res.json() as DoctorReport;
101
+ const ok = body.checks.filter((c) => c.status === "ok").length;
102
+ const warn = body.checks.filter((c) => c.status === "warning").length;
103
+ const err = body.checks.filter((c) => c.status === "error").length;
104
+ expect(body.summary.ok).toBe(ok);
105
+ expect(body.summary.warnings).toBe(warn);
106
+ expect(body.summary.errors).toBe(err);
107
+ });
108
+
109
+ it("never returns Electron-only rows (4.5)", async () => {
110
+ app = await makeApp(() => fakeDeps());
111
+ const res = await app.inject({ method: "GET", url: "/api/doctor" });
112
+ const body = res.json() as DoctorReport;
113
+ for (const c of body.checks) {
114
+ expect(ELECTRON_ONLY_NAMES.has(c.name)).toBe(false);
115
+ }
116
+ });
117
+
118
+ it("returns 200 with a single fallback row when buildDeps throws", async () => {
119
+ app = await makeApp(() => {
120
+ throw new Error("boom — deps unavailable");
121
+ });
122
+ const res = await app.inject({ method: "GET", url: "/api/doctor" });
123
+ // Per task 4.3, the route returns 200 even on internal failure so the
124
+ // client always has something to render.
125
+ expect(res.statusCode).toBe(200);
126
+ const body = res.json() as DoctorReport;
127
+ expect(body.checks.length).toBe(1);
128
+ expect(body.checks[0].status).toBe("error");
129
+ expect(body.checks[0].name).toMatch(/Doctor failed/i);
130
+ expect(body.summary.errors).toBe(1);
131
+ });
132
+ });