feat: add pluggable multi-CLI backend system
Implement BackendAdapter interface with four CLI backends: - ClaudeCodeBackend (extracted from AgentRuntime) - CodexBackend (OpenAI Codex CLI) - GeminiBackend (Google Gemini CLI) - OpenCodeBackend (OpenCode CLI) Add BackendRegistry for resolution/creation via AGENT_BACKEND env var. Refactor AgentRuntime to delegate to BackendAdapter instead of hardcoding Claude CLI. Update GatewayConfig with new env vars (AGENT_BACKEND, BACKEND_CLI_PATH, BACKEND_MODEL, BACKEND_MAX_TURNS). Includes 10 property-based test files and unit tests for edge cases.
This commit is contained in:
113
tests/unit/startup-validation.test.ts
Normal file
113
tests/unit/startup-validation.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { resolveBackendName, createBackend } from "../../src/backends/registry.js";
|
||||
import type { BackendAdapter, BackendAdapterConfig } from "../../src/backends/types.js";
|
||||
|
||||
const defaultAdapterConfig: BackendAdapterConfig = {
|
||||
cliPath: "/usr/bin/claude",
|
||||
workingDir: "/tmp",
|
||||
queryTimeoutMs: 30000,
|
||||
allowedTools: [],
|
||||
maxTurns: 25,
|
||||
};
|
||||
|
||||
describe("Startup validation flow", () => {
|
||||
describe("valid backend creation and validation", () => {
|
||||
it("should create a claude backend and validate successfully when binary is accessible", async () => {
|
||||
const backend = createBackend("claude", defaultAdapterConfig);
|
||||
expect(backend.name()).toBe("claude");
|
||||
// validate() checks fs access — we test the integration via the registry
|
||||
expect(typeof backend.validate).toBe("function");
|
||||
});
|
||||
|
||||
it("should create each valid backend type", () => {
|
||||
const names = ["claude", "codex", "gemini", "opencode"] as const;
|
||||
for (const name of names) {
|
||||
const backend = createBackend(name, defaultAdapterConfig);
|
||||
expect(backend.name()).toBe(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid backend name", () => {
|
||||
it("should throw a descriptive error for an invalid backend name", () => {
|
||||
expect(() => resolveBackendName("invalid-backend")).toThrow(
|
||||
'Invalid backend name "invalid-backend". Valid options are: claude, codex, gemini, opencode',
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw for empty string backend name", () => {
|
||||
expect(() => resolveBackendName("")).toThrow(
|
||||
'Invalid backend name "". Valid options are: claude, codex, gemini, opencode',
|
||||
);
|
||||
});
|
||||
|
||||
it("should default to claude when backend name is undefined", () => {
|
||||
expect(resolveBackendName(undefined)).toBe("claude");
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing CLI binary (validate returns false)", () => {
|
||||
it("should return false from validate() when CLI path does not exist", async () => {
|
||||
const backend = createBackend("claude", {
|
||||
...defaultAdapterConfig,
|
||||
cliPath: "/nonexistent/path/to/cli",
|
||||
});
|
||||
const isValid = await backend.validate();
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false from validate() for codex backend with missing binary", async () => {
|
||||
const backend = createBackend("codex", {
|
||||
...defaultAdapterConfig,
|
||||
cliPath: "/nonexistent/codex-binary",
|
||||
});
|
||||
const isValid = await backend.validate();
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startup wiring simulation", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should exit with code 1 when backend validation fails", async () => {
|
||||
const backendName = resolveBackendName("claude");
|
||||
const backend = createBackend(backendName, {
|
||||
...defaultAdapterConfig,
|
||||
cliPath: "/nonexistent/binary",
|
||||
});
|
||||
|
||||
const isValid = await backend.validate();
|
||||
if (!isValid) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("should not exit when backend validation succeeds", async () => {
|
||||
// Create a mock backend that validates successfully
|
||||
const mockBackend: BackendAdapter = {
|
||||
name: () => "claude",
|
||||
execute: vi.fn(),
|
||||
validate: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const isValid = await mockBackend.validate();
|
||||
if (!isValid) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user