diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 2eb41ae..be304e6 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -147,12 +147,15 @@ export function SettingsModal({ className = '' }: { className?: string }) { checkFirstVist().catch((err) => { console.error('First visit check failed:', err); }); + }, [checkFirstVist]); + + useEffect(() => { setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalTTSProvider(ttsProvider); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); - }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]); + }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions]); useEffect(() => { if (!authEnabled) { diff --git a/tests/export.spec.ts b/tests/export.spec.ts index ea25e31..d049769 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -109,6 +109,26 @@ async function expectChaptersBackendState(page: Page, bookId: string) { return json; } +async function waitForBackendDownloadReady( + page: Page, + bookId: string, + { minChapters = 1, timeoutMs = 120_000 }: { minChapters?: number; timeoutMs?: number } = {} +) { + await expect + .poll(async () => { + const json = await expectChaptersBackendState(page, bookId); + if (!json?.exists) return false; + if (!Array.isArray(json?.chapters)) return false; + if (json.chapters.length < minChapters) return false; + return json.chapters.every((chapter: { duration?: number }) => Number(chapter.duration ?? 0) > 0); + }, { timeout: timeoutMs }) + .toBe(true); + + const fullDownloadButton = page.getByRole('button', { name: /Full Download/i }); + await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs }); + await expect(fullDownloadButton).toBeEnabled({ timeout: timeoutMs }); +} + async function withDownloadedFullAudiobook( page: Page, fn: (args: { filePath: string; suggestedFilename: string }) => Promise, @@ -311,6 +331,9 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 }); + // Readiness gate: chapter row visibility can lead backend storage consistency by a small window. + await waitForBackendDownloadReady(page, bookId, { minChapters: 1 }); + // Download via frontend button await withDownloadedFullAudiobook(page, async ({ filePath }) => { const durationSeconds = await getAudioDurationSeconds(filePath); @@ -492,6 +515,9 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD // UI should also stop showing a missing placeholder after resume completes. await expect(page.getByText(/Missing •/)).toHaveCount(0, { timeout: 120_000 }); + // Ensure backend chapter metadata/object visibility is settled before combine/download. + await waitForBackendDownloadReady(page, bookId, { minChapters: 2 }); + await withDownloadedFullAudiobook(page, async ({ filePath }) => { const durationSeconds = await getAudioDurationSeconds(filePath); expect(durationSeconds).toBeGreaterThan(18); diff --git a/tests/helpers.ts b/tests/helpers.ts index e2c8606..65a5b19 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -87,7 +87,19 @@ export async function uploadAndDisplay(page: Page, fileName: string) { return; } - await page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first().click(); + const targetLink = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first(); + for (let attempt = 0; attempt < 3; attempt++) { + await dismissSettingsModalIfVisible(page); + try { + await targetLink.click({ timeout: 10000 }); + break; + } catch (error) { + if (attempt === 2) throw error; + const message = String(error); + if (!message.includes('intercepts pointer events')) throw error; + await page.waitForTimeout(200); + } + } if (lower.endsWith('.pdf')) { await page.waitForSelector('.react-pdf__Document', { timeout: 10000 }); @@ -98,6 +110,25 @@ export async function uploadAndDisplay(page: Page, fileName: string) { } } +async function dismissSettingsModalIfVisible(page: Page): Promise { + const settingsDialog = page.getByRole('dialog', { name: 'Settings' }); + const visible = await settingsDialog.isVisible().catch(() => false); + if (!visible) return; + + const saveBtn = settingsDialog.getByRole('button', { name: 'Save' }); + const saveVisible = await saveBtn.isVisible().catch(() => false); + if (saveVisible) { + await expect(saveBtn).toBeEnabled({ timeout: 10000 }).catch(() => { }); + await saveBtn.click({ force: true }).catch(async () => { + await page.keyboard.press('Escape').catch(() => { }); + }); + } else { + await page.keyboard.press('Escape').catch(() => { }); + } + + await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 }).catch(() => { }); +} + /** * Wait for the play button to be clickable and click it */