183 lines
7.5 KiB
TypeScript
183 lines
7.5 KiB
TypeScript
import { test, expect, vi } from "vitest";
|
|
import { spawn } from "node:child_process";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { EventEmitter } from "node:events";
|
|
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
|
|
import { loadConfig } from "../src/config.js";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const FAKE = path.resolve(__dirname, "../../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
|
const SCRIPT = path.resolve(__dirname, "../../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
|
|
|
test("spawnFor avvia un runtime e il bridge emette il widget F1", async () => {
|
|
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
|
const rt = await mgr.spawnFor("2026-06-27-100000-x", {});
|
|
const widget = await new Promise<any>((res) => rt.bridge.onClientEvent((e) => e.type === "ui_request" && res(e)));
|
|
expect(widget.ui_request.widget).toBe("select");
|
|
mgr.teardown("2026-06-27-100000-x");
|
|
expect(mgr.count()).toBe(0);
|
|
});
|
|
|
|
test("l'exit del child rimuove il runtime dalla mappa (exit handler)", async () => {
|
|
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
|
const rt = await mgr.spawnFor("exit-test", {});
|
|
expect(mgr.count()).toBe(1);
|
|
// Cause the child to exit on its own and await the 'exit' event (no teardown call).
|
|
const exited = new Promise<void>((res) => rt.child.on("exit", () => res()));
|
|
rt.child.kill();
|
|
await exited;
|
|
// Let the manager's registered exit handler run.
|
|
await new Promise((res) => setImmediate(res));
|
|
expect(mgr.count()).toBe(0);
|
|
expect(mgr.get("exit-test")).toBeUndefined();
|
|
});
|
|
|
|
test("spawnFor sullo STESSO id uccide il vecchio child e sostituisce il runtime (count resta 1)", async () => {
|
|
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
|
const first = await mgr.spawnFor("dup-id", {});
|
|
expect(mgr.count()).toBe(1);
|
|
const firstExited = new Promise<void>((res) => first.child.on("exit", () => res()));
|
|
const second = await mgr.spawnFor("dup-id", {});
|
|
await firstExited; // the old child was killed by the idempotent respawn
|
|
expect(mgr.count()).toBe(1);
|
|
expect(mgr.get("dup-id")).toBe(second);
|
|
expect(second).not.toBe(first);
|
|
mgr.teardown("dup-id");
|
|
});
|
|
|
|
test("l'exit del VECCHIO child non elimina il nuovo runtime (exit identity-checked)", async () => {
|
|
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
|
const first = await mgr.spawnFor("respawn-id", {});
|
|
const second = await mgr.spawnFor("respawn-id", {});
|
|
// The old child's exit handler fires after the respawn; it must NOT evict `second`.
|
|
await new Promise((res) => setImmediate(res));
|
|
expect(mgr.get("respawn-id")).toBe(second);
|
|
expect(mgr.count()).toBe(1);
|
|
void first;
|
|
mgr.teardown("respawn-id");
|
|
});
|
|
|
|
test("oltre maxPiProcesses solleva errore", async () => {
|
|
const cfg = { ...loadConfig({}), maxPiProcesses: 1 };
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
|
|
await mgr.spawnFor("a", {});
|
|
await expect(mgr.spawnFor("b", {})).rejects.toThrow(/max/i);
|
|
mgr.teardown("a");
|
|
});
|
|
|
|
function recordingChild() {
|
|
const ch: any = new EventEmitter();
|
|
ch.stdout = new EventEmitter();
|
|
ch.stderr = new EventEmitter();
|
|
ch._writes = [] as string[];
|
|
ch.stdin = { write: (d: any) => { ch._writes.push(String(d)); return true; } };
|
|
ch.kill = () => {};
|
|
return ch;
|
|
}
|
|
|
|
test("un exit INATTESO del child notifica il client (info error + agent_end)", async () => {
|
|
const cfg = loadConfig({});
|
|
const child = recordingChild();
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
|
|
const rt = await mgr.spawnFor("crash-id", {});
|
|
const seen: any[] = [];
|
|
rt.bridge.onClientEvent((e) => seen.push(e));
|
|
child.emit("exit", 137);
|
|
expect(seen).toEqual([
|
|
{ type: "info", level: "error", text: expect.stringContaining("137") },
|
|
{ type: "system_event", event: "agent_end" },
|
|
]);
|
|
expect(mgr.count()).toBe(0);
|
|
});
|
|
|
|
test("l'exit dopo teardown NON emette eventi al client (uscita attesa)", async () => {
|
|
const cfg = loadConfig({});
|
|
const child = recordingChild();
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
|
|
const rt = await mgr.spawnFor("stop-id", {});
|
|
const seen: any[] = [];
|
|
rt.bridge.onClientEvent((e) => seen.push(e));
|
|
mgr.teardown("stop-id");
|
|
child.emit("exit", 0);
|
|
expect(seen).toEqual([]);
|
|
});
|
|
|
|
test("spawnFor resume mode sends /riprendi-sessione <id>", async () => {
|
|
const cfg = loadConfig({}); // no provider/model/thinking -> no rpc.request handshakes
|
|
const child = recordingChild();
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
|
|
await mgr.spawnFor("sid-9", { mode: "resume" });
|
|
expect(child._writes.join("")).toContain("/riprendi-sessione sid-9");
|
|
expect(child._writes.join("")).not.toContain("/nuova-domanda");
|
|
mgr.teardown("sid-9");
|
|
});
|
|
|
|
test("spawnFor default (new) mode sends /nuova-domanda", async () => {
|
|
const cfg = loadConfig({});
|
|
const child = recordingChild();
|
|
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
|
|
await mgr.spawnFor("sid-10", {});
|
|
expect(child._writes.join("")).toContain("/nuova-domanda");
|
|
mgr.teardown("sid-10");
|
|
});
|
|
|
|
test("production spawn uses explicit Pi path and passes portable data root without rewriting PATH", async () => {
|
|
vi.stubEnv("PATH", "/usr/local/bin:/usr/bin");
|
|
vi.stubEnv("PI_PROVIDER_API_KEY", "provider-secret");
|
|
vi.stubEnv("NODE_EXTRA_CA_CERTS", "/certs/company-ca.pem");
|
|
const calls: any[][] = [];
|
|
const child = recordingChild();
|
|
child.stderr.resume = () => {};
|
|
const spawnFn = (...args: any[]) => { calls.push(args); return child as any; };
|
|
const cfg = loadConfig({
|
|
THT_HARNESS_DIR: "/app/harness",
|
|
PI_BIN: "/usr/local/bin/pi",
|
|
THT_DATA_ROOT: "/data",
|
|
});
|
|
const mgr = new PiProcessManager(cfg, { spawnFn });
|
|
|
|
try {
|
|
await mgr.spawnFor("portable-session", { author: "user@example.test" });
|
|
const [bin, args, options] = calls[0];
|
|
expect(bin).toBe("/usr/local/bin/pi");
|
|
expect(args).toEqual(["--mode", "rpc"]);
|
|
expect(options.cwd).toBe("/app/harness");
|
|
expect(options.env).toMatchObject({
|
|
PATH: "/usr/local/bin:/usr/bin",
|
|
PI_PROVIDER_API_KEY: "provider-secret",
|
|
NODE_EXTRA_CA_CERTS: "/certs/company-ca.pem",
|
|
THT_DATA_ROOT: "/data",
|
|
THT_SESSION: "portable-session",
|
|
THT_AUTHOR: "user@example.test",
|
|
});
|
|
} finally {
|
|
mgr.teardown("portable-session");
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
test("session Pi spawn omits ambient THT_DATA_ROOT when config does not provide one", async () => {
|
|
vi.stubEnv("THT_DATA_ROOT", "/ambient-must-not-leak");
|
|
vi.stubEnv("PI_PROVIDER_API_KEY", "still-inherited");
|
|
const calls: any[][] = [];
|
|
const child = recordingChild();
|
|
child.stderr.resume = () => {};
|
|
const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
|
|
spawnFn: (...args: any[]) => { calls.push(args); return child as any; },
|
|
});
|
|
|
|
try {
|
|
await mgr.spawnFor("no-data-root", {});
|
|
expect(calls[0][2].env).not.toHaveProperty("THT_DATA_ROOT");
|
|
expect(calls[0][2].env.PI_PROVIDER_API_KEY).toBe("still-inherited");
|
|
} finally {
|
|
mgr.teardown("no-data-root");
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|