Track close separately from exit, preserve probe outcomes on cleanup errors, and cover inherited pipes and Windows cwd locks. Add cross-platform CI with the official Codex ACP process chain smoke check. Refs #523
101 lines
4.1 KiB
JavaScript
101 lines
4.1 KiB
JavaScript
// Run with Node (Electron's runtime) and an installed official codex-acp entry point.
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync, spawn } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { createAcpHostRuntime, stopAcpChild } from "../apps/desktop/src/main/acp-host.mjs";
|
|
|
|
const entry = path.resolve(process.argv[2] ?? "");
|
|
assert.ok(process.argv[2] && existsSync(entry), "Pass the installed codex-acp dist/index.js");
|
|
const root = await mkdtemp(path.join(tmpdir(), "edgeever-acp-codex-smoke-"));
|
|
const home = path.join(root, "home");
|
|
await mkdir(home);
|
|
let child;
|
|
let closed = false;
|
|
let workspace;
|
|
let cleaned = false;
|
|
|
|
// Capture process IDs only, never command lines (which may contain credentials).
|
|
const processTree = (parentPid) => {
|
|
const rows = process.platform === "win32"
|
|
? JSON.parse(execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
|
|
"ConvertTo-Json -Compress -InputObject @(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId)",
|
|
], { encoding: "utf8", windowsHide: true, timeout: 10_000 })).map((row) => [row.ProcessId, row.ParentProcessId])
|
|
: execFileSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8", timeout: 10_000 })
|
|
.trim().split("\n").map((line) => line.trim().split(/\s+/).map(Number));
|
|
const descendants = new Set([parentPid]);
|
|
let count;
|
|
do {
|
|
count = descendants.size;
|
|
for (const [pid, ppid] of rows) if (descendants.has(ppid)) descendants.add(pid);
|
|
} while (count !== descendants.size);
|
|
descendants.delete(parentPid);
|
|
return [...descendants];
|
|
};
|
|
let descendants;
|
|
let snapshotError;
|
|
const runtime = createAcpHostRuntime({
|
|
tmpRoot: root,
|
|
handshakeTimeoutMs: 30_000,
|
|
adapterManager: { get: () => ({ command: {
|
|
command: process.execPath,
|
|
args: [entry],
|
|
env: { HOME: home, USERPROFILE: home, CODEX_HOME: home },
|
|
unsetEnv: Object.keys(process.env).filter((key) => /API_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION/i.test(key)),
|
|
} }) },
|
|
spawnImpl: (...args) => {
|
|
child = spawn(...args);
|
|
child.once("close", () => { closed = true; });
|
|
let output = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
if (descendants || snapshotError) return;
|
|
output += chunk;
|
|
const lines = output.split("\n");
|
|
output = lines.pop();
|
|
for (const line of lines) {
|
|
let message;
|
|
try { message = JSON.parse(line); } catch { continue; }
|
|
if (message.result?.protocolVersion == null) continue;
|
|
try { descendants = processTree(child.pid); } catch (error) { snapshotError = error; }
|
|
}
|
|
});
|
|
return child;
|
|
},
|
|
rm: async (directory, options) => {
|
|
workspace = directory;
|
|
assert.equal(closed, true, "Codex ACP must close before workspace cleanup");
|
|
await rm(directory, options);
|
|
cleaned = true;
|
|
},
|
|
});
|
|
try {
|
|
const result = await runtime.probeAdapter({ id: "codex" });
|
|
assert.ok(["available", "needs_login"].includes(result.state), JSON.stringify(result));
|
|
assert.ok(child?.pid, "The official adapter must actually start");
|
|
assert.ifError(snapshotError);
|
|
assert.ok(descendants?.length > 0, "Initialization must start the real Codex descendant process chain");
|
|
assert.equal(closed, true);
|
|
assert.equal(cleaned, true);
|
|
assert.equal(existsSync(workspace), false);
|
|
const alive = (pid) => {
|
|
try { process.kill(pid, 0); return true; } catch (error) {
|
|
if (error.code === "ESRCH") return false;
|
|
throw error;
|
|
}
|
|
};
|
|
const deadline = Date.now() + 5_000;
|
|
while (descendants.some(alive) && Date.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
assert.deepEqual(descendants.filter(alive), [], "Codex descendants must exit too");
|
|
console.log(`Official Codex ACP probe: ${result.state}; process chain closed and workspace removed.`);
|
|
} finally {
|
|
await stopAcpChild(child);
|
|
for (const pid of descendants ?? []) {
|
|
try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
|
|
}
|
|
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
}
|