Skip to content

Commit 2f39e8f

Browse files
committed
perf(test): optimize test execution and error handling
- Reduce Playwright worker allocation from 75% to 50% for better resource management - Disable parallel test execution temporarily to isolate flaky test behavior - Enhance audio utility to fail fast on user cancellation, preventing unnecessary retries - Consolidate test-specific utilities within individual test suites for better isolation - Introduce export functionality test coverage with new export.spec.ts suite
1 parent 5316596 commit 2f39e8f

File tree

7 files changed

+411
-67
lines changed

7 files changed

+411
-67
lines changed

playwright.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ export default defineConfig({
77
testDir: './tests',
88
timeout: 30 * 1000,
99
outputDir: './tests/results',
10-
fullyParallel: false,
10+
// fullyParallel: false,
1111
/* Fail the build on CI if you accidentally left test.only in the source code. */
1212
forbidOnly: !!process.env.CI,
1313
retries: process.env.CI ? 2 : 0,
14-
workers: process.env.CI ? '100%' : '75%',
14+
workers: process.env.CI ? '100%' : '50%',
1515
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
1616
reporter: 'html',
1717
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

src/utils/audio.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ export const withRetry = async <T>(
3939
return await operation();
4040
} catch (error) {
4141
lastError = error instanceof Error ? error : new Error(String(error));
42-
42+
43+
// Do not retry on explicit cancellation/abort errors - surface them
44+
// immediately so callers can stop work quickly when the user cancels.
45+
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
46+
break;
47+
}
48+
4349
if (attempt === maxRetries - 1) {
4450
break;
4551
}
@@ -130,4 +136,4 @@ export const combineAudioChunks = async (
130136
} finally {
131137
if (setIsAudioCombining) setIsAudioCombining(false);
132138
}
133-
}
139+
}

