openreader/tests/export.spec.ts
Richard R 0798cd845f refactor(export): extract download trigger helper and add retry logic to full download
Move download logic into reusable downloadViaTrigger function. Add retries and
button enabled checks to downloadFullAudiobook for improved robustness against
transient UI or backend delays.
2026-05-20 03:44:09 -06:00

606 lines
23 KiB
TypeScript

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';
import util from 'util';
import { execFile } from 'child_process';
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<APIResponse>,
{ attempts = 4, backoffMs = 200 }: { attempts?: number; backoffMs?: number } = {},
): Promise<APIResponse> {
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);
expect(segments[0]).toBe(expectedPrefix);
const bookId = segments[1];
expect(bookId).toBeTruthy();
return bookId;
}
async function openExportModal(page: Page) {
const exportButton = page.getByRole('button', { name: 'Open audiobook export' });
await expect(exportButton).toBeVisible({ timeout: 15_000 });
await exportButton.click();
await expect(page.getByRole('heading', { name: 'Export Audiobook' })).toBeVisible({ timeout: 15_000 });
}
async function setContainerFormatToMP3(page: Page) {
const formatTrigger = page.getByRole('button', { name: /M4B|MP3/i });
await expect(formatTrigger).toBeVisible({ timeout: 15_000 });
await formatTrigger.click();
await page.getByRole('option', { name: 'MP3' }).click();
}
async function startGeneration(page: Page) {
const startButton = page.getByRole('button', { name: 'Start Generation' });
await expect(startButton).toBeVisible({ timeout: 15_000 });
await startButton.click();
}
async function waitForChaptersHeading(page: Page) {
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
}
type DownloadedAudiobook = {
filePath: string;
suggestedFilename: string;
cleanup: () => Promise<void>;
};
async function downloadViaTrigger(
page: Page,
trigger: () => Promise<void>,
timeoutMs = 60_000,
): Promise<DownloadedAudiobook> {
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: timeoutMs }),
trigger(),
]);
const failure = await download.failure();
expect(failure).toBeNull();
const suggestedFilename = download.suggestedFilename();
let createdTempDir: string | null = null;
let filePath = await download.path();
// Some environments/browsers may not expose a stable download path; fall back to saving
// into a temp directory outside the repo (and clean up after assertions).
if (!filePath) {
createdTempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openreader-audiobook-'));
const name = suggestedFilename || `download_${Date.now()}.mp3`;
filePath = path.join(createdTempDir, name);
await download.saveAs(filePath);
}
expect(fs.existsSync(filePath)).toBeTruthy();
const stats = fs.statSync(filePath);
expect(stats.size).toBeGreaterThan(0);
return {
filePath,
suggestedFilename,
cleanup: async () => {
try {
await fs.promises.unlink(filePath);
} catch {
// ignore
}
if (createdTempDir) {
await fs.promises.rm(createdTempDir, { recursive: true, force: true });
}
},
};
}
async function downloadFullAudiobook(page: Page, timeoutMs = 60_000): Promise<DownloadedAudiobook> {
const fullDownloadButton = page.getByRole('button', { name: /Full Download/i });
await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs });
await expect(fullDownloadButton).toBeEnabled({ timeout: timeoutMs });
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await downloadViaTrigger(page, () => fullDownloadButton.click(), timeoutMs);
} catch (error) {
lastError = error;
if (attempt === 3) throw error;
await page.waitForTimeout(250 * attempt);
await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs });
await expect(fullDownloadButton).toBeEnabled({ timeout: timeoutMs });
}
}
throw lastError instanceof Error ? lastError : new Error('Full download failed');
}
async function getAudioDurationSeconds(filePath: string) {
const { stdout } = await execFileAsync('ffprobe', [
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'csv=p=0',
filePath,
]);
return parseFloat(stdout.trim());
}
async function expectChaptersBackendState(page: Page, bookId: string) {
const res = await requestWithRetry(() => page.request.get(`/api/audiobook/status?bookId=${bookId}`));
expect(res.ok()).toBeTruthy();
const json = await res.json();
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<T>(
page: Page,
fn: (args: { filePath: string; suggestedFilename: string }) => Promise<T>,
timeoutMs = 60_000
): Promise<T> {
const dl = await downloadFullAudiobook(page, timeoutMs);
try {
return await fn({ filePath: dl.filePath, suggestedFilename: dl.suggestedFilename });
} finally {
await dl.cleanup();
}
}
async function waitForStableUiChapterCount(
page: Page,
{ stableMs = 2000, timeoutMs = 30000 } = {}
): Promise<number> {
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
const startTime = Date.now();
let lastCount = -1;
let lastStableTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const currentCount = await chapterActionsButtons.count();
if (currentCount !== lastCount) {
lastCount = currentCount;
lastStableTime = Date.now();
} else if (Date.now() - lastStableTime >= stableMs) {
return currentCount;
}
await page.waitForTimeout(200);
}
return Math.max(lastCount, 0);
}
async function cancelGenerationIfVisible(page: Page): Promise<void> {
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) {
const res = await requestWithRetry(() => page.request.delete(`/api/audiobook?bookId=${bookId}`));
expect(res.ok() || res.status() === 404).toBeTruthy();
}
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();
if (count === 0) {
return;
}
const resetButton = resetButtons.first();
await resetButton.click();
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 });
}
test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }, testInfo) => {
// Ensure TTS is mocked and app is ready
await setupTest(page, testInfo);
// Upload and open the sample PDF in the viewer
await uploadAndDisplay(page, 'sample.pdf');
// Capture the generated document/book id from the /pdf/[id] URL
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
// Open the audiobook export modal from the header button
await openExportModal(page);
// While there are no chapters yet, we can still switch the container format.
// Choose MP3 so we can validate MP3 duration end-to-end.
await setContainerFormatToMP3(page);
// Start generation; in test namespace mode the server returns tests/files/sample.mp3
// for each generated chapter via generateTTSBuffer's test mock path.
await startGeneration(page);
// Wait for chapters list to appear and populate at least two items (Pages 1 and 2)
await waitForChaptersHeading(page);
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 });
// Trigger full download from the FRONTEND button and capture via Playwright's download API.
// The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on
// the server-side detected format, so match more loosely on the accessible name.
await withDownloadedFullAudiobook(page, async ({ filePath }) => {
// Use ffprobe (same toolchain as the server) to validate the combined audio duration.
// The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least
// two chapters we should be close to ~20 seconds of audio.
const durationSeconds = await getAudioDurationSeconds(filePath);
// Duration must be within a reasonable window around 20 seconds to allow
// for encoding variations and container overhead.
expect(durationSeconds).toBeGreaterThan(18);
expect(durationSeconds).toBeLessThan(22);
});
// Also check the chapter metadata API for consistency
const json = await expectChaptersBackendState(page, bookId);
expect(json.exists).toBe(true);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBeGreaterThanOrEqual(2);
for (const ch of json.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
await resetAudiobookIfPresent(page, bookId);
});
test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => {
test.setTimeout(120_000);
await setupTest(page, testInfo);
// Upload and open the sample EPUB in the viewer
await uploadAndDisplay(page, 'sample.epub');
// URL should now be /epub/[id]
const bookId = await getBookIdFromUrl(page, 'epub');
await resetAudiobookById(page, bookId);
// Open the audiobook export modal from the header button
await openExportModal(page);
// Set container format to MP3
await setContainerFormatToMP3(page);
// Start generation
await startGeneration(page);
// Progress card should appear with a Cancel button while chapters are being generated
const generationCard = page.locator('div', { hasText: 'Generating Audiobook' }).first();
const cancelButton = generationCard.getByRole('button', { name: 'Cancel' });
await expect(cancelButton).toBeVisible({ timeout: 60_000 });
// 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 cancelGenerationIfVisible(page);
// Cancellation is asynchronous: wait for generation to settle before asserting
// that the inline progress card has disappeared.
await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible({ timeout: 30_000 });
await expect(generationCard).toHaveCount(0, { timeout: 30_000 });
// 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);
// 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 }) => {
const durationSeconds = await getAudioDurationSeconds(filePath);
expect(durationSeconds).toBeGreaterThan(9);
expect(durationSeconds).toBeLessThan(300);
});
await resetAudiobookIfPresent(page, bookId);
});
test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => {
test.setTimeout(60_000);
await setupTest(page, testInfo);
await uploadAndDisplay(page, 'sample.pdf');
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
await openExportModal(page);
await setContainerFormatToMP3(page);
await startGeneration(page);
await waitForChaptersHeading(page);
// Wait for at least one chapter row to appear (one "Chapter actions" button)
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 full-download button once at least one chapter is ready.
await withDownloadedFullAudiobook(page, async ({ filePath }) => {
const durationSeconds = await getAudioDurationSeconds(filePath);
expect(durationSeconds).toBeGreaterThan(9);
expect(durationSeconds).toBeLessThan(300);
});
await resetAudiobookIfPresent(page, bookId);
});
test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => {
await setupTest(page, testInfo);
await uploadAndDisplay(page, 'sample.pdf');
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
await openExportModal(page);
await setContainerFormatToMP3(page);
await startGeneration(page);
await waitForChaptersHeading(page);
// 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();
await cancelGenerationIfVisible(page);
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 });
await resetButton.click();
// Confirm in the Reset Audiobook dialog
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 });
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
await confirmReset.click();
// After reset, generation should be startable again
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
await expect(chapterActionsButtons).toHaveCount(0);
});
test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }, testInfo) => {
test.setTimeout(60_000);
await setupTest(page, testInfo);
await uploadAndDisplay(page, 'sample.pdf');
// Extract bookId from /pdf/[id] URL (for backend verification later)
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
// Open Export Audiobook modal
await openExportModal(page);
// Set container format to MP3
await setContainerFormatToMP3(page);
// Start generation
await startGeneration(page);
// Wait for chapters to appear
await waitForChaptersHeading(page);
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
// Ensure we have at least two chapters for this PDF
await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 });
const chapterCountBefore = await chapterActionsButtons.count();
expect(chapterCountBefore).toBeGreaterThanOrEqual(2);
// Open the actions menu for the first chapter and trigger Regenerate
const firstChapterActions = chapterActionsButtons.first();
await firstChapterActions.click();
// In the headlessui Menu, each option is a menuitem. Use that role instead of button.
const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i });
await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 });
await regenerateMenuItem.click();
// During regeneration, the row may show a "Regenerating" label; wait for any such
// indicator to disappear, signaling completion.
const regeneratingLabel = page.getByText(/Regenerating/);
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
// After regeneration completes in the UI, verify backend chapter state is fully updated
// before triggering a full download to avoid races with ffmpeg concat on Alpine.
const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId);
expect(backendStateAfterRegenerate.exists).toBe(true);
expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true);
expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore);
for (const ch of backendStateAfterRegenerate.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
// Chapter count should remain exactly the same after regeneration (no duplicates)
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
// Full Download should still work and produce a valid combined audiobook
await withDownloadedFullAudiobook(page, async ({ filePath }) => {
const durationSeconds = await getAudioDurationSeconds(filePath);
// With two mocked 10s chapters we expect roughly 20s; allow a small window.
expect(durationSeconds).toBeGreaterThan(18);
expect(durationSeconds).toBeLessThan(22);
});
// Backend should still report the same number of chapters and valid durations
const json = await expectChaptersBackendState(page, bookId);
expect(json.exists).toBe(true);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBe(chapterCountBefore);
for (const ch of json.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
await resetAudiobookIfPresent(page, bookId);
});
test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => {
test.setTimeout(60_000);
await setupTest(page, testInfo);
await uploadAndDisplay(page, 'sample.pdf');
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
await openExportModal(page);
await setContainerFormatToMP3(page);
await startGeneration(page);
await waitForChaptersHeading(page);
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 });
// 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 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).
await expect
.poll(async () => {
const json = await expectChaptersBackendState(page, bookId);
return json.chapters?.length ?? 0;
}, { timeout: 30_000 })
.toBe(1);
const jsonAfterDelete = await expectChaptersBackendState(page, bookId);
expect(jsonAfterDelete.exists).toBe(true);
expect(Array.isArray(jsonAfterDelete.chapters)).toBe(true);
expect(jsonAfterDelete.chapters.length).toBe(1);
expect(jsonAfterDelete.chapters[0]?.index).toBe(1);
// Close and reopen the modal to ensure "resume" loads the missing placeholder from the backend.
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('heading', { name: 'Export Audiobook' })).toHaveCount(0);
await openExportModal(page);
await waitForChaptersHeading(page);
await expect(page.getByText(/Missing •/)).toHaveCount(1, { timeout: 15_000 });
await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible({ timeout: 15_000 });
// Resume should regenerate the missing chapter and allow a full download to succeed.
await page.getByRole('button', { name: 'Resume' }).click();
// Wait for backend to have both chapters again.
await expect
.poll(async () => {
const json = await expectChaptersBackendState(page, bookId);
return json.chapters?.length ?? 0;
}, { timeout: 120_000 })
.toBe(2);
// 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);
expect(durationSeconds).toBeLessThan(22);
});
const jsonAfterResume = await expectChaptersBackendState(page, bookId);
expect(jsonAfterResume.exists).toBe(true);
expect(Array.isArray(jsonAfterResume.chapters)).toBe(true);
expect(jsonAfterResume.chapters.length).toBe(2);
expect(jsonAfterResume.chapters.map((c: { index: number }) => c.index).sort()).toEqual([0, 1]);
for (const ch of jsonAfterResume.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
await resetAudiobookIfPresent(page, bookId);
});