const { test, expect } = require('./_guard');
const path = require('node:path');
const { readFileSync } = require('node:fs');
for (const [role, settingKey, modelKey] of [['llm', 'llm_url', 'llm_model'], ['ocr', 'vision_ocr_url', 'vision_ocr_model']]) {
test(`${role} active panel shows persisted disconnect and restores models on reconnect`, async ({ page }) => {
let discoveries = 0;
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '
' }));
await page.route('**/api/**', route => {
discoveries++;
return route.fulfill({ json: { ok: true, models: ['saved-model'] } });
});
await page.goto('/');
await page.setContent(``);
await page.evaluate(({ settingKey, modelKey }) => {
window.$ = id => document.getElementById(id);
window.escHtml = value => String(value);
window._appSettings = { [settingKey]: 'http://engine/v1', [modelKey]: 'saved-model', engine_active: { [settingKey]: 'saved-engine' }, engine_disabled: { 'saved-engine': true } };
}, { settingKey, modelKey });
await page.addScriptTag({ path: path.resolve('static/js/ai-backends.js') });
await expect(page.locator(`#${role}-active-status`)).toHaveText('Disconnected');
expect(discoveries).toBe(0);
await page.evaluate(role => {
delete window._appSettings.engine_disabled['saved-engine'];
window[role === 'llm' ? 'syncActiveLlmPanel' : 'syncActiveOcrPanel']('http://engine/v1');
}, role);
await expect(page.locator(`#${role}-active-model`)).toHaveValue('saved-model');
await expect(page.locator(`#${role}-active-status`)).toHaveText('1 model');
expect(discoveries).toBe(1);
let delayedModels;
await page.route('**/api/engine-models?**', route => { delayedModels = route; });
await page.evaluate(role => window[role === 'llm' ? 'syncActiveLlmPanel' : 'syncActiveOcrPanel']('http://engine/v1'), role);
await expect.poll(() => !!delayedModels).toBe(true);
await expect(page.locator(`#${role}-active-refresh`)).toBeDisabled();
await page.evaluate(role => {
window._appSettings.engine_disabled['saved-engine'] = true;
window[role === 'llm' ? 'syncActiveLlmPanel' : 'syncActiveOcrPanel']('http://engine/v1');
}, role);
await delayedModels.fulfill({ json: { ok: true, models: ['stale-model'] } });
await expect(page.locator(`#${role}-active-refresh`)).toBeEnabled();
await expect(page.locator(`#${role}-active-status`)).toHaveText('Disconnected');
await expect(page.locator(`#${role}-active-model option`)).toHaveText('Disconnected');
});
}
for (const [role, settingKey] of [['stt', 'faster_whisper_url'], ['tts', 'tts_url'], ['llm', 'llm_url'], ['ocr', 'vision_ocr_url']]) {
test(`${role} cards test repeatedly and activate the chosen model with a saved key`, async ({ page }) => {
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '' }));
const activations = [];
const connections = [];
let rejectDisconnect = false;
const settings = { engine_local_urls: { 'dc-custom-stt': 'http://engine/v1' }, engine_models: { 'custom-stt': 'whisper-b' }, engine_disabled: {}, engine_api_keys: { 'custom-stt': '__configured__' } };
let probes = 0;
await page.route('**/api/**', async route => {
const url = new URL(route.request().url());
if (url.pathname === '/api/probe-url') {
probes++;
expect(url.searchParams.get('name')).toBe('custom-stt');
return route.fulfill({ json: { ok: true } });
}
if (url.pathname.endsWith('/connection')) {
const { connected } = route.request().postDataJSON();
if (!connected && rejectDisconnect) return route.fulfill({ status: 500, json: { ok: false, error: 'Save failed' } });
connections.push(connected);
if (connected) delete settings.engine_disabled['custom-stt']; else settings.engine_disabled['custom-stt'] = true;
return route.fulfill({ json: { ok: true, connected } });
}
if (url.pathname === '/api/engine-models') {
expect(url.searchParams.get('type')).toBe(role);
expect(url.searchParams.get('name')).toBe('custom-stt');
return route.fulfill({ json: { ok: true, models: ['whisper-a', 'whisper-b'] } });
}
if (/\/use-as-(engine|llm)$/.test(url.pathname)) {
expect(route.request().headers()['x-provider-api-key']).toBe('test-only-key');
activations.push(route.request().postDataJSON());
return route.fulfill({ json: { ok: true, key_set: true } });
}
return route.fulfill({ json: settings });
});
await page.goto('/');
await page.setContent(``);
await page.evaluate(() => {
window.$ = id => document.getElementById(id);
window._appSettings = { engine_api_keys: { 'custom-stt': '__configured__' } };
window.escHtml = value => String(value).replace(/[&<>"']/g, c => `${c.charCodeAt(0)};`);
window.toast = () => {};
window.initLlmSnippets = () => {};
window.engineApiKeyValue = () => '';
window.applyAndSaveSettings = async patch => Object.assign(window._appSettings, patch);
window.probeUrl = (url, type, apiKey, name) => fetch('/api/probe-url?' + new URLSearchParams({ url, type, name })).then(r => r.json());
});
await page.addScriptTag({ path: path.resolve('static/js/engines.js') });
await page.evaluate(role => renderLocalContainers([{ id: 'custom-stt', name: 'Display name', label: 'Test engine', role, isCustom: true, url: 'http://engine/v1' }]), role);
await expect(page.locator('.llm-local-name')).toHaveText('Test engine');
await expect(page.locator('.dc-apikey-inp')).toBeVisible();
await page.locator('.dc-connect-btn').click();
await page.locator('.dc-connect-btn').click();
expect(probes).toBe(2);
await expect(page.locator('.dc-use-btn')).not.toHaveClass(/active/);
await page.locator('.dc-models-btn').click();
await expect(page.locator('datalist option[value="whisper-b"]')).toHaveCount(1);
await page.locator('.dc-model-inp').fill('whisper-b');
await page.locator('.dc-model-inp').blur();
await page.locator('.dc-apikey-inp').fill('test-only-key');
await page.locator('.dc-use-btn').click();
await expect.poll(() => activations.length).toBe(1);
expect(activations[0]).toEqual(role === 'llm'
? { url: 'http://engine/v1', model: 'whisper-b' }
: { url: 'http://engine/v1', role, setting_key: settingKey, model: 'whisper-b', activate: true });
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
rejectDisconnect = true;
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
expect(settings.engine_disabled).toEqual({});
rejectDisconnect = false;
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeHidden();
await expect(page.locator('.dc-use-btn')).not.toHaveClass(/active/);
expect(settings.engine_disabled).toEqual({ 'custom-stt': true });
expect(settings.engine_models['custom-stt']).toBe('whisper-b');
expect(settings.engine_api_keys['custom-stt']).toBe('__configured__');
// A stale browser flag cannot reconnect an engine disabled in saved settings.
await page.evaluate(() => localStorage.setItem('dc-con-custom-stt', '1'));
await page.evaluate(role => renderLocalContainers([{ id: 'custom-stt', name: 'Renamed display label', role, isCustom: true, url: 'http://engine/v1' }]), role);
await expect(page.locator('.dc-disconnect-btn')).toBeHidden();
await expect(page.locator('.dc-model-inp')).toHaveValue('whisper-b');
await expect(page.locator('.dc-url-inp')).toHaveValue('http://engine/v1');
await page.locator('.dc-connect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
expect(settings.engine_disabled).toEqual({});
expect(connections).toEqual([true, true, false, true]);
await page.route('**/api/probe-url?**', route => route.fulfill({ json: { ok: false, error: 'Denied' } }));
await page.locator('.dc-connect-btn').click();
await expect(page.locator('.dc-connect-btn')).not.toHaveClass(/\bok\b/);
await page.route('**/api/engine-models?**', route => route.fulfill({ json: { ok: false, error: 'Denied' } }));
await page.locator('.dc-models-btn').click();
await expect(page.locator('.dc-model-inp')).toHaveValue('whisper-b');
if (role === 'stt' || role === 'tts') {
const name = role === 'stt' ? 'nvidia-parakeet-asr' : 'fish-speech-api';
const setting = role === 'stt' ? 'nvidia_router_url' : 'fishspeech_url';
await page.evaluate(({ name, role }) => renderLocalContainers([{ name, role, port: 9000 }]), { name, role });
await page.locator('.dc-apikey-inp').fill('test-only-key');
await page.locator('.dc-model-inp').fill('actual-model-id');
await page.locator('.dc-use-btn').click();
await expect.poll(() => activations.length).toBe(2);
expect(activations[1]).toEqual({ url: 'http://host.docker.internal:9000', role, setting_key: setting, model: 'actual-model-id', activate: true });
}
if (role === 'tts') {
const targets = [
['tts-faster-qwen3-tts-voiceclone', 'tts_url'],
['tts-faster-qwen3-tts-voicedesign', 'voice_design_url'],
['tts-faster-qwen3-tts-customvoice', 'customvoice_url'],
['tts-faster-qwen3-tts-streaming', 'tts_stream_url'],
['fish-speech-api', 'fishspeech_url'], ['kokoro-tts', 'kokoro_url'],
['vibevoice-msft-tts', 'vibevoice_url'], ['nvidia-zeroshot-nim', 'nvidia_zeroshot_url'],
['nvidia-flow-nim', 'nvidia_flow_url'], ['xtts-dgx-spark', 'xtts_url'],
];
await page.evaluate(targets => renderLocalContainers(targets.map(([name]) => ({ name, role: 'tts', port: 9000 }))), targets);
expect((await page.locator('.dc-use-btn').allTextContents()).map(text => text.trim())).toEqual(targets.map(() => 'Use as TTS'));
expect(await page.locator('.dc-use-btn').evaluateAll(buttons => buttons.map(button => button.dataset.dcUseKey))).toEqual(targets.map(([, key]) => key));
}
});
}
test('custom editor probes, saves credentials separately, edits inline by stable ID and keeps failed saves open', async ({ page }) => {
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '' }));
let settings = { custom_engine_cards: [], engine_models: {}, engine_api_keys: {} };
let rejectSave = false;
let delaySave = false;
let pendingSave;
const writes = [];
const checks = [];
const activations = [];
await page.route('**/api/**', async route => {
const request = route.request();
const url = new URL(request.url());
if (/\/use-as-(llm|engine)$/.test(url.pathname)) {
activations.push({ path: url.pathname, body: request.postDataJSON() });
return route.fulfill({ json: { ok: true } });
}
if (url.pathname === '/api/settings' && request.method() === 'POST') {
const body = request.postDataJSON();
writes.push(body);
if (delaySave) { pendingSave = route; return; }
if (rejectSave) return route.fulfill({ status: 500, json: { error: 'Save failed' } });
settings = { ...settings, ...body, engine_api_keys: { ...settings.engine_api_keys } };
for (const key of Object.keys(body.engine_api_keys || {})) settings.engine_api_keys[key] = '__configured__';
return route.fulfill({ json: { ok: true } });
}
if (['/api/probe-url', '/api/engine-models'].includes(url.pathname)) {
checks.push({ type: url.searchParams.get('type'), name: url.searchParams.get('name'), typed: request.headers()['x-provider-api-key'] });
return route.fulfill({ json: { ok: true, models: ['discovered-model'] } });
}
return route.fulfill({ json: settings });
});
await page.goto('/');
const section = readFileSync(path.resolve('static/sections/s-llms.html'), 'utf8');
await page.setContent(['stt', 'tts', 'llm', 'ocr'].map(role => ``).join('') + section.slice(section.indexOf('