@blackbelt-technology/pi-agent-dashboard 0.5.1 → 0.5.2
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 +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- package/packages/shared/src/resolve-jiti.ts +0 -155
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KeeperManager unit tests (task 4.6).
|
|
3
|
+
*
|
|
4
|
+
* Mocks `spawnDetached` and `net.createConnection` to assert:
|
|
5
|
+
* - spawnKeeperFor argv / spawn options shape
|
|
6
|
+
* - writeRpc retry-then-succeed and retry-then-fail behavior
|
|
7
|
+
* - killKeeper sends SIGTERM to the tracked PID via killPidWithGroup
|
|
8
|
+
* - discoverExistingKeepers correctly classifies live / stale / orphan
|
|
9
|
+
*
|
|
10
|
+
* Integration of the real keeper.cjs binary is exercised in
|
|
11
|
+
* `rpc-keeper/__tests__/keeper.test.ts`; this file stays at unit-level.
|
|
12
|
+
*/
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
import type {
|
|
19
|
+
SpawnDetachedOptions,
|
|
20
|
+
SpawnDetachedResult,
|
|
21
|
+
} from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
|
|
22
|
+
import type { ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
23
|
+
import {
|
|
24
|
+
createKeeperManager,
|
|
25
|
+
pidPathFor,
|
|
26
|
+
sockPathFor,
|
|
27
|
+
type KeeperManagerOptions,
|
|
28
|
+
} from "../rpc-keeper/keeper-manager.js";
|
|
29
|
+
|
|
30
|
+
// ── Fake spawnDetached ───────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
class FakeChildProcess extends EventEmitter {
|
|
33
|
+
pid: number | undefined;
|
|
34
|
+
unref = vi.fn();
|
|
35
|
+
kill = vi.fn();
|
|
36
|
+
stdio = [null, null, null] as const;
|
|
37
|
+
constructor(pid: number | undefined) {
|
|
38
|
+
super();
|
|
39
|
+
this.pid = pid;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeFakeSpawnDetached(opts: { pid?: number; ok?: boolean; error?: string } = {}): {
|
|
44
|
+
spawn: (opts: SpawnDetachedOptions) => Promise<SpawnDetachedResult>;
|
|
45
|
+
calls: SpawnDetachedOptions[];
|
|
46
|
+
lastChild: { current: FakeChildProcess | null };
|
|
47
|
+
} {
|
|
48
|
+
const calls: SpawnDetachedOptions[] = [];
|
|
49
|
+
const lastChild = { current: null as FakeChildProcess | null };
|
|
50
|
+
const spawn = async (spawnOpts: SpawnDetachedOptions): Promise<SpawnDetachedResult> => {
|
|
51
|
+
calls.push(spawnOpts);
|
|
52
|
+
if (opts.ok === false) return { ok: false, error: opts.error ?? "forced fail" };
|
|
53
|
+
const c = new FakeChildProcess(opts.pid);
|
|
54
|
+
lastChild.current = c;
|
|
55
|
+
return { ok: true, pid: opts.pid, process: c as unknown as ChildProcess };
|
|
56
|
+
};
|
|
57
|
+
return { spawn, calls, lastChild };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Fake net.createConnection ────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
interface FakeConnectionConfig {
|
|
63
|
+
attempts: Array<"connect-ok" | "error" | "timeout">;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class FakeSocket extends EventEmitter {
|
|
67
|
+
destroyed = false;
|
|
68
|
+
end = vi.fn((_data: unknown, _enc: unknown, cb?: () => void) => {
|
|
69
|
+
if (cb) setImmediate(cb);
|
|
70
|
+
});
|
|
71
|
+
destroy = vi.fn(() => { this.destroyed = true; });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeFakeCreateConnection(cfg: FakeConnectionConfig): {
|
|
75
|
+
createConnection: typeof net.createConnection;
|
|
76
|
+
connectCount: () => number;
|
|
77
|
+
pathsCalled: string[];
|
|
78
|
+
} {
|
|
79
|
+
let i = 0;
|
|
80
|
+
const pathsCalled: string[] = [];
|
|
81
|
+
const fn = ((arg: string | net.NetConnectOpts) => {
|
|
82
|
+
const p = typeof arg === "string" ? arg : (arg as net.IpcNetConnectOpts).path;
|
|
83
|
+
if (typeof p === "string") pathsCalled.push(p);
|
|
84
|
+
const sock = new FakeSocket();
|
|
85
|
+
const behavior = cfg.attempts[i++] ?? "error";
|
|
86
|
+
setImmediate(() => {
|
|
87
|
+
if (behavior === "connect-ok") sock.emit("connect");
|
|
88
|
+
else if (behavior === "error") sock.emit("error", new Error("ECONNREFUSED"));
|
|
89
|
+
// "timeout" → do nothing; KeeperManager's per-attempt timer fires.
|
|
90
|
+
});
|
|
91
|
+
return sock as unknown as net.Socket;
|
|
92
|
+
}) as typeof net.createConnection;
|
|
93
|
+
return { createConnection: fn, connectCount: () => i, pathsCalled };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Common setup ─────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const KNOWN_DEAD_PID = 99999999; // far above max_pid; process.kill returns ESRCH
|
|
99
|
+
|
|
100
|
+
let tmpRoot: string;
|
|
101
|
+
let sessionsDir: string;
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
tmpRoot = mkdtempSync(path.join("/tmp", "km-"));
|
|
105
|
+
sessionsDir = path.join(tmpRoot, ".pi", "dashboard", "sessions");
|
|
106
|
+
});
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function baseOpts(extra: Partial<KeeperManagerOptions> = {}): KeeperManagerOptions {
|
|
112
|
+
return {
|
|
113
|
+
sessionsDir,
|
|
114
|
+
keeperPath: path.resolve(__dirname, "..", "rpc-keeper", "keeper.cjs"),
|
|
115
|
+
nodeBinary: "/usr/bin/node",
|
|
116
|
+
platform: process.platform,
|
|
117
|
+
...extra,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe("KeeperManager.spawnKeeperFor", () => {
|
|
124
|
+
it("delegates to spawnDetached with `node <keeper.cjs> <sessionId>`", async () => {
|
|
125
|
+
const { spawn, calls } = makeFakeSpawnDetached({ pid: 12345 });
|
|
126
|
+
const km = createKeeperManager(baseOpts({ spawnDetached: spawn }));
|
|
127
|
+
|
|
128
|
+
const result = await km.spawnKeeperFor("sess-1", "/some/cwd", { FOO: "bar" });
|
|
129
|
+
|
|
130
|
+
expect(result.success).toBe(true);
|
|
131
|
+
expect(result.pid).toBe(12345);
|
|
132
|
+
expect(result.sockPath).toBe(sockPathFor(sessionsDir, "sess-1"));
|
|
133
|
+
|
|
134
|
+
expect(calls).toHaveLength(1);
|
|
135
|
+
expect(calls[0].cmd).toBe("/usr/bin/node");
|
|
136
|
+
expect(calls[0].args).toEqual([baseOpts().keeperPath!, "sess-1"]);
|
|
137
|
+
expect(calls[0].cwd).toBe("/some/cwd");
|
|
138
|
+
expect(calls[0].stdinMode).toBe("ignore");
|
|
139
|
+
expect(calls[0].detach).toBe(true);
|
|
140
|
+
expect((calls[0].env as { FOO?: string } | undefined)?.FOO).toBe("bar");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns success: false when spawnDetached reports !ok", async () => {
|
|
144
|
+
const { spawn } = makeFakeSpawnDetached({ ok: false, error: "no pid available" });
|
|
145
|
+
const km = createKeeperManager(baseOpts({ spawnDetached: spawn }));
|
|
146
|
+
const result = await km.spawnKeeperFor("sess-x", "/cwd", {});
|
|
147
|
+
expect(result.success).toBe(false);
|
|
148
|
+
expect(result.error).toMatch(/no pid/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns success: false when keeper.cjs path does not exist", async () => {
|
|
152
|
+
const { spawn } = makeFakeSpawnDetached({ pid: 1 });
|
|
153
|
+
const km = createKeeperManager(
|
|
154
|
+
baseOpts({ spawnDetached: spawn, keeperPath: "/does/not/exist/keeper.cjs" }),
|
|
155
|
+
);
|
|
156
|
+
const result = await km.spawnKeeperFor("sess-x", "/cwd", {});
|
|
157
|
+
expect(result.success).toBe(false);
|
|
158
|
+
expect(result.error).toMatch(/keeper\.cjs not found/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("KeeperManager.writeRpc", () => {
|
|
163
|
+
it("writes line on first successful attempt and returns true", async () => {
|
|
164
|
+
const cfg: FakeConnectionConfig = { attempts: ["connect-ok"] };
|
|
165
|
+
const { createConnection, connectCount, pathsCalled } = makeFakeCreateConnection(cfg);
|
|
166
|
+
const km = createKeeperManager(baseOpts({ createConnection }));
|
|
167
|
+
|
|
168
|
+
const ok = await km.writeRpc("sess-1", '{"x":1}');
|
|
169
|
+
expect(ok).toBe(true);
|
|
170
|
+
expect(connectCount()).toBe(1);
|
|
171
|
+
expect(pathsCalled[0]).toBe(sockPathFor(sessionsDir, "sess-1"));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("retries after error and succeeds on attempt 2", async () => {
|
|
175
|
+
const cfg: FakeConnectionConfig = { attempts: ["error", "connect-ok"] };
|
|
176
|
+
const { createConnection, connectCount } = makeFakeCreateConnection(cfg);
|
|
177
|
+
const km = createKeeperManager(baseOpts({ createConnection }));
|
|
178
|
+
|
|
179
|
+
const ok = await km.writeRpc("sess-1", '{"x":1}');
|
|
180
|
+
expect(ok).toBe(true);
|
|
181
|
+
expect(connectCount()).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns false after 3 failed attempts", async () => {
|
|
185
|
+
const cfg: FakeConnectionConfig = { attempts: ["error", "error", "error"] };
|
|
186
|
+
const { createConnection, connectCount } = makeFakeCreateConnection(cfg);
|
|
187
|
+
const km = createKeeperManager(baseOpts({ createConnection }));
|
|
188
|
+
|
|
189
|
+
const ok = await km.writeRpc("sess-1", '{"x":1}');
|
|
190
|
+
expect(ok).toBe(false);
|
|
191
|
+
expect(connectCount()).toBe(3);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("appends trailing newline if missing", async () => {
|
|
195
|
+
let captured = "";
|
|
196
|
+
const fn = ((arg: unknown) => {
|
|
197
|
+
const sock = new FakeSocket();
|
|
198
|
+
sock.end = vi.fn((data: unknown, _enc: unknown, cb?: () => void) => {
|
|
199
|
+
captured = String(data);
|
|
200
|
+
if (cb) setImmediate(cb);
|
|
201
|
+
}) as unknown as FakeSocket["end"];
|
|
202
|
+
setImmediate(() => sock.emit("connect"));
|
|
203
|
+
return sock as unknown as net.Socket;
|
|
204
|
+
}) as typeof net.createConnection;
|
|
205
|
+
|
|
206
|
+
const km = createKeeperManager(baseOpts({ createConnection: fn }));
|
|
207
|
+
await km.writeRpc("sess-1", '{"x":1}');
|
|
208
|
+
expect(captured).toBe('{"x":1}\n');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("does NOT append a second newline if line already ends with \\n", async () => {
|
|
212
|
+
let captured = "";
|
|
213
|
+
const fn = ((arg: unknown) => {
|
|
214
|
+
const sock = new FakeSocket();
|
|
215
|
+
sock.end = vi.fn((data: unknown, _enc: unknown, cb?: () => void) => {
|
|
216
|
+
captured = String(data);
|
|
217
|
+
if (cb) setImmediate(cb);
|
|
218
|
+
}) as unknown as FakeSocket["end"];
|
|
219
|
+
setImmediate(() => sock.emit("connect"));
|
|
220
|
+
return sock as unknown as net.Socket;
|
|
221
|
+
}) as typeof net.createConnection;
|
|
222
|
+
|
|
223
|
+
const km = createKeeperManager(baseOpts({ createConnection: fn }));
|
|
224
|
+
await km.writeRpc("sess-1", '{"x":1}\n');
|
|
225
|
+
expect(captured).toBe('{"x":1}\n');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("KeeperManager.killKeeper", () => {
|
|
230
|
+
it("returns false when no spawn has been tracked for sessionId", () => {
|
|
231
|
+
const km = createKeeperManager(baseOpts());
|
|
232
|
+
expect(km.killKeeper("never-spawned")).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("sends SIGTERM to the tracked PID after a successful spawn", async () => {
|
|
236
|
+
const { spawn } = makeFakeSpawnDetached({ pid: 77777 });
|
|
237
|
+
const km = createKeeperManager(baseOpts({ spawnDetached: spawn }));
|
|
238
|
+
await km.spawnKeeperFor("sess-k", "/cwd", {});
|
|
239
|
+
|
|
240
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
241
|
+
const ok = km.killKeeper("sess-k");
|
|
242
|
+
expect(ok).toBe(true);
|
|
243
|
+
const target = process.platform === "win32" ? 77777 : -77777; // platform-branch-ok
|
|
244
|
+
expect(killSpy).toHaveBeenCalledWith(target, "SIGTERM");
|
|
245
|
+
killSpy.mockRestore();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("KeeperManager.discoverExistingKeepers", () => {
|
|
250
|
+
it("returns empty list when sessions dir is missing", async () => {
|
|
251
|
+
const km = createKeeperManager(baseOpts({ sessionsDir: path.join(tmpRoot, "nope") }));
|
|
252
|
+
const r = await km.discoverExistingKeepers();
|
|
253
|
+
expect(r).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("returns live entry when keeper PID and pi PID are both alive", async () => {
|
|
257
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
258
|
+
const sid = "sess-live";
|
|
259
|
+
const pidFile = pidPathFor(sessionsDir, sid);
|
|
260
|
+
writeFileSync(pidFile, String(process.pid));
|
|
261
|
+
|
|
262
|
+
const km = createKeeperManager(baseOpts({ isPiAliveForSession: () => true }));
|
|
263
|
+
const r = await km.discoverExistingKeepers();
|
|
264
|
+
expect(r).toHaveLength(1);
|
|
265
|
+
expect(r[0].sessionId).toBe(sid);
|
|
266
|
+
expect(r[0].keeperPid).toBe(process.pid);
|
|
267
|
+
expect(existsSync(pidFile)).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("unlinks sidecar when keeper PID is dead", async () => {
|
|
271
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
272
|
+
const sid = "sess-dead-keeper";
|
|
273
|
+
const pidFile = pidPathFor(sessionsDir, sid);
|
|
274
|
+
writeFileSync(pidFile, String(KNOWN_DEAD_PID));
|
|
275
|
+
|
|
276
|
+
const km = createKeeperManager(baseOpts({ isPiAliveForSession: () => true }));
|
|
277
|
+
const r = await km.discoverExistingKeepers();
|
|
278
|
+
expect(r).toEqual([]);
|
|
279
|
+
expect(existsSync(pidFile)).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("kills keeper and unlinks sidecar when pi is dead but keeper is alive", async () => {
|
|
283
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
284
|
+
const sid = "sess-orphan-keeper";
|
|
285
|
+
const pidFile = pidPathFor(sessionsDir, sid);
|
|
286
|
+
writeFileSync(pidFile, String(process.pid));
|
|
287
|
+
|
|
288
|
+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
|
289
|
+
const km = createKeeperManager(baseOpts({ isPiAliveForSession: () => false }));
|
|
290
|
+
const r = await km.discoverExistingKeepers();
|
|
291
|
+
expect(r).toEqual([]);
|
|
292
|
+
const target = process.platform === "win32" ? process.pid : -process.pid; // platform-branch-ok
|
|
293
|
+
const sigtermCalls = killSpy.mock.calls.filter((c) => c[1] === "SIGTERM");
|
|
294
|
+
expect(sigtermCalls).toContainEqual([target, "SIGTERM"]);
|
|
295
|
+
expect(existsSync(pidFile)).toBe(false);
|
|
296
|
+
killSpy.mockRestore();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
parseVersion,
|
|
7
|
+
legacyPathUnder,
|
|
8
|
+
detectLegacyPiInstalls,
|
|
9
|
+
uninstallLegacyPi,
|
|
10
|
+
LEGACY_PI_PACKAGE,
|
|
11
|
+
type LegacyPiInstall,
|
|
12
|
+
} from "../legacy-pi-cleanup.js";
|
|
13
|
+
|
|
14
|
+
describe("parseVersion", () => {
|
|
15
|
+
it("returns version from valid json", () => {
|
|
16
|
+
expect(parseVersion('{"name":"x","version":"1.2.3"}')).toBe("1.2.3");
|
|
17
|
+
});
|
|
18
|
+
it("returns null on parse error", () => {
|
|
19
|
+
expect(parseVersion("not json")).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it("returns null when version missing", () => {
|
|
22
|
+
expect(parseVersion('{"name":"x"}')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("legacyPathUnder", () => {
|
|
27
|
+
it("joins node_modules with legacy package", () => {
|
|
28
|
+
const p = legacyPathUnder("/tmp/nm");
|
|
29
|
+
expect(p.endsWith(path.join("@mariozechner", "pi-coding-agent"))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("detectLegacyPiInstalls (filesystem)", () => {
|
|
34
|
+
let tmpHome: string;
|
|
35
|
+
let origHome: string | undefined;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "legacy-pi-test-"));
|
|
39
|
+
origHome = process.env.HOME;
|
|
40
|
+
process.env.HOME = tmpHome;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
if (origHome !== undefined) process.env.HOME = origHome;
|
|
45
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function plantLegacy(scopeDir: string, version: string): string {
|
|
49
|
+
const pkgDir = path.join(scopeDir, ...LEGACY_PI_PACKAGE.split("/"));
|
|
50
|
+
fs.mkdirSync(pkgDir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(pkgDir, "package.json"),
|
|
53
|
+
JSON.stringify({ name: LEGACY_PI_PACKAGE, version }),
|
|
54
|
+
);
|
|
55
|
+
return pkgDir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it("returns empty when nothing planted", () => {
|
|
59
|
+
// Note: this still consults `npm root -g` from the real system; if that
|
|
60
|
+
// happens to contain @mariozechner/pi-coding-agent the test would
|
|
61
|
+
// see it. We accept that and only assert npx-cache + managed are empty.
|
|
62
|
+
const found = detectLegacyPiInstalls();
|
|
63
|
+
expect(found.filter((f) => f.scope !== "npm-global")).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("detects npx-cache install", () => {
|
|
67
|
+
plantLegacy(path.join(tmpHome, ".npm", "_npx", "abc123", "node_modules"), "0.73.1");
|
|
68
|
+
const found = detectLegacyPiInstalls().filter((f) => f.scope === "npx-cache");
|
|
69
|
+
expect(found).toHaveLength(1);
|
|
70
|
+
expect(found[0].version).toBe("0.73.1");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("detects managed install", () => {
|
|
74
|
+
plantLegacy(path.join(tmpHome, ".pi-dashboard", "node_modules"), "0.70.0");
|
|
75
|
+
const found = detectLegacyPiInstalls().filter((f) => f.scope === "managed");
|
|
76
|
+
expect(found).toHaveLength(1);
|
|
77
|
+
expect(found[0].version).toBe("0.70.0");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("detects multiple npx-cache installs", () => {
|
|
81
|
+
plantLegacy(path.join(tmpHome, ".npm", "_npx", "h1", "node_modules"), "0.72.0");
|
|
82
|
+
plantLegacy(path.join(tmpHome, ".npm", "_npx", "h2", "node_modules"), "0.73.0");
|
|
83
|
+
const found = detectLegacyPiInstalls().filter((f) => f.scope === "npx-cache");
|
|
84
|
+
expect(found).toHaveLength(2);
|
|
85
|
+
expect(found.map((f) => f.version).sort()).toEqual(["0.72.0", "0.73.0"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("uninstallLegacyPi (filesystem subset)", () => {
|
|
90
|
+
let tmpHome: string;
|
|
91
|
+
let origHome: string | undefined;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "legacy-pi-rm-test-"));
|
|
95
|
+
origHome = process.env.HOME;
|
|
96
|
+
process.env.HOME = tmpHome;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
if (origHome !== undefined) process.env.HOME = origHome;
|
|
101
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function plant(scope: "managed" | "npx-cache", version: string): LegacyPiInstall {
|
|
105
|
+
const base =
|
|
106
|
+
scope === "managed"
|
|
107
|
+
? path.join(tmpHome, ".pi-dashboard", "node_modules")
|
|
108
|
+
: path.join(tmpHome, ".npm", "_npx", "x1", "node_modules");
|
|
109
|
+
const pkgDir = path.join(base, ...LEGACY_PI_PACKAGE.split("/"));
|
|
110
|
+
fs.mkdirSync(pkgDir, { recursive: true });
|
|
111
|
+
fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify({ version }));
|
|
112
|
+
return { scope, path: pkgDir, version };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it("removes managed install via rm -rf", () => {
|
|
116
|
+
const install = plant("managed", "0.70.0");
|
|
117
|
+
expect(fs.existsSync(install.path)).toBe(true);
|
|
118
|
+
const results = uninstallLegacyPi([install]);
|
|
119
|
+
expect(results[0]).toEqual({ scope: "managed", path: install.path, removed: true });
|
|
120
|
+
expect(fs.existsSync(install.path)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("removes npx-cache install via rm -rf", () => {
|
|
124
|
+
const install = plant("npx-cache", "0.73.1");
|
|
125
|
+
const results = uninstallLegacyPi([install]);
|
|
126
|
+
expect(results[0].removed).toBe(true);
|
|
127
|
+
expect(fs.existsSync(install.path)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns error result when path does not exist (rm force suppresses, returns removed=true)", () => {
|
|
131
|
+
// fs.rmSync with force:true treats missing paths as success.
|
|
132
|
+
const install: LegacyPiInstall = {
|
|
133
|
+
scope: "managed",
|
|
134
|
+
path: path.join(tmpHome, "does-not-exist"),
|
|
135
|
+
version: null,
|
|
136
|
+
};
|
|
137
|
+
const results = uninstallLegacyPi([install]);
|
|
138
|
+
expect(results[0].removed).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("processes multiple installs independently", () => {
|
|
142
|
+
const a = plant("managed", "0.70.0");
|
|
143
|
+
const b = plant("npx-cache", "0.73.0");
|
|
144
|
+
const results = uninstallLegacyPi([a, b]);
|
|
145
|
+
expect(results.every((r) => r.removed)).toBe(true);
|
|
146
|
+
expect(fs.existsSync(a.path)).toBe(false);
|
|
147
|
+
expect(fs.existsSync(b.path)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|