@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.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 (133) hide show
  1. package/AGENTS.md +342 -267
  2. package/README.md +51 -2
  3. package/docs/architecture.md +266 -25
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
  10. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  11. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  12. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  13. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  14. package/packages/extension/src/bridge-context.ts +7 -0
  15. package/packages/extension/src/bridge.ts +142 -4
  16. package/packages/extension/src/command-handler.ts +6 -0
  17. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  18. package/packages/extension/src/model-tracker.ts +35 -1
  19. package/packages/extension/src/prompt-bus.ts +4 -3
  20. package/packages/extension/src/prompt-expander.ts +50 -2
  21. package/packages/extension/src/provider-register.ts +117 -0
  22. package/packages/extension/src/server-launcher.ts +18 -1
  23. package/packages/extension/src/session-sync.ts +6 -1
  24. package/packages/extension/src/vcs-info.ts +184 -0
  25. package/packages/server/package.json +4 -4
  26. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  27. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  28. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  29. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  30. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  31. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  33. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  34. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  35. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  36. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  37. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  38. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  39. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  40. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  41. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  42. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  43. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  44. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  45. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  46. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  47. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  48. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  49. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  50. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  52. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  53. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  54. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  55. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  56. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  57. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  58. package/packages/server/src/bootstrap-state.ts +18 -0
  59. package/packages/server/src/browser-gateway.ts +58 -21
  60. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  61. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  62. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  63. package/packages/server/src/cli.ts +22 -0
  64. package/packages/server/src/directory-service.ts +31 -0
  65. package/packages/server/src/event-wiring.ts +57 -2
  66. package/packages/server/src/home-lock.d.ts +124 -0
  67. package/packages/server/src/home-lock.js +330 -0
  68. package/packages/server/src/home-lock.js.map +1 -0
  69. package/packages/server/src/idle-timer.ts +15 -1
  70. package/packages/server/src/openspec-tasks.ts +50 -19
  71. package/packages/server/src/pi-core-updater.ts +65 -9
  72. package/packages/server/src/pi-gateway.ts +6 -0
  73. package/packages/server/src/process-manager.ts +62 -11
  74. package/packages/server/src/provider-auth-handlers.ts +9 -0
  75. package/packages/server/src/provider-auth-storage.ts +83 -51
  76. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  77. package/packages/server/src/routes/doctor-routes.ts +140 -0
  78. package/packages/server/src/routes/jj-routes.ts +386 -0
  79. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  80. package/packages/server/src/routes/session-routes.ts +12 -3
  81. package/packages/server/src/routes/system-routes.ts +38 -1
  82. package/packages/server/src/server.ts +16 -9
  83. package/packages/server/src/session-bootstrap.ts +27 -12
  84. package/packages/server/src/session-diff.ts +118 -1
  85. package/packages/server/src/session-discovery.ts +10 -3
  86. package/packages/server/src/session-scanner.ts +4 -2
  87. package/packages/server/src/spawn-failure-log.ts +130 -0
  88. package/packages/server/src/spawn-preflight.ts +82 -0
  89. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  90. package/packages/server/src/terminal-manager.ts +12 -1
  91. package/packages/shared/package.json +1 -1
  92. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  93. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  94. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  95. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  96. package/packages/shared/src/__tests__/config.test.ts +48 -0
  97. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  98. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  99. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  100. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  101. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  102. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  103. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  104. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  105. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  106. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  107. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  108. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  109. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  110. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  111. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  112. package/packages/shared/src/bootstrap-install.ts +196 -2
  113. package/packages/shared/src/browser-protocol.ts +112 -1
  114. package/packages/shared/src/config.ts +29 -0
  115. package/packages/shared/src/dashboard-starter.ts +33 -0
  116. package/packages/shared/src/diff-types.ts +17 -0
  117. package/packages/shared/src/doctor-core.ts +821 -0
  118. package/packages/shared/src/index.ts +9 -0
  119. package/packages/shared/src/installable-list.ts +152 -0
  120. package/packages/shared/src/launch-source-flag.ts +14 -0
  121. package/packages/shared/src/launch-source-types.ts +18 -0
  122. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  123. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  124. package/packages/shared/src/platform/jj.ts +405 -0
  125. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  126. package/packages/shared/src/protocol.ts +60 -2
  127. package/packages/shared/src/rest-api.ts +4 -0
  128. package/packages/shared/src/skill-block-parser.ts +115 -0
  129. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  130. package/packages/shared/src/tool-registry/definitions.ts +19 -5
  131. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  132. package/packages/shared/src/types.ts +91 -0
  133. package/packages/extension/src/git-info.ts +0 -55
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Regression suite for change: fix-stale-sessions-on-reconnect.
3
+ *
4
+ * Pin: on every browser WS connect, the gateway sends exactly one
5
+ * `sessions_snapshot` message containing all sessions and all non-empty
6
+ * per-cwd orders, AND it does NOT iterate per-session `session_added`
7
+ * or per-cwd `sessions_reordered` for the bootstrap.
8
+ */
9
+ import { describe, it, expect, vi } from "vitest";
10
+ import { EventEmitter } from "node:events";
11
+ import { createBrowserGateway } from "../browser-gateway.js";
12
+ import { createMemorySessionManager } from "../memory-session-manager.js";
13
+ import { createMemoryEventStore } from "../memory-event-store.js";
14
+ import type { PiGateway } from "../pi-gateway.js";
15
+ import type { SessionOrderManager } from "../session-order-manager.js";
16
+
17
+ function makeFakeWs() {
18
+ const ws = new EventEmitter() as EventEmitter & {
19
+ send: ReturnType<typeof vi.fn>;
20
+ close: ReturnType<typeof vi.fn>;
21
+ readyState: number;
22
+ OPEN: number;
23
+ };
24
+ ws.send = vi.fn();
25
+ ws.close = vi.fn();
26
+ ws.readyState = 1;
27
+ ws.OPEN = 1;
28
+ return ws;
29
+ }
30
+
31
+ function makeStubPiGateway(): PiGateway {
32
+ return {
33
+ start: vi.fn(),
34
+ stop: vi.fn(),
35
+ sendToSession: vi.fn(),
36
+ getConnectedSessionIds: vi.fn(() => []),
37
+ hasSession: vi.fn(() => false),
38
+ onEvent: vi.fn(),
39
+ } as unknown as PiGateway;
40
+ }
41
+
42
+ function makeStubOrderManager(orders: Record<string, string[]>): SessionOrderManager {
43
+ return {
44
+ insert: vi.fn(),
45
+ remove: vi.fn(),
46
+ getOrder: vi.fn((cwd: string) => orders[cwd] ?? []),
47
+ reorder: vi.fn(),
48
+ getAllOrders: vi.fn(() => orders),
49
+ moveToFront: vi.fn(),
50
+ } as unknown as SessionOrderManager;
51
+ }
52
+
53
+ function sentMessages(ws: ReturnType<typeof makeFakeWs>) {
54
+ return ws.send.mock.calls
55
+ .map((args) => {
56
+ try { return JSON.parse(String(args[0])); } catch { return null; }
57
+ })
58
+ .filter((m): m is Record<string, unknown> => !!m && typeof m === "object");
59
+ }
60
+
61
+ describe("browser-gateway on-connect sessions_snapshot", () => {
62
+ it("sends exactly one sessions_snapshot and no per-session session_added/sessions_reordered", () => {
63
+ const sessionManager = createMemorySessionManager();
64
+ sessionManager.restore({
65
+ id: "alive-1",
66
+ cwd: "/repo/a",
67
+ source: "tui",
68
+ status: "active",
69
+ startedAt: 1,
70
+ hidden: false,
71
+ dataUnavailable: false,
72
+ } as never);
73
+ sessionManager.restore({
74
+ id: "ended-1",
75
+ cwd: "/repo/a",
76
+ source: "tui",
77
+ status: "ended",
78
+ startedAt: 2,
79
+ endedAt: 3,
80
+ hidden: false,
81
+ dataUnavailable: true,
82
+ } as never);
83
+
84
+ const orders: Record<string, string[]> = {
85
+ "/repo/a": ["alive-1"],
86
+ "/repo/empty": [], // should be filtered out of snapshot.orders
87
+ };
88
+
89
+ const gateway = createBrowserGateway(
90
+ sessionManager,
91
+ createMemoryEventStore(() => false),
92
+ makeStubPiGateway(),
93
+ undefined,
94
+ undefined,
95
+ makeStubOrderManager(orders),
96
+ );
97
+
98
+ const ws = makeFakeWs();
99
+ gateway.wss.emit("connection", ws, {});
100
+
101
+ const msgs = sentMessages(ws);
102
+ const snapshots = msgs.filter((m) => m.type === "sessions_snapshot");
103
+ const sessionAddeds = msgs.filter((m) => m.type === "session_added");
104
+ const sessionsReordereds = msgs.filter((m) => m.type === "sessions_reordered");
105
+
106
+ expect(snapshots).toHaveLength(1);
107
+ expect(sessionAddeds).toHaveLength(0);
108
+ expect(sessionsReordereds).toHaveLength(0);
109
+
110
+ const snap = snapshots[0] as { sessions: Array<{ id: string; status: string }>; orders: Record<string, string[]> };
111
+ const ids = snap.sessions.map((s) => s.id).sort();
112
+ expect(ids).toEqual(["alive-1", "ended-1"]); // alive AND ended both included
113
+ expect(snap.orders).toEqual({ "/repo/a": ["alive-1"] }); // empty entry filtered out
114
+ });
115
+
116
+ it("snapshot is sent before pinned_dirs_updated and other on-connect sends", () => {
117
+ const sessionManager = createMemorySessionManager();
118
+ const gateway = createBrowserGateway(
119
+ sessionManager,
120
+ createMemoryEventStore(() => false),
121
+ makeStubPiGateway(),
122
+ undefined,
123
+ undefined,
124
+ makeStubOrderManager({}),
125
+ // Stub preferencesStore so pinned_dirs_updated fires.
126
+ {
127
+ getPinnedDirectories: () => [],
128
+ setPinnedDirectories: () => {},
129
+ getSessionOrder: () => ({}),
130
+ setSessionOrder: () => {},
131
+ } as never,
132
+ );
133
+
134
+ const ws = makeFakeWs();
135
+ gateway.wss.emit("connection", ws, {});
136
+
137
+ const types = sentMessages(ws).map((m) => m.type as string);
138
+ const snapshotIdx = types.indexOf("sessions_snapshot");
139
+ const pinnedIdx = types.indexOf("pinned_dirs_updated");
140
+ expect(snapshotIdx).toBeGreaterThanOrEqual(0);
141
+ expect(pinnedIdx).toBeGreaterThan(snapshotIdx);
142
+ });
143
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Tests for `_buildAuthStatus` — server-side pure derivation that merges
3
+ * the bridge-pushed catalogue, auth.json data, and the local OAuth handler set.
4
+ * See change: replace-hardcoded-provider-lists.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import { _buildAuthStatus, type AuthData } from "../provider-auth-storage.js";
8
+ import type { ProviderHandler } from "../provider-auth-handlers.js";
9
+ import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
10
+
11
+ function makeOAuthHandler(providerId: string, displayName: string, flowType: "auth_code" | "device_code" = "auth_code"): ProviderHandler {
12
+ return {
13
+ flowType,
14
+ providerId,
15
+ displayName,
16
+ callbackPort: 0,
17
+ callbackPath: "/cb",
18
+ buildAuthUrl: () => "",
19
+ exchangeCode: async () => ({ type: "oauth", refresh: "", access: "", expires: 0 }),
20
+ } as ProviderHandler;
21
+ }
22
+
23
+ const ANTHROPIC_HANDLER = makeOAuthHandler("anthropic", "Anthropic (Claude Pro/Max)");
24
+
25
+ describe("_buildAuthStatus", () => {
26
+ it("returns OAuth handler rows with authenticated:false when no catalogue or auth", () => {
27
+ const result = _buildAuthStatus([], {}, [ANTHROPIC_HANDLER]);
28
+ expect(result).toEqual([
29
+ {
30
+ id: "anthropic",
31
+ name: "Anthropic (Claude Pro/Max)",
32
+ flowType: "auth_code",
33
+ authenticated: false,
34
+ },
35
+ ]);
36
+ });
37
+
38
+ it("OAuth row authenticated:true with expires when auth.json has oauth credential", () => {
39
+ const auth: AuthData = {
40
+ anthropic: { type: "oauth", refresh: "r", access: "a", expires: 999 },
41
+ };
42
+ const result = _buildAuthStatus([], auth, [ANTHROPIC_HANDLER]);
43
+ expect(result[0]).toMatchObject({
44
+ id: "anthropic",
45
+ authenticated: true,
46
+ expires: 999,
47
+ });
48
+ });
49
+
50
+ it("emits both anthropic (OAuth) and anthropic-api (API key) rows when catalogue has anthropic", () => {
51
+ const catalogue: ProviderInfo[] = [
52
+ { id: "anthropic", displayName: "Anthropic", hasOAuth: true, configured: false },
53
+ ];
54
+ const result = _buildAuthStatus(catalogue, {}, [ANTHROPIC_HANDLER]);
55
+ expect(result).toHaveLength(2);
56
+ expect(result[0].id).toBe("anthropic");
57
+ expect(result[0].flowType).toBe("auth_code");
58
+ expect(result[1].id).toBe("anthropic-api");
59
+ expect(result[1].name).toBe("Anthropic (API Key)");
60
+ expect(result[1].flowType).toBe("api_key");
61
+ });
62
+
63
+ it("non-collision catalogue ids use bare id and bare display name", () => {
64
+ const catalogue: ProviderInfo[] = [
65
+ { id: "deepseek", displayName: "DeepSeek", hasOAuth: false, configured: false },
66
+ ];
67
+ const result = _buildAuthStatus(catalogue, {}, []);
68
+ expect(result[0]).toEqual({
69
+ id: "deepseek",
70
+ name: "DeepSeek",
71
+ flowType: "api_key",
72
+ authenticated: false,
73
+ });
74
+ });
75
+
76
+ it("masks stored API key (>=12 chars) showing first 5 + ... + last 3", () => {
77
+ const catalogue: ProviderInfo[] = [
78
+ { id: "deepseek", displayName: "DeepSeek", hasOAuth: false, configured: true },
79
+ ];
80
+ const auth: AuthData = { deepseek: { type: "api_key", key: "sk-abcdef123456789" } };
81
+ const result = _buildAuthStatus(catalogue, auth, []);
82
+ expect(result[0].authenticated).toBe(true);
83
+ expect(result[0].maskedKey).toBe("sk-ab...789");
84
+ });
85
+
86
+ it("masks short stored API key as ****", () => {
87
+ const catalogue: ProviderInfo[] = [
88
+ { id: "groq", displayName: "Groq", hasOAuth: false, configured: true },
89
+ ];
90
+ const auth: AuthData = { groq: { type: "api_key", key: "short" } };
91
+ const result = _buildAuthStatus(catalogue, auth, []);
92
+ expect(result[0].maskedKey).toBe("****");
93
+ });
94
+
95
+ it("ambient catalogue entry forces authenticated:true and maskedKey:'(ambient)' even with no auth.json entry", () => {
96
+ const catalogue: ProviderInfo[] = [
97
+ {
98
+ id: "google-vertex",
99
+ displayName: "Google Vertex AI",
100
+ hasOAuth: false,
101
+ configured: false,
102
+ ambient: true,
103
+ },
104
+ ];
105
+ const result = _buildAuthStatus(catalogue, {}, []);
106
+ expect(result[0]).toMatchObject({
107
+ id: "google-vertex",
108
+ authenticated: true,
109
+ ambient: true,
110
+ maskedKey: "(ambient)",
111
+ });
112
+ });
113
+
114
+ it("envVar from catalogue propagates to status row", () => {
115
+ const catalogue: ProviderInfo[] = [
116
+ { id: "openai", displayName: "OpenAI", hasOAuth: false, configured: false, envVar: "OPENAI_API_KEY" },
117
+ ];
118
+ const result = _buildAuthStatus(catalogue, {}, []);
119
+ expect(result[0].envVar).toBe("OPENAI_API_KEY");
120
+ expect(result[0].authenticated).toBe(false);
121
+ });
122
+
123
+ it("OAuth credential under anthropic does NOT mark anthropic-api authenticated", () => {
124
+ const catalogue: ProviderInfo[] = [
125
+ { id: "anthropic", displayName: "Anthropic", hasOAuth: true, configured: true },
126
+ ];
127
+ const auth: AuthData = {
128
+ anthropic: { type: "oauth", refresh: "r", access: "a", expires: 999 },
129
+ };
130
+ const result = _buildAuthStatus(catalogue, auth, [ANTHROPIC_HANDLER]);
131
+ const oauthRow = result.find((r) => r.id === "anthropic");
132
+ const apiRow = result.find((r) => r.id === "anthropic-api");
133
+ expect(oauthRow?.authenticated).toBe(true);
134
+ expect(apiRow?.authenticated).toBe(false);
135
+ expect(apiRow?.maskedKey).toBeUndefined();
136
+ });
137
+
138
+ it("api_key credential at auth.json[anthropic] marks anthropic-api authenticated", () => {
139
+ const catalogue: ProviderInfo[] = [
140
+ { id: "anthropic", displayName: "Anthropic", hasOAuth: true, configured: true },
141
+ ];
142
+ const auth: AuthData = {
143
+ anthropic: { type: "api_key", key: "sk-anthropic-key-1234" },
144
+ };
145
+ const result = _buildAuthStatus(catalogue, auth, [ANTHROPIC_HANDLER]);
146
+ const oauthRow = result.find((r) => r.id === "anthropic");
147
+ const apiRow = result.find((r) => r.id === "anthropic-api");
148
+ expect(oauthRow?.authenticated).toBe(false);
149
+ expect(apiRow?.authenticated).toBe(true);
150
+ expect(apiRow?.maskedKey).toBe("sk-an...234");
151
+ });
152
+
153
+ it("skips API-key rows for catalogue entries marked custom:true", () => {
154
+ const catalogue: ProviderInfo[] = [
155
+ { id: "deepseek", displayName: "DeepSeek", hasOAuth: false, configured: false },
156
+ { id: "proxy", displayName: "proxy", hasOAuth: false, configured: false, custom: true },
157
+ { id: "your-llmproxy", displayName: "your-llmproxy", hasOAuth: false, configured: true, custom: true },
158
+ ];
159
+ const result = _buildAuthStatus(catalogue, {}, []);
160
+ const ids = result.map((r) => r.id);
161
+ expect(ids).toContain("deepseek");
162
+ expect(ids).not.toContain("proxy");
163
+ expect(ids).not.toContain("your-llmproxy");
164
+ });
165
+
166
+ it("OAuth row IS still emitted for a custom provider with an OAuth handler", () => {
167
+ // A custom provider whose id matches a registered OAuth handler
168
+ // should still surface its OAuth row — only the API-key row is
169
+ // suppressed for custom providers.
170
+ const corporateHandler = makeOAuthHandler("corporate-sso", "Corporate SSO");
171
+ const catalogue: ProviderInfo[] = [
172
+ { id: "corporate-sso", displayName: "Corporate SSO", hasOAuth: true, configured: false, custom: true },
173
+ ];
174
+ const result = _buildAuthStatus(catalogue, {}, [corporateHandler]);
175
+ const oauthRow = result.find((r) => r.id === "corporate-sso");
176
+ const apiKeyRow = result.find((r) => r.id === "corporate-sso-api");
177
+ expect(oauthRow).toBeDefined();
178
+ expect(oauthRow?.flowType).toBe("auth_code");
179
+ expect(apiKeyRow).toBeUndefined();
180
+ });
181
+
182
+ it("preserves OAuth handler order then catalogue order", () => {
183
+ const catalogue: ProviderInfo[] = [
184
+ { id: "deepseek", displayName: "DeepSeek", hasOAuth: false, configured: false },
185
+ { id: "groq", displayName: "Groq", hasOAuth: false, configured: false },
186
+ ];
187
+ const result = _buildAuthStatus(catalogue, {}, [ANTHROPIC_HANDLER]);
188
+ expect(result.map((r) => r.id)).toEqual(["anthropic", "deepseek", "groq"]);
189
+ });
190
+ });
@@ -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
+ });
@@ -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
+ });