Files
markdown-converter/tests/main/AiProviders.test.js
T
amitwh 7ab5a0ddb4 feat(ai): add Anthropic-compatible provider; fix async plugin backends
Anthropic-compatible provider:
- New 'anthropic-compatible' option for any base URL speaking the Anthropic
  messages schema (LiteLLM proxies, Bedrock gateways, local servers)
- Sends x-api-key AND Bearer auth when a key is set (gateway-friendly,
  harmless for the official API); keyless proxies supported
- Tolerates base URLs with or without a trailing /v1 segment
- Settings modal, manifest, and provider docs updated

Runtime bug fixes found by booting the app (run-to-verify pass):
- PDF editor: File > Open PDF sends operation=null which matched no switch
  case and crashed on getElementById(undefined); now defaults to the merge
  section
- backlinks-panel: wrong require depth (../../utils -> ../utils) threw at
  panel registration time
- writing-studio stack was written against a synchronous settings backend but
  the real one is IPC-backed: GoalTracker/SnapshotManager/ProjectManager and
  all four panels now await; JSON.parse(Promise) crashes eliminated
- manuscript panel used window.prompt (unavailable in Electron); replaced
  with an inline dialog
- collaboration comment-store/save-load made async to match its IPC IO

617/617 tests green; 4 consecutive clean app boots (no uncaught errors)
2026-09-05 21:53:34 +05:30

191 lines
7.2 KiB
JavaScript

