@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.
Files changed (112) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +31 -0
  3. package/docs/architecture.md +238 -23
  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-expander.test.ts +45 -0
  10. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  11. package/packages/extension/src/bridge.ts +110 -1
  12. package/packages/extension/src/command-handler.ts +6 -0
  13. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  14. package/packages/extension/src/prompt-expander.ts +50 -2
  15. package/packages/extension/src/provider-register.ts +117 -0
  16. package/packages/extension/src/server-launcher.ts +18 -1
  17. package/packages/extension/src/session-sync.ts +5 -0
  18. package/packages/server/package.json +4 -4
  19. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  20. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  21. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  22. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  23. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  24. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  25. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  26. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  27. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  28. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  29. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  30. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  31. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  32. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  33. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  34. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  35. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  36. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  37. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  38. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  39. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  40. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  41. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  42. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  43. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  44. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  45. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  46. package/packages/server/src/bootstrap-state.ts +18 -0
  47. package/packages/server/src/browser-gateway.ts +58 -21
  48. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  49. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  50. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  51. package/packages/server/src/cli.ts +21 -0
  52. package/packages/server/src/directory-service.ts +31 -0
  53. package/packages/server/src/event-wiring.ts +48 -2
  54. package/packages/server/src/home-lock.d.ts +124 -0
  55. package/packages/server/src/home-lock.js +330 -0
  56. package/packages/server/src/home-lock.js.map +1 -0
  57. package/packages/server/src/idle-timer.ts +15 -1
  58. package/packages/server/src/pi-core-updater.ts +65 -9
  59. package/packages/server/src/pi-gateway.ts +6 -0
  60. package/packages/server/src/process-manager.ts +62 -11
  61. package/packages/server/src/provider-auth-handlers.ts +9 -0
  62. package/packages/server/src/provider-auth-storage.ts +83 -51
  63. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  64. package/packages/server/src/routes/doctor-routes.ts +140 -0
  65. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  66. package/packages/server/src/routes/system-routes.ts +38 -1
  67. package/packages/server/src/server.ts +8 -7
  68. package/packages/server/src/session-bootstrap.ts +27 -12
  69. package/packages/server/src/session-discovery.ts +10 -3
  70. package/packages/server/src/session-scanner.ts +4 -2
  71. package/packages/server/src/spawn-failure-log.ts +130 -0
  72. package/packages/server/src/spawn-preflight.ts +82 -0
  73. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  74. package/packages/server/src/terminal-manager.ts +12 -1
  75. package/packages/shared/package.json +1 -1
  76. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  77. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  78. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  79. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  80. package/packages/shared/src/__tests__/config.test.ts +48 -0
  81. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  82. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  83. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  84. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  85. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  86. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  87. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  88. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  89. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  90. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  91. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  93. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  94. package/packages/shared/src/bootstrap-install.ts +196 -2
  95. package/packages/shared/src/browser-protocol.ts +112 -1
  96. package/packages/shared/src/config.ts +15 -0
  97. package/packages/shared/src/dashboard-starter.ts +33 -0
  98. package/packages/shared/src/doctor-core.ts +821 -0
  99. package/packages/shared/src/index.ts +9 -0
  100. package/packages/shared/src/installable-list.ts +152 -0
  101. package/packages/shared/src/launch-source-flag.ts +14 -0
  102. package/packages/shared/src/launch-source-types.ts +18 -0
  103. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  104. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  105. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  106. package/packages/shared/src/protocol.ts +46 -2
  107. package/packages/shared/src/rest-api.ts +4 -0
  108. package/packages/shared/src/skill-block-parser.ts +115 -0
  109. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  110. package/packages/shared/src/tool-registry/definitions.ts +18 -5
  111. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  112. package/packages/shared/src/types.ts +57 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tests for SpawnRegisterWatchdog.
