tts-voice-creator-clone-and.../tests/engine-connections.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

325 lines
20 KiB
JavaScript

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: '<!doctype html><html><body></body></html>' }));
await page.route('**/api/**', route => {
discoveries++;
return route.fulfill({ json: { ok: true, models: ['saved-model'] } });
});
await page.goto('/');
await page.setContent(`<select id="${role}-active-url"><option value="http://engine/v1">Engine</option></select><select id="${role}-active-model"></select><span id="${role}-active-status"></span><button id="${role}-active-refresh">Refresh</button>`);
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: '<!doctype html><html><body></body></html>' }));
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(`<div data-page="${role}"><div id="dc-grid-${role}"></div></div>`);
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: '<!doctype html><html><body></body></html>' }));
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 => `<div data-page="${role}"><div id="dc-grid-${role}"></div></div>`).join('') + section.slice(section.indexOf('<dialog'), section.indexOf('</dialog>') + 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' } });
});