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('') + 9)); await page.addStyleTag({ path: path.resolve('static/style.css') }); await page.evaluate(() => { window.$ = id => document.getElementById(id); window._appSettings = { custom_engine_cards: [], engine_models: {}, engine_api_keys: {} }; window.escHtml = value => String(value).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); window.toast = () => {}; window.initLlmSnippets = () => {}; }); await page.addScriptTag({ path: path.resolve('static/js/engines.js') }); await page.evaluate(() => openAddEngineDialog('stt')); await page.locator('#aed-name').fill('Example STT'); await page.locator('#aed-url').fill('http://engine/v1'); await page.locator('#aed-api-key').fill('test-only-key'); for (const invalid of ['[1]', '{', '{"nested":{"api_key":"test-only-key"}}', '{"headers":{"x-api-key":"test-only-key"}}', '{"llm_api_key":"test-only-key"}', '{"BearerToken":"test-only-key"}']) { await page.locator('#aed-params').fill(invalid); await page.locator('#aed-save').click(); await page.locator('#aed-test').click(); expect(writes).toHaveLength(0); expect(checks).toHaveLength(0); await expect(page.locator('#add-engine-dlg')).toBeVisible(); } await page.locator('#aed-params').fill('{"temperature":0.7,"max_tokens":50}'); await page.locator('#aed-active').check(); await page.locator('#aed-test').click(); await page.locator('#aed-load-models').click(); await expect(page.locator('#aed-models option')).toHaveAttribute('value', 'discovered-model'); await page.locator('#aed-model').fill('manual-model'); await page.locator('#aed-save').click(); await expect(page.locator('#add-engine-dlg')).not.toBeVisible(); expect(writes).toHaveLength(1); const id = writes[0].custom_engine_cards[0].id; expect(writes[0].engine_api_keys).toEqual({ [id]: 'test-only-key' }); expect(writes[0].engine_models[id]).toBe('manual-model'); expect(writes[0].custom_engine_cards[0].params).toEqual({ temperature: 0.7, max_tokens: 50 }); await expect.poll(() => activations.length).toBe(1); expect(activations[0]).toEqual({ path: `/api/local-containers/${id}/use-as-engine`, body: { url: 'http://engine/v1', model: 'manual-model', role: 'stt', activate: true, setting_key: 'faster_whisper_url' } }); expect(checks).toEqual([{ type: 'stt', name: id, typed: 'test-only-key' }, { type: 'stt', name: id, typed: 'test-only-key' }]); expect(JSON.stringify(writes[0].custom_engine_cards)).not.toContain('test-only-key'); expect(await page.evaluate(() => localStorage.getItem('engines-custom-cards'))).not.toContain('test-only-key'); await expect(page.locator('#dc-grid-stt .dc-edit-btn')).toBeVisible(); await page.locator('#dc-grid-stt .dc-edit-btn').click(); await expect(page.locator('#add-engine-dlg')).not.toBeVisible(); await expect(page.locator('#dc-grid-stt .add-engine-form')).toBeVisible(); await page.locator('#aed-name').fill('Cancelled change'); await page.locator('#aed-api-key').fill('discarded-test-key'); await page.locator('#aed-cancel').click(); await expect(page.locator('#dc-grid-stt .llm-local-name')).toHaveText('Example STT'); await expect(page.locator('#dc-grid-stt .dc-edit-btn')).toBeFocused(); await page.locator('#dc-grid-stt .dc-url-inp').fill('http://visible-override/v1'); await page.locator('#dc-grid-stt .dc-container-name-inp').fill('visible-container'); await page.locator('#dc-grid-stt .dc-edit-btn').click(); await expect(page.locator('#aed-url')).toHaveValue('http://visible-override/v1'); await expect(page.locator('#aed-container')).toHaveValue('visible-container'); await expect(page.locator('#aed-name')).toHaveValue('Example STT'); await expect(page.locator('#aed-api-key')).toHaveValue(''); await expect(page.locator('#aed-api-key')).toHaveAttribute('placeholder', /Configured/); await expect(page.locator('#aed-model')).toHaveValue('manual-model'); await expect(page.locator('#aed-params')).toHaveValue(/"temperature": 0.7/); await page.locator('#aed-active').uncheck(); await page.locator('#aed-name').fill('Renamed STT'); await page.locator('#aed-url').fill('http://changed-engine/v1'); let delayedModels; await page.route('**/api/engine-models?**', route => { delayedModels = route; }); await page.locator('#aed-load-models').click(); await expect.poll(() => !!delayedModels).toBe(true); await page.locator('#aed-url').fill('http://new-engine/v1'); await delayedModels.fulfill({ json: { ok: true, models: ['stale-model'] } }); await expect(page.locator('#aed-load-models')).toBeEnabled(); await expect(page.locator('#aed-models option')).toHaveCount(0); delaySave = true; await page.locator('#aed-save').click(); await expect.poll(() => !!pendingSave).toBe(true); await expect(page.locator('#aed-cancel')).toBeDisabled(); await page.evaluate(() => { openAddEngineDialog('tts'); editCustomEngineCard(window._appSettings.custom_engine_cards[0]); renderLocalContainers([]); loadLocalContainers(); }); await expect(page.locator('#aed-name')).toHaveValue('Renamed STT'); await expect(page.locator('#dc-grid-stt .add-engine-form')).toBeVisible(); delaySave = false; await pendingSave.fulfill({ status: 500, json: { error: 'Save failed' } }); await expect(page.locator('#aed-save')).toBeEnabled(); await expect(page.locator('#aed-cancel')).toBeEnabled(); await expect(page.locator('#dc-grid-stt .add-engine-form')).toBeVisible(); await expect(page.locator('#aed-name')).toHaveValue('Renamed STT'); await expect(page.locator('#aed-url')).toHaveValue('http://new-engine/v1'); rejectSave = false; await page.route('**/api/settings', route => route.request().method() === 'GET' ? route.abort() : route.fallback()); await page.locator('#aed-save').click(); await expect(page.locator('#add-engine-dlg')).not.toBeVisible(); await expect(page.locator('#dc-grid-stt .llm-local-name')).toHaveText('Renamed STT'); expect(writes.at(-1).custom_engine_cards[0].id).toBe(id); expect(writes.at(-1)).not.toHaveProperty('engine_api_keys'); expect(writes.at(-1).engine_local_urls['dc-' + id]).toBe('http://new-engine/v1'); expect(await page.evaluate(id => localStorage.getItem('dc-url-' + id), id)).toBe('http://new-engine/v1'); await page.route('**/api/local-containers/*/use-as-llm', route => { activations.push({ path: new URL(route.request().url()).pathname, body: route.request().postDataJSON() }); return route.fulfill({ status: 400, json: { ok: false, error: 'Denied' } }); }); await page.evaluate(() => { window.engineTestMessages = []; window.toast = message => window.engineTestMessages.push(message); editCustomEngineCard(window._appSettings.custom_engine_cards[0]); }); await page.locator('#aed-role').selectOption('llm'); await page.locator('#aed-active').check(); const savesBeforeActivation = writes.length; await page.locator('#aed-save').click(); await expect(page.locator('#add-engine-dlg')).not.toBeVisible(); await expect.poll(() => page.evaluate(() => window.engineTestMessages.some(message => message.includes('saved; activation failed')))).toBe(true); await expect(page.locator('#dc-grid-llm .llm-local-name')).toHaveText('Renamed STT'); await expect(page.locator('#dc-grid-stt .llm-local-card')).toHaveCount(0); for (const role of ['ocr', 'tts', 'stt']) { await page.evaluate(() => editCustomEngineCard(window._appSettings.custom_engine_cards[0])); await expect(page.locator('.dc-editing .add-engine-form')).toBeVisible(); expect(await page.locator('.add-engine-form').evaluate(form => form.getBoundingClientRect().right <= innerWidth)).toBe(true); await page.locator('#aed-role').selectOption(role); await page.locator('#aed-active').uncheck(); await page.locator('#aed-save').click(); await expect(page.locator(`#dc-grid-${role} .dc-edit-btn`)).toBeVisible(); } expect(writes).toHaveLength(savesBeforeActivation + 4); expect(activations.at(-1)).toEqual({ path: `/api/local-containers/${id}/use-as-llm`, body: { url: 'http://new-engine/v1', model: 'manual-model' } }); });