diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index f91c8e6..bf5bffc 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -238,12 +238,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) { resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions; } - if (appConfig.voice !== APP_CONFIG_DEFAULTS.voice) { - resetPatch.voice = APP_CONFIG_DEFAULTS.voice; - } - if (Object.keys(appConfig.savedVoices || {}).length > 0) { - resetPatch.savedVoices = APP_CONFIG_DEFAULTS.savedVoices; - } + // Keep voice selection state intact so player/Audiobook voice pickers still + // work when the TTS providers tab is hidden. This reset is only for provider + // configuration fields. if (Object.keys(resetPatch).length === 0) return; diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 30543e8..ea25e31 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -122,38 +122,45 @@ async function withDownloadedFullAudiobook( } } -/** - * Poll the backend until the chapter count stops changing for `stableMs` milliseconds. - * This helps avoid race conditions where in-flight TTS requests complete after cancellation. - */ -async function waitForStableChapterCount( +async function waitForStableUiChapterCount( page: Page, - bookId: string, { stableMs = 2000, timeoutMs = 30000 } = {} -): Promise<{ count: number; json: ReturnType extends Promise ? T : never }> { +): Promise { + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); const startTime = Date.now(); let lastCount = -1; let lastStableTime = Date.now(); - let lastJson: Awaited> | null = null; while (Date.now() - startTime < timeoutMs) { - const json = await expectChaptersBackendState(page, bookId); - const currentCount = json.chapters?.length ?? 0; - lastJson = json; - + const currentCount = await chapterActionsButtons.count(); if (currentCount !== lastCount) { lastCount = currentCount; lastStableTime = Date.now(); } else if (Date.now() - lastStableTime >= stableMs) { - // Count has been stable for stableMs - return { count: currentCount, json }; + return currentCount; } - await page.waitForTimeout(200); } - // Timeout reached, return whatever we have - return { count: lastCount, json: lastJson! }; + return Math.max(lastCount, 0); +} + +async function cancelGenerationIfVisible(page: Page): Promise { + const generationCard = page.locator('div', { hasText: 'Generating Audiobook' }).first(); + const cancelButton = generationCard.getByRole('button', { name: 'Cancel' }); + const isVisible = await cancelButton.isVisible({ timeout: 2000 }).catch(() => false); + if (!isVisible) return; + + for (let attempt = 0; attempt < 3; attempt++) { + try { + await cancelButton.click({ timeout: 5000 }); + return; + } catch { + const cardGone = (await generationCard.count()) === 0; + if (cardGone) return; + await page.waitForTimeout(150); + } + } } async function resetAudiobookById(page: Page, bookId: string) { @@ -257,43 +264,25 @@ 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 }); - // 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); + // UI-first readiness check: wait until at least one chapter row is visible before cancellation. + await waitForChaptersHeading(page); + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 60_000 }); // Now cancel the in-flight generation - await cancelButton.click(); + await cancelGenerationIfVisible(page); // 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. - const { count: chapterCountAfterCancel, json: jsonAfterCancel } = await waitForStableChapterCount( - page, - bookId, - { stableMs: 2000, timeoutMs: 30000 } - ); - expect(jsonAfterCancel.exists).toBe(true); - expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true); + // Chapter list can continue updating briefly after cancellation; wait for UI count to stabilize. + const chapterCountAfterCancel = await waitForStableUiChapterCount(page, { stableMs: 2000, timeoutMs: 30000 }); expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(1); - // UI refresh can lag behind backend stabilization after cancellation; require at - // at least the stabilized backend chapter count instead of exact backend parity. - await expect - .poll(async () => chapterActionsButtons.count(), { timeout: 60_000 }) - .toBeGreaterThanOrEqual(chapterCountAfterCancel); + // Keep assertions frontend-driven: chapter rows should remain visible and usable. + await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 60_000 }); // The Full Download button should still be available for the partially generated audiobook await withDownloadedFullAudiobook(page, async ({ filePath }) => { @@ -302,13 +291,6 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async expect(durationSeconds).toBeLessThan(300); }); - // Backend should still reflect the same number of chapters as when we first - // observed the stabilized post-cancellation state. - const json = await expectChaptersBackendState(page, bookId); - expect(json.exists).toBe(true); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBeGreaterThanOrEqual(chapterCountAfterCancel); - await resetAudiobookIfPresent(page); }); @@ -353,20 +335,13 @@ 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); + // UI-first readiness check: wait for at least one chapter row. + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 120_000 }); // 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 cancelGenerationIfVisible(page); await expect(generationCard).toHaveCount(0, { timeout: 60_000 }); // Wait for Reset button to become visible, indicating resumable/generated state @@ -382,14 +357,7 @@ test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { // After reset, generation should be startable again await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); - - // Backend should report no existing chapters for this bookId - const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); - expect(res.ok()).toBeTruthy(); - const json = await res.json(); - expect(json.exists).toBe(false); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBe(0); + await expect(chapterActionsButtons).toHaveCount(0); }); test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }, testInfo) => { diff --git a/tests/play.spec.ts b/tests/play.spec.ts index 3d19121..b9bc0c8 100644 --- a/tests/play.spec.ts +++ b/tests/play.spec.ts @@ -97,12 +97,37 @@ test.describe('Play/Pause Tests', () => { expect(await options.count()).toBeGreaterThan(0); await selectVoiceAndAssertPlayback(page, 'af_bella'); - //await expectProcessingTransition(page); // Final state should be playing await expectMediaState(page, 'playing'); }); + test('keeps selected single voice instead of resetting to first option', async ({ page }) => { + await playTTSAndWaitForASecond(page, 'sample.pdf'); + + await openVoicesMenu(page); + const options = page.locator('[role="option"]:visible'); + const optionCount = await options.count(); + expect(optionCount).toBeGreaterThan(1); + + const uniqueVoices: string[] = []; + for (let i = 0; i < optionCount; i++) { + const candidate = (await options.nth(i).innerText()).trim(); + if (candidate && !uniqueVoices.includes(candidate)) uniqueVoices.push(candidate); + } + expect(uniqueVoices.length).toBeGreaterThan(1); + + const selectedVoice = uniqueVoices[1] || ''; + expect(selectedVoice).not.toBe(''); + + await selectVoiceAndAssertPlayback(page, selectedVoice); + + const ttsbar = page.locator('[data-app-ttsbar]'); + await expect(ttsbar.getByRole('button', { name: selectedVoice }).first()).toBeVisible(); + await page.waitForTimeout(1000); + await expect(ttsbar.getByRole('button', { name: selectedVoice }).first()).toBeVisible(); + }); + if (!process.env.CI) test('selects multiple Kokoro voices and resumes playing', async ({ page }) => { // Start playback await playTTSAndWaitForASecond(page, 'sample.pdf');