Files
openclaw/src/agents/cli-runner.test-support.ts
2026-03-28 02:09:43 -04:00

257 lines
7.1 KiB
TypeScript

import fs from "node:fs/promises";
import { beforeEach, vi } from "vitest";
import { buildAnthropicCliBackend } from "../../extensions/anthropic/test-api.js";
import { buildGoogleGeminiCliBackend } from "../../extensions/google/test-api.js";
import { buildOpenAICodexCliBackend } from "../../extensions/openai/test-api.js";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { mergeMockedModule } from "../test-utils/vitest-module-mocks.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
export const supervisorSpawnMock = vi.fn();
export const enqueueSystemEventMock = vi.fn();
export const requestHeartbeatNowMock = vi.fn();
export const SMALL_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const hoisted = vi.hoisted(() => {
type BootstrapContext = {
bootstrapFiles: WorkspaceBootstrapFile[];
contextFiles: EmbeddedContextFile[];
};
return {
resolveBootstrapContextForRunMock: vi.fn<() => Promise<BootstrapContext>>(async () => ({
bootstrapFiles: [],
contextFiles: [],
})),
};
});
vi.mock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => ({
spawn: (...args: unknown[]) => supervisorSpawnMock(...args),
cancel: vi.fn(),
cancelScope: vi.fn(),
reconcileOrphans: vi.fn(),
getRecord: vi.fn(),
}),
}));
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
}));
vi.mock("../infra/heartbeat-wake.js", async (importOriginal) => {
return await mergeMockedModule(
await importOriginal<typeof import("../infra/heartbeat-wake.js")>(),
() => ({
requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args),
}),
);
});
vi.mock("./bootstrap-files.js", () => ({
makeBootstrapWarn: () => () => {},
resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock,
}));
type MockRunExit = {
reason:
| "manual-cancel"
| "overall-timeout"
| "no-output-timeout"
| "spawn-error"
| "signal"
| "exit";
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
durationMs: number;
stdout: string;
stderr: string;
timedOut: boolean;
noOutputTimedOut: boolean;
};
type TestCliBackendConfig = {
command: string;
env?: Record<string, string>;
clearEnv?: string[];
};
export function createManagedRun(exit: MockRunExit, pid = 1234) {
return {
runId: "run-supervisor",
pid,
startedAtMs: Date.now(),
stdin: undefined,
wait: vi.fn().mockResolvedValue(exit),
cancel: vi.fn(),
};
}
export function mockSuccessfulCliRun() {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
}
export const EXISTING_CODEX_CONFIG = {
agents: {
defaults: {
cliBackends: {
"codex-cli": {
command: "codex",
args: ["exec", "--json"],
resumeArgs: ["exec", "resume", "{sessionId}", "--json"],
output: "text",
modelArg: "--model",
sessionMode: "existing",
},
},
},
},
} satisfies OpenClawConfig;
export async function setupCliRunnerTestModule() {
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
{
pluginId: "anthropic",
backend: buildAnthropicCliBackend(),
source: "test",
},
{
pluginId: "openai",
backend: buildOpenAICodexCliBackend(),
source: "test",
},
{
pluginId: "google",
backend: buildGoogleGeminiCliBackend(),
source: "test",
},
];
setActivePluginRegistry(registry);
supervisorSpawnMock.mockClear();
enqueueSystemEventMock.mockClear();
requestHeartbeatNowMock.mockClear();
hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({
bootstrapFiles: [],
contextFiles: [],
});
vi.resetModules();
vi.doMock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => ({
spawn: (...args: unknown[]) => supervisorSpawnMock(...args),
cancel: vi.fn(),
cancelScope: vi.fn(),
reconcileOrphans: vi.fn(),
getRecord: vi.fn(),
}),
}));
vi.doMock("../infra/system-events.js", () => ({
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
}));
vi.doMock("../infra/heartbeat-wake.js", async () => {
return await mergeMockedModule(
await vi.importActual<typeof import("../infra/heartbeat-wake.js")>(
"../infra/heartbeat-wake.js",
),
() => ({
requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args),
}),
);
});
vi.doMock("./bootstrap-files.js", () => ({
makeBootstrapWarn: () => () => {},
resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock,
}));
return (await import("./cli-runner.js")).runCliAgent;
}
export function stubBootstrapContext(params: {
bootstrapFiles: WorkspaceBootstrapFile[];
contextFiles: EmbeddedContextFile[];
}) {
hoisted.resolveBootstrapContextForRunMock.mockResolvedValueOnce(params);
}
export async function runCliAgentWithBackendConfig(params: {
runCliAgent: typeof import("./cli-runner.js").runCliAgent;
backend: TestCliBackendConfig;
runId: string;
}) {
await params.runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {
agents: {
defaults: {
cliBackends: {
"codex-cli": params.backend,
},
},
},
} satisfies OpenClawConfig,
prompt: "hi",
provider: "codex-cli",
model: "gpt-5.2-codex",
timeoutMs: 1_000,
runId: params.runId,
cliSessionId: "thread-123",
});
}
export async function runExistingCodexCliAgent(params: {
runCliAgent: typeof import("./cli-runner.js").runCliAgent;
runId: string;
cliSessionBindingAuthProfileId: string;
authProfileId: string;
}) {
await params.runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: EXISTING_CODEX_CONFIG,
prompt: "hi",
provider: "codex-cli",
model: "gpt-5.4",
timeoutMs: 1_000,
runId: params.runId,
cliSessionBinding: {
sessionId: "thread-123",
authProfileId: params.cliSessionBindingAuthProfileId,
},
authProfileId: params.authProfileId,
});
}
export async function withTempImageFile(
prefix: string,
): Promise<{ tempDir: string; sourceImage: string }> {
const os = await import("node:os");
const path = await import("node:path");
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const sourceImage = path.join(tempDir, "image.png");
await fs.writeFile(sourceImage, Buffer.from(SMALL_PNG_BASE64, "base64"));
return { tempDir, sourceImage };
}
beforeEach(() => {
vi.unstubAllEnvs();
});