tests/export.spec.ts

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
import fs from 'fs';
3+
import util from 'util';
4+
import { execFile } from 'child_process';
5+
import { setupTest, uploadAndDisplay } from './helpers';
6+
7+
const execFileAsync = util.promisify(execFile);
8+
9+
async function getBookIdFromUrl(page: Page, expectedPrefix: 'pdf' | 'epub') {
10+
const url = new URL(page.url());
11+
const segments = url.pathname.split('/').filter(Boolean);
12+
expect(segments[0]).toBe(expectedPrefix);
13+
const bookId = segments[1];
14+
expect(bookId).toBeTruthy();
15+
return bookId;
16+
}
17+
18+
async function openExportModal(page: Page) {
19+
const exportButton = page.getByRole('button', { name: 'Open audiobook export' });
20+
await expect(exportButton).toBeVisible({ timeout: 15_000 });
21+
await exportButton.click();
22+
await expect(page.getByRole('heading', { name: 'Export Audiobook' })).toBeVisible({ timeout: 15_000 });
23+
}
24+
25+
async function setContainerFormatToMP3(page: Page) {
26+
const formatTrigger = page.getByRole('button', { name: /M4B|MP3/i });
27+
await expect(formatTrigger).toBeVisible({ timeout: 15_000 });
28+
await formatTrigger.click();
29+
await page.getByRole('option', { name: 'MP3' }).click();
30+
}
31+
32+
async function startGeneration(page: Page) {
33+
const startButton = page.getByRole('button', { name: 'Start Generation' });
34+
await expect(startButton).toBeVisible({ timeout: 15_000 });
35+
await startButton.click();
36+
}
37+
38+
async function waitForChaptersHeading(page: Page) {
39+
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
40+
}
41+
42+
async function downloadFullAudiobook(page: Page, timeoutMs = 60_000) {
43+
const fullDownloadButton = page.getByRole('button', { name: /Full Download/i });
44+
await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs });
45+
const [download] = await Promise.all([
46+
page.waitForEvent('download', { timeout: timeoutMs }),
47+
fullDownloadButton.click(),
48+
]);
49+
const downloadedPath = await download.path();
50+
expect(downloadedPath).toBeTruthy();
51+
const stats = fs.statSync(downloadedPath!);
52+
expect(stats.size).toBeGreaterThan(0);
53+
return downloadedPath!;
54+
}
55+
56+
async function getAudioDurationSeconds(filePath: string) {
57+
const { stdout } = await execFileAsync('ffprobe', [
58+
'-v',
59+
'error',
60+
'-show_entries',
61+
'format=duration',
62+
'-of',
63+
'csv=p=0',
64+
filePath,
65+
]);
66+
return parseFloat(stdout.trim());
67+
}
68+
69+
async function expectChaptersBackendState(page: Page, bookId: string) {
70+
const res = await page.request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
71+
expect(res.ok()).toBeTruthy();
72+
const json = await res.json();
73+
return json;
74+
}
75+
76+
async function resetAudiobookIfPresent(page: Page) {
77+
const resetButtons = page.getByRole('button', { name: 'Reset' });
78+
const count = await resetButtons.count();
79+
80+
if (count === 0) {
81+
return;
82+
}
83+
84+
const resetButton = resetButtons.first();
85+
await resetButton.click();
86+
87+
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15_000 });
88+
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
89+
await confirmReset.click();
90+
91+
await expect(
92+
page.getByText(/Click "Start Generation" to begin creating your audiobook/i)
93+
).toBeVisible({ timeout: 60_000 });
94+
}
95+
96+
test.describe('Audiobook export', () => {
97+
test.describe.configure({ mode: 'serial', timeout: 120_000 });
98+
99+
test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => {
100+
// Ensure TTS is mocked and app is ready
101+
await setupTest(page);
102+
103+
// Upload and open the sample PDF in the viewer
104+
await uploadAndDisplay(page, 'sample.pdf');
105+
106+
// Capture the generated document/book id from the /pdf/[id] URL
107+
const bookId = await getBookIdFromUrl(page, 'pdf');
108+
109+
// Open the audiobook export modal from the header button
110+
await openExportModal(page);
111+
112+
// While there are no chapters yet, we can still switch the container format.
113+
// Choose MP3 so we can validate MP3 duration end-to-end.
114+
await setContainerFormatToMP3(page);
115+
116+
// Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page
117+
await startGeneration(page);
118+
119+
// Wait for chapters list to appear and populate at least two items (Pages 1 and 2)
120+
await waitForChaptersHeading(page);
121+
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
122+
await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 });
123+
124+
// Trigger full download from the FRONTEND button and capture via Playwright's download API.
125+
// The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on
126+
// the server-side detected format, so match more loosely on the accessible name.
127+
const downloadedPath = await downloadFullAudiobook(page);
128+
129+
// Use ffprobe (same toolchain as the server) to validate the combined audio duration.
130+
// The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least
131+
// two chapters we should be close to ~20 seconds of audio.
132+
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
133+
// Duration must be within a reasonable window around 20 seconds to allow
134+
// for encoding variations and container overhead.
135+
expect(durationSeconds).toBeGreaterThan(18);
136+
expect(durationSeconds).toBeLessThan(22);
137+
138+
// Also check the chapter metadata API for consistency
139+
const json = await expectChaptersBackendState(page, bookId);
140+
expect(json.exists).toBe(true);
141+
expect(Array.isArray(json.chapters)).toBe(true);
142+
expect(json.chapters.length).toBeGreaterThanOrEqual(2);
143+
for (const ch of json.chapters) {
144+
expect(ch.duration).toBeGreaterThan(0);
145+
}
146+
147+
await resetAudiobookIfPresent(page);
148+
});
149+
150+
test('handles partial EPUB audiobook generation, cancel, and full download of partial audiobook', async ({ page }) => {
151+
await setupTest(page);
152+
153+
// Upload and open the sample EPUB in the viewer
154+
await uploadAndDisplay(page, 'sample.epub');
155+
156+
// URL should now be /epub/[id]
157+
const bookId = await getBookIdFromUrl(page, 'epub');
158+
159+
// Open the audiobook export modal from the header button
160+
await openExportModal(page);
161+
162+
// Set container format to MP3
163+
await setContainerFormatToMP3(page);
164+
165+
// Start generation
166+
await startGeneration(page);
167+
168+
// Progress card should appear with a Cancel button while chapters are being generated
169+
const cancelButton = page.getByRole('button', { name: 'Cancel' });
170+
await expect(cancelButton).toBeVisible({ timeout: 60_000 });
171+
172+
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
173+
174+
// Wait until at least 3 chapters are listed in the UI; record the exact count at the
175+
// moment we decide to cancel, and assert that no additional chapters are added afterward.
176+
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
177+
await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 });
178+
const chapterCountBeforeCancel = await chapterActionsButtons.count();
179+
expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3);
180+
181+
// Now cancel the in-flight generation
182+
await cancelButton.click();
183+
184+
// After cancellation, the inline progress card's Cancel button should be gone
185+
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
186+
187+
// After cancellation, determine the canonical chapter count from the backend and
188+
// assert that the UI eventually reflects this count. Some in-flight chapters may
189+
// complete right as we cancel, so we treat the backend state as source of truth.
190+
const jsonAfterCancel = await expectChaptersBackendState(page, bookId);
191+
expect(jsonAfterCancel.exists).toBe(true);
192+
expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true);
193+
const chapterCountAfterCancel = jsonAfterCancel.chapters.length;
194+
expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel);
195+
196+
// Wait for the UI to reflect the final backend chapter count to avoid race
197+
// conditions between the modal's soft refresh and our assertions.
198+
await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 });
199+
200+
// The Full Download button should still be available for the partially generated audiobook
201+
const downloadedPath = await downloadFullAudiobook(page);
202+
203+
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
204+
expect(durationSeconds).toBeGreaterThan(25);
205+
expect(durationSeconds).toBeLessThan(300);
206+
207+
// Backend should still reflect the same number of chapters as when we first
208+
// observed the stabilized post-cancellation state, and should not contain
209+
// additional "impartial" chapters produced after cancellation.
210+
const json = await expectChaptersBackendState(page, bookId);
211+
expect(json.exists).toBe(true);
212+
expect(Array.isArray(json.chapters)).toBe(true);
213+
expect(json.chapters.length).toBe(chapterCountAfterCancel);
214+
215+
await resetAudiobookIfPresent(page);
216+
});
217+
218+
test('downloads a single chapter via chapter actions menu (PDF)', async ({ page }) => {
219+
await setupTest(page);
220+
await uploadAndDisplay(page, 'sample.pdf');
221+
222+
const bookId = await getBookIdFromUrl(page, 'pdf');
223+
224+
await openExportModal(page);
225+
await setContainerFormatToMP3(page);
226+
await startGeneration(page);
227+
228+
await waitForChaptersHeading(page);
229+
230+
// Wait for at least one chapter row to appear (one "Chapter actions" button)
231+
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
232+
await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 });
233+
234+
// Download via frontend button
235+
const downloadedPath = await downloadFullAudiobook(page);
236+
237+
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
238+
// For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter.
239+
expect(durationSeconds).toBeGreaterThan(9);
240+
expect(durationSeconds).toBeLessThan(300);
241+
242+
await resetAudiobookIfPresent(page);
243+
});
244+
245+
test('reset removes all generated chapters for a PDF audiobook', async ({ page }) => {
246+
await setupTest(page);
247+
await uploadAndDisplay(page, 'sample.pdf');
248+
249+
const bookId = await getBookIdFromUrl(page, 'pdf');
250+
251+
await openExportModal(page);
252+
await setContainerFormatToMP3(page);
253+
await startGeneration(page);
254+
255+
await waitForChaptersHeading(page);
256+
257+
// Wait for Reset button to become visible, indicating resumable/generated state
258+
const resetButton = page.getByRole('button', { name: 'Reset' });
259+
await expect(resetButton).toBeVisible({ timeout: 120_000 });
260+
261+
await resetButton.click();
262+
263+
// Confirm in the Reset Audiobook dialog
264+
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 });
265+
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
266+
await confirmReset.click();
267+
268+
// After reset, the hint text for starting generation should re-appear
269+
await expect(
270+
page.getByText(/Click "Start Generation" to begin creating your audiobook/i)
271+
).toBeVisible({ timeout: 60_000 });
272+
273+
// Backend should report no existing chapters for this bookId
274+
const res = await page.request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
275+
expect(res.ok()).toBeTruthy();
276+
const json = await res.json();
277+
expect(json.exists).toBe(false);
278+
expect(Array.isArray(json.chapters)).toBe(true);
279+
expect(json.chapters.length).toBe(0);
280+
});
281+
282+
test('regenerates a PDF audiobook chapter and preserves chapter count and full download', async ({ page }) => {
283+
await setupTest(page);
284+
await uploadAndDisplay(page, 'sample.pdf');
285+
286+
// Extract bookId from /pdf/[id] URL (for backend verification later)
287+
const bookId = await getBookIdFromUrl(page, 'pdf');
288+
289+
// Open Export Audiobook modal
290+
await openExportModal(page);
291+
292+
// Set container format to MP3
293+
await setContainerFormatToMP3(page);
294+
295+
// Start generation
296+
await startGeneration(page);
297+
298+
// Wait for chapters to appear
299+
await waitForChaptersHeading(page);
300+
301+
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
302+
// Ensure we have at least two chapters for this PDF
303+
await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 });
304+
const chapterCountBefore = await chapterActionsButtons.count();
305+
expect(chapterCountBefore).toBeGreaterThanOrEqual(2);
306+
307+
// Open the actions menu for the first chapter and trigger Regenerate
308+
const firstChapterActions = chapterActionsButtons.first();
309+
await firstChapterActions.click();
310+
311+
// In the headlessui Menu, each option is a menuitem. Use that role instead of button.
312+
const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i });
313+
await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 });
314+
await regenerateMenuItem.click();
315+
316+
// During regeneration, the row may show a "Regenerating" label; wait for any such
317+
// indicator to disappear, signaling completion.
318+
const regeneratingLabel = page.getByText(/Regenerating/);
319+
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
320+
321+
// Chapter count should remain exactly the same after regeneration (no duplicates)
322+
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
323+
324+
// Full Download should still work and produce a valid combined audiobook
325+
const downloadedPath = await downloadFullAudiobook(page);
326+
327+
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
328+
// With two mocked 10s chapters we expect roughly 20s; allow a small window.
329+
expect(durationSeconds).toBeGreaterThan(18);
330+
expect(durationSeconds).toBeLessThan(22);
331+
332+
// Backend should still report the same number of chapters and valid durations
333+
const json = await expectChaptersBackendState(page, bookId);
334+
expect(json.exists).toBe(true);
335+
expect(Array.isArray(json.chapters)).toBe(true);
336+
expect(json.chapters.length).toBe(chapterCountBefore);
337+
for (const ch of json.chapters) {
338+
expect(ch.duration).toBeGreaterThan(0);
339+
}
340+
341+
await resetAudiobookIfPresent(page);
342+
});
343+
});

tests/folders.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect } from '@playwright/test';
2-
import { setupTest, uploadFiles, ensureDocumentsListed, deleteAllLocalDocuments, waitForDocumentListHintPersist } from './helpers';
2+
import { setupTest, uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist } from './helpers';
33

44
test.describe('Document folders and hint persistence', () => {
55
test.beforeEach(async ({ page }) => {

0 commit comments

Comments
 (0)