/**
* @jest-environment node
*
* AiProviders tests with a stubbed fetch — no network access. Verifies the
* provider dispatch, auth handling, timeouts, and the user-safe error paths.
*/
const {
complete,
resolveSettings,
AiProviderError,
PROVIDER_DEFAULTS,
MAX_PROMPT_CHARS,
} = require('../../src/main/AiProviders');
/** Build a fetch stub returning the given JSON with status 200. */
function okFetch(body) {
return jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => body,
});
}
describe('AiProviders', () => {
describe('resolveSettings', () => {
it('applies provider defaults for base URL and model', () => {
const s = resolveSettings({ provider: 'ollama' });
expect(s.baseUrl).toBe(PROVIDER_DEFAULTS.ollama.baseUrl);
expect(s.model).toBe(PROVIDER_DEFAULTS.ollama.defaultModel);
expect(s.apiKey).toBe('');
});
it('does not require an API key for local providers', () => {
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
// Self-hosted Anthropic-compatible proxies may front their own auth
expect(() =>
resolveSettings({ provider: 'anthropic-compatible', baseUrl: 'http://localhost:4000' })
).not.toThrow();
});
it('requires an API key for remote providers', () => {
expect(() => resolveSettings({ provider: 'openai' })).toThrow(AiProviderError);
expect(() => resolveSettings({ provider: 'anthropic' })).toThrow(/API key/);
});
it('rejects unknown providers and non-http(s) base URLs', () => {
expect(() => resolveSettings({ provider: 'nope' })).toThrow(/Unknown AI provider/);
expect(() =>
resolveSettings({ provider: 'openai-compatible', apiKey: 'k', baseUrl: 'file:///etc' })
).toThrow(/Invalid API base URL/);
});
it('clamps temperature into [0, 2]', () => {
expect(resolveSettings({ provider: 'ollama', temperature: 99 }).temperature).toBe(2);
expect(resolveSettings({ provider: 'ollama', temperature: -5 }).temperature).toBe(0);
});
});
describe('complete', () => {
const messages = [{ role: 'user', content: 'hello' }];
it('rejects requests with no messages', async () => {
await expect(complete({ provider: 'ollama', messages: [] })).rejects.toThrow(/No messages/);
});
it('rejects prompts over the size cap', async () => {
await expect(
complete({
provider: 'ollama',
messages: [{ role: 'user', content: 'x'.repeat(MAX_PROMPT_CHARS + 1) }],
})
).rejects.toThrow(/too large/i);
});
it('sends the OpenAI chat-completions shape with a bearer key', async () => {
const fetchImpl = okFetch({ choices: [{ message: { content: 'hi there' } }] });
const result = await complete(
{ provider: 'openai', apiKey: 'sk-test', messages },
{ fetchImpl }
);
expect(result.content).toBe('hi there');
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.openai.com/v1/chat/completions');
expect(init.headers.Authorization).toBe('Bearer sk-test');
const body = JSON.parse(init.body);
expect(body.messages[0].role).toBe('user');
});
it('prepends the system prompt as a system message for OpenAI-style providers', async () => {
const fetchImpl = okFetch({ choices: [{ message: { content: 'ok' } }] });
await complete({ provider: 'ollama', system: 'be brief', messages }, { fetchImpl });
const body = JSON.parse(fetchImpl.mock.calls[0][1].body);
expect(body.messages[0]).toEqual({ role: 'system', content: 'be brief' });
});
it('sends the Anthropic messages shape with x-api-key header', async () => {
const fetchImpl = okFetch({
content: [{ type: 'text', text: 'claude says hi' }],
});
const result = await complete(
{ provider: 'anthropic', apiKey: 'ak-test', system: 'be nice', messages },
{ fetchImpl }
);
expect(result.content).toBe('claude says hi');
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.anthropic.com/v1/messages');
expect(init.headers['x-api-key']).toBe('ak-test');
expect(init.headers['anthropic-version']).toBe('2023-06-01');
const body = JSON.parse(init.body);
expect(body.system).toBe('be nice');
});
it('routes anthropic-compatible to the custom base with Bearer + x-api-key auth', async () => {
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'proxy ok' }] });
const result = await complete(
{
provider: 'anthropic-compatible',
baseUrl: 'http://localhost:4000',
apiKey: 'proxy-key',
messages,
},
{ fetchImpl }
);
expect(result.content).toBe('proxy ok');
const [url, init] = fetchImpl.mock.calls[0];
// Base without a /v1 suffix gets one appended
expect(url).toBe('http://localhost:4000/v1/messages');
// Both auth styles are sent so LiteLLM/Bedrock-style gateways accept it
expect(init.headers['x-api-key']).toBe('proxy-key');
expect(init.headers.Authorization).toBe('Bearer proxy-key');
expect(init.headers['anthropic-version']).toBe('2023-06-01');
});
it('does not double the /v1 segment when the base already ends with it', async () => {
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'ok' }] });
await complete(
{
provider: 'anthropic-compatible',
baseUrl: 'http://localhost:4000/v1/',
messages,
},
{ fetchImpl }
);
// Trailing slash is stripped by resolveSettings, so the base ends in /v1
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('http://localhost:4000/v1/messages');
// Keyless proxies must work without auth headers
expect(init.headers.Authorization).toBeUndefined();
expect(init.headers['x-api-key']).toBeUndefined();
});
it('rejects anthropic-compatible without a base URL', async () => {
await expect(
complete({ provider: 'anthropic-compatible', messages }, { fetchImpl: okFetch({}) })
).rejects.toThrow(/Invalid API base URL/);
});
it('surfaces HTTP failures as user-safe errors without the body', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
await expect(
complete({ provider: 'openai', apiKey: 'bad', messages }, { fetchImpl })
).rejects.toThrow(/HTTP 401/);
});
it('rejects unexpected response shapes', async () => {
const fetchImpl = okFetch({ unexpected: true });
await expect(
complete({ provider: 'openai', apiKey: 'k', messages }, { fetchImpl })
).rejects.toThrow(/unexpected response/i);
});
it('surfaces aborts as timeouts', async () => {
const fetchImpl = jest.fn(
(_url, init) =>
new Promise((_res, rej) => {
init.signal.addEventListener('abort', () =>
rej(new Error('The operation was aborted'))
);
})
);
await expect(
complete({ provider: 'ollama', messages }, { fetchImpl, timeoutMs: 30 })
).rejects.toThrow();
});
});
});