@blackbelt-technology/pi-agent-dashboard 0.4.6 → 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.
- package/AGENTS.md +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +57 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end test: `providers_list` arriving from a (fake) bridge updates
|
|
3
|
+
* the provider-catalogue cache, and `getAuthStatus()` reflects it.
|
|
4
|
+
* See change: replace-hardcoded-provider-lists.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { WebSocket } from "ws";
|
|
8
|
+
import { createServer, type DashboardServer } from "../server.js";
|
|
9
|
+
import { _resetForTests, getLatestCatalogue } from "../provider-catalogue-cache.js";
|
|
10
|
+
import { getAuthStatus } from "../provider-auth-storage.js";
|
|
11
|
+
|
|
12
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
|
|
14
|
+
async function connectSession(piPort: number, sessionId: string): Promise<WebSocket> {
|
|
15
|
+
const ws = new WebSocket(`ws://localhost:${piPort}`);
|
|
16
|
+
await new Promise<void>((resolve) => {
|
|
17
|
+
ws.on("open", () => {
|
|
18
|
+
ws.send(JSON.stringify({
|
|
19
|
+
type: "session_register",
|
|
20
|
+
sessionId,
|
|
21
|
+
cwd: "/tmp",
|
|
22
|
+
source: "cli",
|
|
23
|
+
}));
|
|
24
|
+
ws.send(JSON.stringify({ type: "replay_complete", sessionId }));
|
|
25
|
+
setTimeout(resolve, 60);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
return ws;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("providers_list — server wiring", () => {
|
|
32
|
+
let server: DashboardServer;
|
|
33
|
+
let piPort: number;
|
|
34
|
+
let browserPort: number;
|
|
35
|
+
let testPort = 19500;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
_resetForTests();
|
|
39
|
+
testPort += 2;
|
|
40
|
+
browserPort = testPort;
|
|
41
|
+
piPort = testPort + 1;
|
|
42
|
+
server = await createServer({
|
|
43
|
+
port: browserPort,
|
|
44
|
+
piPort,
|
|
45
|
+
dev: true,
|
|
46
|
+
autoShutdown: false,
|
|
47
|
+
shutdownIdleSeconds: 999,
|
|
48
|
+
tunnel: false,
|
|
49
|
+
editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
|
|
50
|
+
});
|
|
51
|
+
await server.start();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
await server.stop();
|
|
56
|
+
_resetForTests();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("incoming providers_list updates the cache and is visible via getAuthStatus", async () => {
|
|
60
|
+
const piWs = await connectSession(piPort, "p1");
|
|
61
|
+
expect(getLatestCatalogue()).toEqual([]);
|
|
62
|
+
|
|
63
|
+
piWs.send(JSON.stringify({
|
|
64
|
+
type: "providers_list",
|
|
65
|
+
sessionId: "p1",
|
|
66
|
+
providers: [
|
|
67
|
+
{ id: "deepseek", displayName: "DeepSeek", hasOAuth: false, configured: false },
|
|
68
|
+
{ id: "fireworks", displayName: "Fireworks", hasOAuth: false, configured: false, envVar: "FIREWORKS_API_KEY" },
|
|
69
|
+
],
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
await wait(80);
|
|
73
|
+
|
|
74
|
+
const cached = getLatestCatalogue();
|
|
75
|
+
expect(cached).toHaveLength(2);
|
|
76
|
+
expect(cached.map((p) => p.id).sort()).toEqual(["deepseek", "fireworks"]);
|
|
77
|
+
|
|
78
|
+
const status = getAuthStatus();
|
|
79
|
+
const deepseekRow = status.find((r) => r.id === "deepseek");
|
|
80
|
+
const fireworksRow = status.find((r) => r.id === "fireworks");
|
|
81
|
+
expect(deepseekRow).toBeDefined();
|
|
82
|
+
expect(deepseekRow?.flowType).toBe("api_key");
|
|
83
|
+
expect(fireworksRow?.envVar).toBe("FIREWORKS_API_KEY");
|
|
84
|
+
|
|
85
|
+
piWs.close();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hasOpenSpecDir` — synchronous spawn-free probe used by
|
|
3
|
+
* the WS on-connect snapshot to disambiguate "no openspec here" from
|
|
4
|
+
* "openspec here, polling pending".
|
|
5
|
+
*
|
|
6
|
+
* See change: fix-cold-boot-openspec-protocol.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { hasOpenSpecDir } from "../directory-service.js";
|
|
13
|
+
|
|
14
|
+
describe("hasOpenSpecDir", () => {
|
|
15
|
+
let tmp: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "has-openspec-dir-"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns true when <cwd>/openspec/changes exists as a directory", () => {
|
|
26
|
+
fs.mkdirSync(path.join(tmp, "openspec", "changes"), { recursive: true });
|
|
27
|
+
expect(hasOpenSpecDir(tmp)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns false when openspec dir is absent (ENOENT)", () => {
|
|
31
|
+
expect(hasOpenSpecDir(tmp)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns false when openspec exists but openspec/changes does not", () => {
|
|
35
|
+
fs.mkdirSync(path.join(tmp, "openspec"));
|
|
36
|
+
expect(hasOpenSpecDir(tmp)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns false when openspec/changes is a regular file, not a directory", () => {
|
|
40
|
+
fs.mkdirSync(path.join(tmp, "openspec"));
|
|
41
|
+
fs.writeFileSync(path.join(tmp, "openspec", "changes"), "not a dir");
|
|
42
|
+
expect(hasOpenSpecDir(tmp)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns false when openspec/changes is a symlink to a non-directory", () => {
|
|
46
|
+
fs.mkdirSync(path.join(tmp, "openspec"));
|
|
47
|
+
const target = path.join(tmp, "target.txt");
|
|
48
|
+
fs.writeFileSync(target, "x");
|
|
49
|
+
fs.symlinkSync(target, path.join(tmp, "openspec", "changes"));
|
|
50
|
+
expect(hasOpenSpecDir(tmp)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns true when openspec/changes is a symlink to a directory", () => {
|
|
54
|
+
fs.mkdirSync(path.join(tmp, "openspec"));
|
|
55
|
+
const target = path.join(tmp, "target-dir");
|
|
56
|
+
fs.mkdirSync(target);
|
|
57
|
+
fs.symlinkSync(target, path.join(tmp, "openspec", "changes"));
|
|
58
|
+
expect(hasOpenSpecDir(tmp)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns false for a non-existent cwd", () => {
|
|
62
|
+
expect(hasOpenSpecDir("/this/path/does/not/exist/__nope__")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /api/health response shape after Phase A additions.
|
|
3
|
+
*
|
|
4
|
+
* Asserts:
|
|
5
|
+
* - `pid` field is present (regression pin).
|
|
6
|
+
* - `starter` field is present, defaults to "Standalone".
|
|
7
|
+
*
|
|
8
|
+
* Note: the "Standalone default for missing DASHBOARD_STARTER" case is
|
|
9
|
+
* also covered exhaustively in packages/shared/src/__tests__/dashboard-starter.test.ts.
|
|
10
|
+
* This test pins the contract at the HTTP layer so a refactor cannot silently
|
|
11
|
+
* drop either field from the health response.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
14
|
+
import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
|
|
15
|
+
|
|
16
|
+
let handle: TestServerHandle | undefined;
|
|
17
|
+
|
|
18
|
+
describe("GET /api/health — Phase A shape", () => {
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
if (handle) {
|
|
21
|
+
try { await handle.stop(); } catch { /* already stopped */ }
|
|
22
|
+
handle = undefined;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("includes pid field (regression pin)", async () => {
|
|
27
|
+
handle = await createTestServer();
|
|
28
|
+
const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
|
|
29
|
+
expect(res.status).toBe(200);
|
|
30
|
+
const body = await res.json() as Record<string, unknown>;
|
|
31
|
+
expect(typeof body.pid).toBe("number");
|
|
32
|
+
expect(body.pid).toBe(process.pid);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("includes starter field defaulting to Standalone", async () => {
|
|
36
|
+
handle = await createTestServer();
|
|
37
|
+
const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
|
|
38
|
+
expect(res.status).toBe(200);
|
|
39
|
+
const body = await res.json() as Record<string, unknown>;
|
|
40
|
+
// When bootstrapState has no starter set, defaults to "Standalone".
|
|
41
|
+
expect(body.starter).toBe("Standalone");
|
|
42
|
+
});
|
|
43
|
+
});
|