Add a test to verify selected voice persists instead of resetting to default. Also refactors export tests to be UI-driven rather than backend-dependent.
511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
import { test, expect, Page } 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);
|
|
|
|
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 downloadFullAudiobook(page: Page, timeoutMs = 60_000): Promise<DownloadedAudiobook> {
|
|
const fullDownloadButton = page.getByRole('button', { name: /Full Download/i });
|
|
await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs });
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent('download', { timeout: timeoutMs }),
|
|
fullDownloadButton.click(),
|
|
]);
|
|
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 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 page.request.get(`/api/audiobook/status?bookId=${bookId}`);
|
|
expect(res.ok()).toBeTruthy();
|
|
const json = await res.json();
|
|
return json;
|
|
}
|
|
|
|
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 page.request.delete(`/api/audiobook?bookId=${bookId}`);
|
|
expect(res.ok() || res.status() === 404).toBeTruthy();
|
|
}
|
|
|
|
async function resetAudiobookIfPresent(page: Page) {
|
|
const resetButtons = page.getByRole('button', { name: 'Reset' });
|
|
const count = await resetButtons.count();
|
|
|
|
if (count === 0) {
|
|
return;
|
|
}
|
|
|
|
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();
|
|
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; this will call the mocked /api/tts which returns a 10s sample.mp3 per page
|
|
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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test('exports a single MP3 audiobook PDF page via chapters menu', 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);
|
|
|
|
// 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 });
|
|
|
|
// Download via frontend button
|
|
await withDownloadedFullAudiobook(page, async ({ filePath }) => {
|
|
const durationSeconds = await getAudioDurationSeconds(filePath);
|
|
// For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter.
|
|
expect(durationSeconds).toBeGreaterThan(9);
|
|
expect(durationSeconds).toBeLessThan(300);
|
|
});
|
|
|
|
await resetAudiobookIfPresent(page);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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 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 });
|
|
|
|
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);
|
|
});
|