mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features
- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio), summarize/improve/translate commands, proofread via ai:analyze; calls proxied through main so API keys stay out of the renderer - Collaboration plugin: anchor-based comments in .comments/ sidecars with drift detection and F8 navigation - Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel - Crash recovery: debounced session snapshots with restore prompt on launch - Version history: pre-save snapshots, History panel with restore/diff/delete - Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI) - XLSX export (native workbooks via JSZip), ODT headers/footers + page size - Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering - Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter, writing heatmap, writing-studio panels wired with rail icons - Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links, REPL first-run confirmation - Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI dangling --css/--reference-doc flags, dead converter button 8 new test suites; 613 tests green; lint clean
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @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();
|
||||
});
|
||||
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user