server/acp-shutdown.e2e.test.ts
Rama main · réplica de lectura del repositorio original.
import { spawn } from "node:child_process";
import { closeSync, openSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { launchVerificationServer, runControlOmb, verificationServerArgs, verificationServerEnvironment } from "../scripts/control-omb.ts";
import { waitForExit } from "./testing/cleanup.ts";
it("keeps an ACP partial reply in the transcript across graceful shutdown and restart", async () => {
const fixture = await launchVerificationServer({}, undefined, undefined, undefined, undefined, undefined, ["cline"]);
const { dataDir, url, logPath } = fixture.info;
const port = Number(new URL(url).port);
let server = fixture.child;
const start = async () => {
const env = verificationServerEnvironment({}, dataDir, port);
const log = openSync(logPath, "a", 0o600);
// Windows child.kill("SIGTERM") terminates immediately without running Node's signal handlers.
const bootstrap = `process.on('message', message => { if (message === 'stop') process.emit('SIGTERM'); });
await import(${JSON.stringify(new URL("./index.ts", import.meta.url).href)});`;
server = spawn(process.execPath, [...verificationServerArgs(env).slice(0, -1), "--input-type=module", "-e", bootstrap], {
cwd: fileURLToPath(new URL("..", import.meta.url)), env, stdio: ["ignore", log, log, "ipc"],
});
closeSync(log);
await expect.poll(async () => {
try { return (await fetch(`${url}/api/health`)).ok; } catch { return false; }
}, { timeout: 30_000 }).toBe(true);
};
const stop = async () => {
if (server.connected) {
server.send("stop");
await waitForExit(server, { graceMs: 10_000 });
} else {
await waitForExit(server, { signal: "SIGTERM" });
}
};
const cli = (...args: string[]) => runControlOmb([...args, "--url", url]) as Promise<any>;
try {
await stop();
const configPath = join(dataDir, "config.json");
const config = JSON.parse(readFileSync(configPath, "utf8"));
config.instances.cline.environment.FAKE_ACP_MODE = "stall-after-text";
writeFileSync(configPath, JSON.stringify(config));
await start();
const { bot } = await cli("new-bot", "--name", "Shutdown probe");
await cli("set-model", "--bot", bot.id, "--instance", "cline", "--model", "cline-default");
await cli("send", "--bot", bot.id, "--task", bot.activeTaskId, "--text", "hello");
await expect.poll(() => {
try {
return readFileSync(join(dataDir, "events", `${bot.activeTaskId}.ndjson`), "utf8")
.includes('"delta":"half an answer, then silence"');
} catch { return false; }
}, { timeout: 15_000 }).toBe(true);
await stop();
expect(server.exitCode).toBe(0);
const db = new DatabaseSync(join(dataDir, "messages.db"), { readOnly: true });
try {
expect(db.prepare("SELECT text FROM messages WHERE thread_id = ? AND role = 'bot' AND kind = 'text' AND text = ?")
.all(bot.activeTaskId, "half an answer, then silence")).toEqual([{ text: "half an answer, then silence" }]);
} finally { db.close(); }
await start();
const transcript = await cli("messages", "--bot", bot.id, "--task", bot.activeTaskId, "--limit", "20");
expect(JSON.stringify(transcript)).toContain("half an answer, then silence");
} finally {
await stop();
await fixture.close();
}
}, 90_000);