@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,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(
|
|
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: "
|
|
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("
|
|
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
|
|
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", () => {
|