diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 5765461..5d7904d 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -111,7 +111,9 @@ export default function EPUBPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + if (h > 0) { + setContainerHeight(`${h}px`); + } // compute max horizontal padding while preserving a minimum readable width, // but still allow some padding on small screens @@ -122,9 +124,15 @@ export default function EPUBPage() { setMaxPadPx(maxPad); }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, activeSidebar]); // Nudge EPUB renderer to reflow on horizontal padding changes useEffect(() => { diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index d3f6e55..83dbdcd 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -111,7 +111,9 @@ export default function HTMLPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + if (h > 0) { + setContainerHeight(`${h}px`); + } // Adaptive minimum content width: allow some padding on narrow screens const vw = window.innerWidth; @@ -121,9 +123,15 @@ export default function HTMLPage() { setMaxPadPx(maxPad); }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, activeSidebar]); const handleGenerateAudiobook = useCallback(async ( onProgress: (progress: number) => void, diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index ba23846..4cfb922 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -79,6 +79,7 @@ export default function PDFViewerPage() { console.log('Loading new document (from page.tsx)'); let didRedirect = false; let startedLoad = false; + let loadSucceeded = false; try { if (!id) { setError('Document not found'); @@ -101,8 +102,20 @@ export default function PDFViewerPage() { startedLoad = true; inFlightDocIdRef.current = resolved; stop(); // Reset TTS when loading new document - await setCurrentDocument(resolved); - loadedDocIdRef.current = resolved; + for (let attempt = 0; attempt < 2; attempt += 1) { + const loaded = await setCurrentDocument(resolved); + if (loaded) { + loadSucceeded = true; + loadedDocIdRef.current = resolved; + break; + } + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + if (!loadSucceeded) { + throw new Error(`Failed to load PDF document ${resolved}`); + } } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); @@ -110,7 +123,7 @@ export default function PDFViewerPage() { if (startedLoad) { inFlightDocIdRef.current = null; } - if (!didRedirect && startedLoad) { + if (!didRedirect && startedLoad && loadSucceeded) { setIsLoading(false); } } @@ -148,12 +161,21 @@ export default function PDFViewerPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + // Avoid locking the reader at 0px during transient startup layout states. + if (h > 0) { + setContainerHeight(`${h}px`); + } }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, isParseReady, isAtLimit, activeSidebar]); const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300)); const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50)); diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 90fe3ec..9c89201 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -37,7 +37,6 @@ import { highlightWordIndex, } from '@/lib/client/pdf'; import { buildPageTextFromBlocks } from '@/lib/client/pdf-block-text'; -import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings, @@ -72,7 +71,7 @@ export interface PdfDocumentState { parsedOverlayEnabled: boolean; setParsedOverlayEnabled: (enabled: boolean) => void; forceReparseParsedPdf: () => Promise; - setCurrentDocument: (id: string) => Promise; + setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; // PDF functionality @@ -264,24 +263,6 @@ export function usePdfDocument(): PdfDocumentState { return; } - const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => { - const page = pageFromParsed(pageNum); - if (!page) return []; - const skipKinds = new Set(documentSettings.pdf?.skipBlockKinds ?? []); - return page.blocks - .filter((block) => !skipKinds.has(block.kind)) - .map((block) => ({ - sourceKey: `pdf:${pageNum}:${block.id}`, - text: block.text, - locator: { - readerType: 'pdf', - page: block.fragments[0]?.page ?? pageNum, - blockId: block.id, - } as TTSSegmentLocator, - })) - .filter((unit) => unit.text.trim().length > 0); - }; - const getPageText = async (pageNumber: number, shouldCache = false): Promise => { // Ignore stale/in-flight work if the document or worker changed. if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { @@ -345,14 +326,12 @@ export function usePdfDocument(): PdfDocumentState { if (text !== currDocText || text === '') { setCurrDocText(text); - const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); setTTSText(text, { location: currDocPageNumber, previousText: prevText, nextLocation: nextPageNumber, nextText: nextText, upcomingLocations: additionalUpcoming, - ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } } catch (error) { @@ -389,7 +368,7 @@ export function usePdfDocument(): PdfDocumentState { * @param {string} id - The unique identifier of the document to set * @returns {Promise} */ - const setCurrentDocument = useCallback(async (id: string): Promise => { + const setCurrentDocument = useCallback(async (id: string): Promise => { // --- race-condition guard --- const seq = ++docLoadSeqRef.current; docLoadAbortRef.current?.abort(); @@ -416,10 +395,10 @@ export function usePdfDocument(): PdfDocumentState { setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); const meta = await getDocumentMetadata(id, { signal: controller.signal }); - if (seq !== docLoadSeqRef.current) return; // stale + if (seq !== docLoadSeqRef.current) return false; // stale if (!meta) { console.error('Document not found on server'); - return; + return false; } if (meta.type === 'pdf') { startParsedPolling(id, (meta.parseStatus ?? null) as PdfParseStatus | null); @@ -427,25 +406,28 @@ export function usePdfDocument(): PdfDocumentState { } const doc = await ensureCachedDocument(meta, { signal: controller.signal }); - if (seq !== docLoadSeqRef.current) return; // stale + if (seq !== docLoadSeqRef.current) return false; // stale if (doc.type !== 'pdf') { console.error('Document is not a PDF'); - return; + return false; } setCurrDocName(doc.name); // IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the // buffer passed into the worker; we always pass clones to react-pdf. setCurrDocData(doc.data.slice(0)); + return true; } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return; + if (error instanceof DOMException && error.name === 'AbortError') return false; console.error('Failed to get document:', error); + return false; } finally { // Clean up the controller only if it's still ours (a newer call hasn't replaced it). if (docLoadAbortRef.current === controller) { docLoadAbortRef.current = null; } } + return false; }, [ setCurrDocId, setCurrDocName, diff --git a/tests/export.spec.ts b/tests/export.spec.ts index d8137a0..d89a445 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from '@playwright/test'; +import type { APIResponse } from '@playwright/test'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -8,6 +9,35 @@ import { setupTest, uploadAndDisplay } from './helpers'; const execFileAsync = util.promisify(execFile); +function isTransientRequestError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const msg = error.message || ''; + return ( + msg.includes('ECONNRESET') || + msg.includes('socket hang up') || + msg.includes('ERR_SOCKET_CLOSED') || + msg.includes('ETIMEDOUT') || + msg.includes('fetch failed') + ); +} + +async function requestWithRetry( + fn: () => Promise, + { attempts = 4, backoffMs = 200 }: { attempts?: number; backoffMs?: number } = {}, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (!isTransientRequestError(error) || attempt === attempts) throw error; + await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt)); + } + } + throw lastError instanceof Error ? lastError : new Error('Request failed'); +} + async function getBookIdFromUrl(page: Page, expectedPrefix: 'pdf' | 'epub') { const url = new URL(page.url()); const segments = url.pathname.split('/').filter(Boolean); @@ -103,7 +133,7 @@ async function getAudioDurationSeconds(filePath: string) { } async function expectChaptersBackendState(page: Page, bookId: string) { - const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); + const res = await requestWithRetry(() => page.request.get(`/api/audiobook/status?bookId=${bookId}`)); expect(res.ok()).toBeTruthy(); const json = await res.json(); return json; @@ -184,11 +214,20 @@ async function cancelGenerationIfVisible(page: Page): Promise { } async function resetAudiobookById(page: Page, bookId: string) { - const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`); + const res = await requestWithRetry(() => page.request.delete(`/api/audiobook?bookId=${bookId}`)); expect(res.ok() || res.status() === 404).toBeTruthy(); } -async function resetAudiobookIfPresent(page: Page) { +async function resetAudiobookIfPresent(page: Page, bookId?: string) { + // Prefer backend reset when bookId is available: deterministic and independent of modal timing. + if (bookId) { + await resetAudiobookById(page, bookId); + await page.reload(); + await openExportModal(page); + await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); + return; + } + const resetButtons = page.getByRole('button', { name: 'Reset' }); const count = await resetButtons.count(); @@ -199,8 +238,12 @@ async function resetAudiobookIfPresent(page: Page) { const resetButton = resetButtons.first(); await resetButton.click(); - await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15_000 }); - const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); + const resetDialog = page + .getByRole('dialog') + .filter({ has: page.getByRole('heading', { name: 'Reset Audiobook' }) }) + .first(); + await expect(resetDialog).toBeVisible({ timeout: 15_000 }); + const confirmReset = resetDialog.getByRole('button', { name: 'Reset' }); await confirmReset.click(); await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); @@ -256,7 +299,7 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => { @@ -312,7 +355,7 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async expect(durationSeconds).toBeLessThan(300); }); - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => { @@ -343,7 +386,7 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page expect(durationSeconds).toBeLessThan(300); }); - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { @@ -455,7 +498,7 @@ test('regenerates a single MP3 audiobook PDF page and exports full audiobook', a expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => { @@ -476,7 +519,9 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD // Delete the first chapter via the backend API so the audiobook has a missing index (0). // This is more reliable than clicking through the chapter actions menu in headless runs. - const deleteRes = await page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`); + const deleteRes = await requestWithRetry(() => + page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`) + ); expect(deleteRes.ok()).toBeTruthy(); // Wait for backend to reflect only one remaining chapter (index 1). @@ -534,5 +579,5 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); diff --git a/tests/files/sample.pdf b/tests/files/sample.pdf index 9e0e7e9..f08b7aa 100644 Binary files a/tests/files/sample.pdf and b/tests/files/sample.pdf differ diff --git a/tests/helpers.ts b/tests/helpers.ts index 5a7a0f5..d002522 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -22,6 +22,21 @@ function sha256HexOfFile(filePath: string) { return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } +async function waitForPdfViewerReady(page: Page, timeout = 60000) { + await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/, { timeout: Math.min(timeout, 20000) }); + await expect + .poll(async () => { + const documentVisible = await page.locator('.react-pdf__Document').first().isVisible().catch(() => false); + if (documentVisible) return true; + const pageVisible = await page.locator('.react-pdf__Page').first().isVisible().catch(() => false); + if (pageVisible) return true; + const canvasVisible = await page.locator('.react-pdf__Page canvas').first().isVisible().catch(() => false); + if (canvasVisible) return true; + return false; + }, { timeout }) + .toBe(true); +} + /** * Upload a sample epub or pdf */ @@ -63,7 +78,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) { await expect(targetLink).toBeVisible({ timeout: 15000 }); await dismissOnboardingModals(page); await targetLink.click(); - await page.waitForSelector('.react-pdf__Document', { timeout: 15000 }); + await waitForPdfViewerReady(page, 60000); return; } @@ -80,7 +95,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) { } if (lower.endsWith('.pdf')) { - await page.waitForSelector('.react-pdf__Document', { timeout: 10000 }); + await waitForPdfViewerReady(page, 60000); } else if (lower.endsWith('.epub')) { await page.waitForSelector('.epub-container', { timeout: 10000 }); } else if (lower.endsWith('.txt') || lower.endsWith('.md')) { @@ -350,10 +365,28 @@ export async function ensureDocumentsListed(page: Page, fileNames: string[]) { // Click the document link row by filename export async function clickDocumentLink(page: Page, fileName: string) { - await page + const link = page .getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }) - .first() - .click(); + .first(); + await expect(link).toBeVisible({ timeout: 15_000 }); + + const href = await link.getAttribute('href'); + if (!href) { + await link.click(); + return; + } + + const navigatedByClick = await Promise.all([ + page + .waitForURL((url) => url.pathname === href, { timeout: 8_000 }) + .then(() => true) + .catch(() => false), + link.click(), + ]).then(([ok]) => ok); + + if (!navigatedByClick) { + await page.goto(href); + } } // Expect correct URL and viewer to be visible for a given file by extension diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts index c9d9bd8..20c7114 100644 --- a/tests/unit/pdf-audiobook-adapter.spec.ts +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -73,4 +73,67 @@ test.describe('pdf audiobook adapter', () => { expect(chapters[1].title).toBe('Second'); expect(chapters[1].text).toContain('More body.'); }); + + test('keeps a single section chapter when only one heading is present', async () => { + const parsed: ParsedPdfDocument = { + schemaVersion: 1, + documentId: 'doc-2', + parserVersion: 'test', + parsedAt: Date.now(), + pages: [ + { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'p1-title', + kind: 'doc_title', + text: 'Sample PDF', + fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Sample PDF', readingOrder: 0 }], + }, + { + id: 'p1-text', + kind: 'text', + text: 'First page body.', + fragments: [{ page: 1, bbox: [0, 50, 100, 79], text: 'First page body.', readingOrder: 1 }], + }, + ], + }, + { + pageNumber: 2, + width: 100, + height: 100, + blocks: [ + { + id: 'p2-text', + kind: 'text', + text: 'Second page body.', + fragments: [{ page: 2, bbox: [0, 50, 100, 79], text: 'Second page body.', readingOrder: 0 }], + }, + ], + }, + ], + }; + + const settings: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: [], + chaptersFromSections: true, + }, + }; + + const adapter = createPdfAudiobookSourceAdapter({ + parsed, + settings, + smartSentenceSplitting: false, + }); + + const chapters = await adapter.prepareChapters(); + expect(chapters).toHaveLength(1); + expect(chapters[0].title).toBe('Sample PDF'); + expect(chapters[0].text).toContain('First page body.'); + expect(chapters[0].text).toContain('Second page body.'); + }); });