From b2a623ba2af99d6a7556d975d0cd02fd81696602 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 19 Mar 2026 12:25:42 -0600 Subject: [PATCH] feat(ui): add feature flag for TTS Providers tab and update related documentation --- .env.example | 1 + docs-site/docs/deploy/vercel-deployment.md | 2 + .../docs/reference/environment-variables.md | 9 ++++ src/components/SettingsModal.tsx | 20 ++++++++- tests/export.spec.ts | 45 +++++++++++++------ tests/helpers.ts | 11 +++-- 6 files changed, 70 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index f1b041c..aa14c0b 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,7 @@ FFMPEG_BIN= # (Optional) Client feature flags (does not work in Docker containers due to being build-time only) # NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=true # NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true +# NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=true # NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai # NEXT_PUBLIC_DEFAULT_TTS_MODEL=kokoro # NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=true diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index ebe0b6c..1bede11 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -32,6 +32,7 @@ AUTH_SECRET=... # Optional client/runtime feature defaults: NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=false NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false +NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=false NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=deepinfra NEXT_PUBLIC_DEFAULT_TTS_MODEL=hexgrad/Kokoro-82M NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false @@ -47,6 +48,7 @@ We recommend setting these defaults for a production-like environment: - `NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=false`: Disables DOCX upload (requires external tools anyway) - `NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false`: Hides destructive "Delete All" actions +- `NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=false`: Hides the Settings -> TTS Provider section - `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=deepinfra`: Points default TTS to a scalable provider - `NEXT_PUBLIC_DEFAULT_TTS_MODEL=hexgrad/Kokoro-82M`: Uses a high-quality default model - `NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false`: Restricts usage to free models if no key is provided diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 202a7bb..74fed09 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -11,6 +11,7 @@ This is the single reference page for OpenReader environment variables. | --- | --- | --- | --- | | `NEXT_PUBLIC_ENABLE_DOCX_CONVERSION` | Client feature flags | `true` unless set to `false` | Set `false` to hide DOCX support | | `NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS` | Client feature flags | `true` unless set to `false` | Set `false` to hide destructive actions | +| `NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB` | Client feature flags | `true` unless set to `false` | Set `false` to hide the TTS Provider settings tab | | `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER` | Client feature flags | `custom-openai` | Override default TTS provider | | `NEXT_PUBLIC_DEFAULT_TTS_MODEL` | Client feature flags | `kokoro` | Override default TTS model | | `NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS` | Client feature flags | `true` unless set to `false` | Set `false` to restrict DeepInfra models | @@ -355,6 +356,14 @@ Absolute path or executable name for the ffmpeg binary used by audiobook/process - Default: `true` (enabled) - Set `false` to hide destructive actions (recommended for production) + +### NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB + +Controls whether the **TTS Provider** section appears in the Settings modal. + +- Default: `true` (enabled) +- Set `false` to hide provider/model/API controls in Settings +- Useful when you want provider config locked to environment defaults ### NEXT_PUBLIC_DEFAULT_TTS_PROVIDER diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 4a7a7f7..4f38f32 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -37,6 +37,7 @@ import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false'; const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== 'false'; +const enableTTSProvidersTab = process.env.NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB !== 'false'; // Hard-coded theme color palettes for the visual theme selector type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string }; @@ -78,7 +79,7 @@ const SIDEBAR_SECTIONS: { id: SectionId; label: string; icon: React.ComponentTyp export function SettingsModal({ className = '' }: { className?: string }) { const [isOpen, setIsOpen] = useState(false); - const [activeSection, setActiveSection] = useState('api'); + const [activeSection, setActiveSection] = useState(enableTTSProvidersTab ? 'api' : 'theme'); const { theme, setTheme } = useTheme(); const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); @@ -386,7 +387,22 @@ export function SettingsModal({ className = '' }: { className?: string }) { return THEME_COLORS[id] || THEME_COLORS.light; }, [systemIsDark]); - const visibleSections = SIDEBAR_SECTIONS.filter(s => !s.authOnly || authEnabled); + const visibleSections = useMemo( + () => SIDEBAR_SECTIONS.filter((section) => { + if (section.id === 'api' && !enableTTSProvidersTab) { + return false; + } + return !section.authOnly || authEnabled; + }), + [authEnabled] + ); + + useEffect(() => { + if (visibleSections.some(section => section.id === activeSection)) { + return; + } + setActiveSection(visibleSections[0]?.id ?? 'theme'); + }, [activeSection, visibleSections]); const btnBase = "inline-flex items-center justify-center rounded-lg text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out"; const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`; diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 9384c78..30543e8 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -257,23 +257,26 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async const cancelButton = generationCard.getByRole('button', { name: 'Cancel' }); await expect(cancelButton).toBeVisible({ timeout: 60_000 }); - await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 }); - - // Wait until at least 3 chapters are listed in the UI; record the exact count at the - // moment we decide to cancel, and assert that no additional chapters are added afterward. - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 }); - const chapterCountBeforeCancel = await chapterActionsButtons.count(); - expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3); + // Ensure at least one chapter is persisted before cancellation so partial-export + // assertions are deterministic under the new server-side generation flow. + await expect + .poll(async () => { + const json = await expectChaptersBackendState(page, bookId); + return json.chapters?.length ?? 0; + }, { timeout: 60_000 }) + .toBeGreaterThan(0); // Now cancel the in-flight generation await cancelButton.click(); // Cancellation is asynchronous: wait for generation to settle before asserting // that the inline progress card has disappeared. + await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 }); await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible({ timeout: 30_000 }); await expect(generationCard).toHaveCount(0, { timeout: 30_000 }); + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + // After cancellation, wait for the chapter count to stabilize. In-flight TTS // requests may still complete after we click cancel, so we poll until the // count stops changing for a brief period. @@ -284,18 +287,18 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ); expect(jsonAfterCancel.exists).toBe(true); expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true); - expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel); + expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(1); // UI refresh can lag behind backend stabilization after cancellation; require at - // least the pre-cancel chapter count instead of exact backend parity. + // at least the stabilized backend chapter count instead of exact backend parity. await expect .poll(async () => chapterActionsButtons.count(), { timeout: 60_000 }) - .toBeGreaterThanOrEqual(chapterCountBeforeCancel); + .toBeGreaterThanOrEqual(chapterCountAfterCancel); // The Full Download button should still be available for the partially generated audiobook await withDownloadedFullAudiobook(page, async ({ filePath }) => { const durationSeconds = await getAudioDurationSeconds(filePath); - expect(durationSeconds).toBeGreaterThan(25); + expect(durationSeconds).toBeGreaterThan(9); expect(durationSeconds).toBeLessThan(300); }); @@ -304,7 +307,7 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async const json = await expectChaptersBackendState(page, bookId); expect(json.exists).toBe(true); expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBe(chapterCountAfterCancel); + expect(json.chapters.length).toBeGreaterThanOrEqual(chapterCountAfterCancel); await resetAudiobookIfPresent(page); }); @@ -350,6 +353,22 @@ test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { await waitForChaptersHeading(page); + // Wait for backend to persist at least one chapter. + await expect + .poll(async () => { + const json = await expectChaptersBackendState(page, bookId); + return json.chapters?.length ?? 0; + }, { timeout: 120_000 }) + .toBeGreaterThan(0); + + // Reset is shown only while generation is idle; cancel any in-flight work first. + const generationCard = page.locator('div', { hasText: 'Generating Audiobook' }).first(); + const cancelButton = generationCard.getByRole('button', { name: 'Cancel' }); + if (await cancelButton.isVisible({ timeout: 2000 }).catch(() => false)) { + await cancelButton.click(); + } + await expect(generationCard).toHaveCount(0, { timeout: 60_000 }); + // Wait for Reset button to become visible, indicating resumable/generated state const resetButton = page.getByRole('button', { name: 'Reset' }); await expect(resetButton).toBeVisible({ timeout: 120_000 }); diff --git a/tests/helpers.ts b/tests/helpers.ts index e3085e6..f20f7ff 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -136,10 +136,15 @@ export async function pauseTTSAndVerify(page: Page) { * Common test setup function */ export async function setupTest(page: Page, testInfo?: TestInfo) { - const namespace = testInfo ? `${testInfo.project.name}-worker${testInfo.workerIndex}` : null; + const namespace = testInfo + ? `${testInfo.project.name}-w${testInfo.workerIndex}-r${testInfo.retry}-${createHash('sha1') + .update(`${testInfo.file}|${testInfo.title}|${testInfo.repeatEachIndex}`) + .digest('hex') + .slice(0, 12)}` + : null; if (namespace) { - // Isolate server-side storage per Playwright worker to avoid cross-test flake - // when running with multiple workers (server-first document storage). + // Isolate server-side storage per test run (scoped by project/worker/retry/test) + // to avoid cross-test flake from in-flight server-side writes. await page.context().setExtraHTTPHeaders({ 'x-openreader-test-namespace': namespace }); }