const { test, expect } = require('./_guard');
const path = require('node:path');
test('footer chooses dynamic custom TTS ID and settings preserve custom STT preference', async ({ page }) => {
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
let activated = false;
let rejectConnection = false;
let lastEngineOnly = false;
const connectionChanges = [];
const settings = {
custom_engine_cards: [
{ id: 'stable-stt', name: 'Renamed STT', role: 'stt' },
{ id: 'stable-tts', name: 'Renamed voice connection', role: 'tts', isCustom: true, url: 'http://engine/v1' },
],
stt_preferred_backend: 'custom:stable-stt',
engine_disabled: {},
engine_models: { 'stable-tts': 'keep-model' },
engine_api_keys: { 'stable-tts': '__configured__' },
engine_local_urls: { 'dc-stable-tts': 'http://engine/v1' },
};
const backends = [
{ id: 'openai', label: 'Voice Clone API', title: 'Qwen3 TTS VoiceClone', model: 'tts-1', available: true },
{ id: 'custom:stable-tts', label: 'Different API label', title: 'Renamed voice connection', model: 'actual-inference-model', available: true },
{ id: 'legacy', label: 'Legacy backend', model: 'legacy-model', available: true },
{ id: 'custom:offline', label: 'Offline', available: false },
];
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '
' }));
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
if (url.pathname.endsWith('/connection')) {
if (rejectConnection) return route.fulfill({ status: 400, json: { ok: false, error: 'Denied' } });
const connected = route.request().postDataJSON().connected;
connectionChanges.push(connected);
if (connected) delete settings.engine_disabled['stable-tts']; else settings.engine_disabled['stable-tts'] = true;
return route.fulfill({ json: { ok: true, connected } });
}
if (url.pathname === '/api/probe-url') return route.fulfill({ json: { ok: true } });
if (url.pathname.endsWith('/use-as-engine')) { activated = true; return route.fulfill({ json: { ok: true } }); }
if (url.pathname === '/api/tts-backends') {
const visible = activated ? backends.filter(backend => backend.id !== 'custom:stable-tts' || !settings.engine_disabled['stable-tts']) : [backends[0]];
return route.fulfill({ json: { backends: lastEngineOnly ? visible.filter(backend => backend.id === 'custom:stable-tts') : visible } });
}
return route.fulfill({ json: settings });
});
await page.goto('/');
await page.setContent('');
await page.evaluate(() => {
window._sttBackends = [];
window.logErr = () => {};
window.initLlmSnippets = () => {};
});
await page.addScriptTag({ path: path.resolve('static/js/utils.js') });
await page.addScriptTag({ path: path.resolve('static/js/settings.js') });
await page.addScriptTag({ path: path.resolve('static/js/engines.js') });
await page.addScriptTag({ path: path.resolve('static/js/ai-backends.js') });
await page.evaluate(settings => {
window._appSettings = settings;
window._ttsBackends = [{ id: 'openai', label: 'Default', available: true }];
renderLocalContainers([settings.custom_engine_cards[1]]);
}, settings);
await page.locator('.dc-use-btn').click();
await expect(page.locator('#tts-backend-select')).toHaveValue('custom:stable-tts');
await expect(page.locator('#tts-backend-select option[value="openai"]')).toHaveText('Qwen3 TTS VoiceClone');
await expect(page.locator('#tts-backend-select option[value="custom:stable-tts"]')).toHaveText('Renamed voice connection');
await expect(page.locator('#tts-backend-select option[value="legacy"]')).toHaveText('Legacy backend');
expect(await page.evaluate(() => {
const select = document.createElement('select');
select.innerHTML = styleBackendOptions('custom:stable-tts');
return [...select.options].map(option => ({ id: option.value, label: option.textContent, selected: option.selected }));
})).toEqual([
{ id: 'openai', label: 'Qwen3 TTS VoiceClone', selected: false },
{ id: 'custom:stable-tts', label: 'Renamed voice connection', selected: true },
{ id: 'legacy', label: 'Legacy backend', selected: false },
]);
await page.locator('.status-engine').click();
expect(errors).toEqual([]);
await expect(page.locator('.status-flyup-item[data-val="custom:stable-tts"]')).toHaveText('Renamed voice connection');
await expect(page.locator('.status-flyup-item[data-val="openai"]')).toHaveText('Qwen3 TTS VoiceClone');
await expect(page.locator('.status-flyup-item[data-val="legacy"]')).toHaveText('Legacy backend');
await expect(page.locator('.status-flyup-item[data-val="custom:offline"]')).toHaveCount(0);
await page.locator('.status-flyup-item[data-val="custom:stable-tts"]').click();
await expect(page.locator('#tts-backend-select')).toHaveValue('custom:stable-tts');
await page.locator('#tts-backend-select').selectOption('openai');
await page.locator('.status-engine').click();
await expect(page.locator('.status-flyup-item[data-val="openai"]')).toHaveClass(/is-active/);
await page.locator('.status-flyup-item[data-val="custom:stable-tts"]').click();
await expect(page.locator('#tts-backend-select')).toHaveValue('custom:stable-tts');
await page.evaluate(() => {
syncPreferredSttBackend(window._appSettings);
syncPreferredSttBackend(window._appSettings);
});
await expect(page.locator('#s-stt-preferred-backend')).toHaveValue('custom:stable-stt');
await expect(page.locator('#s-stt-preferred-backend option[value="custom:stable-stt"]')).toHaveCount(1);
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
rejectConnection = true;
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
await expect(page.locator('#tts-backend-select')).toHaveValue('custom:stable-tts');
rejectConnection = false;
await page.locator('.status-engine').click();
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('.status-flyup')).toHaveCount(0);
await expect(page.locator('#tts-backend-select option[value="custom:stable-tts"]')).toHaveCount(0);
await expect(page.locator('.dc-disconnect-btn')).toBeHidden();
await expect(page.locator('.dc-url-inp')).toHaveValue('http://engine/v1');
await expect(page.locator('.dc-model-inp')).toHaveValue('keep-model');
expect(await page.evaluate(() => window._appSettings.engine_api_keys['stable-tts'] === '__configured__')).toBe(true);
await page.evaluate(() => {
localStorage.setItem('dc-con-stable-tts', '1');
renderLocalContainers([window._appSettings.custom_engine_cards[1]]);
});
await expect(page.locator('.dc-disconnect-btn')).toBeHidden();
await expect(page.locator('.dc-connect-btn')).not.toHaveClass(/\bok\b/);
await page.locator('.dc-connect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
await expect(page.locator('#tts-backend-select option[value="custom:stable-tts"]')).toHaveCount(1);
await expect(page.locator('#tts-backend-select')).toHaveValue('openai');
expect(connectionChanges).toEqual([false, true]);
lastEngineOnly = true;
await page.evaluate(() => refreshTtsBackendAvailability('custom:stable-tts'));
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('#tts-backend-select')).toBeDisabled();
await expect(page.locator('#tts-backend-select')).toHaveValue('');
});
test('LLM footer selection updates active, rehearser and conversation model pickers', async ({ page }) => {
const writes = [];
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '' }));
await page.route('**/api/**', route => {
if (route.request().method() === 'POST') writes.push(route.request().postDataJSON());
return route.fulfill({ json: { ok: true, models: ['old-model', 'picked-model'] } });
});
await page.goto('/');
const options = '';
await page.setContent('' +
['llm-active-model', 'reh-llm-model', 'conv-llm-model-select'].map(id => ``).join(''));
await page.evaluate(() => {
window._appSettings = { llm_url: 'http://engine/v1', llm_model: 'old-model' };
window.logErr = () => {};
window.applyAndSaveSettings = patch => fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
});
await page.addScriptTag({ path: path.resolve('static/js/utils.js') });
await page.addScriptTag({ path: path.resolve('static/js/benchmark-voice-picker.js') });
await page.addScriptTag({ path: path.resolve('static/js/ai-backends.js') });
await expect(page.locator('#llm-active-status')).toHaveText('2 models');
await page.locator('.status-engine').click();
await page.locator('.status-flyup-item[data-val="picked-model"]').click();
for (const id of ['llm-active-model', 'reh-llm-model', 'conv-llm-model-select']) {
await expect(page.locator('#' + id)).toHaveValue('picked-model');
}
await expect(page.locator('#llm-active-status')).toHaveText('Active model: picked-model');
await expect(page.locator('.bvp-root[data-bvp-for="llm-active-model"] .bvp-trigger-content')).toHaveText('picked-model');
expect(await page.evaluate(() => window._appSettings.llm_model)).toBe('picked-model');
await expect.poll(() => writes.some(patch => patch.llm_model === 'picked-model')).toBe(true);
await page.evaluate(() => {
window._appSettings.engine_active = { llm_url: 'chosen' };
window._appSettings.engine_disabled = { chosen: true };
document.body.insertAdjacentHTML('beforeend', '');
window.updateStatusBar();
});
await expect(page.locator('[data-status-kind="llm"] .status-engine-value')).toHaveText('Disconnected');
await page.locator('.status-engine[data-status-kind="llm"]').first().click();
await expect(page.locator('.status-flyup')).toContainText('LLM disconnected');
await expect(page.locator('.status-flyup-item')).toHaveCount(0);
expect(await page.evaluate(() => window._appSettings.llm_model)).toBe('picked-model');
});
test('disconnect refresh keeps sidebar navigation on Engines and Settings', async ({ page }) => {
const fs = require('node:fs');
const index = fs.readFileSync(path.resolve('static/index.html'), 'utf8');
const tabs = index.match(/