Files

201 lines
6.8 KiB
JavaScript
Raw Permalink Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, once } = require("node:events");
const { PassThrough } = require("node:stream");
const {
CodexAppServerConnection,
buildCodexAppServerKey,
buildCodexAppServerLaunch,
} = require("./connection.cjs");
function createFakeChild() {
const child = new EventEmitter();
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killed = false;
child.kill = () => { child.killed = true; };
return child;
}
async function readJsonLine(stream) {
const [chunk] = await once(stream, "data");
return JSON.parse(String(chunk).trim());
}
test("buildCodexAppServerLaunch runs JS entries through Node without a shell", () => {
assert.deepEqual(
buildCodexAppServerLaunch("/opt/codex/bin/codex.js", ["app-server", "--help"], { nodePath: "/usr/bin/node" }),
{
command: "/usr/bin/node",
args: ["/opt/codex/bin/codex.js", "app-server", "--help"],
env: { ELECTRON_RUN_AS_NODE: "1" },
},
);
assert.deepEqual(
buildCodexAppServerLaunch("/usr/local/bin/codex"),
{ command: "/usr/local/bin/codex", args: ["app-server", "--stdio"] },
);
assert.throws(() => buildCodexAppServerLaunch("C:\\npm\\codex.cmd"), /shell shim/);
});
test("App Server connection initializes once and correlates JSONL requests", async () => {
const child = createFakeChild();
const notifications = [];
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: { HOME: "/tmp/home" },
appVersion: "1.2.3",
spawnImpl: () => child,
onNotification: (message) => notifications.push(message),
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
assert.equal(initialize.method, "initialize");
assert.equal(initialize.params.clientInfo.name, "netcatty");
assert.equal(initialize.params.capabilities.experimentalApi, true);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: { userAgent: "codex" } })}\n`);
await startPromise;
const initialized = await readJsonLine(child.stdin);
assert.equal(initialized.method, "initialized");
const requestPromise = connection.request("model/list", { limit: 100 });
const request = await readJsonLine(child.stdin);
assert.equal(request.method, "model/list");
child.stdout.write(`${JSON.stringify({ id: request.id, result: { data: [], nextCursor: null } })}\n`);
assert.deepEqual(await requestPromise, { data: [], nextCursor: null });
child.stdout.write(`${JSON.stringify({ method: "warning", params: { message: "heads up" } })}\n`);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(notifications[0].method, "warning");
connection.close();
});
test("App Server connection preserves a Chinese response split across UTF-8 chunks", async () => {
const child = createFakeChild();
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
const response = Buffer.from(`${JSON.stringify({
id: initialize.id,
result: { message: "中文" },
})}\n`, "utf8");
const split = response.indexOf(Buffer.from("中", "utf8")) + 1;
child.stdout.write(response.subarray(0, split));
child.stdout.write(response.subarray(split));
await startPromise;
await readJsonLine(child.stdin);
connection.close();
});
test("App Server connection rejects an unterminated oversized JSONL message", async () => {
const child = createFakeChild();
let fatal;
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
maxJsonlLineBytes: 8,
spawnImpl: () => child,
onFatal: (error) => { fatal = error; },
});
const startPromise = connection.start();
await readJsonLine(child.stdin);
child.stdout.write("123456789");
await assert.rejects(startPromise, /message exceeded 8 bytes/);
assert.match(fatal.message, /message exceeded 8 bytes/);
assert.equal(child.killed, true);
});
test("App Server connection rejects pending RPCs when the process exits", async () => {
const child = createFakeChild();
let fatal;
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
spawnImpl: () => child,
onFatal: (error) => { fatal = error; },
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin); // initialized notification
const request = connection.request("thread/start", {});
await readJsonLine(child.stdin);
child.emit("exit", 1, null);
await assert.rejects(request, /exited unexpectedly/);
assert.match(fatal.message, /code 1/);
});
test("App Server close force-kills a child that ignores SIGTERM", async () => {
const child = createFakeChild();
const signals = [];
child.kill = (signal) => {
signals.push(signal);
return true;
};
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
closeKillGraceMs: 5,
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin);
connection.close();
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
});
test("App Server close does not retain or re-kill a child that exits synchronously on SIGTERM", async () => {
const child = createFakeChild();
const signals = [];
child.kill = (signal) => {
signals.push(signal);
if (signal === "SIGTERM") child.emit("exit", 0, "SIGTERM");
return true;
};
const connection = new CodexAppServerConnection({
binPath: "/usr/bin/codex",
env: {},
closeKillGraceMs: 5,
spawnImpl: () => child,
});
const startPromise = connection.start();
const initialize = await readJsonLine(child.stdin);
child.stdout.write(`${JSON.stringify({ id: initialize.id, result: {} })}\n`);
await startPromise;
await readJsonLine(child.stdin);
connection.close();
assert.equal(connection.getClosingProcessCountForTests(), 0);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(signals, ["SIGTERM"]);
});
test("App Server process keys include executable and environment identity", () => {
assert.notEqual(
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
buildCodexAppServerKey("/b/codex", { HOME: "/a" }),
);
assert.notEqual(
buildCodexAppServerKey("/a/codex", { HOME: "/a" }),
buildCodexAppServerKey("/a/codex", { HOME: "/b" }),
);
});