The floating assistant was broken end-to-end; Sami said "Do it that works".
Three user-visible bugs fixed + the promised LLM delegation implemented:
FRONTEND (status/js/ai-chat.js — rewritten):
- Response parsing: old code read data.data; the API returns the FLAT ok()
envelope — EVERY reply rendered "Sorry, something went wrong". Now reads
the real shape (contract asserted by route test).
- Suggestion chips: onclick="...send('"Deploy Plex"')" — nested quotes,
invalid JS, dead buttons (shipped like this). Now data-driven buttons with
addEventListener.
- Deploy button: called window.openAppSelector which NEVER existed anywhere
in the tree (grep-verified). Now opens the REAL deploy modal via new
window.openDeployConfigFor(templateId) exported by app-selector.js —
resolves against the live API template cache, reuses showDeployConfig
(same path a manual catalog click takes). Widgets route to the grid.
- Conversation history (last 6 turns) now rides along per request, engine
badge in header, ⚙ settings sub-panel (baseUrl/model/API key + Test +
Remove), all server text via textContent (no HTML injection), CSP-safe
(no inline handlers).
BACKEND (routes/ai-intent.js):
- TWO engines, one contract. LLM classifier (OpenAI-compatible chat
completions) with system prompt embedding the LIVE 78-app catalog + the
six wizard categories; regex patterns kept as the zero-config default and
the automatic failover (HTTP error, timeout via AbortController,
unparseable output -> pattern answers; response carries engine +
llmError so the UI can say which served — no silent degradation).
- Closed-vocabulary schema validation: intent MUST be in the known set else
'unknown'; appId MUST exist in APP_TEMPLATES else nulled (hallucinated
ids cannot leak into deploy plans); categories filtered to the wizard's
real keys; reply stripped of control chars + bounded to 600; history
role-filtered (only user/assistant pass — client-injected system turns
dropped) + capped; input capped to 1000 chars.
- enrich() rebuilds the response contract per intent; pattern-canned
response is dropped when the LLM rerouted the intent (no stale shape);
deployPlan body.port now fills from the template's defaultPort (was
hardcoded null); deployPlan shape asserted against wizard usage.
- Key storage: PUT /ai/settings stores {baseUrl,model,apiKey} in the
ENCRYPTED credential store (key 'ai:llm') via ctx.credentialManager —
NOT config.json (GET /config echoes the raw file to the browser; that
route's shape was verified before this decision). Blank apiKey on PUT
keeps the stored one. GET /ai/settings reports configured/source/model/
hasApiKey — the key value NEVER crosses the wire (asserted). Env
fallback AI_LLM_BASE_URL/AI_LLM_API_KEY/AI_LLM_MODEL + legacy
LLM_PROXY_URL for headless installs. POST /ai/settings/test = one-shot
real classify probe returning ok/sample or ok:false/reason (never
throws). DELETE clears.
- GET /ai/capabilities now reports engine + llm status (pattern was honest
marketing before; now it can be true).
MOUNT (src/app.js): aiIntentRoutes receives siteConfig, log, fetchImpl
(ctx.fetchT — test-injectable) and credentialManager.
Docs: OpenAPI +4 AI path entries (intent, capabilities, settings, settings/test); CHANGELOG entry; dist bundle rebuilt
(openDeployConfigFor present in features.js; SW cache c62511c40d).
Tests: 20 NEW in __tests__/routes/ai-intent-engine.test.js (engine
selection w/ and w/o store, off-catalog phrasing via mock LLM,
hallucinated-id nulling, illegal intent coercion, HTTP-500 failover,
AbortError timeout failover, markdown-fence repair, reply sanitization,
history cap+role filter, env config path, intent-disagreement rebuild w/
real jellyfin port 8096, flat envelope contract, 400s, 1000-char cap,
settings GET/PUT/DELETE/test incl. key-never-echoed assertions,
capabilities engine reporting) + the 18 original pattern tests unchanged +
composition-root e2e 8 pass. Full suite: 3643 passed; only the pre-existing
pdf-parse billing family red (6 failed in documented band [5,7], patch
touches no billing file).
369 lines
18 KiB
JavaScript
369 lines
18 KiB
JavaScript
/**
|
|
* DC-163: AI Intent Router — dual-engine tests.
|
|
*
|
|
* Covers:
|
|
* - classify() engine selection: pattern-only when unconfigured, LLM when
|
|
* credential store returns config, failover to pattern on HTTP errors /
|
|
* timeouts / junk output.
|
|
* - closed-vocabulary schema: invented template ids nulled; illegal intent
|
|
* values coerced to 'unknown'; reply sanitized + length-bounded.
|
|
* - enrich() response-shape contract the chat panel consumes.
|
|
* - HTTP surface: POST /ai/intent flat envelope, /ai/settings GET/PUT/
|
|
* DELETE/POST-test, key never echoed, capabilities engine reporting.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
const path = require('path');
|
|
|
|
const intentMod = require('../../routes/ai-intent');
|
|
|
|
// test key value built via concat (display-layer redactor note)
|
|
const LLM_TEST_KEY = 'sk-' + 'testsecret123';
|
|
|
|
function fakeCredStore(payload) {
|
|
const store = new Map();
|
|
if (payload !== undefined) store.set('ai:llm', payload);
|
|
return {
|
|
_store: store,
|
|
retrieve: jest.fn(async (k) => {
|
|
if (!store.has(k)) { throw new Error('not found'); }
|
|
return store.get(k);
|
|
}),
|
|
store: jest.fn(async (k, v) => { store.set(k, v); return { key: k, stored: true }; }),
|
|
delete: jest.fn(async (k) => { store.delete(k); return true; }),
|
|
};
|
|
}
|
|
|
|
function llmReplyFetch(content, calls) {
|
|
return async (url, opts) => {
|
|
if (calls) calls.push({ url, opts });
|
|
return {
|
|
ok: true, status: 200,
|
|
json: async () => ({ choices: [{ message: { content } }] }),
|
|
};
|
|
};
|
|
}
|
|
|
|
const silentLog = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
|
|
|
describe('DC-163 classify engine selection', () => {
|
|
test('no credential store + no env → pattern engine, zero fetches', async () => {
|
|
let fired = 0;
|
|
const out = await intentMod.classify({
|
|
message: 'Deploy Plex',
|
|
history: [],
|
|
deps: { credentialManager: null, env: {}, fetchImpl: async () => { fired++; return {}; }, log: silentLog, timeoutMs: 1000 },
|
|
});
|
|
expect(out.engine).toBe('pattern');
|
|
expect(out.intent).toBe('deploy');
|
|
expect(out.appId).toBe('plex');
|
|
expect(fired).toBe(0);
|
|
});
|
|
|
|
test('credential store config → llm engine; off-catalog phrasing understood', async () => {
|
|
const calls = [];
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' }));
|
|
const fetchImpl = llmReplyFetch('{"intent":"deploy","appId":"jellyfin","reply":"Jellyfin it is"}', calls);
|
|
const out = await intentMod.classify({
|
|
message: 'I want a private YouTube for my living room TV',
|
|
history: [],
|
|
deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 5000 },
|
|
});
|
|
expect(out.engine).toBe('llm');
|
|
expect(out.intent).toBe('deploy');
|
|
expect(out.appId).toBe('jellyfin'); // regex would have said 'unknown'
|
|
// prompt carries the real catalog
|
|
const sysPrompt = calls[0].opts.body ? JSON.parse(calls[0].opts.body).messages[0].content : '';
|
|
expect(sysPrompt).toContain('jellyfin');
|
|
expect(sysPrompt).toContain('radicale');
|
|
// auth header: Bearer with the stored key (shape check, value compared)
|
|
expect(calls[0].opts.headers.Authorization).toBe(`Bearer ${LLM_TEST_KEY}`);
|
|
});
|
|
|
|
test('hallucinated template id is nulled (closed vocabulary)', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const out = await intentMod.classify({
|
|
message: 'deploy YouTube Premium please',
|
|
history: [],
|
|
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"youtube-premium","reply":"ok"}'), log: silentLog, timeoutMs: 5000 },
|
|
});
|
|
expect(out.engine).toBe('llm');
|
|
expect(out.appId || null).toBeNull();
|
|
});
|
|
|
|
test('illegal intent string coerces to unknown', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const out = await intentMod.classify({
|
|
message: 'frobnicate the widget',
|
|
history: [],
|
|
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"rm -rf","appId":null,"reply":"hmm"}'), log: silentLog, timeoutMs: 5000 },
|
|
});
|
|
expect(out.intent).toBe('unknown');
|
|
});
|
|
|
|
test('LLM HTTP 500 → pattern failover with llmError recorded', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const fetchImpl = async () => ({ ok: false, status: 500, json: async () => ({}) });
|
|
const out = await intentMod.classify({
|
|
message: 'why is plex down',
|
|
history: [],
|
|
deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 1000 },
|
|
});
|
|
expect(out.engine).toBe('pattern');
|
|
expect(out.intent).toBe('diagnose');
|
|
expect(out.serviceId).toBe('plex');
|
|
expect(out.llmError).toBeTruthy();
|
|
});
|
|
|
|
test('LLM timeout → AbortError surfaces as timeout failover', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const fetchImpl = (url, opts) => new Promise((_res, rej) => {
|
|
opts.signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; rej(e); });
|
|
});
|
|
const out = await intentMod.classify({
|
|
message: 'Back up everything',
|
|
history: [],
|
|
deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 80 },
|
|
});
|
|
expect(out.engine).toBe('pattern');
|
|
expect(out.intent).toBe('backup');
|
|
expect(String(out.llmError)).toMatch(/timeout/i);
|
|
}, 10000);
|
|
|
|
test('markdown-fenced JSON + prefix prose is repaired and parsed', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const out = await intentMod.classify({
|
|
message: 'Block ads on my network',
|
|
history: [],
|
|
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('Sure! ```json\n{"intent":"recommend","appId":null,"categories":["home-network"],"reply":"AdGuard rules"}\n```'), log: silentLog, timeoutMs: 5000 },
|
|
});
|
|
expect(out.engine).toBe('llm');
|
|
expect(out.intent).toBe('recommend');
|
|
expect(out.llmReply).toBe('AdGuard rules');
|
|
});
|
|
|
|
test('reply control chars stripped and bounded', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const long = 'x'.repeat(2000) + '\u0000evil';
|
|
const out = await intentMod.classify({
|
|
message: 'what is deployed',
|
|
history: [],
|
|
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch(JSON.stringify({ intent: 'list', reply: long })), log: silentLog, timeoutMs: 5000 },
|
|
});
|
|
expect(out.llmReply.length).toBeLessThanOrEqual(600);
|
|
expect(out.llmReply.indexOf('evil')).toBe(-1);
|
|
// eslint-disable-next-line no-control-regex -- deliberately asserting control chars were stripped
|
|
expect(out.llmReply).not.toMatch(new RegExp('[\\u0000-\\u001F]'));
|
|
});
|
|
|
|
test('history is role-filtered and capped at MAX_HISTORY', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const calls = [];
|
|
const fetchImpl = llmReplyFetch('{"intent":"deploy","appId":"gitea","reply":"ok"}', calls);
|
|
const hist = Array.from({ length: 20 }, (_, i) => ({ role: i % 3 === 2 ? 'system' : (i % 2 ? 'user' : 'assistant'), content: 'c' + i }));
|
|
await intentMod.classify({ message: 'yes deploy it', history: hist, deps: { credentialManager: cm, env: {}, fetchImpl, log: silentLog, timeoutMs: 5000 } });
|
|
const msgs = JSON.parse(calls[0].opts.body).messages;
|
|
expect(msgs.length).toBeLessThanOrEqual(8); // system + ≤6 history + user
|
|
expect(msgs.filter(m => m.role === 'system').length).toBe(1); // injected 'system' roles dropped
|
|
});
|
|
|
|
test('env AI_LLM_* config works without credential store', async () => {
|
|
const out = await intentMod.classify({
|
|
message: 'Deploy Plex',
|
|
history: [],
|
|
deps: { credentialManager: null, env: { AI_LLM_BASE_URL: 'http://llm.test/v1', AI_LLM_API_KEY: '***' + 'envkey', AI_LLM_MODEL: 'env-model' }, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"uptime-kuma","reply":"sure"}'), log: silentLog, timeoutMs: 5000 },
|
|
});
|
|
expect(out.engine).toBe('llm');
|
|
expect(out.model).toBe('env-model');
|
|
expect(out.appId).toBe('uptime-kuma'); // NOT in the old regex list — catalog-wide
|
|
});
|
|
|
|
test('LLM disagreement: pattern said recommend, LLM says deploy → deploy response rebuilt', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const out = await intentMod.classify({
|
|
message: 'I want to stream movies', // regex: recommend
|
|
history: [],
|
|
deps: { credentialManager: cm, env: {}, fetchImpl: llmReplyFetch('{"intent":"deploy","appId":"jellyfin","reply":"Jellyfin streams movies"}'), log: silentLog, timeoutMs: 5000 },
|
|
});
|
|
expect(out.intent).toBe('deploy');
|
|
expect(out.appId).toBe('jellyfin');
|
|
const enriched = intentMod.enrich({ ...out });
|
|
expect(enriched.response.message).toBeTruthy();
|
|
expect(enriched.response.appId).toBe('jellyfin');
|
|
expect(enriched.deployPlan.templateId).toBe('jellyfin');
|
|
expect(enriched.deployPlan.body.port).toBe(8096); // real template defaultPort, not null
|
|
});
|
|
});
|
|
|
|
// ─── HTTP surface ────────────────────────────────────────────────────────────
|
|
function buildRouter({ credentialManager = fakeCredStore(undefined), env = {}, fetchImpl = null } = {}) {
|
|
jest.resetModules();
|
|
const mod = require('../../routes/ai-intent');
|
|
const asyncHandler = (fn) => async (req, res, next) => {
|
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
};
|
|
const errorResponse = (res, code, msg, extra) => res.status(code).json({ success: false, error: msg, ...(extra || {}) });
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/v1', mod({ asyncHandler, errorResponse, log: silentLog, siteConfig: null, credentialManager, fetchImpl, env }));
|
|
app.use((err, req, res, next) => { res.status(err.statusCode || 500).json({ success: false, error: err.message }); }); // eslint-disable-line no-unused-vars
|
|
return app;
|
|
}
|
|
|
|
async function start(app) {
|
|
const server = await new Promise((resolve) => {
|
|
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
|
});
|
|
return { server, base: `http://127.0.0.1:${server.address().port}/api/v1` };
|
|
}
|
|
|
|
describe('DC-163 ai HTTP routes', () => {
|
|
test('POST /ai/intent — flat ok() envelope (no data.data wrapper)', async () => {
|
|
const app = buildRouter();
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/ai/intent`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: 'Deploy Plex' }),
|
|
});
|
|
const body = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(body.success).toBe(true);
|
|
expect(body.intent).toBe('deploy');
|
|
expect(body.engine).toBe('pattern');
|
|
expect(body.response.message).toBeTruthy();
|
|
expect(body.response.appId).toBe('plex');
|
|
expect(body.deployPlan.body.name).toBe('Plex');
|
|
expect(body.timestamp).toBeTruthy();
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('POST /ai/intent — 400 on missing/non-string message', async () => {
|
|
const app = buildRouter();
|
|
const { server, base } = await start(app);
|
|
try {
|
|
for (const bad of [{}, { message: '' }, { message: 42 }, { message: ' '}]) {
|
|
const res = await fetch(`${base}/ai/intent`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(bad) });
|
|
expect(res.status).toBe(400);
|
|
}
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('POST /ai/intent — message over 1000 chars is truncated, not rejected', async () => {
|
|
const app = buildRouter();
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/ai/intent`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: 'Deploy Plex ' + 'z'.repeat(1500) }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.message.length).toBeLessThanOrEqual(1000);
|
|
expect(body.intent).toBe('deploy');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('GET /ai/settings — reports configuration WITHOUT the api key', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' }));
|
|
const app = buildRouter({ credentialManager: cm });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/ai/settings`);
|
|
const body = await res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.configured).toBe(true);
|
|
expect(body.source).toBe('credential-store');
|
|
expect(body.model).toBe('test-model');
|
|
const raw = JSON.stringify(body);
|
|
expect(raw).not.toContain(LLM_TEST_KEY);
|
|
expect(body.hasApiKey).toBe(true);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('PUT /ai/settings — stores in credential store; requires baseUrl+apiKey on first save', async () => {
|
|
const cm = fakeCredStore(undefined);
|
|
const app = buildRouter({ credentialManager: cm });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
// missing key → 400
|
|
const r1 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'http://llm.test/v1' }) });
|
|
expect(r1.status).toBe(400);
|
|
// bad baseUrl → 400
|
|
const r2 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'ftp://x', apiKey: LLM_TEST_KEY }) });
|
|
expect(r2.status).toBe(400);
|
|
// valid → stored encrypted (credentialManager.store), response never echoes key
|
|
const r3 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseUrl: 'http://llm.test/v1', model: 'test-model', apiKey: LLM_TEST_KEY }) });
|
|
const b3 = await r3.json();
|
|
expect(r3.status).toBe(200);
|
|
expect(b3.saved).toBe(true);
|
|
expect(JSON.stringify(b3)).not.toContain(LLM_TEST_KEY);
|
|
const stored = JSON.parse(cm._store.get('ai:llm'));
|
|
expect(stored.apiKey).toBe(LLM_TEST_KEY);
|
|
// partial update keeps stored key
|
|
const r4 = await fetch(`${base}/ai/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'new-model' }) });
|
|
expect(r4.status).toBe(200);
|
|
expect(JSON.parse(cm._store.get('ai:llm')).apiKey).toBe(LLM_TEST_KEY);
|
|
expect(JSON.parse(cm._store.get('ai:llm')).model).toBe('new-model');
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('DELETE /ai/settings — clears stored config', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const app = buildRouter({ credentialManager: cm });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/ai/settings`, { method: 'DELETE' });
|
|
expect(res.status).toBe(200);
|
|
expect(cm._store.has('ai:llm')).toBe(false);
|
|
const after = await (await fetch(`${base}/ai/settings`)).json();
|
|
expect(after.configured).toBe(false);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('POST /ai/settings/test — end-to-end probe returns sample intent, no key', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY, model: 'test-model' }));
|
|
const fetchImpl = llmReplyFetch('{"intent":"backup","appId":null,"reply":"Backup it is"}');
|
|
const app = buildRouter({ credentialManager: cm, fetchImpl });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/ai/settings/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(true);
|
|
expect(body.engine).toBe('llm');
|
|
expect(body.sample.intent).toBe('backup');
|
|
expect(JSON.stringify(body)).not.toContain(LLM_TEST_KEY);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('POST /ai/settings/test — failure degrades to ok:false with reason (never throws)', async () => {
|
|
const cm = fakeCredStore(JSON.stringify({ baseUrl: 'http://llm.test/v1', apiKey: LLM_TEST_KEY }));
|
|
const app = buildRouter({ credentialManager: cm, fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const res = await fetch(`${base}/ai/settings/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.ok).toBe(false);
|
|
expect(body.reason).toMatch(/ECONNREFUSED/);
|
|
} finally { server.close(); }
|
|
});
|
|
|
|
test('GET /ai/capabilities — engine reflects live configuration', async () => {
|
|
const app = buildRouter();
|
|
const { server, base } = await start(app);
|
|
try {
|
|
const body = await (await fetch(`${base}/ai/capabilities`)).json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.engine).toBe('pattern');
|
|
expect(body.llm.configured).toBe(false);
|
|
expect(body.llm.howToEnable).toMatch(/credential store/);
|
|
expect(body.intents).toContain('deploy');
|
|
expect(body.capabilities.length).toBeGreaterThanOrEqual(6);
|
|
} finally { server.close(); }
|
|
});
|
|
});
|