feat: add comprehensive integration tests for auto-mode-service

- Created git-test-repo helper for managing test git repositories
- Added 13 integration tests covering:
  - Worktree operations (create, error handling, non-worktree mode)
  - Feature execution (status updates, model selection, duplicate prevention)
  - Auto loop (start/stop, pending features, max concurrency, events)
  - Error handling (provider errors, continue after failures)
- Integration tests use real git operations with temporary repos
- All 416 tests passing with 72.65% overall coverage
- Service coverage improved: agent-service 58%, auto-mode-service 44%, feature-loader 66%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-13 13:34:27 +01:00
parent 0473b35db3
commit 23ff99d2e2
32 changed files with 9001 additions and 3 deletions

View File

@@ -0,0 +1,242 @@
import { describe, it, expect } from "vitest";
import { BaseProvider } from "@/providers/base-provider.js";
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from "@/providers/types.js";
// Concrete implementation for testing the abstract class
class TestProvider extends BaseProvider {
getName(): string {
return "test-provider";
}
async *executeQuery(
_options: ExecuteOptions
): AsyncGenerator<ProviderMessage> {
yield { type: "text", text: "test response" };
}
async detectInstallation(): Promise<InstallationStatus> {
return { installed: true };
}
getAvailableModels(): ModelDefinition[] {
return [
{ id: "test-model-1", name: "Test Model 1", description: "A test model" },
];
}
}
describe("base-provider.ts", () => {
describe("constructor", () => {
it("should initialize with empty config when none provided", () => {
const provider = new TestProvider();
expect(provider.getConfig()).toEqual({});
});
it("should initialize with provided config", () => {
const config: ProviderConfig = {
apiKey: "test-key",
baseUrl: "https://test.com",
};
const provider = new TestProvider(config);
expect(provider.getConfig()).toEqual(config);
});
it("should call getName() during initialization", () => {
const provider = new TestProvider();
expect(provider.getName()).toBe("test-provider");
});
});
describe("validateConfig", () => {
it("should return valid when config exists", () => {
const provider = new TestProvider({ apiKey: "test" });
const result = provider.validateConfig();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
it("should return invalid when config is undefined", () => {
// Create provider without config
const provider = new TestProvider();
// Manually set config to undefined to test edge case
(provider as any).config = undefined;
const result = provider.validateConfig();
expect(result.valid).toBe(false);
expect(result.errors).toContain("Provider config is missing");
});
it("should return valid for empty config object", () => {
const provider = new TestProvider({});
const result = provider.validateConfig();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("should include warnings array in result", () => {
const provider = new TestProvider();
const result = provider.validateConfig();
expect(result).toHaveProperty("warnings");
expect(Array.isArray(result.warnings)).toBe(true);
});
});
describe("supportsFeature", () => {
it("should support 'tools' feature", () => {
const provider = new TestProvider();
expect(provider.supportsFeature("tools")).toBe(true);
});
it("should support 'text' feature", () => {
const provider = new TestProvider();
expect(provider.supportsFeature("text")).toBe(true);
});
it("should not support unknown features", () => {
const provider = new TestProvider();
expect(provider.supportsFeature("vision")).toBe(false);
expect(provider.supportsFeature("mcp")).toBe(false);
expect(provider.supportsFeature("unknown")).toBe(false);
});
it("should be case-sensitive", () => {
const provider = new TestProvider();
expect(provider.supportsFeature("TOOLS")).toBe(false);
expect(provider.supportsFeature("Text")).toBe(false);
});
});
describe("getConfig", () => {
it("should return current config", () => {
const config: ProviderConfig = {
apiKey: "test-key",
model: "test-model",
};
const provider = new TestProvider(config);
expect(provider.getConfig()).toEqual(config);
});
it("should return same reference", () => {
const config: ProviderConfig = { apiKey: "test" };
const provider = new TestProvider(config);
const retrieved1 = provider.getConfig();
const retrieved2 = provider.getConfig();
expect(retrieved1).toBe(retrieved2);
});
});
describe("setConfig", () => {
it("should merge partial config with existing config", () => {
const provider = new TestProvider({ apiKey: "original-key" });
provider.setConfig({ model: "new-model" });
expect(provider.getConfig()).toEqual({
apiKey: "original-key",
model: "new-model",
});
});
it("should override existing fields", () => {
const provider = new TestProvider({ apiKey: "old-key", model: "old-model" });
provider.setConfig({ apiKey: "new-key" });
expect(provider.getConfig()).toEqual({
apiKey: "new-key",
model: "old-model",
});
});
it("should accept empty object", () => {
const provider = new TestProvider({ apiKey: "test" });
const originalConfig = provider.getConfig();
provider.setConfig({});
expect(provider.getConfig()).toEqual(originalConfig);
});
it("should handle multiple updates", () => {
const provider = new TestProvider();
provider.setConfig({ apiKey: "key1" });
provider.setConfig({ model: "model1" });
provider.setConfig({ baseUrl: "https://test.com" });
expect(provider.getConfig()).toEqual({
apiKey: "key1",
model: "model1",
baseUrl: "https://test.com",
});
});
it("should preserve other fields when updating one field", () => {
const provider = new TestProvider({
apiKey: "key",
model: "model",
baseUrl: "https://test.com",
});
provider.setConfig({ model: "new-model" });
expect(provider.getConfig()).toEqual({
apiKey: "key",
model: "new-model",
baseUrl: "https://test.com",
});
});
});
describe("abstract methods", () => {
it("should require getName implementation", () => {
const provider = new TestProvider();
expect(typeof provider.getName).toBe("function");
expect(provider.getName()).toBe("test-provider");
});
it("should require executeQuery implementation", async () => {
const provider = new TestProvider();
expect(typeof provider.executeQuery).toBe("function");
const generator = provider.executeQuery({
prompt: "test",
projectDirectory: "/test",
});
const result = await generator.next();
expect(result.value).toEqual({ type: "text", text: "test response" });
});
it("should require detectInstallation implementation", async () => {
const provider = new TestProvider();
expect(typeof provider.detectInstallation).toBe("function");
const status = await provider.detectInstallation();
expect(status).toHaveProperty("installed");
});
it("should require getAvailableModels implementation", () => {
const provider = new TestProvider();
expect(typeof provider.getAvailableModels).toBe("function");
const models = provider.getAvailableModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,398 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ClaudeProvider } from "@/providers/claude-provider.js";
import * as sdk from "@anthropic-ai/claude-agent-sdk";
import { collectAsyncGenerator } from "../../utils/helpers.js";
vi.mock("@anthropic-ai/claude-agent-sdk");
describe("claude-provider.ts", () => {
let provider: ClaudeProvider;
beforeEach(() => {
vi.clearAllMocks();
provider = new ClaudeProvider();
delete process.env.ANTHROPIC_API_KEY;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
});
describe("getName", () => {
it("should return 'claude' as provider name", () => {
expect(provider.getName()).toBe("claude");
});
});
describe("executeQuery", () => {
it("should execute simple text query", async () => {
const mockMessages = [
{ type: "text", text: "Response 1" },
{ type: "text", text: "Response 2" },
];
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
for (const msg of mockMessages) {
yield msg;
}
})()
);
const generator = provider.executeQuery({
prompt: "Hello",
cwd: "/test",
});
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: "text", text: "Response 1" });
expect(results[1]).toEqual({ type: "text", text: "Response 2" });
});
it("should pass correct options to SDK", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const generator = provider.executeQuery({
prompt: "Test prompt",
model: "claude-opus-4-5-20251101",
cwd: "/test/dir",
systemPrompt: "You are helpful",
maxTurns: 10,
allowedTools: ["Read", "Write"],
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test prompt",
options: expect.objectContaining({
model: "claude-opus-4-5-20251101",
systemPrompt: "You are helpful",
maxTurns: 10,
cwd: "/test/dir",
allowedTools: ["Read", "Write"],
permissionMode: "acceptEdits",
}),
});
});
it("should use default allowed tools when not specified", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test",
options: expect.objectContaining({
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
],
}),
});
});
it("should enable sandbox by default", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test",
options: expect.objectContaining({
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
});
});
it("should pass abortController if provided", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const abortController = new AbortController();
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
abortController,
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test",
options: expect.objectContaining({
abortController,
}),
});
});
it("should handle conversation history", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const conversationHistory = [
{ role: "user" as const, content: "Previous message" },
{ role: "assistant" as const, content: "Previous response" },
];
const generator = provider.executeQuery({
prompt: "Current message",
cwd: "/test",
conversationHistory,
});
await collectAsyncGenerator(generator);
// Should pass an async generator as prompt
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
expect(typeof callArgs.prompt).not.toBe("string");
});
it("should handle array prompt (with images)", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const arrayPrompt = [
{ type: "text", text: "Describe this" },
{ type: "image", source: { type: "base64", data: "..." } },
];
const generator = provider.executeQuery({
prompt: arrayPrompt as any,
cwd: "/test",
});
await collectAsyncGenerator(generator);
// Should pass an async generator as prompt for array inputs
const callArgs = vi.mocked(sdk.query).mock.calls[0][0];
expect(typeof callArgs.prompt).not.toBe("string");
});
it("should use maxTurns default of 20", async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: "text", text: "test" };
})()
);
const generator = provider.executeQuery({
prompt: "Test",
cwd: "/test",
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: "Test",
options: expect.objectContaining({
maxTurns: 20,
}),
});
});
});
describe("detectInstallation", () => {
it("should return installed with SDK method", async () => {
const result = await provider.detectInstallation();
expect(result.installed).toBe(true);
expect(result.method).toBe("sdk");
});
it("should detect ANTHROPIC_API_KEY", async () => {
process.env.ANTHROPIC_API_KEY = "test-key";
const result = await provider.detectInstallation();
expect(result.hasApiKey).toBe(true);
expect(result.authenticated).toBe(true);
});
it("should detect CLAUDE_CODE_OAUTH_TOKEN", async () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-token";
const result = await provider.detectInstallation();
expect(result.hasApiKey).toBe(true);
expect(result.authenticated).toBe(true);
});
it("should return hasApiKey false when no keys present", async () => {
const result = await provider.detectInstallation();
expect(result.hasApiKey).toBe(false);
expect(result.authenticated).toBe(false);
});
});
describe("getAvailableModels", () => {
it("should return 4 Claude models", () => {
const models = provider.getAvailableModels();
expect(models).toHaveLength(4);
});
it("should include Claude Opus 4.5", () => {
const models = provider.getAvailableModels();
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
expect(opus).toBeDefined();
expect(opus?.name).toBe("Claude Opus 4.5");
expect(opus?.provider).toBe("anthropic");
});
it("should include Claude Sonnet 4", () => {
const models = provider.getAvailableModels();
const sonnet = models.find((m) => m.id === "claude-sonnet-4-20250514");
expect(sonnet).toBeDefined();
expect(sonnet?.name).toBe("Claude Sonnet 4");
});
it("should include Claude 3.5 Sonnet", () => {
const models = provider.getAvailableModels();
const sonnet35 = models.find(
(m) => m.id === "claude-3-5-sonnet-20241022"
);
expect(sonnet35).toBeDefined();
});
it("should include Claude 3.5 Haiku", () => {
const models = provider.getAvailableModels();
const haiku = models.find((m) => m.id === "claude-3-5-haiku-20241022");
expect(haiku).toBeDefined();
});
it("should mark Opus as default", () => {
const models = provider.getAvailableModels();
const opus = models.find((m) => m.id === "claude-opus-4-5-20251101");
expect(opus?.default).toBe(true);
});
it("should all support vision and tools", () => {
const models = provider.getAvailableModels();
models.forEach((model) => {
expect(model.supportsVision).toBe(true);
expect(model.supportsTools).toBe(true);
});
});
it("should have correct context windows", () => {
const models = provider.getAvailableModels();
models.forEach((model) => {
expect(model.contextWindow).toBe(200000);
});
});
it("should have modelString field matching id", () => {
const models = provider.getAvailableModels();
models.forEach((model) => {
expect(model.modelString).toBe(model.id);
});
});
});
describe("supportsFeature", () => {
it("should support 'tools' feature", () => {
expect(provider.supportsFeature("tools")).toBe(true);
});
it("should support 'text' feature", () => {
expect(provider.supportsFeature("text")).toBe(true);
});
it("should support 'vision' feature", () => {
expect(provider.supportsFeature("vision")).toBe(true);
});
it("should support 'thinking' feature", () => {
expect(provider.supportsFeature("thinking")).toBe(true);
});
it("should not support 'mcp' feature", () => {
expect(provider.supportsFeature("mcp")).toBe(false);
});
it("should not support 'cli' feature", () => {
expect(provider.supportsFeature("cli")).toBe(false);
});
it("should not support unknown features", () => {
expect(provider.supportsFeature("unknown")).toBe(false);
});
});
describe("validateConfig", () => {
it("should validate config from base class", () => {
const result = provider.validateConfig();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
describe("config management", () => {
it("should get and set config", () => {
provider.setConfig({ apiKey: "test-key" });
const config = provider.getConfig();
expect(config.apiKey).toBe("test-key");
});
it("should merge config updates", () => {
provider.setConfig({ apiKey: "key1" });
provider.setConfig({ model: "model1" });
const config = provider.getConfig();
expect(config.apiKey).toBe("key1");
expect(config.model).toBe("model1");
});
});
});

View File

@@ -0,0 +1,362 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CodexCliDetector } from "@/providers/codex-cli-detector.js";
import * as cp from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
vi.mock("child_process");
vi.mock("fs");
describe("codex-cli-detector.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.OPENAI_API_KEY;
});
describe("getConfigDir", () => {
it("should return .codex directory in user home", () => {
const homeDir = os.homedir();
const configDir = CodexCliDetector.getConfigDir();
expect(configDir).toBe(path.join(homeDir, ".codex"));
});
});
describe("getAuthPath", () => {
it("should return auth.json path in config directory", () => {
const authPath = CodexCliDetector.getAuthPath();
expect(authPath).toContain(".codex");
expect(authPath).toContain("auth.json");
});
});
describe("checkAuth", () => {
const mockAuthPath = "/home/user/.codex/auth.json";
beforeEach(() => {
vi.spyOn(CodexCliDetector, "getAuthPath").mockReturnValue(mockAuthPath);
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should detect token object authentication", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
token: {
access_token: "test_access",
refresh_token: "test_refresh",
},
})
);
const result = CodexCliDetector.checkAuth();
expect(result.authenticated).toBe(true);
expect(result.method).toBe("cli_tokens");
expect(result.hasAuthFile).toBe(true);
});
it("should detect token with Id_token field", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
token: {
Id_token: "test_id_token",
},
})
);
const result = CodexCliDetector.checkAuth();
expect(result.authenticated).toBe(true);
expect(result.method).toBe("cli_tokens");
});
it("should detect root-level tokens", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
access_token: "test_access",
refresh_token: "test_refresh",
})
);
const result = CodexCliDetector.checkAuth();
expect(result.authenticated).toBe(true);
expect(result.method).toBe("cli_tokens");
});
it("should detect API key in auth file", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
api_key: "test-api-key",
})
);
const result = CodexCliDetector.checkAuth();
expect(result.authenticated).toBe(true);
expect(result.method).toBe("auth_file");
});
it("should detect openai_api_key field", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
openai_api_key: "test-key",
})
);
const result = CodexCliDetector.checkAuth();
expect(result.authenticated).toBe(true);
expect(result.method).toBe("auth_file");
});
it("should detect environment variable authentication", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
vi.mocked(fs.existsSync).mockReturnValue(false);
process.env.OPENAI_API_KEY = "env-api-key";
const result = CodexCliDetector.checkAuth();
expect(result.authenticated).toBe(true);
expect(result.method).toBe("env");
expect(result.hasEnvKey).toBe(true);
expect(result.hasAuthFile).toBe(false);
});
it("should return not authenticated when no auth found", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = CodexCliDetector.checkAuth();
expect(result.authenticated).toBe(false);
expect(result.method).toBe("none");
expect(result.hasAuthFile).toBe(false);
expect(result.hasEnvKey).toBe(false);
});
it("should handle malformed auth file", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue("invalid json");
const result = CodexCliDetector.checkAuth();
expect(result.authenticated).toBe(false);
expect(result.method).toBe("none");
});
it("should return auth result with required fields", () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = CodexCliDetector.checkAuth();
expect(result).toHaveProperty("authenticated");
expect(result).toHaveProperty("method");
expect(typeof result.authenticated).toBe("boolean");
expect(typeof result.method).toBe("string");
});
});
describe("detectCodexInstallation", () => {
// Note: Full detection logic involves OS-specific commands (which/where, npm, brew)
// and is better tested in integration tests. Here we test the basic structure.
it("should return hasApiKey when OPENAI_API_KEY is set and CLI not found", () => {
vi.mocked(cp.execSync).mockImplementation(() => {
throw new Error("command not found");
});
vi.mocked(fs.existsSync).mockReturnValue(false);
process.env.OPENAI_API_KEY = "test-key";
const result = CodexCliDetector.detectCodexInstallation();
expect(result.installed).toBe(false);
expect(result.hasApiKey).toBe(true);
});
it("should return not installed when nothing found", () => {
vi.mocked(cp.execSync).mockImplementation(() => {
throw new Error("command failed");
});
vi.mocked(fs.existsSync).mockReturnValue(false);
delete process.env.OPENAI_API_KEY;
const result = CodexCliDetector.detectCodexInstallation();
expect(result.installed).toBe(false);
expect(result.hasApiKey).toBeUndefined();
});
it("should return installation status object with installed boolean", () => {
vi.mocked(cp.execSync).mockImplementation(() => {
throw new Error();
});
vi.mocked(fs.existsSync).mockReturnValue(false);
const result = CodexCliDetector.detectCodexInstallation();
expect(result).toHaveProperty("installed");
expect(typeof result.installed).toBe("boolean");
});
});
describe("getCodexVersion", () => {
// Note: Testing execSync calls is difficult in unit tests and better suited for integration tests
// The method structure and error handling can be verified indirectly through other tests
it("should return null when given invalid path", () => {
const version = CodexCliDetector.getCodexVersion("/nonexistent/path");
expect(version).toBeNull();
});
});
describe("getInstallationInfo", () => {
it("should return installed status when CLI is detected", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: true,
path: "/usr/bin/codex",
version: "0.5.0",
method: "cli",
});
const info = CodexCliDetector.getInstallationInfo();
expect(info.status).toBe("installed");
expect(info.method).toBe("cli");
expect(info.version).toBe("0.5.0");
expect(info.path).toBe("/usr/bin/codex");
expect(info.recommendation).toContain("ready for GPT-5.1/5.2");
});
it("should return api_key_only when API key is set but CLI not installed", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
hasApiKey: true,
});
const info = CodexCliDetector.getInstallationInfo();
expect(info.status).toBe("api_key_only");
expect(info.method).toBe("api-key-only");
expect(info.recommendation).toContain("OPENAI_API_KEY detected");
expect(info.recommendation).toContain("Install Codex CLI");
expect(info.installCommands).toBeDefined();
});
it("should return not_installed when nothing detected", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
const info = CodexCliDetector.getInstallationInfo();
expect(info.status).toBe("not_installed");
expect(info.recommendation).toContain("Install OpenAI Codex CLI");
expect(info.installCommands).toBeDefined();
});
it("should include install commands for all platforms", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: false,
});
const info = CodexCliDetector.getInstallationInfo();
expect(info.installCommands).toHaveProperty("npm");
expect(info.installCommands).toHaveProperty("macos");
expect(info.installCommands).toHaveProperty("linux");
expect(info.installCommands).toHaveProperty("windows");
});
});
describe("getInstallCommands", () => {
it("should return installation commands for all platforms", () => {
const commands = CodexCliDetector.getInstallCommands();
expect(commands.npm).toContain("npm install");
expect(commands.npm).toContain("@openai/codex");
expect(commands.macos).toContain("brew install");
expect(commands.linux).toContain("npm install");
expect(commands.windows).toContain("npm install");
});
});
describe("isModelSupported", () => {
it("should return true for supported models", () => {
expect(CodexCliDetector.isModelSupported("gpt-5.1-codex-max")).toBe(true);
expect(CodexCliDetector.isModelSupported("gpt-5.1-codex")).toBe(true);
expect(CodexCliDetector.isModelSupported("gpt-5.1-codex-mini")).toBe(true);
expect(CodexCliDetector.isModelSupported("gpt-5.1")).toBe(true);
expect(CodexCliDetector.isModelSupported("gpt-5.2")).toBe(true);
});
it("should return false for unsupported models", () => {
expect(CodexCliDetector.isModelSupported("gpt-4")).toBe(false);
expect(CodexCliDetector.isModelSupported("claude-opus")).toBe(false);
expect(CodexCliDetector.isModelSupported("unknown-model")).toBe(false);
});
});
describe("getDefaultModel", () => {
it("should return gpt-5.2 as default", () => {
const defaultModel = CodexCliDetector.getDefaultModel();
expect(defaultModel).toBe("gpt-5.2");
});
});
describe("getFullStatus", () => {
it("should include installation, auth, and info", () => {
vi.spyOn(CodexCliDetector, "detectCodexInstallation").mockReturnValue({
installed: true,
path: "/usr/bin/codex",
});
vi.spyOn(CodexCliDetector, "checkAuth").mockReturnValue({
authenticated: true,
method: "cli_verified",
hasAuthFile: true,
hasEnvKey: false,
});
const status = CodexCliDetector.getFullStatus();
expect(status).toHaveProperty("status");
expect(status).toHaveProperty("auth");
expect(status).toHaveProperty("installation");
expect(status.auth.authenticated).toBe(true);
expect(status.installation.installed).toBe(true);
});
});
});

