From 52e512f17d5afe084952a2d940ea1e504b6f6a8a Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 6 Apr 2026 09:59:37 -0600 Subject: [PATCH] test(ui,tests): stabilize audiobook export tests and harden settings modal interactions - Add waitForBackendDownloadReady helper to tests/export.spec.ts to wait for backend chapter metadata and enabled full-download UI before attempting combines/downloads. Use it in two export tests to reduce flakiness related to eventual backend storage visibility. - Improve uploadAndDisplay helper in tests/helpers.ts to retry link click when transient "intercepts pointer events" errors occur, ensuring test clicks succeed under flaky UI conditions. Add dismissSettingsModalIfVisible to detect and close the Settings dialog before interacting with page links (click Save or press Escape, wait for dialog to hide). - Update SettingsModal.tsx useEffect dependencies to avoid referencing checkFirstVist in the local state sync effect and add separate effect for checkFirstVist for clearer lifecycle. Why: Reduce end-to-end test flakiness by waiting for backend readiness and making UI interactions more robust; simplify SettingsModal effect dependencies to prevent unnecessary re-runs. Breaking changes: none. --- src/components/SettingsModal.tsx | 5 ++++- tests/export.spec.ts | 26 +++++++++++++++++++++++++ tests/helpers.ts | 33 +++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) 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 */