- Move TTS provider resolution and voice-fetch logic into shared catalog and make
remote fetches more resilient (timeouts, abort handling, baseUrl normalization,
treat explicit empty custom-openai responses as valid).
- Add continuation guard to TTS feature set and update API voices endpoint error
logging to better surface auth/session failures.
- Extract and stabilize ebook pipeline and epub adapter:
- Cache prepared EPUB chapters to avoid repeated parsing and handle missing/empty
chapter titles safely.
- Fail early when created chapter lacks a bookId.
- Improve config/preferences handling:
- Replace JSON stringify comparisons with a deepEqual utility to compare defaults.
- Tighten ConfigContext effect dependencies and consolidate synced preference
queuing logic to include additional keys.
- Harden end-to-end test helpers and unit tests:
- Make settings dialog dismissal deterministic (use Enter/Escape and explicit
visibility/enabled checks) and simplify upload retry behavior.
- Add unit test to ensure custom-openai empty voice lists are preserved.
Why: Reduce runtime and test flakiness, centralize TTS logic for easier maintenance,
and make EPUB processing and preference diffing more robust. Breaking changes: none.
115 lines
3.5 KiB
TypeScript
115 lines
3.5 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
|
|
import {
|
|
getDefaultVoices,
|
|
providerSupportsCustomModel,
|
|
resolveVoices,
|
|
resolveProviderModels,
|
|
supportsTtsInstructions,
|
|
} from '../../src/lib/shared/tts-provider-catalog';
|
|
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
|
|
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
|
|
|
|
test.describe('tts provider catalog', () => {
|
|
test('resolves provider models with Deepinfra gating unchanged', () => {
|
|
expect(resolveProviderModels('openai').map((model) => model.id)).toEqual([
|
|
'tts-1',
|
|
'tts-1-hd',
|
|
'gpt-4o-mini-tts',
|
|
]);
|
|
|
|
expect(resolveProviderModels('deepinfra', {
|
|
showAllDeepInfra: false,
|
|
apiKey: '',
|
|
}).map((model) => model.id)).toEqual([
|
|
'hexgrad/Kokoro-82M',
|
|
]);
|
|
|
|
expect(resolveProviderModels('deepinfra', {
|
|
showAllDeepInfra: false,
|
|
apiKey: 'token',
|
|
}).map((model) => model.id)).toEqual([
|
|
'hexgrad/Kokoro-82M',
|
|
'canopylabs/orpheus-3b-0.1-ft',
|
|
'sesame/csm-1b',
|
|
'ResembleAI/chatterbox',
|
|
'Zyphra/Zonos-v0.1-hybrid',
|
|
'Zyphra/Zonos-v0.1-transformer',
|
|
'custom',
|
|
]);
|
|
});
|
|
|
|
test('resolves default voices and instruction support unchanged', () => {
|
|
expect(getDefaultVoices('openai', 'gpt-4o-mini-tts')).toContain('sage');
|
|
expect(getDefaultVoices('custom-openai', 'kokoro')).toContain('af_sarah');
|
|
expect(getDefaultVoices('deepinfra', 'ResembleAI/chatterbox')).toEqual(['None']);
|
|
expect(getDefaultVoices('deepinfra', 'Zyphra/Zonos-v0.1-transformer')).toEqual(['random']);
|
|
expect(supportsTtsInstructions('gpt-4o-mini-tts')).toBe(true);
|
|
expect(supportsTtsInstructions('tts-1')).toBe(false);
|
|
expect(providerSupportsCustomModel('openai')).toBe(false);
|
|
expect(providerSupportsCustomModel('deepinfra')).toBe(true);
|
|
});
|
|
|
|
test('keeps explicit empty custom-openai voices response', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({ voices: [] }),
|
|
}) as Response;
|
|
|
|
try {
|
|
await expect(resolveVoices({
|
|
provider: 'custom-openai',
|
|
model: 'kokoro',
|
|
apiKey: 'token',
|
|
baseUrl: 'https://example.com',
|
|
})).resolves.toEqual([]);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('config helpers', () => {
|
|
test('persists voice preferences by provider/model pair', () => {
|
|
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
|
|
|
|
const voiceUpdate = applyConfigUpdate({
|
|
ttsProvider: 'openai',
|
|
ttsModel: 'tts-1',
|
|
savedVoices: {},
|
|
}, 'voice', 'alloy');
|
|
expect(voiceUpdate.storagePatch).toEqual({
|
|
savedVoices: { 'openai:tts-1': 'alloy' },
|
|
voice: 'alloy',
|
|
});
|
|
|
|
const providerUpdate = applyConfigUpdate({
|
|
ttsProvider: 'openai',
|
|
ttsModel: 'tts-1',
|
|
savedVoices: {
|
|
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
|
|
},
|
|
}, 'ttsProvider', 'deepinfra');
|
|
expect(providerUpdate.storagePatch).toEqual({
|
|
ttsProvider: 'deepinfra',
|
|
voice: '',
|
|
});
|
|
});
|
|
|
|
test('builds synced preference patches and honors non-default filtering', () => {
|
|
expect(buildSyncedPreferencePatch({
|
|
voiceSpeed: 1.2,
|
|
baseUrl: 'http://localhost',
|
|
ttsModel: 'kokoro',
|
|
})).toEqual({
|
|
voiceSpeed: 1.2,
|
|
ttsModel: 'kokoro',
|
|
});
|
|
|
|
expect(buildSyncedPreferencePatch({
|
|
voiceSpeed: 1,
|
|
ttsModel: 'kokoro',
|
|
}, { nonDefaultOnly: true })).toEqual({});
|
|
});
|
|
});
|