View File

@@ -0,0 +1,430 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { CodexConfigManager } from "@/providers/codex-config-manager.js";
import * as fs from "fs/promises";
import * as os from "os";
import * as path from "path";
import { tomlConfigFixture } from "../../fixtures/configs.js";
vi.mock("fs/promises");
describe("codex-config-manager.ts", () => {
let manager: CodexConfigManager;
beforeEach(() => {
vi.clearAllMocks();
manager = new CodexConfigManager();
});
describe("constructor", () => {
it("should initialize with user config path", () => {
const expectedPath = path.join(os.homedir(), ".codex", "config.toml");
expect(manager["userConfigPath"]).toBe(expectedPath);
});
it("should initialize with null project config path", () => {
expect(manager["projectConfigPath"]).toBeNull();
});
});
describe("setProjectPath", () => {
it("should set project config path", () => {
manager.setProjectPath("/my/project");
const configPath = manager["projectConfigPath"];
expect(configPath).toContain("my");
expect(configPath).toContain("project");
expect(configPath).toContain(".codex");
expect(configPath).toContain("config.toml");
});
it("should handle paths with special characters", () => {
manager.setProjectPath("/path with spaces/project");
expect(manager["projectConfigPath"]).toContain("path with spaces");
});
});
describe("getConfigPath", () => {
it("should return user config path when no project path set", async () => {
const result = await manager.getConfigPath();
expect(result).toBe(manager["userConfigPath"]);
});
it("should return project config path when it exists", async () => {
manager.setProjectPath("/my/project");
vi.mocked(fs.access).mockResolvedValue(undefined);
const result = await manager.getConfigPath();
expect(result).toContain("my");
expect(result).toContain("project");
expect(result).toContain(".codex");
expect(result).toContain("config.toml");
});
it("should fall back to user config when project config doesn't exist", async () => {
manager.setProjectPath("/my/project");
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
const result = await manager.getConfigPath();
expect(result).toBe(manager["userConfigPath"]);
});
it("should create user config directory if it doesn't exist", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
await manager.getConfigPath();
const expectedDir = path.dirname(manager["userConfigPath"]);
expect(fs.mkdir).toHaveBeenCalledWith(expectedDir, { recursive: true });
});
});
describe("parseToml", () => {
it("should parse simple key-value pairs", () => {
const toml = `
key1 = "value1"
key2 = "value2"
`;
const result = manager.parseToml(toml);
expect(result.key1).toBe("value1");
expect(result.key2).toBe("value2");
});
it("should parse boolean values", () => {
const toml = `
enabled = true
disabled = false
`;
const result = manager.parseToml(toml);
expect(result.enabled).toBe(true);
expect(result.disabled).toBe(false);
});
it("should parse integer values", () => {
const toml = `
count = 42
negative = -10
`;
const result = manager.parseToml(toml);
expect(result.count).toBe(42);
expect(result.negative).toBe(-10);
});
it("should parse float values", () => {
const toml = `
pi = 3.14
negative = -2.5
`;
const result = manager.parseToml(toml);
expect(result.pi).toBe(3.14);
expect(result.negative).toBe(-2.5);
});
it("should skip comments", () => {
const toml = `
# This is a comment
key = "value"
# Another comment
`;
const result = manager.parseToml(toml);
expect(result.key).toBe("value");
expect(Object.keys(result)).toHaveLength(1);
});
it("should skip empty lines", () => {
const toml = `
key1 = "value1"
key2 = "value2"
`;
const result = manager.parseToml(toml);
expect(result.key1).toBe("value1");
expect(result.key2).toBe("value2");
});
it("should parse sections", () => {
const toml = `
[section1]
key1 = "value1"
key2 = "value2"
`;
const result = manager.parseToml(toml);
expect(result.section1).toBeDefined();
expect(result.section1.key1).toBe("value1");
expect(result.section1.key2).toBe("value2");
});
it("should parse nested sections", () => {
const toml = `
[section.subsection]
key = "value"
`;
const result = manager.parseToml(toml);
expect(result.section).toBeDefined();
expect(result.section.subsection).toBeDefined();
expect(result.section.subsection.key).toBe("value");
});
it("should parse MCP server configuration", () => {
const result = manager.parseToml(tomlConfigFixture);
expect(result.experimental_use_rmcp_client).toBe(true);
expect(result.mcp_servers).toBeDefined();
expect(result.mcp_servers["automaker-tools"]).toBeDefined();
expect(result.mcp_servers["automaker-tools"].command).toBe("node");
});
it("should handle quoted strings with spaces", () => {
const toml = `key = "value with spaces"`;
const result = manager.parseToml(toml);
expect(result.key).toBe("value with spaces");
});
it("should handle single-quoted strings", () => {
const toml = `key = 'single quoted'`;
const result = manager.parseToml(toml);
expect(result.key).toBe("single quoted");
});
it("should return empty object for empty input", () => {
const result = manager.parseToml("");
expect(result).toEqual({});
});
});
describe("readConfig", () => {
it("should read and parse existing config", async () => {
vi.mocked(fs.readFile).mockResolvedValue(tomlConfigFixture);
const result = await manager.readConfig("/path/to/config.toml");
expect(result.experimental_use_rmcp_client).toBe(true);
expect(result.mcp_servers).toBeDefined();
});
it("should return empty object when file doesn't exist", async () => {
const error: any = new Error("ENOENT");
error.code = "ENOENT";
vi.mocked(fs.readFile).mockRejectedValue(error);
const result = await manager.readConfig("/nonexistent.toml");
expect(result).toEqual({});
});
it("should throw other errors", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied"));
await expect(manager.readConfig("/path.toml")).rejects.toThrow(
"Permission denied"
);
});
});
describe("escapeTomlString", () => {
it("should escape backslashes", () => {
const result = manager.escapeTomlString("path\\to\\file");
expect(result).toBe("path\\\\to\\\\file");
});
it("should escape double quotes", () => {
const result = manager.escapeTomlString('say "hello"');
expect(result).toBe('say \\"hello\\"');
});
it("should escape newlines", () => {
const result = manager.escapeTomlString("line1\nline2");
expect(result).toBe("line1\\nline2");
});
it("should escape carriage returns", () => {
const result = manager.escapeTomlString("line1\rline2");
expect(result).toBe("line1\\rline2");
});
it("should escape tabs", () => {
const result = manager.escapeTomlString("col1\tcol2");
expect(result).toBe("col1\\tcol2");
});
});
describe("formatValue", () => {
it("should format strings with quotes", () => {
const result = manager.formatValue("test");
expect(result).toBe('"test"');
});
it("should format booleans as strings", () => {
expect(manager.formatValue(true)).toBe("true");
expect(manager.formatValue(false)).toBe("false");
});
it("should format numbers as strings", () => {
expect(manager.formatValue(42)).toBe("42");
expect(manager.formatValue(3.14)).toBe("3.14");
});
it("should escape special characters in strings", () => {
const result = manager.formatValue('path\\with"quotes');
expect(result).toBe('"path\\\\with\\"quotes"');
});
});
describe("writeConfig", () => {
it("should write TOML config to file", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const config = {
experimental_use_rmcp_client: true,
mcp_servers: {
"test-server": {
command: "node",
args: ["server.js"],
},
},
};
await manager.writeConfig("/path/config.toml", config);
expect(fs.writeFile).toHaveBeenCalledWith(
"/path/config.toml",
expect.stringContaining("experimental_use_rmcp_client = true"),
"utf-8"
);
expect(fs.writeFile).toHaveBeenCalledWith(
"/path/config.toml",
expect.stringContaining("[mcp_servers.test-server]"),
"utf-8"
);
});
it("should create config directory if it doesn't exist", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await manager.writeConfig("/path/to/config.toml", {});
expect(fs.mkdir).toHaveBeenCalledWith("/path/to", { recursive: true });
});
it("should include env section for MCP servers", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const config = {
mcp_servers: {
"test-server": {
command: "node",
env: {
MY_VAR: "value",
},
},
},
};
await manager.writeConfig("/path/config.toml", config);
const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(writtenContent).toContain("[mcp_servers.test-server.env]");
expect(writtenContent).toContain('MY_VAR = "value"');
});
});
describe("configureMcpServer", () => {
it("should configure automaker-tools MCP server", async () => {
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
vi.mocked(fs.readFile).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await manager.configureMcpServer(
"/my/project",
"/path/to/mcp-server.js"
);
expect(result).toContain("config.toml");
const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(writtenContent).toContain("[mcp_servers.automaker-tools]");
expect(writtenContent).toContain('command = "node"');
expect(writtenContent).toContain("/path/to/mcp-server.js");
expect(writtenContent).toContain("AUTOMAKER_PROJECT_PATH");
});
it("should preserve existing MCP servers", async () => {
const existingConfig = `
[mcp_servers.other-server]
command = "other"
`;
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
vi.mocked(fs.readFile).mockResolvedValue(existingConfig);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await manager.configureMcpServer("/project", "/server.js");
const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(writtenContent).toContain("[mcp_servers.other-server]");
expect(writtenContent).toContain("[mcp_servers.automaker-tools]");
});
});
describe("removeMcpServer", () => {
it("should remove automaker-tools MCP server", async () => {
const configWithServer = `
[mcp_servers.automaker-tools]
command = "node"
[mcp_servers.other-server]
command = "other"
`;
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
vi.mocked(fs.readFile).mockResolvedValue(configWithServer);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await manager.removeMcpServer("/project");
const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(writtenContent).not.toContain("automaker-tools");
expect(writtenContent).toContain("other-server");
});
it("should remove mcp_servers section if empty", async () => {
const configWithOnlyAutomaker = `
[mcp_servers.automaker-tools]
command = "node"
`;
vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT"));
vi.mocked(fs.readFile).mockResolvedValue(configWithOnlyAutomaker);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await manager.removeMcpServer("/project");
const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
expect(writtenContent).not.toContain("mcp_servers");
});
it("should handle errors gracefully", async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error("Read error"));
// Should not throw
await expect(manager.removeMcpServer("/project")).resolves.toBeUndefined();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,293 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ProviderFactory } from "@/providers/provider-factory.js";
import { ClaudeProvider } from "@/providers/claude-provider.js";
import { CodexProvider } from "@/providers/codex-provider.js";
describe("provider-factory.ts", () => {
let consoleSpy: any;
beforeEach(() => {
consoleSpy = {
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
};
});
afterEach(() => {
consoleSpy.warn.mockRestore();
});
describe("getProviderForModel", () => {
describe("OpenAI/Codex models (gpt-*)", () => {
it("should return CodexProvider for gpt-5.2", () => {
const provider = ProviderFactory.getProviderForModel("gpt-5.2");
expect(provider).toBeInstanceOf(CodexProvider);
});
it("should return CodexProvider for gpt-5.1-codex", () => {
const provider = ProviderFactory.getProviderForModel("gpt-5.1-codex");
expect(provider).toBeInstanceOf(CodexProvider);
});
it("should return CodexProvider for gpt-4", () => {
const provider = ProviderFactory.getProviderForModel("gpt-4");
expect(provider).toBeInstanceOf(CodexProvider);
});
it("should be case-insensitive for gpt models", () => {
const provider1 = ProviderFactory.getProviderForModel("GPT-5.2");
const provider2 = ProviderFactory.getProviderForModel("Gpt-5.1");
expect(provider1).toBeInstanceOf(CodexProvider);
expect(provider2).toBeInstanceOf(CodexProvider);
});
});
describe("Unsupported o-series models", () => {
it("should default to ClaudeProvider for o1 (not supported by Codex CLI)", () => {
const provider = ProviderFactory.getProviderForModel("o1");
expect(provider).toBeInstanceOf(ClaudeProvider);
expect(consoleSpy.warn).toHaveBeenCalled();
});
it("should default to ClaudeProvider for o3", () => {
const provider = ProviderFactory.getProviderForModel("o3");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should default to ClaudeProvider for o1-mini", () => {
const provider = ProviderFactory.getProviderForModel("o1-mini");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
});
describe("Claude models (claude-* prefix)", () => {
it("should return ClaudeProvider for claude-opus-4-5-20251101", () => {
const provider = ProviderFactory.getProviderForModel(
"claude-opus-4-5-20251101"
);
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for claude-sonnet-4-20250514", () => {
const provider = ProviderFactory.getProviderForModel(
"claude-sonnet-4-20250514"
);
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for claude-haiku-4-5", () => {
const provider = ProviderFactory.getProviderForModel("claude-haiku-4-5");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should be case-insensitive for claude models", () => {
const provider = ProviderFactory.getProviderForModel(
"CLAUDE-OPUS-4-5-20251101"
);
expect(provider).toBeInstanceOf(ClaudeProvider);
});
});
describe("Claude aliases", () => {
it("should return ClaudeProvider for 'haiku'", () => {
const provider = ProviderFactory.getProviderForModel("haiku");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for 'sonnet'", () => {
const provider = ProviderFactory.getProviderForModel("sonnet");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for 'opus'", () => {
const provider = ProviderFactory.getProviderForModel("opus");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should be case-insensitive for aliases", () => {
const provider1 = ProviderFactory.getProviderForModel("HAIKU");
const provider2 = ProviderFactory.getProviderForModel("Sonnet");
const provider3 = ProviderFactory.getProviderForModel("Opus");
expect(provider1).toBeInstanceOf(ClaudeProvider);
expect(provider2).toBeInstanceOf(ClaudeProvider);
expect(provider3).toBeInstanceOf(ClaudeProvider);
});
});
describe("Unknown models", () => {
it("should default to ClaudeProvider for unknown model", () => {
const provider = ProviderFactory.getProviderForModel("unknown-model-123");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should warn when defaulting to Claude", () => {
ProviderFactory.getProviderForModel("random-model");
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("Unknown model prefix")
);
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("random-model")
);
expect(consoleSpy.warn).toHaveBeenCalledWith(
expect.stringContaining("defaulting to Claude")
);
});
it("should handle empty string", () => {
const provider = ProviderFactory.getProviderForModel("");
expect(provider).toBeInstanceOf(ClaudeProvider);
expect(consoleSpy.warn).toHaveBeenCalled();
});
});
});
describe("getAllProviders", () => {
it("should return array of all providers", () => {
const providers = ProviderFactory.getAllProviders();
expect(Array.isArray(providers)).toBe(true);
});
it("should include ClaudeProvider", () => {
const providers = ProviderFactory.getAllProviders();
const hasClaudeProvider = providers.some(
(p) => p instanceof ClaudeProvider
);
expect(hasClaudeProvider).toBe(true);
});
it("should include CodexProvider", () => {
const providers = ProviderFactory.getAllProviders();
const hasCodexProvider = providers.some((p) => p instanceof CodexProvider);
expect(hasCodexProvider).toBe(true);
});
it("should return exactly 2 providers", () => {
const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(2);
});
it("should create new instances each time", () => {
const providers1 = ProviderFactory.getAllProviders();
const providers2 = ProviderFactory.getAllProviders();
expect(providers1[0]).not.toBe(providers2[0]);
expect(providers1[1]).not.toBe(providers2[1]);
});
});
describe("checkAllProviders", () => {
it("should return installation status for all providers", async () => {
const statuses = await ProviderFactory.checkAllProviders();
expect(statuses).toHaveProperty("claude");
expect(statuses).toHaveProperty("codex");
});
it("should call detectInstallation on each provider", async () => {
const statuses = await ProviderFactory.checkAllProviders();
expect(statuses.claude).toHaveProperty("installed");
expect(statuses.codex).toHaveProperty("installed");
});
it("should return correct provider names as keys", async () => {
const statuses = await ProviderFactory.checkAllProviders();
const keys = Object.keys(statuses);
expect(keys).toContain("claude");
expect(keys).toContain("codex");
expect(keys).toHaveLength(2);
});
});
describe("getProviderByName", () => {
it("should return ClaudeProvider for 'claude'", () => {
const provider = ProviderFactory.getProviderByName("claude");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return ClaudeProvider for 'anthropic'", () => {
const provider = ProviderFactory.getProviderByName("anthropic");
expect(provider).toBeInstanceOf(ClaudeProvider);
});
it("should return CodexProvider for 'codex'", () => {
const provider = ProviderFactory.getProviderByName("codex");
expect(provider).toBeInstanceOf(CodexProvider);
});
it("should return CodexProvider for 'openai'", () => {
const provider = ProviderFactory.getProviderByName("openai");
expect(provider).toBeInstanceOf(CodexProvider);
});
it("should be case-insensitive", () => {
const provider1 = ProviderFactory.getProviderByName("CLAUDE");
const provider2 = ProviderFactory.getProviderByName("Codex");
const provider3 = ProviderFactory.getProviderByName("ANTHROPIC");
expect(provider1).toBeInstanceOf(ClaudeProvider);
expect(provider2).toBeInstanceOf(CodexProvider);
expect(provider3).toBeInstanceOf(ClaudeProvider);
});
it("should return null for unknown provider", () => {
const provider = ProviderFactory.getProviderByName("unknown");
expect(provider).toBeNull();
});
it("should return null for empty string", () => {
const provider = ProviderFactory.getProviderByName("");
expect(provider).toBeNull();
});
it("should create new instance each time", () => {
const provider1 = ProviderFactory.getProviderByName("claude");
const provider2 = ProviderFactory.getProviderByName("claude");
expect(provider1).not.toBe(provider2);
expect(provider1).toBeInstanceOf(ClaudeProvider);
expect(provider2).toBeInstanceOf(ClaudeProvider);
});
});
describe("getAllAvailableModels", () => {
it("should return array of models", () => {
const models = ProviderFactory.getAllAvailableModels();
expect(Array.isArray(models)).toBe(true);
});
it("should include models from all providers", () => {
const models = ProviderFactory.getAllAvailableModels();
expect(models.length).toBeGreaterThan(0);
});
it("should return models with required fields", () => {
const models = ProviderFactory.getAllAvailableModels();
models.forEach((model) => {
expect(model).toHaveProperty("id");
expect(model).toHaveProperty("name");
expect(typeof model.id).toBe("string");
expect(typeof model.name).toBe("string");
});
});
it("should aggregate models from both Claude and Codex", () => {
const models = ProviderFactory.getAllAvailableModels();
// Claude models should include claude-* in their IDs
const hasClaudeModels = models.some((m) =>
m.id.toLowerCase().includes("claude")
);
// Codex models should include gpt-* in their IDs
const hasCodexModels = models.some((m) =>
m.id.toLowerCase().includes("gpt")
);
expect(hasClaudeModels).toBe(true);
expect(hasCodexModels).toBe(true);
});
});
});