pi-mono/packages/coding-agent/test/experimental-server-replacement.test.ts
2026-08-17 01:25:40 +03:00

158 lines
5.4 KiB
TypeScript

import { type ChildProcess, spawn } from "node:child_process";
import { once } from "node:events";
import { lstat, mkdir, mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import { runClient } from "../src/experimental/client.ts";
import { configureExperimentalWorkerModel, createExperimentalSessions } from "./experimental-session-support.ts";
const cliPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const processes = new Set<ChildProcess>();
const directories = new Set<string>();
interface RunningCli {
readonly child: ChildProcess;
readonly output: () => string;
readonly errors: () => string;
}
async function startServer(home: string, agentDir: string, serverDir: string): Promise<RunningCli> {
const child = spawn(
process.execPath,
["--import", "tsx", cliPath, "server", "--provider", "anthropic", "--model", "claude-sonnet-4-5"],
{
cwd: fileURLToPath(new URL("../../..", import.meta.url)),
env: {
...process.env,
HOME: home,
PI_CODING_AGENT_DIR: agentDir,
PI_EXPERIMENTAL: "1",
PI_SERVER_DIR: serverDir,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
processes.add(child);
let output = "";
let errors = "";
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
output += chunk;
});
child.stderr?.on("data", (chunk: string) => {
errors += chunk;
});
return { child, output: () => output, errors: () => errors };
}
async function waitForOutput(process: RunningCli, pattern: RegExp, timeoutMs = 10_000): Promise<RegExpMatchArray> {
const existing = process.output().match(pattern);
if (existing) return existing;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(
new Error(`Timed out waiting for ${pattern}; stdout: ${process.output()}; stderr: ${process.errors()}`),
);
}, timeoutMs);
const inspect = (): void => {
const match = process.output().match(pattern);
if (!match) return;
cleanup();
resolve(match);
};
const exited = (code: number | null, signal: NodeJS.Signals | null): void => {
cleanup();
reject(
new Error(
`Server exited before ${pattern} (${signal ?? code ?? "unknown"}); stdout: ${process.output()}; stderr: ${process.errors()}`,
),
);
};
const cleanup = (): void => {
clearTimeout(timeout);
process.child.stdout?.off("data", inspect);
process.child.off("exit", exited);
};
process.child.stdout?.on("data", inspect);
process.child.once("exit", exited);
});
}
async function waitForExit(process: ChildProcess): Promise<void> {
if (process.exitCode !== null || process.signalCode !== null) return;
await once(process, "exit");
}
async function stop(process: ChildProcess): Promise<void> {
if (process.exitCode !== null || process.signalCode !== null) return;
const exited = once(process, "exit");
process.kill("SIGTERM");
await exited;
}
afterEach(async () => {
await Promise.all([...processes].map((process) => stop(process)));
processes.clear();
await Promise.all([...directories].map((directory) => rm(directory, { recursive: true, force: true })));
directories.clear();
});
describe.skipIf(process.platform === "win32")("experimental CLI server replacement", () => {
test("a second CLI server takes over the stable coordinator socket", async () => {
const root = await mkdtemp(join("/tmp", "pcs-"));
directories.add(root);
const home = join(root, "long-home-segment".repeat(8));
await mkdir(home);
const agentDir = join(root, "agent");
const serverDir = join(root, "server");
await configureExperimentalWorkerModel(agentDir);
await createExperimentalSessions(join(agentDir, "experimental", "sessions"), ["demo-1", "demo-2"]);
const first = await startServer(home, agentDir, serverDir);
const firstIdentity = await waitForOutput(
first,
/Server: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/,
);
const firstSocket = await waitForOutput(first, /Socket: (.+\.sock)/);
expect(firstSocket[1]).toBe(join(serverDir, `${firstIdentity[1]}.sock`));
expect((await lstat(join(serverDir, `control-${firstIdentity[1]}.sock`))).isSocket()).toBe(true);
await runClient({
command: "client",
sessionId: "demo-1",
connect: { transport: "unix", path: firstSocket[1]! },
});
const replacement = await startServer(home, agentDir, serverDir);
const replacementIdentity = await waitForOutput(
replacement,
/Server: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/,
);
const replacementSocket = await waitForOutput(replacement, /Socket: (.+\.sock)/);
await waitForExit(first.child);
expect(replacementIdentity[1]).toBe(firstIdentity[1]);
expect(replacementSocket[1]).toBe(firstSocket[1]);
expect(replacement.child.pid).not.toBe(first.child.pid);
await expect(
runClient({
command: "client",
connect: { transport: "unix", path: replacementSocket[1]! },
}),
).resolves.toMatchObject({
kind: "list",
sessions: [
{ serverId: firstIdentity[1], sessionId: "demo-1" },
{ serverId: firstIdentity[1], sessionId: "demo-2" },
],
});
await expect(
runClient({
command: "client",
sessionId: "demo-1",
connect: { transport: "unix", path: replacementSocket[1]! },
}),
).resolves.toMatchObject({ kind: "attached", sessionId: "demo-1" });
});
});