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>
313 lines
20 KiB
JavaScript
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([]);
|
|
});
|