tts-voice-creator-clone-and.../tests/engine-footer.spec.js
mARTin-B78 0551b6651f feat(voices): redesigned My Voices list, working copy, engine-aware TTS availability
My Voices
- Top bar with search, filter toggle (active-count badge), synth-source drawer, group and table buttons;
  filters/sort in a collapsible labelled panel; one-line sort header with select-all/none checkbox
- Groups chip list is a collapsed accordion; bottom bar is Refresh/Sync/Import plus one Actions (N) menu
  (Edit / Audio / Share / Danger), disabled until voices are checked; touch targets stay 44px
- Copy selected works again (copyText was no longer exported), shows "N voice names copied to clipboard"
  and falls back to a copy dialog when the async clipboard is unavailable

TTS / engines
- Health probe works for /v1 card URLs and, behind a LiteLLM gateway, checks the selected model's own
  upstream so stopped containers are not reported as available
- Unhiding a voice publishes a missing entry to the engine's voices.json (no more silent default-voice fallback)
- Status-bar TTS picker also drives the My Voices and STT-test selectors; Use as TTS refreshes the status bar

Includes the engine connect/disconnect, custom TTS, STT footer and magazine auth work that was already
in the working tree, with their tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 04:44:27 +02:00

313 lines
20 KiB
JavaScript

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: '<!doctype html><html><body></body></html>' }));
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('<button class="status-engine" data-status-kind="tts">TTS</button><select id="tts-backend-select"><option value="openai">Default</option></select><select id="s-stt-preferred-backend"><option value="">Configured</option></select><div data-page="tts"><div id="dc-grid-tts"></div></div>');
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: '<!doctype html><html><body></body></html>' }));
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 = '<option value="old-model">old-model</option><option value="picked-model">picked-model</option>';
await page.setContent('<button class="status-engine" data-status-kind="llm">LLM</button><select id="llm-active-url"><option value="http://engine/v1">Engine</option></select><span id="llm-active-status"></span>' +
['llm-active-model', 'reh-llm-model', 'conv-llm-model-select'].map(id => `<select id="${id}">${options}</select>`).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', '<div id="status-bar"></div>');
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(/<nav class="tabs"[\s\S]*?<\/nav>/)[0];
const sidebar = index.match(/<aside id="sidebar"[\s\S]*?<\/aside>/)[0];
const errors = [];
const changes = [];
let noBackends = false;
page.on('pageerror', error => errors.push(String(error)));
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<html><body></body></html>' }));
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
if (url.pathname.endsWith('/connection')) {
changes.push(route.request().postDataJSON().connected);
return route.fulfill({ json: { ok: true, connected: false } });
}
if (url.pathname === '/api/tts-backends') return route.fulfill({ json: { backends: noBackends ? [] : [{ id: 'openai', available: true }] } });
return route.fulfill({ json: { engine_disabled: {} } });
});
await page.goto('/#s-llms');
await page.setContent(tabs + sidebar + '<main id="main-content"><section id="s-llms" class="page-section"></section><section id="s-settings" class="page-section"></section><section id="s-tryout" class="page-section"></section></main>');
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.evaluate(() => {
window.loadLocalContainers = () => {};
window.initLlmSnippets = () => {};
document.body.insertAdjacentHTML('beforeend', ['tts', 'stt', 'ocr', 'llm'].map(role => '<div data-page="' + role + '"><div id="dc-grid-' + role + '"></div></div>').join(''));
});
await page.addScriptTag({ path: path.resolve('static/nav.js') });
for (const section of ['s-llms', 's-settings']) {
await page.evaluate(section => navTo(section), section);
for (const role of ['tts', 'stt', 'ocr', 'llm']) {
noBackends = role === 'llm';
await page.evaluate(role => {
localStorage.setItem('dc-con-mock-' + role, '1');
renderLocalContainers([{ id: 'mock-' + role, name: 'Mock ' + role, role, isCustom: true, url: 'http://engine/v1' }]);
}, role);
const disconnect = page.locator('#dc-grid-' + role + ' .dc-disconnect-btn');
await disconnect.click();
await expect(disconnect).toBeHidden();
await expect(page).toHaveURL(new RegExp('#' + section + '$'));
await expect(page.locator('#' + section)).toHaveClass(/is-active/);
await expect(page.locator('[data-nav-section="' + section + '"]').first()).toHaveClass(/active/);
}
}
await expect(page.locator('.tab[data-tab="library"]')).toHaveClass(/backend-unavailable/);
expect(changes).toEqual(Array(8).fill(false));
expect(errors).toEqual([]);
});
test('legacy tabs still leave an unavailable active backend', async ({ page }) => {
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<html><body></body></html>' }));
await page.route('**/api/tts-backends', route => route.fulfill({ json: { backends: [] } }));
await page.goto('/');
await page.setContent('<div class="tab active" data-tab="library" data-backend-required="voice_clone"></div><div class="tab" data-tab="settings"></div>');
await page.addScriptTag({ path: path.resolve('static/js/utils.js') });
await page.addScriptTag({ path: path.resolve('static/js/settings.js') });
await page.evaluate(() => refreshTtsBackendAvailability());
await expect(page.locator('.tab[data-tab="settings"]')).toHaveClass(/active/);
});
test('STT connection and activation refresh card titles and chosen footer backend', async ({ page }) => {
const errors = [];
const writes = [];
page.on('pageerror', error => errors.push(String(error)));
const settings = {
stt_preferred_backend: 'faster_whisper', engine_disabled: { 'stable-stt': true },
custom_engine_cards: [{ id: 'stable-stt', name: 'DGX Realtime STT', role: 'stt', isCustom: true, url: 'http://engine/v1' }],
};
const backends = [
{ id: 'configured', engine_id: 'stable-stt', label: 'Configured alias', title: 'DGX Realtime STT', available: true, connected: true },
{ id: 'faster_whisper', engine_id: 'stable-stt', label: 'Configured alias', title: 'DGX Realtime STT', available: true, connected: true, url: 'http://engine/v1' },
{ id: 'custom:stable-stt', engine_id: 'stable-stt', label: 'Inference model', title: 'DGX Realtime STT', available: true, connected: true, url: 'http://engine/v1' },
{ id: 'custom:other-stt', engine_id: 'other-stt', label: 'Inference model', title: 'DGX Realtime STT', available: true, connected: true, url: 'http://engine/v1' },
{ id: 'native', engine_id: 'native-stt', title: 'Disconnected native', available: true, connected: false },
{ id: 'legacy-stt', label: 'Legacy STT', available: true },
{ id: 'offline-stt', title: 'Offline STT', available: false },
];
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<html><body></body></html>' }));
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
if (url.pathname === '/api/probe-url') return route.fulfill({ json: { ok: true } });
if (url.pathname.endsWith('/connection')) {
delete settings.engine_disabled['stable-stt'];
return route.fulfill({ json: { ok: true } });
}
if (url.pathname.endsWith('/use-as-engine')) {
settings.stt_preferred_backend = 'custom:stable-stt';
return route.fulfill({ json: { ok: true } });
}
if (url.pathname === '/api/stt-backends') return route.fulfill({ json: { backends: backends.filter(b => b.id !== 'custom:stable-stt' || !settings.engine_disabled['stable-stt']) } });
if (url.pathname === '/api/settings' && route.request().method() === 'POST') {
const patch = route.request().postDataJSON();
writes.push(patch);
Object.assign(settings, patch);
}
return route.fulfill({ json: settings });
});
await page.goto('/');
const ids = ['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'];
await page.setContent('<button class="status-engine" data-status-kind="stt">STT</button><select id="s-stt-preferred-backend"><option value="faster_whisper">Configured</option><option value="legacy-stt">Legacy</option></select>' +
ids.map(id => `<select id="${id}"><option value="faster_whisper">Stale</option></select>`).join('') + '<div data-page="stt"><div id="dc-grid-stt"></div></div>');
await page.evaluate(settings => { window._appSettings = settings; window.logErr = () => {}; window.initLlmSnippets = () => {}; }, settings);
for (const file of ['utils', 'settings', 'stt', 'engines', 'ai-backends']) await page.addScriptTag({ path: path.resolve('static/js/' + file + '.js') });
await page.evaluate(settings => { window._appSettings = settings; }, settings);
await page.evaluate(() => refreshSttBackends());
await page.evaluate(() => renderLocalContainers(window._appSettings.custom_engine_cards));
await page.locator('.dc-connect-btn').click();
for (const id of ids) {
await expect(page.locator('#' + id)).toHaveValue('faster_whisper');
await expect(page.locator('#' + id + ' option[value="custom:stable-stt"]')).toHaveCount(0);
}
await page.locator('.dc-use-btn').click();
for (const id of ids) {
await expect(page.locator('#' + id)).toHaveValue('custom:stable-stt');
await expect(page.locator('#' + id + ' option[value="custom:stable-stt"]')).toHaveText('DGX Realtime STT');
}
await expect(page.locator('#conv-stt-select option[value="faster_whisper"]')).toHaveCount(0);
await expect(page.locator('#conv-stt-select option[value="configured"]')).toHaveCount(0);
await expect(page.locator('#conv-stt-select option[value="custom:other-stt"]')).toHaveCount(1);
await expect(page.locator('#conv-stt-select option[value="native"]')).toBeDisabled();
await page.locator('#conv-stt-select').selectOption('legacy-stt');
await page.locator('.status-engine').click();
await expect(page.locator('.status-flyup-item[data-val="custom:stable-stt"]')).toHaveClass(/is-active/);
await expect(page.locator('.status-flyup-item[data-val="faster_whisper"]')).toHaveCount(0);
await expect(page.locator('.status-flyup-item[data-val="configured"]')).toHaveCount(0);
await expect(page.locator('.status-flyup-item[data-val="custom:other-stt"]')).toHaveCount(1);
await expect(page.locator('.status-flyup-item[data-val="native"]')).toHaveCount(0);
await expect(page.locator('.status-flyup-item[data-val="legacy-stt"]')).toHaveText('Legacy STT');
await expect(page.locator('.status-flyup-item[data-val="offline-stt"]')).toHaveCount(0);
await page.locator('.status-flyup-item[data-val="legacy-stt"]').click();
for (const id of [...ids, 's-stt-preferred-backend']) await expect(page.locator('#' + id)).toHaveValue('legacy-stt');
await expect.poll(() => writes.some(patch => patch.stt_preferred_backend === 'legacy-stt')).toBe(true);
await page.evaluate(() => { document.body.insertAdjacentHTML('beforeend', '<div id="status-bar"></div>'); updateStatusBar(); });
await expect(page.locator('[data-status-kind="stt"] .status-engine-value')).toHaveText('Legacy STT');
expect(await page.evaluate(() => sttBackendChoices(_sttBackends, 'faster_whisper').filter(b => b.engine_id === 'stable-stt' && b.available).map(b => b.id))).toEqual(['faster_whisper']);
expect(await page.evaluate(() => sttBackendChoices([
{ id: 'nvidia_parakeet', engine_id: 'nvidia-parakeet-asr', available: true },
{ id: 'nvidia_router', engine_id: 'nvidia-parakeet-asr', available: true },
], 'nvidia_router').map(b => b.id))).toEqual(['nvidia_router']);
expect(errors).toEqual([]);
});