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
This commit is contained in:
parent
5316596a1b
commit
2f39e8f014
7 changed files with 411 additions and 67 deletions
|
|
@ -7,11 +7,11 @@ export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
timeout: 30 * 1000,
|
timeout: 30 * 1000,
|
||||||
outputDir: './tests/results',
|
outputDir: './tests/results',
|
||||||
fullyParallel: false,
|
// fullyParallel: false,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? '100%' : '75%',
|
workers: process.env.CI ? '100%' : '50%',
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,13 @@ export const withRetry = async <T>(
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
// Do not retry on explicit cancellation/abort errors - surface them
|
||||||
|
// immediately so callers can stop work quickly when the user cancels.
|
||||||
|
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (attempt === maxRetries - 1) {
|
if (attempt === maxRetries - 1) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -130,4 +136,4 @@ export const combineAudioChunks = async (
|
||||||
} finally {
|
} finally {
|
||||||
if (setIsAudioCombining) setIsAudioCombining(false);
|
if (setIsAudioCombining) setIsAudioCombining(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
343
tests/export.spec.ts
Normal file
343
tests/export.spec.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import fs from 'fs';
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFullAudiobook(page: Page, timeoutMs = 60_000) {
|
||||||
|
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 downloadedPath = await download.path();
|
||||||
|
expect(downloadedPath).toBeTruthy();
|
||||||
|
const stats = fs.statSync(downloadedPath!);
|
||||||
|
expect(stats.size).toBeGreaterThan(0);
|
||||||
|
return downloadedPath!;
|
||||||
|
}
|
||||||
|
|
||||||
|
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/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const json = await res.json();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.getByText(/Click "Start Generation" to begin creating your audiobook/i)
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Audiobook export', () => {
|
||||||
|
test.describe.configure({ mode: 'serial', timeout: 120_000 });
|
||||||
|
|
||||||
|
test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => {
|
||||||
|
// Ensure TTS is mocked and app is ready
|
||||||
|
await setupTest(page);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
|
// 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(downloadedPath);
|
||||||
|
// 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('handles partial EPUB audiobook generation, cancel, and full download of partial audiobook', async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// 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 cancelButton = page.getByRole('button', { name: 'Cancel' });
|
||||||
|
await expect(cancelButton).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Wait until at least 3 chapters are listed in the UI; record the exact count at the
|
||||||
|
// moment we decide to cancel, and assert that no additional chapters are added afterward.
|
||||||
|
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||||
|
await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 });
|
||||||
|
const chapterCountBeforeCancel = await chapterActionsButtons.count();
|
||||||
|
expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
// Now cancel the in-flight generation
|
||||||
|
await cancelButton.click();
|
||||||
|
|
||||||
|
// After cancellation, the inline progress card's Cancel button should be gone
|
||||||
|
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// After cancellation, determine the canonical chapter count from the backend and
|
||||||
|
// assert that the UI eventually reflects this count. Some in-flight chapters may
|
||||||
|
// complete right as we cancel, so we treat the backend state as source of truth.
|
||||||
|
const jsonAfterCancel = await expectChaptersBackendState(page, bookId);
|
||||||
|
expect(jsonAfterCancel.exists).toBe(true);
|
||||||
|
expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true);
|
||||||
|
const chapterCountAfterCancel = jsonAfterCancel.chapters.length;
|
||||||
|
expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel);
|
||||||
|
|
||||||
|
// Wait for the UI to reflect the final backend chapter count to avoid race
|
||||||
|
// conditions between the modal's soft refresh and our assertions.
|
||||||
|
await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 });
|
||||||
|
|
||||||
|
// The Full Download button should still be available for the partially generated audiobook
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
|
expect(durationSeconds).toBeGreaterThan(25);
|
||||||
|
expect(durationSeconds).toBeLessThan(300);
|
||||||
|
|
||||||
|
// Backend should still reflect the same number of chapters as when we first
|
||||||
|
// observed the stabilized post-cancellation state, and should not contain
|
||||||
|
// additional "impartial" chapters produced after cancellation.
|
||||||
|
const json = await expectChaptersBackendState(page, bookId);
|
||||||
|
expect(json.exists).toBe(true);
|
||||||
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
|
expect(json.chapters.length).toBe(chapterCountAfterCancel);
|
||||||
|
|
||||||
|
await resetAudiobookIfPresent(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloads a single chapter via chapter actions menu (PDF)', async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
|
||||||
|
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
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
|
// 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('reset removes all generated chapters for a PDF audiobook', async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
|
||||||
|
await openExportModal(page);
|
||||||
|
await setContainerFormatToMP3(page);
|
||||||
|
await startGeneration(page);
|
||||||
|
|
||||||
|
await waitForChaptersHeading(page);
|
||||||
|
|
||||||
|
// 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, the hint text for starting generation should re-appear
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Click "Start Generation" to begin creating your audiobook/i)
|
||||||
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Backend should report no existing chapters for this bookId
|
||||||
|
const res = await page.request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const json = await res.json();
|
||||||
|
expect(json.exists).toBe(false);
|
||||||
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
|
expect(json.chapters.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regenerates a PDF audiobook chapter and preserves chapter count and full download', async ({ page }) => {
|
||||||
|
await setupTest(page);
|
||||||
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
|
// Extract bookId from /pdf/[id] URL (for backend verification later)
|
||||||
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { setupTest, uploadFiles, ensureDocumentsListed, deleteAllLocalDocuments, waitForDocumentListHintPersist } from './helpers';
|
import { setupTest, uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist } from './helpers';
|
||||||
|
|
||||||
test.describe('Document folders and hint persistence', () => {
|
test.describe('Document folders and hint persistence', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -212,32 +212,6 @@ export async function deleteAllLocalDocuments(page: Page) {
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the current list order (by visible .document-link elements)
|
|
||||||
export async function getNamesInOrder(page: Page): Promise<string[]> {
|
|
||||||
const texts = await page.locator('.document-link').allInnerTexts();
|
|
||||||
return texts.map(t => t.split('\n')[0].trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set sort field (by listbox button) to a given field label
|
|
||||||
export async function setSortField(page: Page, fieldLabel: string) {
|
|
||||||
// The listbox trigger shows current field (e.g. "Name"|"Size")
|
|
||||||
await page.getByRole('button', { name: /Name|Size|Date/i }).click();
|
|
||||||
await page.getByRole('option', { name: new RegExp(`^${escapeRegExp(fieldLabel)}$`, 'i') }).click();
|
|
||||||
// Verify it reflects the chosen field
|
|
||||||
await expect(page.getByRole('button', { name: new RegExp(`^${escapeRegExp(fieldLabel)}$`, 'i') })).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure sort direction button shows an expected label (toggle as needed)
|
|
||||||
export async function ensureSortDirection(page: Page, expectedLabel: RegExp) {
|
|
||||||
// Direction button text is one of: A-Z, Z-A, Newest, Oldest, Smallest, Largest
|
|
||||||
const directionButton = page.getByRole('button', { name: /A-Z|Z-A|Newest|Oldest|Smallest|Largest/ });
|
|
||||||
const current = (await directionButton.textContent())?.trim() ?? '';
|
|
||||||
if (!expectedLabel.test(current)) {
|
|
||||||
await directionButton.click();
|
|
||||||
await expect(directionButton).toHaveText(expectedLabel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the Voices dropdown from the TTS bar and return the button locator
|
// Open the Voices dropdown from the TTS bar and return the button locator
|
||||||
export async function openVoicesMenu(page: Page) {
|
export async function openVoicesMenu(page: Page) {
|
||||||
const ttsbar = page.locator('[data-app-ttsbar]');
|
const ttsbar = page.locator('[data-app-ttsbar]');
|
||||||
|
|
@ -305,37 +279,6 @@ export async function expectProcessingTransition(page: Page) {
|
||||||
await expectMediaState(page, 'playing');
|
await expectMediaState(page, 'playing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open Speed popover in TTS bar
|
|
||||||
export async function openSpeedPopover(page: Page) {
|
|
||||||
const ttsbar = page.locator('[data-app-ttsbar]');
|
|
||||||
const buttons = ttsbar.getByRole('button');
|
|
||||||
// Heuristic: the Speed control is the first button in the TTS bar and shows something like "1x"
|
|
||||||
const speedBtn = buttons.first();
|
|
||||||
await expect(speedBtn).toBeVisible({ timeout: 10000 });
|
|
||||||
await speedBtn.click();
|
|
||||||
// Popover panel should appear with sliders
|
|
||||||
await page.waitForSelector('input[type="range"]', { timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change the "Native model speed" slider to a specific value and assert processing -> playing
|
|
||||||
export async function changeNativeSpeedAndAssert(page: Page, newSpeed: number) {
|
|
||||||
await openSpeedPopover(page);
|
|
||||||
const slider = page.locator('input[type="range"]').first();
|
|
||||||
|
|
||||||
// Set the slider value programmatically and dispatch events to trigger handlers
|
|
||||||
const valueStr = String(newSpeed);
|
|
||||||
await slider.evaluate((el, v) => {
|
|
||||||
const input = el as HTMLInputElement;
|
|
||||||
input.value = v;
|
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
input.dispatchEvent(new Event('mouseup', { bubbles: true }));
|
|
||||||
input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'ArrowRight' }));
|
|
||||||
input.dispatchEvent(new Event('touchend', { bubbles: true }));
|
|
||||||
}, valueStr);
|
|
||||||
|
|
||||||
await expectProcessingTransition(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect navigator.mediaSession.playbackState to equal given state
|
// Expect navigator.mediaSession.playbackState to equal given state
|
||||||
export async function expectMediaState(page: Page, state: 'playing' | 'paused') {
|
export async function expectMediaState(page: Page, state: 'playing' | 'paused') {
|
||||||
// WebKit (and sometimes other engines) may not reliably update navigator.mediaSession.playbackState.
|
// WebKit (and sometimes other engines) may not reliably update navigator.mediaSession.playbackState.
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,31 @@ import {
|
||||||
clickDocumentLink,
|
clickDocumentLink,
|
||||||
expectViewerForFile,
|
expectViewerForFile,
|
||||||
uploadAndDisplay,
|
uploadAndDisplay,
|
||||||
navigateToPdfPageViaNavigator,
|
|
||||||
countRenderedPdfPages,
|
|
||||||
triggerViewportResize,
|
|
||||||
playTTSAndWaitForASecond,
|
playTTSAndWaitForASecond,
|
||||||
expectProcessingTransition,
|
expectProcessingTransition,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
|
// Single-spec helpers kept local to avoid cluttering shared helpers:
|
||||||
|
async function navigateToPdfPageViaNavigator(page: any, targetPage: number) {
|
||||||
|
// Navigator popover shows "X / Y"
|
||||||
|
const navTrigger = page.getByRole('button', { name: /\d+\s*\/\s*\d+/ });
|
||||||
|
await expect(navTrigger).toBeVisible({ timeout: 10000 });
|
||||||
|
await navTrigger.click();
|
||||||
|
|
||||||
|
const input = page.getByLabel('Page number');
|
||||||
|
await expect(input).toBeVisible({ timeout: 10000 });
|
||||||
|
await input.fill(String(targetPage));
|
||||||
|
await input.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countRenderedPdfPages(page: any): Promise<number> {
|
||||||
|
return await page.locator('.react-pdf__Page').count();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerViewportResize(page: any, width: number, height: number) {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Document link navigation by type', () => {
|
test.describe('Document link navigation by type', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupTest(page);
|
await setupTest(page);
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,48 @@ import { test, expect } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
setupTest,
|
setupTest,
|
||||||
playTTSAndWaitForASecond,
|
playTTSAndWaitForASecond,
|
||||||
pauseTTSAndVerify,
|
|
||||||
openVoicesMenu,
|
openVoicesMenu,
|
||||||
selectVoiceAndAssertPlayback,
|
selectVoiceAndAssertPlayback,
|
||||||
changeNativeSpeedAndAssert,
|
|
||||||
expectMediaState,
|
expectMediaState,
|
||||||
expectProcessingTransition,
|
expectProcessingTransition,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
|
// Single-spec helpers kept local instead of living in shared helpers:
|
||||||
|
async function pauseTTSAndVerify(page: any) {
|
||||||
|
// Click pause to stop playback
|
||||||
|
await page.getByRole('button', { name: 'Pause' }).click();
|
||||||
|
// Check for play button to be visible
|
||||||
|
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSpeedPopover(page: any) {
|
||||||
|
const ttsbar = page.locator('[data-app-ttsbar]');
|
||||||
|
const buttons = ttsbar.getByRole('button');
|
||||||
|
// Heuristic: the Speed control is the first button in the TTS bar and shows something like "1x"
|
||||||
|
const speedBtn = buttons.first();
|
||||||
|
await expect(speedBtn).toBeVisible({ timeout: 10000 });
|
||||||
|
await speedBtn.click();
|
||||||
|
// Popover panel should appear with sliders
|
||||||
|
await page.waitForSelector('input[type="range"]', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeNativeSpeedAndAssert(page: any, newSpeed: number) {
|
||||||
|
await openSpeedPopover(page);
|
||||||
|
const slider = page.locator('input[type="range"]').first();
|
||||||
|
|
||||||
|
// Set the slider value programmatically and dispatch events to trigger handlers
|
||||||
|
const valueStr = String(newSpeed);
|
||||||
|
await slider.evaluate((input: HTMLInputElement, v: string) => {
|
||||||
|
input.value = v;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event('mouseup', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'ArrowRight' }));
|
||||||
|
input.dispatchEvent(new Event('touchend', { bubbles: true }));
|
||||||
|
}, valueStr);
|
||||||
|
|
||||||
|
await expectProcessingTransition(page);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Play/Pause Tests', () => {
|
test.describe('Play/Pause Tests', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await setupTest(page);
|
await setupTest(page);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue