mirror of
https://github.com/samsonjs/Peekaboo.git
synced 2026-04-20 13:45:50 +00:00
- Update CI configuration to use macOS-15 runner with Xcode 16.3 - Expand test coverage with comprehensive new test suites: * JSONOutputTests.swift - JSON encoding/decoding and MCP compliance * LoggerTests.swift - Thread-safe logging functionality * ImageCaptureLogicTests.swift - Image capture command logic * TestTags.swift - Centralized test tagging system - Improve existing tests with Swift Testing patterns and async support - Make Logger thread-safe with concurrent dispatch queue - Add performance, concurrency, and edge case testing - Fix compilation issues and optimize test performance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
474 lines
15 KiB
TypeScript
474 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import {
|
|
parseAIProviders,
|
|
isProviderAvailable,
|
|
analyzeImageWithProvider,
|
|
getDefaultModelForProvider,
|
|
determineProviderAndModel,
|
|
} from "../../../src/utils/ai-providers";
|
|
import { AIProvider } from "../../../src/types";
|
|
import OpenAI from "openai";
|
|
|
|
const mockLogger = {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
} as any;
|
|
|
|
global.fetch = vi.fn();
|
|
|
|
// Centralized mock for OpenAI().chat.completions.create
|
|
const mockChatCompletionsCreate = vi.fn();
|
|
|
|
vi.mock("openai", () => {
|
|
const MockedOpenAI = vi.fn().mockImplementation(() => {
|
|
return {
|
|
chat: {
|
|
completions: {
|
|
create: mockChatCompletionsCreate,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
return { default: MockedOpenAI }; // Simulate default export
|
|
});
|
|
// No need for `let mockOpenAICreate` outside, use mockChatCompletionsCreate directly.
|
|
|
|
describe("AI Providers Utility", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
delete process.env.PEEKABOO_OLLAMA_BASE_URL;
|
|
delete process.env.OPENAI_API_KEY;
|
|
delete process.env.ANTHROPIC_API_KEY;
|
|
(global.fetch as vi.Mock).mockReset();
|
|
mockChatCompletionsCreate.mockReset(); // Reset the shared mock function
|
|
});
|
|
|
|
describe("parseAIProviders", () => {
|
|
it("should return empty array for empty or whitespace string", () => {
|
|
expect(parseAIProviders("")).toEqual([]);
|
|
expect(parseAIProviders(" ")).toEqual([]);
|
|
});
|
|
|
|
it("should parse a single provider string", () => {
|
|
expect(parseAIProviders("ollama/llava")).toEqual([
|
|
{ provider: "ollama", model: "llava" },
|
|
]);
|
|
});
|
|
|
|
it("should parse multiple comma-separated providers", () => {
|
|
const expected: AIProvider[] = [
|
|
{ provider: "ollama", model: "llava" },
|
|
{ provider: "openai", model: "gpt-4o" },
|
|
];
|
|
expect(parseAIProviders("ollama/llava, openai/gpt-4o")).toEqual(expected);
|
|
});
|
|
|
|
it("should handle extra whitespace", () => {
|
|
expect(parseAIProviders(" ollama/llava , openai/gpt-4o ")).toEqual([
|
|
{ provider: "ollama", model: "llava" },
|
|
{ provider: "openai", model: "gpt-4o" },
|
|
]);
|
|
});
|
|
|
|
it("should filter out entries without a model or provider name", () => {
|
|
expect(
|
|
parseAIProviders("ollama/, /gpt-4o, openai/llama3, incomplete"),
|
|
).toEqual([{ provider: "openai", model: "llama3" }]);
|
|
});
|
|
it("should filter out entries with only provider or only model or no slash or empty parts", () => {
|
|
expect(parseAIProviders("ollama/")).toEqual([]);
|
|
expect(parseAIProviders("/gpt-4o")).toEqual([]);
|
|
expect(parseAIProviders("ollama")).toEqual([]);
|
|
expect(parseAIProviders("ollama/,,openai/gpt4")).toEqual([
|
|
{ provider: "openai", model: "gpt4" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("isProviderAvailable", () => {
|
|
it("should return true for available Ollama (fetch ok)", async () => {
|
|
(global.fetch as vi.Mock).mockResolvedValue({ ok: true });
|
|
const result = await isProviderAvailable(
|
|
{ provider: "ollama", model: "llava" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(true);
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
"http://localhost:11434/api/tags",
|
|
);
|
|
});
|
|
|
|
it("should use PEEKABOO_OLLAMA_BASE_URL for Ollama check", async () => {
|
|
process.env.PEEKABOO_OLLAMA_BASE_URL = "http://custom-ollama:11434";
|
|
(global.fetch as vi.Mock).mockResolvedValue({ ok: true });
|
|
await isProviderAvailable(
|
|
{ provider: "ollama", model: "llava" },
|
|
mockLogger,
|
|
);
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
"http://custom-ollama:11434/api/tags",
|
|
);
|
|
});
|
|
|
|
it("should return false for unavailable Ollama (fetch fails)", async () => {
|
|
(global.fetch as vi.Mock).mockRejectedValue(new Error("Network Error"));
|
|
const result = await isProviderAvailable(
|
|
{ provider: "ollama", model: "llava" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(false);
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
{ error: new Error("Network Error") },
|
|
"Ollama not available",
|
|
);
|
|
});
|
|
|
|
it("should return false for unavailable Ollama (response not ok)", async () => {
|
|
(global.fetch as vi.Mock).mockResolvedValue({ ok: false });
|
|
const result = await isProviderAvailable(
|
|
{ provider: "ollama", model: "llava" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("should return true for available OpenAI (API key set)", async () => {
|
|
process.env.OPENAI_API_KEY = "test-key";
|
|
const result = await isProviderAvailable(
|
|
{ provider: "openai", model: "gpt-4o" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should return false for unavailable OpenAI (API key not set)", async () => {
|
|
const result = await isProviderAvailable(
|
|
{ provider: "openai", model: "gpt-4o" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("should return true for available Anthropic (API key set)", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "test-key";
|
|
const result = await isProviderAvailable(
|
|
{ provider: "anthropic", model: "claude-3" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should return false for unavailable Anthropic (API key not set)", async () => {
|
|
const result = await isProviderAvailable(
|
|
{ provider: "anthropic", model: "claude-3" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("should return false and log warning for unknown provider", async () => {
|
|
const result = await isProviderAvailable(
|
|
{ provider: "unknown", model: "test" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(false);
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
{ provider: "unknown" },
|
|
"Unknown AI provider",
|
|
);
|
|
});
|
|
|
|
it("should handle errors during ollama availability check gracefully (fetch throws)", async () => {
|
|
const fetchError = new Error("Unexpected fetch error");
|
|
(global.fetch as vi.Mock).mockImplementationOnce(() => {
|
|
// Ensure this mock is specific to the ollama check path that uses fetch
|
|
if (
|
|
(global.fetch as vi.Mock).mock.calls.some((call) =>
|
|
call[0].includes("/api/tags"),
|
|
)
|
|
) {
|
|
throw fetchError;
|
|
}
|
|
// Fallback for other fetches if any, though not expected in this test path
|
|
return Promise.resolve({ ok: true, json: async () => ({}) });
|
|
});
|
|
const result = await isProviderAvailable(
|
|
{ provider: "ollama", model: "llava" },
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe(false);
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
{ error: fetchError },
|
|
"Ollama not available",
|
|
);
|
|
expect(mockLogger.error).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ error: fetchError, provider: "ollama" }),
|
|
"Error checking provider availability",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("analyzeImageWithProvider", () => {
|
|
const imageBase64 = "test-base64-image";
|
|
const question = "What is this?";
|
|
|
|
it("should call analyzeWithOllama for ollama provider", async () => {
|
|
(global.fetch as vi.Mock).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ response: "Ollama says hello" }),
|
|
});
|
|
const result = await analyzeImageWithProvider(
|
|
{ provider: "ollama", model: "llava" },
|
|
"path/img.png",
|
|
imageBase64,
|
|
question,
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe("Ollama says hello");
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
"http://localhost:11434/api/generate",
|
|
expect.any(Object),
|
|
);
|
|
expect(
|
|
JSON.parse((global.fetch as vi.Mock).mock.calls[0][1].body),
|
|
).toEqual(
|
|
expect.objectContaining({
|
|
model: "llava",
|
|
prompt: question,
|
|
images: [imageBase64],
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should throw Ollama API error if response not ok", async () => {
|
|
(global.fetch as vi.Mock).mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
text: async () => "Internal Server Error",
|
|
});
|
|
await expect(
|
|
analyzeImageWithProvider(
|
|
{ provider: "ollama", model: "llava" },
|
|
"path/img.png",
|
|
imageBase64,
|
|
question,
|
|
mockLogger,
|
|
),
|
|
).rejects.toThrow("Ollama API error: 500 - Internal Server Error");
|
|
});
|
|
|
|
it("should call analyzeWithOpenAI for openai provider", async () => {
|
|
process.env.OPENAI_API_KEY = "test-key";
|
|
mockChatCompletionsCreate.mockResolvedValueOnce({
|
|
choices: [{ message: { content: "OpenAI says hello" } }],
|
|
});
|
|
|
|
const result = await analyzeImageWithProvider(
|
|
{ provider: "openai", model: "gpt-4o" },
|
|
"path/img.png",
|
|
imageBase64,
|
|
question,
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe("OpenAI says hello");
|
|
expect(mockChatCompletionsCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
model: "gpt-4o",
|
|
messages: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
role: "user",
|
|
content: expect.arrayContaining([
|
|
{ type: "text", text: question },
|
|
{
|
|
type: "image_url",
|
|
image_url: { url: `data:image/jpeg;base64,${imageBase64}` },
|
|
},
|
|
]),
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should throw error if OpenAI API key is missing for openai provider", async () => {
|
|
await expect(
|
|
analyzeImageWithProvider(
|
|
{ provider: "openai", model: "gpt-4o" },
|
|
"path/img.png",
|
|
imageBase64,
|
|
question,
|
|
mockLogger,
|
|
),
|
|
).rejects.toThrow("OpenAI API key not configured");
|
|
});
|
|
|
|
it("should return default message if OpenAI provides no response content", async () => {
|
|
process.env.OPENAI_API_KEY = "test-key";
|
|
mockChatCompletionsCreate.mockResolvedValueOnce({
|
|
choices: [{ message: { content: null } }],
|
|
});
|
|
|
|
const result = await analyzeImageWithProvider(
|
|
{ provider: "openai", model: "gpt-4o" },
|
|
"path/img.png",
|
|
imageBase64,
|
|
question,
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe("No response from OpenAI");
|
|
});
|
|
|
|
it("should return default message if Ollama provides no response content", async () => {
|
|
(global.fetch as vi.Mock).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ response: null }),
|
|
});
|
|
const result = await analyzeImageWithProvider(
|
|
{ provider: "ollama", model: "llava" },
|
|
"path/img.png",
|
|
imageBase64,
|
|
question,
|
|
mockLogger,
|
|
);
|
|
expect(result).toBe("No response from Ollama");
|
|
});
|
|
|
|
it("should throw error for anthropic provider (not implemented)", async () => {
|
|
await expect(
|
|
analyzeImageWithProvider(
|
|
{ provider: "anthropic", model: "claude-3" },
|
|
"path/img.png",
|
|
imageBase64,
|
|
question,
|
|
mockLogger,
|
|
),
|
|
).rejects.toThrow("Anthropic support not yet implemented");
|
|
});
|
|
|
|
it("should throw error for unsupported provider", async () => {
|
|
await expect(
|
|
analyzeImageWithProvider(
|
|
{ provider: "unknown", model: "test" },
|
|
"path/img.png",
|
|
imageBase64,
|
|
question,
|
|
mockLogger,
|
|
),
|
|
).rejects.toThrow("Unsupported AI provider: unknown");
|
|
});
|
|
});
|
|
|
|
describe("getDefaultModelForProvider", () => {
|
|
it("should return correct default models", () => {
|
|
expect(getDefaultModelForProvider("ollama")).toBe("llava:latest");
|
|
expect(getDefaultModelForProvider("openai")).toBe("gpt-4o");
|
|
expect(getDefaultModelForProvider("anthropic")).toBe(
|
|
"claude-3-sonnet-20240229",
|
|
);
|
|
expect(getDefaultModelForProvider("unknown")).toBe("unknown");
|
|
});
|
|
});
|
|
|
|
describe("determineProviderAndModel", () => {
|
|
let configuredProviders: AIProvider[];
|
|
|
|
beforeEach(() => {
|
|
configuredProviders = [
|
|
{ provider: "ollama", model: "llava:custom" },
|
|
{ provider: "openai", model: "gpt-4o-mini" },
|
|
];
|
|
});
|
|
|
|
it("should select a specifically requested and available provider", async () => {
|
|
process.env.OPENAI_API_KEY = "test-key";
|
|
const result = await determineProviderAndModel(
|
|
{ type: "openai" },
|
|
configuredProviders,
|
|
mockLogger,
|
|
);
|
|
expect(result.provider).toBe("openai");
|
|
expect(result.model).toBe("gpt-4o-mini");
|
|
});
|
|
|
|
it("should use a requested model over the configured default", async () => {
|
|
process.env.OPENAI_API_KEY = "test-key";
|
|
const result = await determineProviderAndModel(
|
|
{ type: "openai", model: "gpt-4-turbo" },
|
|
configuredProviders,
|
|
mockLogger,
|
|
);
|
|
expect(result.provider).toBe("openai");
|
|
expect(result.model).toBe("gpt-4-turbo");
|
|
});
|
|
|
|
it("should throw if requested provider is not configured", async () => {
|
|
await expect(
|
|
determineProviderAndModel(
|
|
{ type: "anthropic" },
|
|
configuredProviders,
|
|
mockLogger,
|
|
),
|
|
).rejects.toThrow(
|
|
"Provider 'anthropic' is not enabled in server's PEEKABOO_AI_PROVIDERS configuration.",
|
|
);
|
|
});
|
|
|
|
it("should throw if requested provider is not available", async () => {
|
|
// OPENAI_API_KEY is not set
|
|
await expect(
|
|
determineProviderAndModel(
|
|
{ type: "openai" },
|
|
configuredProviders,
|
|
mockLogger,
|
|
),
|
|
).rejects.toThrow(
|
|
"Provider 'openai' is configured but not currently available.",
|
|
);
|
|
});
|
|
|
|
it("should auto-select the first available provider", async () => {
|
|
(global.fetch as vi.Mock).mockResolvedValue({ ok: true }); // Ollama is available
|
|
process.env.OPENAI_API_KEY = "test-key"; // OpenAI is also available
|
|
|
|
const result = await determineProviderAndModel(
|
|
undefined, // auto mode
|
|
configuredProviders,
|
|
mockLogger,
|
|
);
|
|
|
|
// Should pick the first one in the list: Ollama
|
|
expect(result.provider).toBe("ollama");
|
|
expect(result.model).toBe("llava:custom");
|
|
});
|
|
|
|
it("should fall back to the next available provider in auto mode", async () => {
|
|
(global.fetch as vi.Mock).mockResolvedValue({ ok: false }); // Ollama is NOT available
|
|
process.env.OPENAI_API_KEY = "test-key"; // OpenAI IS available
|
|
|
|
const result = await determineProviderAndModel(
|
|
undefined, // auto mode
|
|
configuredProviders,
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result.provider).toBe("openai");
|
|
expect(result.model).toBe("gpt-4o-mini");
|
|
});
|
|
|
|
it("should return null if no providers are available in auto mode", async () => {
|
|
(global.fetch as vi.Mock).mockResolvedValue({ ok: false }); // Ollama is NOT available
|
|
// OPENAI_API_KEY is not set
|
|
|
|
const result = await determineProviderAndModel(
|
|
undefined, // auto mode
|
|
configuredProviders,
|
|
mockLogger,
|
|
);
|
|
|
|
expect(result.provider).toBeNull();
|
|
expect(result.model).toBe("");
|
|
});
|
|
});
|
|
});
|