codex-plugin-cc/tests/fake-codex-fixture.mjs
ZETA a1266348d6
fix: make test suite portable across platforms (#56)
* fix: make test suite portable across platforms

Replace hardcoded macOS path with fileURLToPath for cross-platform
compatibility. Add .cmd wrapper creation and platform-aware PATH
separator in test fixtures so the fake codex binary is discoverable
on Windows. Add shell and windowsHide options to the test helper
run() function to match production behavior.

Test results on Windows improve from 12/64 pass to 59/64 pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip shell for absolute-path commands to avoid Windows space-in-path breakage

When `process.execPath` resolves to a path with spaces (e.g.,
`C:\Program Files\nodejs\node.exe`), `shell: true` causes cmd.exe
to split the path at the space. Guard with `path.isAbsolute()` so
only bare command names (which need `.cmd` shim resolution) use the
shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Dominik Kundel <dkundel@openai.com>
2026-03-31 12:40:08 -07:00

528 lines
18 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { writeExecutable } from "./helpers.mjs";
export function installFakeCodex(binDir, behavior = "review-ok") {
const statePath = path.join(binDir, "fake-codex-state.json");
const scriptPath = path.join(binDir, "codex");
const source = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const readline = require("node:readline");
const STATE_PATH = ${JSON.stringify(statePath)};
const BEHAVIOR = ${JSON.stringify(behavior)};
const interruptibleTurns = new Map();
function loadState() {
if (!fs.existsSync(STATE_PATH)) {
return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], capabilities: null, lastInterrupt: null };
}
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
}
function saveState(state) {
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
}
function requiresExperimental(field, message, state) {
if (!(field in (message.params || {}))) {
return false;
}
return !state.capabilities || state.capabilities.experimentalApi !== true;
}
function now() {
return Math.floor(Date.now() / 1000);
}
function buildThread(thread) {
return {
id: thread.id,
preview: thread.preview || "",
ephemeral: Boolean(thread.ephemeral),
modelProvider: "openai",
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
status: { type: "idle" },
path: null,
cwd: thread.cwd,
cliVersion: "fake-codex",
source: "appServer",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: thread.name || null,
turns: []
};
}
function buildTurn(id, status = "inProgress", error = null) {
return { id, status, items: [], error };
}
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
function nextThread(state, cwd, ephemeral) {
const thread = {
id: "thr_" + state.nextThreadId++,
cwd: cwd || process.cwd(),
name: null,
preview: "",
ephemeral: Boolean(ephemeral),
createdAt: now(),
updatedAt: now()
};
state.threads.unshift(thread);
saveState(state);
return thread;
}
function ensureThread(state, threadId) {
const thread = state.threads.find((candidate) => candidate.id === threadId);
if (!thread) {
throw new Error("unknown thread " + threadId);
}
return thread;
}
function nextTurnId(state) {
const turnId = "turn_" + state.nextTurnId++;
saveState(state);
return turnId;
}
function emitTurnCompleted(threadId, turnId, item) {
const items = Array.isArray(item) ? item : [item];
send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } });
for (const entry of items) {
if (entry && entry.started) {
send({ method: "item/started", params: { threadId, turnId, item: entry.started } });
}
if (entry && entry.completed) {
send({ method: "item/completed", params: { threadId, turnId, item: entry.completed } });
}
}
send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "completed") } });
}
function emitTurnCompletedLater(threadId, turnId, item, delayMs) {
setTimeout(() => {
emitTurnCompleted(threadId, turnId, item);
}, delayMs);
}
function nativeReviewText(target) {
if (target.type === "baseBranch") {
return "Reviewed changes against " + target.branch + ".\\nNo material issues found.";
}
if (target.type === "custom") {
return "Reviewed custom target.\\nNo material issues found.";
}
return "Reviewed uncommitted changes.\\nNo material issues found.";
}
function structuredReviewPayload(prompt) {
if (prompt.includes("adversarial software review")) {
if (BEHAVIOR === "adversarial-clean") {
return JSON.stringify({
verdict: "approve",
summary: "No material issues found.",
findings: [],
next_steps: []
});
}
return JSON.stringify({
verdict: "needs-attention",
summary: "One adversarial concern surfaced.",
findings: [
{
severity: "high",
title: "Missing empty-state guard",
body: "The change assumes data is always present.",
file: "src/app.js",
line_start: 4,
line_end: 6,
confidence: 0.87,
recommendation: "Handle empty collections before indexing."
}
],
next_steps: ["Add an empty-state test."]
});
}
if (BEHAVIOR === "invalid-json") {
return "not valid json";
}
return JSON.stringify({
verdict: "approve",
summary: "No material issues found.",
findings: [],
next_steps: []
});
}
function taskPayload(prompt, resume) {
if (prompt.includes("<task>") && prompt.includes("Only review the work from the previous Claude turn.")) {
if (BEHAVIOR === "adversarial-clean") {
return "ALLOW: No blocking issues found in the previous turn.";
}
return "BLOCK: Missing empty-state guard in src/app.js:4-6.";
}
if (resume || prompt.includes("Continue from the current thread state") || prompt.includes("follow up")) {
return "Resumed the prior run.\\nFollow-up prompt accepted.";
}
return "Handled the requested task.\\nTask prompt accepted.";
}
const args = process.argv.slice(2);
if (args[0] === "--version") {
console.log("codex-cli test");
process.exit(0);
}
if (args[0] === "app-server" && args[1] === "--help") {
console.log("fake app-server help");
process.exit(0);
}
if (args[0] === "login" && args[1] === "status") {
if (BEHAVIOR === "logged-out") {
console.error("not authenticated");
process.exit(1);
}
console.log("logged in");
process.exit(0);
}
if (args[0] === "login") {
process.exit(0);
}
if (args[0] !== "app-server") {
process.exit(1);
}
const bootState = loadState();
bootState.appServerStarts = (bootState.appServerStarts || 0) + 1;
saveState(bootState);
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
if (!line.trim()) {
return;
}
const message = JSON.parse(line);
const state = loadState();
try {
switch (message.method) {
case "initialize":
state.capabilities = message.params.capabilities || null;
saveState(state);
send({ id: message.id, result: { userAgent: "fake-codex-app-server" } });
break;
case "initialized":
break;
case "thread/start": {
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
}
const thread = nextThread(state, message.params.cwd, message.params.ephemeral);
send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
send({ method: "thread/started", params: { thread: { id: thread.id } } });
break;
}
case "thread/name/set": {
const thread = ensureThread(state, message.params.threadId);
thread.name = message.params.name;
thread.updatedAt = now();
saveState(state);
send({ id: message.id, result: {} });
break;
}
case "thread/list": {
let threads = state.threads.slice();
if (message.params.cwd) {
threads = threads.filter((thread) => thread.cwd === message.params.cwd);
}
if (message.params.searchTerm) {
threads = threads.filter((thread) => (thread.name || "").includes(message.params.searchTerm));
}
threads.sort((left, right) => right.updatedAt - left.updatedAt);
send({ id: message.id, result: { data: threads.map(buildThread), nextCursor: null } });
break;
}
case "thread/resume": {
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
throw new Error("thread/resume.persistFullHistory requires experimentalApi capability");
}
const thread = ensureThread(state, message.params.threadId);
thread.updatedAt = now();
saveState(state);
send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } });
break;
}
case "review/start": {
const thread = ensureThread(state, message.params.threadId);
let reviewThread = thread;
if (message.params.delivery === "detached") {
reviewThread = nextThread(state, thread.cwd, true);
send({ method: "thread/started", params: { thread: { id: reviewThread.id } } });
}
const turnId = nextTurnId(state);
send({ id: message.id, result: { turn: buildTurn(turnId), reviewThreadId: reviewThread.id } });
emitTurnCompleted(reviewThread.id, turnId, [
{
started: { type: "enteredReviewMode", id: turnId, review: "current changes" }
},
...(BEHAVIOR === "with-reasoning"
? [
{
completed: {
type: "reasoning",
id: "reasoning_" + turnId,
summary: [{ text: "Reviewed the changed files and checked the likely regression paths." }],
content: []
}
}
]
: []),
{
completed: { type: "exitedReviewMode", id: turnId, review: nativeReviewText(message.params.target) }
}
]);
break;
}
case "turn/start": {
const thread = ensureThread(state, message.params.threadId);
const prompt = (message.params.input || [])
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\\n");
const turnId = nextTurnId(state);
thread.updatedAt = now();
state.lastTurnStart = {
threadId: message.params.threadId,
turnId,
model: message.params.model ?? null,
effort: message.params.effort ?? null,
prompt
};
saveState(state);
send({ id: message.id, result: { turn: buildTurn(turnId) } });
const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict
? structuredReviewPayload(prompt)
: taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state"));
if (
BEHAVIOR === "with-subagent" ||
BEHAVIOR === "with-late-subagent-message" ||
BEHAVIOR === "with-subagent-no-main-turn-completed"
) {
const subThread = nextThread(state, thread.cwd, true);
const subThreadRecord = ensureThread(state, subThread.id);
subThreadRecord.name = "design-challenger";
saveState(state);
const subTurnId = nextTurnId(state);
send({ method: "thread/started", params: { thread: { ...buildThread(subThreadRecord), name: "design-challenger", agentNickname: "design-challenger" } } });
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
send({
method: "item/started",
params: {
threadId: thread.id,
turnId,
item: {
type: "collabAgentToolCall",
id: "collab_" + turnId,
tool: "wait",
status: "inProgress",
senderThreadId: thread.id,
receiverThreadIds: [subThread.id],
prompt: "Challenge the implementation approach",
model: null,
reasoningEffort: null,
agentsStates: {
[subThread.id]: { status: "inProgress", message: "Investigating design tradeoffs" }
}
}
}
});
if (BEHAVIOR === "with-late-subagent-message") {
send({
method: "item/completed",
params: {
threadId: thread.id,
turnId,
item: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
}
});
}
send({ method: "turn/started", params: { threadId: subThread.id, turn: buildTurn(subTurnId) } });
send({
method: "item/completed",
params: {
threadId: subThread.id,
turnId: subTurnId,
item: {
type: "reasoning",
id: "reasoning_" + subTurnId,
summary: [{ text: "Questioned the retry strategy and the cache invalidation boundaries." }],
content: []
}
}
});
send({
method: "item/completed",
params: {
threadId: subThread.id,
turnId: subTurnId,
item: {
type: "agentMessage",
id: "msg_" + subTurnId,
text: "The design assumes retries are harmless, but they can duplicate side effects without stronger idempotency guarantees.",
phase: "analysis"
}
}
});
send({ method: "turn/completed", params: { threadId: subThread.id, turn: buildTurn(subTurnId, "completed") } });
send({
method: "item/completed",
params: {
threadId: thread.id,
turnId,
item: {
type: "collabAgentToolCall",
id: "collab_" + turnId,
tool: "wait",
status: "completed",
senderThreadId: thread.id,
receiverThreadIds: [subThread.id],
prompt: "Challenge the implementation approach",
model: null,
reasoningEffort: null,
agentsStates: {
[subThread.id]: { status: "completed", message: "Finished" }
}
}
}
});
if (BEHAVIOR !== "with-late-subagent-message") {
send({
method: "item/completed",
params: {
threadId: thread.id,
turnId,
item: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
}
});
}
if (BEHAVIOR !== "with-subagent-no-main-turn-completed") {
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
}
break;
}
const items = [
...(BEHAVIOR === "with-reasoning"
? [
{
completed: {
type: "reasoning",
id: "reasoning_" + turnId,
summary: [{ text: "Inspected the prompt, gathered evidence, and checked the highest-risk paths first." }],
content: []
}
}
]
: []),
{
completed: { type: "agentMessage", id: "msg_" + turnId, text: payload, phase: "final_answer" }
}
];
if (BEHAVIOR === "interruptible-slow-task") {
send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } });
const timer = setTimeout(() => {
if (!interruptibleTurns.has(turnId)) {
return;
}
interruptibleTurns.delete(turnId);
for (const entry of items) {
if (entry && entry.completed) {
send({ method: "item/completed", params: { threadId: thread.id, turnId, item: entry.completed } });
}
}
send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } });
}, 400);
interruptibleTurns.set(turnId, { threadId: thread.id, timer });
} else if (BEHAVIOR === "slow-task") {
emitTurnCompletedLater(thread.id, turnId, items, 400);
} else {
emitTurnCompleted(thread.id, turnId, items);
}
break;
}
case "turn/interrupt": {
state.lastInterrupt = {
threadId: message.params.threadId,
turnId: message.params.turnId
};
saveState(state);
const pending = interruptibleTurns.get(message.params.turnId);
if (pending) {
clearTimeout(pending.timer);
interruptibleTurns.delete(message.params.turnId);
send({
method: "turn/completed",
params: {
threadId: pending.threadId,
turn: buildTurn(message.params.turnId, "interrupted")
}
});
}
send({ id: message.id, result: {} });
break;
}
default:
send({ id: message.id, error: { code: -32601, message: "Unsupported method: " + message.method } });
break;
}
} catch (error) {
send({ id: message.id, error: { code: -32000, message: error.message } });
}
});
`;
writeExecutable(scriptPath, source);
// On Windows, npm global binaries are invoked via .cmd wrappers.
// Create a codex.cmd so the fake binary is discoverable by spawn with shell: true.
if (process.platform === "win32") {
const cmdWrapper = `@echo off\r\nnode "%~dp0codex" %*\r\n`;
fs.writeFileSync(path.join(binDir, "codex.cmd"), cmdWrapper, { encoding: "utf8" });
}
}
export function buildEnv(binDir) {
const sep = process.platform === "win32" ? ";" : ":";
return {
...process.env,
PATH: `${binDir}${sep}${process.env.PATH}`
};
}