Files
scrollsmith/test/unit/ollamaClient.test.js
T
keligrubb 7ea9f93dc8
Release / generate-dungeon (push) Failing after 2m30s
Release / upload-to-gitea-release (push) Has been skipped
Improve LLM client immutability and CI model defaults. (#9)
Replace mutable Ollama model export with a const fallback and initializeModel return value, resolving the model from the environment after optional API discovery. Use a for-of loop over attempt indices instead of let in the retry path.

Continue PDF generation when map image generation or upscaling fails, and avoid mutating request headers in place.

Document Open WebUI-style URLs in the README, pin OLLAMA_MODEL in the Gitea release workflow, and adjust integration and unit tests for the new initialization behavior.

Reviewed-on: #9
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-04-15 02:45:25 +00:00

266 lines
8.5 KiB
JavaScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
cleanText,
inferApiType,
callOllama,
callOllamaExplicit,
initializeModel,
OLLAMA_MODEL,
} from "../../src/ollamaClient.js";
describe("cleanText", () => {
it("strips markdown headers", () => {
expect(cleanText("# Title")).toBe("Title");
expect(cleanText("## Sub")).toBe("Sub");
});
it("replaces bold with plain text", () => {
expect(cleanText("**bold**")).toBe("bold");
});
it("removes asterisks and underscores", () => {
expect(cleanText("*a* _b_")).toBe("a b");
});
it("collapses whitespace to single spaces and trims", () => {
expect(cleanText(" a b \n c ")).toBe("a b c");
});
});
describe("inferApiType", () => {
it("returns ollama-generate for null/undefined/empty string", () => {
expect(inferApiType(null)).toBe("ollama-generate");
expect(inferApiType(undefined)).toBe("ollama-generate");
expect(inferApiType("")).toBe("ollama-generate");
});
it("returns open-webui for URL with /api/chat/completions", () => {
expect(inferApiType("http://host/api/chat/completions")).toBe("open-webui");
});
it("returns ollama-chat for URL with /api/chat", () => {
expect(inferApiType("http://host/api/chat")).toBe("ollama-chat");
});
it("returns ollama-generate for plain base URL", () => {
expect(inferApiType("http://localhost:11434")).toBe("ollama-generate");
});
});
describe("callOllama (mocked fetch)", () => {
const originalFetch = globalThis.fetch;
const originalEnv = process.env.OLLAMA_API_URL;
beforeEach(() => {
process.env.OLLAMA_API_URL = "http://localhost:11434";
globalThis.fetch = vi.fn();
});
afterEach(() => {
process.env.OLLAMA_API_URL = originalEnv;
globalThis.fetch = originalFetch;
});
it("returns cleaned text from ollama-generate response", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ response: "**Hello** world" }),
});
const result = await callOllama("Hi", undefined, 1, "test");
expect(result).toBe("Hello world");
});
it("throws on non-ok response", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Error",
text: () => Promise.resolve("server error"),
});
await expect(callOllama("Hi", undefined, 1, "test")).rejects.toThrow("Ollama request failed");
});
it("throws on non-ok response when response.text() rejects", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: false,
status: 502,
statusText: "Bad Gateway",
text: () => Promise.reject(new Error("body read error")),
});
await expect(callOllama("Hi", undefined, 1, "test")).rejects.toThrow("Ollama request failed");
});
it("retries on failure then succeeds", async () => {
vi.mocked(globalThis.fetch)
.mockRejectedValueOnce(new Error("network error"))
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ response: "Retry ok" }),
});
const result = await callOllama("Hi", undefined, 2, "test");
expect(result).toBe("Retry ok");
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
});
describe("callOllamaExplicit (mocked fetch)", () => {
const originalFetch = globalThis.fetch;
const originalUrl = process.env.OLLAMA_API_URL;
const originalKey = process.env.OLLAMA_API_KEY;
beforeEach(() => {
globalThis.fetch = vi.fn();
});
afterEach(() => {
process.env.OLLAMA_API_URL = originalUrl;
process.env.OLLAMA_API_KEY = originalKey;
globalThis.fetch = originalFetch;
});
it("returns content from open-webui response shape", async () => {
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "**Open** answer" } }],
}),
});
const result = await callOllamaExplicit(
"Hi",
"model",
1,
"test",
"open-webui"
);
expect(result).toBe("Open answer");
});
it("sends Authorization header when open-webui and OLLAMA_API_KEY set", async () => {
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
process.env.OLLAMA_API_KEY = "secret-key";
process.env.OLLAMA_MODEL = "";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "ok" } }],
}),
});
await callOllamaExplicit("Hi", "model", 1, "test", "open-webui");
const [, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(opts?.headers?.Authorization).toBe("Bearer secret-key");
});
it("returns content from ollama-chat response shape", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({ message: { content: "Chat **reply**" } }),
});
const result = await callOllamaExplicit(
"Hi",
"model",
1,
"test",
"ollama-chat"
);
expect(result).toBe("Chat reply");
});
it("throws when response has no content", async () => {
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
await expect(
callOllamaExplicit("Hi", "model", 1, "test", "ollama-generate")
).rejects.toThrow("No response from Ollama");
});
});
describe("initializeModel (mocked fetch)", () => {
const originalFetch = globalThis.fetch;
const originalEnv = process.env.OLLAMA_API_URL;
const originalOllamaModel = process.env.OLLAMA_MODEL;
beforeEach(() => {
process.env.OLLAMA_API_URL = "http://localhost:11434";
process.env.OLLAMA_MODEL = "";
globalThis.fetch = vi.fn();
});
afterEach(() => {
process.env.OLLAMA_API_URL = originalEnv;
process.env.OLLAMA_MODEL = originalOllamaModel;
globalThis.fetch = originalFetch;
});
it("does not fetch when OLLAMA_MODEL is set", async () => {
process.env.OLLAMA_MODEL = "existing-model";
await initializeModel();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("does not set env when fetch returns not ok", async () => {
process.env.OLLAMA_MODEL = "";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: false,
status: 404,
json: () => Promise.resolve({}),
});
const resolved = await initializeModel();
expect(resolved).toBe(OLLAMA_MODEL);
expect(process.env.OLLAMA_MODEL).toBe("");
});
it("fetches /api/tags when OLLAMA_MODEL not set", async () => {
process.env.OLLAMA_MODEL = "";
process.env.OLLAMA_API_URL = "http://localhost:11434";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ models: [{ name: "test-model" }] }),
});
const resolved = await initializeModel();
expect(globalThis.fetch).toHaveBeenCalled();
const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(String(url)).toMatch(/\/api\/tags$/);
expect(opts?.method || "GET").toBe("GET");
expect(resolved).toBe("test-model");
expect(process.env.OLLAMA_MODEL).toBe("test-model");
});
it("fetches /api/v1/models when URL has open-webui path and sets model from data.data id", async () => {
process.env.OLLAMA_MODEL = "";
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [{ id: "webui-model" }] }),
});
await initializeModel();
const [url] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/models$/);
expect(process.env.OLLAMA_MODEL).toBe("webui-model");
});
it("sets model from data.data[0].name when id missing", async () => {
process.env.OLLAMA_MODEL = "";
process.env.OLLAMA_API_URL = "http://host/api/chat/completions";
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }),
});
await initializeModel();
expect(process.env.OLLAMA_MODEL).toBe("webui-model-name");
});
it("catches fetch failure and warns", async () => {
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("network"));
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await initializeModel();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Could not fetch default model"));
warn.mockRestore();
});
});