3
+ * Uses vitest fake timers. See change: spawn-failure-diagnostics.
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import WebSocket from "ws";
7
+
8
+ // Silence appendSpawnFailure in unit tests.
9
+ vi.mock("../spawn-failure-log.js", () => ({
10
+ appendSpawnFailure: vi.fn(),
11
+ }));
12
+
13
+ import { SpawnRegisterWatchdog } from "../spawn-register-watchdog.js";
14
+
15
+ function makeMockWs(readyState: number = WebSocket.OPEN): { ws: WebSocket; messages: string[] } {
16
+ const messages: string[] = [];
17
+ const ws = {
18
+ readyState,
19
+ send: vi.fn((data: string) => messages.push(data)),
20
+ } as unknown as WebSocket;
21
+ return { ws, messages };
22
+ }
23
+
24
+ describe("SpawnRegisterWatchdog", () => {
25
+ beforeEach(() => {
26
+ vi.useFakeTimers();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.useRealTimers();
31
+ });
32
+
33
+ it("clamps timeoutMs below 5000 to 5000", () => {
34
+ const w = new SpawnRegisterWatchdog(1000);
35
+ expect(w.timeoutMs).toBe(5000);
36
+ });
37
+
38
+ it("clamps timeoutMs above 120000 to 120000", () => {
39
+ const w = new SpawnRegisterWatchdog(999999);
40
+ expect(w.timeoutMs).toBe(120000);
41
+ });
42
+
43
+ it("headless arm + clearByPid cancels watchdog", () => {
44
+ const { ws, messages } = makeMockWs();
45
+ const w = new SpawnRegisterWatchdog(10000);
46
+ w.arm({ pid: 123, cwd: "/p/x", mechanism: "headless", ws });
47
+ w.clearByPid(123);
48
+ vi.advanceTimersByTime(15000);
49
+ expect(messages).toHaveLength(0);
50
+ });
51
+
52
+ it("headless arm + clearByCwd (pid mismatch) still cancels watchdog", () => {
53
+ // Regression: Unix headless wraps pi in `sh -c "… | pi"`, so spawnResult.pid
54
+ // is the sh wrapper while session_register reports pi's real pid. Watchdog
55
+ // must clear via cwd even when pid was indexed at arm time.
56
+ const { ws, messages } = makeMockWs();
57
+ const w = new SpawnRegisterWatchdog(10000);
58
+ w.arm({ pid: 51250, cwd: "/p/x", mechanism: "headless", ws });
59
+ w.clearByCwd("/p/x");
60
+ vi.advanceTimersByTime(15000);
61
+ expect(messages).toHaveLength(0);
62
+ });
63
+
64
+ it("tmux arm + clearByCwd cancels watchdog", () => {
65
+ const { ws, messages } = makeMockWs();
66
+ const w = new SpawnRegisterWatchdog(10000);
67
+ w.arm({ cwd: "/p/x", mechanism: "tmux", ws });
68
+ w.clearByCwd("/p/x");
69
+ vi.advanceTimersByTime(15000);
70
+ expect(messages).toHaveLength(0);
71
+ });
72
+
73
+ it("arm without clear fires spawn_register_timeout", () => {
74
+ const { ws, messages } = makeMockWs();
75
+ const w = new SpawnRegisterWatchdog(10000);
76
+ w.arm({ pid: 42, cwd: "/p/y", mechanism: "headless", ws });
77
+ vi.advanceTimersByTime(10001);
78
+ expect(messages).toHaveLength(1);
79
+ const msg = JSON.parse(messages[0]!);
80
+ expect(msg.type).toBe("spawn_register_timeout");
81
+ expect(msg.cwd).toBe("/p/y");
82
+ expect(msg.pid).toBe(42);
83
+ });
84
+
85
+ it("tmux timeout omits pid", () => {
86
+ const { ws, messages } = makeMockWs();
87
+ const w = new SpawnRegisterWatchdog(10000);
88
+ w.arm({ cwd: "/p/z", mechanism: "tmux", ws });
89
+ vi.advanceTimersByTime(10001);
90
+ expect(messages).toHaveLength(1);
91
+ const msg = JSON.parse(messages[0]!);
92
+ expect(msg.pid).toBeUndefined();
93
+ });
94
+
95
+ it("clear on unknown key is a no-op", () => {
96
+ const w = new SpawnRegisterWatchdog(10000);
97
+ expect(() => w.clearByPid(999)).not.toThrow();
98
+ expect(() => w.clearByCwd("/never/seen")).not.toThrow();
99
+ });
100
+
101
+ it("timeout fires silently when ws is closed", () => {
102
+ const { ws, messages } = makeMockWs(WebSocket.CLOSED);
103
+ const w = new SpawnRegisterWatchdog(10000);
104
+ w.arm({ cwd: "/p/q", mechanism: "tmux", ws });
105
+ expect(() => vi.advanceTimersByTime(10001)).not.toThrow();
106
+ expect(messages).toHaveLength(0);
107
+ });
108
+
109
+ it("late clearByCwd within 60s emits spawn_register_recovered", () => {
110
+ const { ws, messages } = makeMockWs();
111
+ const w = new SpawnRegisterWatchdog(10000);
112
+ w.arm({ cwd: "/p/r", mechanism: "tmux", ws });
113
+
114
+ // Fire the watchdog.
115
+ vi.advanceTimersByTime(10001);
116
+ expect(messages[0]).toContain("spawn_register_timeout");
117
+
118
+ // Late registration within 60s.
119
+ vi.advanceTimersByTime(5000);
120
+ w.clearByCwd("/p/r");
121
+
122
+ expect(messages).toHaveLength(2);
123
+ const recovery = JSON.parse(messages[1]!);
124
+ expect(recovery.type).toBe("spawn_register_recovered");
125
+ expect(recovery.cwd).toBe("/p/r");
126
+ });
127
+
128
+ it("late clear past 60s TTL is silent", () => {
129
+ const { ws, messages } = makeMockWs();
130
+ const w = new SpawnRegisterWatchdog(10000);
131
+ w.arm({ cwd: "/p/s", mechanism: "tmux", ws });
132
+
133
+ vi.advanceTimersByTime(10001);
134
+ expect(messages).toHaveLength(1);
135
+
136
+ // Past 60s TTL.
137
+ vi.advanceTimersByTime(61000);
138
+ w.clearByCwd("/p/s");
139
+
140
+ // No recovery message.
141
+ expect(messages).toHaveLength(1);
142
+ });
143
+
144
+ it("recovery skipped when ws closed at recovery time", () => {
145
+ const messages: string[] = [];
146
+ // Start with OPEN, then we'll swap to CLOSED.
147
+ const ws = {
148
+ readyState: WebSocket.OPEN,
149
+ send: vi.fn((data: string) => messages.push(data)),
150
+ } as unknown as WebSocket;
151
+
152
+ const w = new SpawnRegisterWatchdog(10000);
153
+ w.arm({ cwd: "/p/t", mechanism: "tmux", ws });
154
+ vi.advanceTimersByTime(10001);
155
+
156
+ // Close the ws before recovery.
157
+ (ws as unknown as { readyState: number }).readyState = WebSocket.CLOSED;
158
+
159
+ vi.advanceTimersByTime(5000);
160
+ w.clearByCwd("/p/t");
161
+
162
+ // Only the timeout message was sent (before ws was closed).
163
+ const recoveries = messages.filter((m) => m.includes("spawn_register_recovered"));
164
+ expect(recoveries).toHaveLength(0);
165
+ });
166
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { handleSubscribe } from "../browser-handlers/subscription-handler.js";
2
+ import { handleSubscribe, replaySessionAssets } from "../browser-handlers/subscription-handler.js";
3
3
  import { createMemoryEventStore } from "../memory-event-store.js";
4
4
  import { createMemorySessionManager } from "../memory-session-manager.js";
5
5
  import type { BrowserHandlerContext } from "../browser-handlers/handler-context.js";
@@ -30,16 +30,18 @@ function createMockContext(overrides: Partial<BrowserHandlerContext> = {}): Brow
30
30
  }
31
31
 
32
32
  describe("handleSubscribe — metadata requests on subscribe", () => {
33
- it("sends request_commands, request_models, and request_roles to piGateway", () => {
33
+ it("sends request_commands, request_models, request_providers, and request_roles to piGateway", () => {
34
+ // request_providers added by change: replace-hardcoded-provider-lists.
34
35
  const ctx = createMockContext();
35
36
  const subs = new Set<string>();
36
37
  handleSubscribe({ type: "subscribe", sessionId: "s1" }, subs, ctx);
37
38
 
38
39
  const calls = (ctx.piGateway.sendToSession as any).mock.calls;
39
- expect(calls).toHaveLength(3);
40
+ expect(calls).toHaveLength(4);
40
41
  expect(calls[0]).toEqual(["s1", { type: "request_commands", sessionId: "s1" }]);
41
42
  expect(calls[1]).toEqual(["s1", { type: "request_models", sessionId: "s1" }]);
42
- expect(calls[2]).toEqual(["s1", { type: "request_roles", sessionId: "s1" }]);
43
+ expect(calls[2]).toEqual(["s1", { type: "request_providers", sessionId: "s1" }]);
44
+ expect(calls[3]).toEqual(["s1", { type: "request_roles", sessionId: "s1" }]);
43
45
  });
44
46
  });
45
47
 
@@ -107,9 +109,17 @@ describe("handleSubscribe — stale lastSeq detection", () => {
107
109
  expect(clearReplaying).toHaveBeenCalledWith(ctx.ws, "s1", 5); // lastSent = 5
108
110
  });
109
111
 
110
- it("does not mark replaying for fresh subscribe (lastSeq: 0)", async () => {
112
+ it("marks replaying for fresh subscribe (lastSeq: 0) when events exist", async () => {
113
+ // Regression: cold subscribe must suppress live events during paginated
114
+ // replay. Without suppression, a live `event` arriving between batches
115
+ // bumps the client's maxSeq past the next batch's firstSeq, triggering
116
+ // the `firstSeq <= maxSeq` reset rule on the client which wipes state
117
+ // and rebuilds from only the last batch — leaving the chat showing
118
+ // only the tail messages.
119
+ // See change: fix-cold-subscribe-replay-interleave.
111
120
  const markReplaying = vi.fn();
112
- const ctx = createMockContext({ markReplaying });
121
+ const clearReplaying = vi.fn();
122
+ const ctx = createMockContext({ markReplaying, clearReplaying });
113
123
  for (let i = 0; i < 3; i++) ctx.eventStore.insertEvent("s1", makeEvent());
114
124
 
115
125
  const subs = new Set<string>();
@@ -117,6 +127,20 @@ describe("handleSubscribe — stale lastSeq detection", () => {
117
127
 
118
128
  await new Promise((r) => setTimeout(r, 50));
119
129
 
130
+ expect(markReplaying).toHaveBeenCalledWith(ctx.ws, "s1");
131
+ expect(clearReplaying).toHaveBeenCalledWith(ctx.ws, "s1", 3); // lastSent = 3
132
+ });
133
+
134
+ it("does not mark replaying for fresh subscribe when there are no events", async () => {
135
+ const markReplaying = vi.fn();
136
+ const ctx = createMockContext({ markReplaying });
137
+ // No events inserted — hasEvents() returns false; falls through to the
138
+ // empty-session branch.
139
+ const subs = new Set<string>();
140
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 0 }, subs, ctx);
141
+
142
+ await new Promise((r) => setTimeout(r, 50));
143
+
120
144
  expect(markReplaying).not.toHaveBeenCalled();
121
145
  });
122
146
 
@@ -193,3 +217,71 @@ describe("handleSubscribe — stale lastSeq detection", () => {
193
217
  expect(allEvents).toHaveLength(3);
194
218
  });
195
219
  });
220
+
221
+ // chat-markdown-local-images-and-math
222
+ describe("replaySessionAssets — emits one asset_register per Session.assets entry", () => {
223
+ it("sends nothing when session has no assets", () => {
224
+ const ctx = createMockContext();
225
+ ctx.sessionManager.register({ id: "s1", cwd: "/c", source: "dashboard" } as any);
226
+ replaySessionAssets({} as any, "s1", ctx);
227
+ expect((ctx.sendTo as any).mock.calls).toHaveLength(0);
228
+ });
229
+
230
+ it("sends one asset_register per asset on the session", () => {
231
+ const ctx = createMockContext();
232
+ ctx.sessionManager.register({ id: "s1", cwd: "/c", source: "dashboard" } as any);
233
+ ctx.sessionManager.update("s1", {
234
+ assets: {
235
+ abc: { data: "AAAA", mimeType: "image/png" },
236
+ def: { data: "BBBB", mimeType: "image/svg+xml" },
237
+ },
238
+ } as any);
239
+ const ws = {} as any;
240
+ replaySessionAssets(ws, "s1", ctx);
241
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
242
+ const assetMsgs = calls.filter(([, m]) => m.type === "asset_register");
243
+ expect(assetMsgs).toHaveLength(2);
244
+ const byHash = Object.fromEntries(assetMsgs.map(([, m]: any) => [m.hash, m]));
245
+ expect(byHash.abc).toMatchObject({ data: "AAAA", mimeType: "image/png", sessionId: "s1" });
246
+ expect(byHash.def).toMatchObject({ data: "BBBB", mimeType: "image/svg+xml", sessionId: "s1" });
247
+ });
248
+
249
+ it("skips malformed asset entries defensively", () => {
250
+ const ctx = createMockContext();
251
+ ctx.sessionManager.register({ id: "s1", cwd: "/c", source: "dashboard" } as any);
252
+ // Force a malformed entry past the type check.
253
+ ctx.sessionManager.update("s1", {
254
+ assets: {
255
+ good: { data: "AAAA", mimeType: "image/png" },
256
+ bad: { data: 123, mimeType: "image/png" } as any,
257
+ },
258
+ } as any);
259
+ replaySessionAssets({} as any, "s1", ctx);
260
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
261
+ const assetMsgs = calls.filter(([, m]) => m.type === "asset_register");
262
+ expect(assetMsgs).toHaveLength(1);
263
+ expect((assetMsgs[0][1] as any).hash).toBe("good");
264
+ });
265
+ });
266
+
267
+ describe("handleSubscribe — asset replay precedes events", () => {
268
+ it("sends asset_register messages before event_replay batches", async () => {
269
+ const ctx = createMockContext();
270
+ ctx.sessionManager.register({ id: "s1", cwd: "/c", source: "dashboard" } as any);
271
+ ctx.sessionManager.update("s1", {
272
+ assets: { h1: { data: "AAAA", mimeType: "image/png" } },
273
+ } as any);
274
+ ctx.eventStore.insertEvent("s1", makeEvent("message_update"));
275
+
276
+ const subs = new Set<string>();
277
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 0 }, subs, ctx);
278
+ await new Promise((r) => setTimeout(r, 50));
279
+
280
+ const calls = (ctx.sendTo as any).mock.calls as Array<[any, ServerToBrowserMessage]>;
281
+ const firstAssetIdx = calls.findIndex(([, m]) => m.type === "asset_register");
282
+ const firstEventIdx = calls.findIndex(([, m]) => m.type === "event_replay");
283
+ expect(firstAssetIdx).toBeGreaterThanOrEqual(0);
284
+ expect(firstEventIdx).toBeGreaterThanOrEqual(0);
285
+ expect(firstAssetIdx).toBeLessThan(firstEventIdx);
286
+ });
287
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tests for POST /api/electron/reextract
3
+ * See change: simplify-electron-bootstrap-derived-state (task 6.4 / 6.9).
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import Fastify, { type FastifyInstance } from "fastify";
7
+ import { registerSystemRoutes } from "../routes/system-routes.js";
8
+ import type { BootstrapStateStore, BootstrapState } from "../bootstrap-state.js";
9
+
10
+ function noGuard() {
11
+ return async () => { /* allow all */ };
12
+ }
13
+
14
+ function makeBootstrapState(starter: string): BootstrapStateStore {
15
+ return {
16
+ get: () => ({
17
+ status: "ready",
18
+ starter: starter as any,
19
+ installable: { total: 0, installed: 0, failed: [] },
20
+ } as BootstrapState),
21
+ set: () => {},
22
+ subscribe: () => () => {},
23
+ } as unknown as BootstrapStateStore;
24
+ }
25
+
26
+ function makeNoopDeps(bootstrapState?: BootstrapStateStore) {
27
+ return {
28
+ sessionManager: { listActive: () => [], listAll: () => [] } as never,
29
+ preferencesStore: { flush: () => {} } as never,
30
+ metaPersistence: { flushAll: () => {} } as never,
31
+ config: { port: 8000, piPort: 9999, dev: false } as never,
32
+ networkGuard: noGuard(),
33
+ bootstrapState,
34
+ };
35
+ }
36
+
37
+ describe("POST /api/electron/reextract", () => {
38
+ let fastify: FastifyInstance;
39
+
40
+ beforeEach(async () => {
41
+ fastify = Fastify();
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await fastify.close();
46
+ });
47
+
48
+ it("returns 403 when starter is Bridge", async () => {
49
+ const deps = makeNoopDeps(makeBootstrapState("Bridge"));
50
+ registerSystemRoutes(fastify, deps);
51
+ await fastify.ready();
52
+
53
+ const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
54
+ expect(res.statusCode).toBe(403);
55
+ const body = res.json() as Record<string, unknown>;
56
+ expect(body.error).toBe("reextract_not_allowed");
57
+ expect(body.starter).toBe("Bridge");
58
+ });
59
+
60
+ it("returns 403 when starter is Standalone", async () => {
61
+ const deps = makeNoopDeps(makeBootstrapState("Standalone"));
62
+ registerSystemRoutes(fastify, deps);
63
+ await fastify.ready();
64
+
65
+ const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
66
+ expect(res.statusCode).toBe(403);
67
+ const body = res.json() as Record<string, unknown>;
68
+ expect(body.error).toBe("reextract_not_allowed");
69
+ expect(body.starter).toBe("Standalone");
70
+ });
71
+
72
+ it("returns 202 when starter is Electron", async () => {
73
+ const deps = makeNoopDeps(makeBootstrapState("Electron"));
74
+ registerSystemRoutes(fastify, deps);
75
+ await fastify.ready();
76
+
77
+ const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
78
+ expect(res.statusCode).toBe(202);
79
+ const body = res.json() as Record<string, unknown>;
80
+ expect(body.ok).toBe(true);
81
+ });
82
+
83
+ it("returns 403 when no bootstrapState (defaults to Standalone)", async () => {
84
+ const deps = makeNoopDeps(undefined);
85
+ registerSystemRoutes(fastify, deps);
86
+ await fastify.ready();
87
+
88
+ const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
89
+ expect(res.statusCode).toBe(403);
90
+ });
91
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for GET /api/spawn-failures endpoint.
3
+ * See change: spawn-failure-diagnostics.
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import Fastify, { type FastifyInstance } from "fastify";
7
+ import { registerSystemRoutes } from "../routes/system-routes.js";
8
+
9
+ // Mock the spawn-failure-log module.
10
+ vi.mock("../spawn-failure-log.js", () => ({
11
+ readSpawnFailures: vi.fn().mockReturnValue([]),
12
+ appendSpawnFailure: vi.fn(),
13
+ SpawnFailureEntry: undefined,
14
+ }));
15
+
16
+ import { readSpawnFailures } from "../spawn-failure-log.js";
17
+
18
+ const mockReadSpawnFailures = vi.mocked(readSpawnFailures);
19
+
20
+ function makeNoopDeps() {
21
+ return {
22
+ sessionManager: {} as never,
23
+ preferencesStore: { flush: () => {} } as never,
24
+ metaPersistence: { flushAll: () => {} } as never,
25
+ config: { port: 8000, piPort: 9999, dev: false } as never,
26
+ directoryService: {} as never,
27
+ piGateway: {
28
+ broadcast: vi.fn(),
29
+ announceRestart: vi.fn(),
30
+ } as never,
31
+ idleTimer: {} as never,
32
+ serverVersion: "test",
33
+ localhostGuard: () => async () => {},
34
+ tunnelStatus: () => ({ active: false }),
35
+ serverConfig: { dev: false } as never,
36
+ pluginStatusStore: { getAll: () => [] } as never,
37
+ };
38
+ }
39
+
40
+ describe("GET /api/spawn-failures", () => {
41
+ let app: FastifyInstance;
42
+
43
+ beforeEach(async () => {
44
+ vi.clearAllMocks();
45
+ app = Fastify({ logger: false });
46
+ registerSystemRoutes(app, makeNoopDeps() as never);
47
+ await app.ready();
48
+ });
49
+
50
+ afterEach(async () => {
51
+ await app.close();
52
+ });
53
+
54
+ it("returns empty entries when no log exists", async () => {
55
+ mockReadSpawnFailures.mockReturnValue([]);
56
+ const res = await app.inject({ method: "GET", url: "/api/spawn-failures" });
57
+ expect(res.statusCode).toBe(200);
58
+ const body = JSON.parse(res.body);
59
+ expect(body).toHaveProperty("entries");
60
+ expect(body.entries).toEqual([]);
61
+ expect(mockReadSpawnFailures).toHaveBeenCalledWith(50);
62
+ });
63
+
64
+ it("passes custom limit", async () => {
65
+ mockReadSpawnFailures.mockReturnValue([]);
66
+ await app.inject({ method: "GET", url: "/api/spawn-failures?limit=10" });
67
+ expect(mockReadSpawnFailures).toHaveBeenCalledWith(10);
68
+ });
69
+
70
+ it("falls back to default limit on NaN", async () => {
71
+ mockReadSpawnFailures.mockReturnValue([]);
72
+ await app.inject({ method: "GET", url: "/api/spawn-failures?limit=abc" });
73
+ expect(mockReadSpawnFailures).toHaveBeenCalledWith(50);
74
+ });
75
+
76
+ it("returns entries from the log", async () => {
77
+ const entry = { ts: "2026-01-01T00:00:00Z", cwd: "/p/x", strategy: "headless", code: "PI_CRASHED", message: "crashed" };
78
+ mockReadSpawnFailures.mockReturnValue([entry] as never);
79
+ const res = await app.inject({ method: "GET", url: "/api/spawn-failures" });
80
+ const body = JSON.parse(res.body);
81
+ expect(body.entries).toHaveLength(1);
82
+ expect(body.entries[0].code).toBe("PI_CRASHED");
83
+ });
84
+ });
@@ -242,6 +242,51 @@ describe("TerminalManager", () => {
242
242
  handlers.message(resizeMsg, false);
243
243
  expect(mockPtyResize).toHaveBeenCalledWith(120, 40);
244
244
  });
245
+
246
+ // Resize floor — see change: fix-terminal-half-height-dual-mount.
247
+ // PTYs at <2 cols/rows are non-functional for every supported shell
248
+ // and the most common cause is a transient display:none container
249
+ // measured by FitAddon during a route transition.
250
+ describe("resize floor", () => {
251
+ function attachAndSendResize(cols: number, rows: number) {
252
+ const session = manager.spawn("/tmp");
253
+ const handlers: Record<string, Function> = {};
254
+ const mockWs = {
255
+ send: vi.fn(),
256
+ on: vi.fn((event: string, cb: any) => { handlers[event] = cb; }),
257
+ readyState: 1,
258
+ OPEN: 1,
259
+ } as any;
260
+ manager.attach(session.id, mockWs);
261
+ const msg = Buffer.from(JSON.stringify({ type: "resize", cols, rows }));
262
+ handlers.message(msg, false);
263
+ }
264
+
265
+ it("ignores resize with cols below floor (cols=1)", () => {
266
+ attachAndSendResize(1, 24);
267
+ expect(mockPtyResize).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it("ignores resize with rows below floor (rows=0)", () => {
271
+ attachAndSendResize(80, 0);
272
+ expect(mockPtyResize).not.toHaveBeenCalled();
273
+ });
274
+
275
+ it("ignores resize with both dimensions below floor", () => {
276
+ attachAndSendResize(1, 1);
277
+ expect(mockPtyResize).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it("accepts resize at the floor (cols=2, rows=2)", () => {
281
+ attachAndSendResize(2, 2);
282
+ expect(mockPtyResize).toHaveBeenCalledWith(2, 2);
283
+ });
284
+
285
+ it("accepts a normal resize", () => {
286
+ attachAndSendResize(80, 24);
287
+ expect(mockPtyResize).toHaveBeenCalledWith(80, 24);
288
+ });
289
+ });
245
290
  });
246
291
 
247
292
  describe("PTY exit", () => {