fix(api): stabilize DOCX to PDF conversion and cleanup
- Isolate concurrent LibreOffice runs with per-job profile directories (soffice -env:UserInstallation) and per-job temp folders - Poll for generated PDF and verify stable file size before reading - Consolidate temp artifacts under docstore/tmp and clean up via rm -r - Add headless/nologo flags and improve error handling ui(accessibility): - ConfirmDialog exposes proper dialog roles - DocumentFolder toggle adds type, aria-expanded, aria-controls, and title; associate content panel with an id - HTMLViewer wraps content in .html-container for HTML/TXT views test: add comprehensive Playwright specs and helpers - Accessibility, API health, deletion flows, folders, navigation, playback, and upload scenarios - Add sample.md and unsupported.xyz; update sample.pdf; extend helpers ci: run Playwright workflow on version1.0.0 branch
This commit is contained in:
parent
42665884d7
commit
21870ed576
16 changed files with 868 additions and 52 deletions
2
.github/workflows/playwright.yml
vendored
2
.github/workflows/playwright.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
branches: [ main, master, version1.0.0 ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
jobs:
|
||||
|
|
|
|||
|
|
@ -1,26 +1,40 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, mkdir, unlink, readFile } from 'fs/promises';
|
||||
import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
const TEMP_DIR = path.join(process.cwd(), 'temp');
|
||||
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
|
||||
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
|
||||
|
||||
async function ensureTempDir() {
|
||||
if (!existsSync(DOCSTORE_DIR)) {
|
||||
await mkdir(DOCSTORE_DIR, { recursive: true });
|
||||
}
|
||||
if (!existsSync(TEMP_DIR)) {
|
||||
await mkdir(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function convertDocxToPdf(inputPath: string, outputDir: string): Promise<void> {
|
||||
async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn('soffice', [
|
||||
const args: string[] = [];
|
||||
if (profileDir) {
|
||||
// Ensure a per-job profile to isolate concurrent soffice instances
|
||||
// Note: mkdir is async; we prepare the directory before calling this in POST, but safe to include here too
|
||||
// (we avoid awaiting here; POST ensures creation)
|
||||
args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`);
|
||||
}
|
||||
args.push(
|
||||
'--headless',
|
||||
'--nologo',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', outputDir,
|
||||
inputPath
|
||||
]);
|
||||
);
|
||||
const process = spawn('soffice', args);
|
||||
|
||||
process.on('error', (error) => {
|
||||
reject(error);
|
||||
|
|
@ -36,6 +50,29 @@ async function convertDocxToPdf(inputPath: string, outputDir: string): Promise<v
|
|||
});
|
||||
}
|
||||
|
||||
async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100): Promise<string> {
|
||||
const end = Date.now() + timeoutMs;
|
||||
while (Date.now() < end) {
|
||||
const files = await readdir(dir);
|
||||
const pdf = files.find(f => f.toLowerCase().endsWith('.pdf'));
|
||||
if (pdf) {
|
||||
const pdfPath = path.join(dir, pdf);
|
||||
try {
|
||||
const first = await stat(pdfPath);
|
||||
await new Promise((res) => setTimeout(res, intervalMs));
|
||||
const second = await stat(pdfPath);
|
||||
if (second.size > 0 && second.size === first.size) {
|
||||
return pdfPath;
|
||||
}
|
||||
} catch {
|
||||
// If stat fails (transient), continue polling
|
||||
}
|
||||
}
|
||||
await new Promise((res) => setTimeout(res, intervalMs));
|
||||
}
|
||||
throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await ensureTempDir();
|
||||
|
|
@ -59,24 +96,25 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const tempId = randomUUID();
|
||||
const inputPath = path.join(TEMP_DIR, `${tempId}.docx`);
|
||||
const outputPath = path.join(TEMP_DIR, `${tempId}.pdf`);
|
||||
const jobDir = path.join(TEMP_DIR, tempId);
|
||||
await mkdir(jobDir, { recursive: true });
|
||||
const profileDir = path.join(jobDir, 'lo-profile');
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
const inputPath = path.join(jobDir, 'input.docx');
|
||||
|
||||
// Write the uploaded file
|
||||
await writeFile(inputPath, buffer);
|
||||
|
||||
try {
|
||||
// Convert the file
|
||||
await convertDocxToPdf(inputPath, TEMP_DIR);
|
||||
await convertDocxToPdf(inputPath, jobDir, profileDir);
|
||||
|
||||
// Return the PDF file
|
||||
const pdfContent = await readFile(outputPath);
|
||||
|
||||
const pdfPath = await waitForPdfReady(jobDir);
|
||||
const pdfContent = await readFile(pdfPath);
|
||||
|
||||
// Clean up temp files
|
||||
await Promise.all([
|
||||
unlink(inputPath),
|
||||
unlink(outputPath)
|
||||
]).catch(console.error);
|
||||
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
|
||||
|
||||
return new NextResponse(pdfContent, {
|
||||
headers: {
|
||||
|
|
@ -86,11 +124,7 @@ export async function POST(req: NextRequest) {
|
|||
});
|
||||
} catch (error) {
|
||||
// Clean up temp files on error
|
||||
await Promise.all([
|
||||
unlink(inputPath),
|
||||
unlink(outputPath)
|
||||
]).catch(console.error);
|
||||
|
||||
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export function ConfirmDialog({
|
|||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
role={undefined}
|
||||
className="relative z-50"
|
||||
onClose={onClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
@ -60,7 +61,7 @@ export function ConfirmDialog({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogPanel role='dialog' className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function HTMLViewer({ className = '' }: HTMLViewerProps) {
|
|||
return (
|
||||
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className={`min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
||||
<div className={`html-container min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
||||
{isTxtFile ? (
|
||||
currDocData
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -70,11 +70,15 @@ export function DocumentFolder({
|
|||
<div className="flex items-center">
|
||||
<h3 className="text-sm px-1 font-semibold leading-tight">{folder.name}</h3>
|
||||
<Button
|
||||
onClick={() => onToggleCollapse(folder.id)}
|
||||
className="ml-0.5 transform transition-transform duration-200 ease-in-out hover:scale-[1.08] hover:text-accent"
|
||||
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||
type="button"
|
||||
onClick={() => onToggleCollapse(folder.id)}
|
||||
className="ml-0.5 p-1 inline-flex items-center justify-center transform transition-transform duration-200 ease-in-out hover:scale-[1.08] hover:text-accent"
|
||||
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-controls={`folder-panel-${folder.id}`}
|
||||
title={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||
>
|
||||
<ChevronIcon className={`transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
|
||||
<ChevronIcon className={`w-4 h-4 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -98,7 +102,7 @@ export function DocumentFolder({
|
|||
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
||||
>
|
||||
<div className="space-y-1 origin-top">
|
||||
<div id={`folder-panel-${folder.id}`} className="space-y-1 origin-top">
|
||||
{sortedDocuments.map(doc => (
|
||||
<DocumentListItem
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
|
|
|
|||
88
tests/accessibility.spec.ts
Normal file
88
tests/accessibility.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
setupTest,
|
||||
uploadFiles,
|
||||
ensureDocumentsListed,
|
||||
uploadAndDisplay,
|
||||
expectProcessingTransition,
|
||||
} from './helpers';
|
||||
|
||||
test.describe('Accessibility smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupTest(page);
|
||||
});
|
||||
|
||||
test('dropzone input and hint text are accessible', async ({ page }) => {
|
||||
// Input is present and visible
|
||||
await expect(page.locator('input[type="file"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Hint text present (supports compact or default variants)
|
||||
await expect(
|
||||
page.getByText(/Drop your file\(s\) here|Drop files or click|Drop your file\(s\) here, or click to select/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('document links have roles and accessible names', async ({ page }) => {
|
||||
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
||||
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
||||
|
||||
await expect(page.getByRole('link', { name: /sample\.pdf/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /sample\.epub/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /sample\.txt/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('ConfirmDialog exposes role=dialog with title and actions', async ({ page }) => {
|
||||
await uploadFiles(page, 'sample.pdf');
|
||||
await ensureDocumentsListed(page, ['sample.pdf']);
|
||||
|
||||
// Open the confirm dialog by clicking the row delete button
|
||||
await page.getByRole('button', { name: 'Delete document' }).first().click();
|
||||
|
||||
// Title and dialog role visible
|
||||
const heading = page.getByRole('heading', { name: 'Delete Document' });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
const dialog = heading.locator('xpath=ancestor::*[@role="dialog"][1]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Has a destructive action (Delete)
|
||||
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
|
||||
// Close with Escape to avoid deleting test data
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TTS controls expose aria labels and are keyboard focusable', async ({ page }) => {
|
||||
await uploadAndDisplay(page, 'sample.pdf');
|
||||
|
||||
// TTS bar present
|
||||
const ttsbar = page.locator('[data-app-ttsbar]');
|
||||
await expect(ttsbar).toBeVisible();
|
||||
|
||||
// Verify control labels
|
||||
const backBtn = page.getByRole('button', { name: 'Skip backward' });
|
||||
const playBtn = page.getByRole('button', { name: 'Play' });
|
||||
const fwdBtn = page.getByRole('button', { name: 'Skip forward' });
|
||||
|
||||
await expect(backBtn).toBeVisible();
|
||||
await expect(playBtn).toBeVisible();
|
||||
await expect(fwdBtn).toBeVisible();
|
||||
|
||||
// Keyboard focus checks
|
||||
await page.focus('button[aria-label="Skip backward"]');
|
||||
await expect(backBtn).toBeFocused();
|
||||
|
||||
await page.focus('button[aria-label="Play"]');
|
||||
await expect(playBtn).toBeFocused();
|
||||
|
||||
await page.focus('button[aria-label="Skip forward"]');
|
||||
await expect(fwdBtn).toBeFocused();
|
||||
|
||||
// Toggle play and verify aria-label swap to Pause, then back to Play
|
||||
await playBtn.click();
|
||||
await expectProcessingTransition(page);
|
||||
await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Pause' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
20
tests/api.spec.ts
Normal file
20
tests/api.spec.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('API health checks', () => {
|
||||
test('GET /api/tts/voices returns 200 and a non-empty voices array', async ({ request }) => {
|
||||
const res = await request.get('/api/tts/voices');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const json = await res.json();
|
||||
expect(Array.isArray(json.voices)).toBeTruthy();
|
||||
expect(json.voices.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('GET /api/audio/convert/chapters returns 200 with exists flag and chapters array', async ({ request }) => {
|
||||
const bookId = `healthcheck-${Date.now()}`;
|
||||
const res = await request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const json = await res.json();
|
||||
expect(json).toHaveProperty('exists');
|
||||
expect(Array.isArray(json.chapters)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
48
tests/delete.spec.ts
Normal file
48
tests/delete.spec.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName, deleteAllLocalDocuments, ensureDocumentsListed } from './helpers';
|
||||
|
||||
test.describe('Document deletion flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupTest(page);
|
||||
});
|
||||
|
||||
test('deletes a document and updates list', async ({ page }) => {
|
||||
// Upload two documents
|
||||
await uploadFile(page, 'sample.pdf');
|
||||
await uploadFile(page, 'sample.txt');
|
||||
|
||||
// Verify both appear
|
||||
await expectDocumentListed(page, 'sample.pdf');
|
||||
await expectDocumentListed(page, 'sample.txt');
|
||||
|
||||
// Delete the TXT document via row action
|
||||
await deleteDocumentByName(page, 'sample.txt');
|
||||
|
||||
// Assert the TXT document is removed, PDF remains
|
||||
await expectNoDocumentLink(page, 'sample.txt');
|
||||
await expectDocumentListed(page, 'sample.pdf');
|
||||
|
||||
// Optional: summary exists (best-effort)
|
||||
const summary = page.locator('[data-doc-summary]');
|
||||
await expect(summary).toBeVisible();
|
||||
});
|
||||
|
||||
test('deletes all local documents from Settings modal', async ({ page }) => {
|
||||
// Upload multiple docs (PDF + EPUB)
|
||||
await uploadFile(page, 'sample.pdf');
|
||||
await uploadFile(page, 'sample.epub');
|
||||
|
||||
// Verify both appear
|
||||
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']);
|
||||
|
||||
// Delete all local documents via Settings
|
||||
await deleteAllLocalDocuments(page);
|
||||
|
||||
// Assert both documents are removed
|
||||
await expectNoDocumentLink(page, 'sample.pdf');
|
||||
await expectNoDocumentLink(page, 'sample.epub');
|
||||
|
||||
// Uploader should be visible when no docs remain
|
||||
await expect(page.locator('input[type=file]')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
20
tests/files/sample.md
Normal file
20
tests/files/sample.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Sample Markdown
|
||||
|
||||
This is a test document with basic Markdown elements.
|
||||
|
||||
## Section One
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
|
||||
Visit [OpenAI](https://www.openai.com) for more information.
|
||||
|
||||
### Subsection
|
||||
|
||||
Bold text: **strong**; Italic text: _emphasis_.
|
||||
|
||||
Code:
|
||||
|
||||
```js
|
||||
console.log('hello markdown');
|
||||
```
|
||||
Binary file not shown.
1
tests/files/unsupported.xyz
Normal file
1
tests/files/unsupported.xyz
Normal file
|
|
@ -0,0 +1 @@
|
|||
This is an unsupported file type used for upload rejection tests.
|
||||
96
tests/folders.spec.ts
Normal file
96
tests/folders.spec.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { setupTest, uploadFiles, ensureDocumentsListed, deleteAllLocalDocuments } from './helpers';
|
||||
|
||||
test.describe('Document folders and hint persistence', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupTest(page);
|
||||
});
|
||||
|
||||
// Utility to get the draggable row for a given filename (by link)
|
||||
const rowFor = (page: any, fileName: string) => {
|
||||
const link = page.getByRole('link', { name: new RegExp(fileName, 'i') }).first();
|
||||
// The draggable attribute lives on the row container ancestor
|
||||
return link.locator('xpath=ancestor::*[@draggable="true"][1]');
|
||||
};
|
||||
|
||||
test('Folder creation via drag-and-drop with persistence', async ({ page }) => {
|
||||
// Upload three docs
|
||||
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
||||
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
||||
|
||||
// Drag PDF onto EPUB to create a folder
|
||||
const pdfRow = rowFor(page, 'sample.pdf');
|
||||
const epubRow = rowFor(page, 'sample.epub');
|
||||
await pdfRow.dragTo(epubRow);
|
||||
|
||||
// Folder name dialog appears
|
||||
await expect(page.getByRole('heading', { name: 'Create New Folder' })).toBeVisible();
|
||||
const nameInput = page.getByPlaceholder('Enter folder name');
|
||||
await nameInput.fill('My Folder');
|
||||
await nameInput.press('Enter');
|
||||
|
||||
// Folder shows with both docs
|
||||
const folderHeading = page.getByRole('heading', { name: 'My Folder' });
|
||||
await expect(folderHeading).toBeVisible();
|
||||
|
||||
// Scope checks inside the folder container
|
||||
const folderContainer = folderHeading.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]');
|
||||
await expect(folderContainer.getByRole('link', { name: /sample\.pdf/i })).toBeVisible();
|
||||
await expect(folderContainer.getByRole('link', { name: /sample\.epub/i })).toBeVisible();
|
||||
|
||||
// Drag third doc (TXT) into folder
|
||||
const txtRow = rowFor(page, 'sample.txt');
|
||||
await txtRow.dragTo(folderContainer);
|
||||
await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toBeVisible();
|
||||
|
||||
// Collapse folder and verify items are hidden
|
||||
const collapseBtn = folderContainer.getByRole('button', { name: 'Collapse folder' });
|
||||
await collapseBtn.scrollIntoViewIfNeeded();
|
||||
await expect(collapseBtn).toBeVisible();
|
||||
await collapseBtn.click();
|
||||
await expect(folderContainer.getByRole('button', { name: 'Expand folder' })).toBeVisible();
|
||||
await expect(folderContainer.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0);
|
||||
await expect(folderContainer.getByRole('link', { name: /sample\.epub/i })).toHaveCount(0);
|
||||
await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toHaveCount(0);
|
||||
|
||||
// Reload and verify persisted folder with collapsed state and documents
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const folderHeadingAfter = page.getByRole('heading', { name: 'My Folder' });
|
||||
await expect(folderHeadingAfter).toBeVisible();
|
||||
const folderContainerAfter = folderHeadingAfter.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]');
|
||||
|
||||
// Still collapsed after reload
|
||||
await expect(folderContainerAfter.getByRole('button', { name: 'Expand folder' })).toBeVisible();
|
||||
await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0);
|
||||
await expect(folderContainerAfter.getByRole('link', { name: /sample\.epub/i })).toHaveCount(0);
|
||||
await expect(folderContainerAfter.getByRole('link', { name: /sample\.txt/i })).toHaveCount(0);
|
||||
|
||||
// Expand and verify all three documents visible
|
||||
const expandBtn = folderContainerAfter.getByRole('button', { name: 'Expand folder' });
|
||||
await expandBtn.scrollIntoViewIfNeeded();
|
||||
await expect(expandBtn).toBeVisible();
|
||||
await expandBtn.click();
|
||||
await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toBeVisible();
|
||||
await expect(folderContainerAfter.getByRole('link', { name: /sample\.epub/i })).toBeVisible();
|
||||
await expect(folderContainerAfter.getByRole('link', { name: /sample\.txt/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Dismiss “Drag files to make folders” hint persists after reload', async ({ page }) => {
|
||||
// Need at least 2 docs for the hint to appear
|
||||
await uploadFiles(page, 'sample.pdf', 'sample.epub');
|
||||
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']);
|
||||
|
||||
const hint = page.getByText('Drag files on top of each other to make folders');
|
||||
await expect(hint).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dismiss hint' }).click();
|
||||
|
||||
// Hint should disappear
|
||||
await expect(hint).toHaveCount(0);
|
||||
|
||||
// Reload and ensure it remains dismissed
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText('Drag files on top of each other to make folders')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
288
tests/helpers.ts
288
tests/helpers.ts
|
|
@ -1,6 +1,10 @@
|
|||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
const DIR = './tests/files/';
|
||||
// Small util to safely use filenames inside regex patterns
|
||||
function escapeRegExp(input: string) {
|
||||
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a sample epub or pdf
|
||||
|
|
@ -14,19 +18,26 @@ export async function uploadFile(page: Page, filePath: string) {
|
|||
* Upload and display a document
|
||||
*/
|
||||
export async function uploadAndDisplay(page: Page, fileName: string) {
|
||||
// Upload the file
|
||||
await uploadFile(page, fileName);
|
||||
|
||||
// Wait for link with document-link class
|
||||
const size = fileName.endsWith('.pdf') ? '0.02 MB' : '0.33 MB';
|
||||
await page.getByRole('link', { name: `${fileName} ${size}` }).click();
|
||||
|
||||
// Wait for the document to load
|
||||
if (fileName.endsWith('.pdf')) {
|
||||
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
|
||||
const lower = fileName.toLowerCase();
|
||||
|
||||
if (lower.endsWith('.docx')) {
|
||||
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible();
|
||||
const pdfName = fileName.replace(/\.docx$/i, '.pdf');
|
||||
await page.getByRole('link', { name: new RegExp(escapeRegExp(pdfName), 'i') }).click();
|
||||
await page.waitForSelector('.react-pdf__Document', { timeout: 15000 });
|
||||
return;
|
||||
}
|
||||
else if (fileName.endsWith('.epub')) {
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).click();
|
||||
|
||||
if (lower.endsWith('.pdf')) {
|
||||
await page.waitForSelector('.react-pdf__Document', { timeout: 10000 });
|
||||
} else if (lower.endsWith('.epub')) {
|
||||
await page.waitForSelector('.epub-container', { timeout: 10000 });
|
||||
} else if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
||||
await page.waitForSelector('.html-container', { timeout: 10000 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +76,16 @@ export async function playTTSAndWaitForASecond(page: Page, fileName: string) {
|
|||
// play for 1s
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
/**
|
||||
* Pause TTS playback and verify paused state
|
||||
*/
|
||||
export async function pauseTTSAndVerify(page: Page) {
|
||||
// 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Common test setup function
|
||||
|
|
@ -80,4 +101,253 @@ export async function setupTest(page: Page) {
|
|||
// Click the "done" button to dismiss the welcome message
|
||||
await page.getByRole('tab', { name: '🔑 API' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
|
||||
|
||||
// Assert a document link containing the given filename appears in the list
|
||||
export async function expectDocumentListed(page: Page, fileName: string) {
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
// Assert a document link containing the given filename does NOT exist
|
||||
export async function expectNoDocumentLink(page: Page, fileName: string) {
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
// Upload multiple files in sequence
|
||||
export async function uploadFiles(page: Page, ...fileNames: string[]) {
|
||||
for (const name of fileNames) {
|
||||
await uploadFile(page, name);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a set of documents are visible in the list
|
||||
export async function ensureDocumentsListed(page: Page, fileNames: string[]) {
|
||||
for (const name of fileNames) {
|
||||
await expectDocumentListed(page, name);
|
||||
}
|
||||
}
|
||||
|
||||
// Click the document link row by filename
|
||||
export async function clickDocumentLink(page: Page, fileName: string) {
|
||||
await page
|
||||
.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
// Expect correct URL and viewer to be visible for a given file by extension
|
||||
export async function expectViewerForFile(page: Page, fileName: string) {
|
||||
const lower = fileName.toLowerCase();
|
||||
if (lower.endsWith('.pdf') || lower.endsWith('.docx')) {
|
||||
// DOCX converts to PDF, so viewer expectations are PDF
|
||||
await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/);
|
||||
await expect(page.locator('.react-pdf__Document')).toBeVisible({ timeout: 15000 });
|
||||
return;
|
||||
}
|
||||
if (lower.endsWith('.epub')) {
|
||||
await expect(page).toHaveURL(/\/epub\/[A-Za-z0-9._%-]+$/);
|
||||
await expect(page.locator('.epub-container')).toBeVisible({ timeout: 15000 });
|
||||
return;
|
||||
}
|
||||
if (lower.endsWith('.txt') || lower.endsWith('.md')) {
|
||||
await expect(page).toHaveURL(/\/html\/[A-Za-z0-9._%-]+$/);
|
||||
await expect(page.locator('.html-container')).toBeVisible({ timeout: 15000 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a single document by filename via row action and confirm dialog
|
||||
export async function deleteDocumentByName(page: Page, fileName: string) {
|
||||
const link = page.getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }).first();
|
||||
await link.locator('xpath=..').getByRole('button', { name: 'Delete document' }).click();
|
||||
|
||||
const heading = page.getByRole('heading', { name: 'Delete Document' });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');
|
||||
await confirmBtn.click();
|
||||
}
|
||||
|
||||
// Open Settings modal and navigate to Documents tab
|
||||
export async function openSettingsDocumentsTab(page: Page) {
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('tab', { name: '📄 Documents' }).click();
|
||||
}
|
||||
|
||||
// Delete all local documents through Settings and close dialogs
|
||||
export async function deleteAllLocalDocuments(page: Page) {
|
||||
await openSettingsDocumentsTab(page);
|
||||
await page.getByRole('button', { name: 'Delete local docs' }).click();
|
||||
|
||||
const heading = page.getByRole('heading', { name: 'Delete Local Documents' });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');
|
||||
await confirmBtn.click();
|
||||
|
||||
// Close any remaining modal layers
|
||||
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
|
||||
export async function openVoicesMenu(page: Page) {
|
||||
const ttsbar = page.locator('[data-app-ttsbar]');
|
||||
await expect(ttsbar).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// If the listbox/options already exist, assume it's open and return (idempotent)
|
||||
const alreadyOpen = await page.locator('[role="listbox"], [role="option"]').count();
|
||||
if (alreadyOpen > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer a stable selector using accessible name if present, otherwise fall back to a
|
||||
// button whose label matches any known default voice (including "af_" prefixed ones),
|
||||
// and finally the last button heuristic.
|
||||
const candidateByName = ttsbar.getByRole('button', { name: /Voices|(af_)?(alloy|ash|coral|echo|fable|onyx|nova|sage|shimmer)/i });
|
||||
|
||||
const hasNamed = await candidateByName.count();
|
||||
const voicesButton = hasNamed > 0 ? candidateByName.first() : ttsbar.getByRole('button').last();
|
||||
|
||||
await expect(voicesButton).toBeVisible();
|
||||
await voicesButton.click();
|
||||
|
||||
// Wait for the options panel to appear; tolerate different render strategies by
|
||||
// waiting for either the listbox container or at least one option.
|
||||
await Promise.race([
|
||||
page.waitForSelector('[role="listbox"]', { timeout: 10000 }),
|
||||
page.waitForSelector('[role="option"]', { timeout: 10000 }),
|
||||
]);
|
||||
}
|
||||
|
||||
// Select a voice from the Voices dropdown and assert processing -> playing
|
||||
export async function selectVoiceAndAssertPlayback(page: Page, voiceName: string | RegExp) {
|
||||
// Ensure the menu is open without toggling it closed if already open
|
||||
const optionCount = await page.locator('[role="option"]').count();
|
||||
if (optionCount === 0) {
|
||||
await openVoicesMenu(page);
|
||||
}
|
||||
|
||||
await page.getByRole('option', { name: voiceName }).first().click();
|
||||
await expectProcessingTransition(page);
|
||||
}
|
||||
|
||||
// Assert skip buttons disabled during processing, then enabled, and playbackState=playing
|
||||
export async function expectProcessingTransition(page: Page) {
|
||||
// Try to detect a brief processing phase where skip buttons are disabled,
|
||||
// but tolerate cases where processing completes too quickly to observe.
|
||||
const disabledForward = page.locator('button[aria-label="Skip forward"][disabled]');
|
||||
const disabledBackward = page.locator('button[aria-label="Skip backward"][disabled]');
|
||||
try {
|
||||
await Promise.all([
|
||||
expect(disabledForward).toBeVisible({ timeout: 3000 }),
|
||||
expect(disabledBackward).toBeVisible({ timeout: 3000 }),
|
||||
]);
|
||||
} catch {
|
||||
// Processing may have completed before we observed disabled state; cause warning but continue
|
||||
}
|
||||
|
||||
// Wait for the TTS to stop processing and buttons to be enabled
|
||||
await Promise.all([
|
||||
page.waitForSelector('button[aria-label="Skip forward"]:not([disabled])', { timeout: 45000 }),
|
||||
page.waitForSelector('button[aria-label="Skip backward"]:not([disabled])', { timeout: 45000 }),
|
||||
]);
|
||||
|
||||
// Ensure media session is 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
|
||||
export async function expectMediaState(page: Page, state: 'playing' | 'paused') {
|
||||
await page.waitForFunction((s) => navigator.mediaSession?.playbackState === s, state, { timeout: 20000 });
|
||||
}
|
||||
|
||||
// Use Navigator to go to a specific page number (PDF)
|
||||
export async function navigateToPdfPageViaNavigator(page: Page, 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');
|
||||
}
|
||||
|
||||
// Count currently rendered react-pdf Page components
|
||||
export async function countRenderedPdfPages(page: Page): Promise<number> {
|
||||
return await page.locator('.react-pdf__Page').count();
|
||||
}
|
||||
|
||||
// Count currently rendered text layers (active page(s))
|
||||
export async function countRenderedTextLayers(page: Page): Promise<number> {
|
||||
return await page.locator('.react-pdf__Page__textContent').count();
|
||||
}
|
||||
|
||||
// Force viewport resize to trigger resize hooks (e.g., EPUB)
|
||||
export async function triggerViewportResize(page: Page, width: number, height: number) {
|
||||
await page.setViewportSize({ width, height });
|
||||
}
|
||||
107
tests/navigation.spec.ts
Normal file
107
tests/navigation.spec.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
setupTest,
|
||||
uploadFiles,
|
||||
ensureDocumentsListed,
|
||||
clickDocumentLink,
|
||||
expectViewerForFile,
|
||||
uploadAndDisplay,
|
||||
navigateToPdfPageViaNavigator,
|
||||
countRenderedPdfPages,
|
||||
triggerViewportResize,
|
||||
playTTSAndWaitForASecond,
|
||||
expectProcessingTransition,
|
||||
} from './helpers';
|
||||
|
||||
test.describe('Document link navigation by type', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupTest(page);
|
||||
});
|
||||
|
||||
test('navigates to /pdf, /epub, /html and renders correct viewers', async ({ page }) => {
|
||||
// Upload documents
|
||||
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
||||
|
||||
// Ensure links exist
|
||||
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
||||
|
||||
// PDF
|
||||
await clickDocumentLink(page, 'sample.pdf');
|
||||
await expectViewerForFile(page, 'sample.pdf');
|
||||
await page.goBack();
|
||||
|
||||
// EPUB
|
||||
await clickDocumentLink(page, 'sample.epub');
|
||||
await expectViewerForFile(page, 'sample.epub');
|
||||
await page.goBack();
|
||||
|
||||
// TXT (HTML viewer)
|
||||
await clickDocumentLink(page, 'sample.txt');
|
||||
await expectViewerForFile(page, 'sample.txt');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PDF view modes and Navigator', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupTest(page);
|
||||
});
|
||||
|
||||
test('switches Single/Dual/Scroll modes and uses Navigator to change page', async ({ page }) => {
|
||||
// Open PDF viewer
|
||||
await uploadAndDisplay(page, 'sample.pdf');
|
||||
await expect(page.locator('.react-pdf__Document')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Open document settings (page-level settings)
|
||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||
|
||||
// The mode Listbox initially shows "Single Page" by default; switch to "Two Pages"
|
||||
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click();
|
||||
await page.getByRole('option', { name: 'Two Pages' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Expect dual-page rendering (sample.pdf has >= 2 pages)
|
||||
const dualCount = await countRenderedPdfPages(page);
|
||||
expect(dualCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Switch to Continuous Scroll
|
||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||
await page.getByRole('button', { name: /Single Page|Two Pages|Continuous Scroll/i }).click();
|
||||
await page.getByRole('option', { name: 'Continuous Scroll' }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Expect continuous scroll renders at least as many pages as dual mode
|
||||
const scrollCount = await countRenderedPdfPages(page);
|
||||
expect(scrollCount).toBeGreaterThanOrEqual(dualCount);
|
||||
|
||||
// Use Navigator to go to a page (clamps to last if too large)
|
||||
await navigateToPdfPageViaNavigator(page, 999);
|
||||
// Navigator jump is configured to pause; ensure Play is visible then resume
|
||||
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole('button', { name: 'Play' }).click();
|
||||
await expectProcessingTransition(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('EPUB resize pauses TTS', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupTest(page);
|
||||
});
|
||||
|
||||
test('resizing viewport pauses playback and play resumes after', async ({ page }) => {
|
||||
// Start playback on EPUB
|
||||
await playTTSAndWaitForASecond(page, 'sample.epub');
|
||||
|
||||
// Trigger a significant viewport resize to fire useEPUBResize
|
||||
await triggerViewportResize(page, 1200, 900);
|
||||
await page.waitForTimeout(750); // allow resize flag to propagate
|
||||
await triggerViewportResize(page, 900, 700);
|
||||
await page.waitForTimeout(750);
|
||||
|
||||
// After resize, playback should have paused (Play button visible)
|
||||
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Resume playback and ensure processing -> playing
|
||||
await page.getByRole('button', { name: 'Play' }).click();
|
||||
await expectProcessingTransition(page);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { setupTest, playTTSAndWaitForASecond } from './helpers';
|
||||
import {
|
||||
setupTest,
|
||||
playTTSAndWaitForASecond,
|
||||
pauseTTSAndVerify,
|
||||
openVoicesMenu,
|
||||
selectVoiceAndAssertPlayback,
|
||||
changeNativeSpeedAndAssert,
|
||||
expectMediaState,
|
||||
} from './helpers';
|
||||
|
||||
test.describe('Play/Pause Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
|
@ -12,21 +20,54 @@ test.describe('Play/Pause Tests', () => {
|
|||
// Play TTS for the PDF document
|
||||
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
||||
|
||||
// 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 });
|
||||
// Pause TTS and verify paused state
|
||||
await pauseTTSAndVerify(page);
|
||||
});
|
||||
|
||||
test('plays and pauses TTS for an EPUB document', async ({ page }) => {
|
||||
// Play TTS for the EPUB document
|
||||
await playTTSAndWaitForASecond(page, 'sample.epub');
|
||||
|
||||
// Click pause to stop playback
|
||||
await page.getByRole('button', { name: 'Pause' }).click();
|
||||
// Pause TTS and verify paused state
|
||||
await pauseTTSAndVerify(page);
|
||||
});
|
||||
|
||||
test('plays and pauses TTS for an DOCX document', async ({ page }) => {
|
||||
// Play TTS for the DOCX document
|
||||
await playTTSAndWaitForASecond(page, 'sample.docx');
|
||||
|
||||
// Check for play button to be visible
|
||||
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
|
||||
// Pause TTS and verify paused state
|
||||
await pauseTTSAndVerify(page);
|
||||
});
|
||||
|
||||
test('plays and pauses TTS for a TXT document', async ({ page }) => {
|
||||
// Play TTS for the TXT document
|
||||
await playTTSAndWaitForASecond(page, 'sample.txt');
|
||||
|
||||
// Pause TTS and verify paused state
|
||||
await pauseTTSAndVerify(page);
|
||||
});
|
||||
|
||||
test('loads voices and switches voice with processing state then resumes play', async ({ page }) => {
|
||||
// Start playback
|
||||
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
||||
|
||||
// Ensure basic TTS controls are present
|
||||
await expect(page.getByRole('button', { name: 'Skip backward' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Skip forward' })).toBeVisible();
|
||||
|
||||
// Open voices list and assert options render
|
||||
await openVoicesMenu(page);
|
||||
const options = page.getByRole('option');
|
||||
expect(await options.count()).toBeGreaterThan(0);
|
||||
|
||||
// Switch to the first available voice and assert processing -> playing
|
||||
await selectVoiceAndAssertPlayback(page, /.*/);
|
||||
});
|
||||
|
||||
test('changing TTS native speed toggles processing and returns to playing', async ({ page }) => {
|
||||
await playTTSAndWaitForASecond(page, 'sample.pdf');
|
||||
await changeNativeSpeedAndAssert(page, 1.5);
|
||||
await expectMediaState(page, 'playing');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { uploadFile, uploadAndDisplay, setupTest } from './helpers';
|
||||
import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers';
|
||||
|
||||
test.describe('Document Upload Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
|
@ -8,12 +8,17 @@ test.describe('Document Upload Tests', () => {
|
|||
|
||||
test('uploads a PDF document', async ({ page }) => {
|
||||
await uploadFile(page, 'sample.pdf');
|
||||
await expect(page.getByText('sample.pdf')).toBeVisible({ timeout: 10000 });
|
||||
await expectDocumentListed(page, 'sample.pdf');
|
||||
});
|
||||
|
||||
test('uploads an EPUB document', async ({ page }) => {
|
||||
await uploadFile(page, 'sample.epub');
|
||||
await expect(page.getByText('sample.epub')).toBeVisible({ timeout: 10000 });
|
||||
await expectDocumentListed(page, 'sample.epub');
|
||||
});
|
||||
|
||||
test('uploads a TXT document', async ({ page }) => {
|
||||
await uploadFile(page, 'sample.txt');
|
||||
await expectDocumentListed(page, 'sample.txt');
|
||||
});
|
||||
|
||||
test('uploads and converts a DOCX document', async ({ page }) => {
|
||||
|
|
@ -24,20 +29,101 @@ test.describe('Document Upload Tests', () => {
|
|||
// Should see the converting message
|
||||
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible();
|
||||
// After conversion, should see the PDF with the same name
|
||||
await expect(page.getByText('sample.pdf')).toBeVisible({ timeout: 15000 });
|
||||
await expectDocumentListed(page, 'sample.pdf');
|
||||
});
|
||||
|
||||
test('displays a PDF document', async ({ page }) => {
|
||||
await uploadAndDisplay(page, 'sample.pdf');
|
||||
await expect(page.locator('.react-pdf__Document')).toBeVisible();
|
||||
await expectViewerForFile(page, 'sample.pdf');
|
||||
// Additional content checks specific to the sample PDF
|
||||
await expect(page.locator('.react-pdf__Page')).toBeVisible();
|
||||
await expect(page.getByText('Sample PDF')).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays an EPUB document', async ({ page }) => {
|
||||
await uploadAndDisplay(page, 'sample.epub');
|
||||
await expect(page.locator('.epub-container')).toBeVisible({ timeout: 10000 });
|
||||
await expectViewerForFile(page, 'sample.epub');
|
||||
// Keep navigation button assertions
|
||||
await expect(page.getByRole('button', { name: '‹' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '›' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays a DOCX document as PDF after conversion', async ({ page }) => {
|
||||
await uploadAndDisplay(page, 'sample.docx');
|
||||
await expectViewerForFile(page, 'sample.docx'); // DOCX converts to PDF
|
||||
// Keep specific content checks
|
||||
await expect(page.locator('.react-pdf__Page')).toBeVisible();
|
||||
await expect(page.getByText('Demonstration of DOCX')).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays a TXT document', async ({ page }) => {
|
||||
await uploadAndDisplay(page, 'sample.txt');
|
||||
await expectViewerForFile(page, 'sample.txt');
|
||||
await expect(page.getByText('Lorem ipsum dolor sit amet')).toBeVisible();
|
||||
});
|
||||
|
||||
test('uploads PDF/EPUB/TXT and opens correct viewer for each', async ({ page }) => {
|
||||
// Upload multiple files
|
||||
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
||||
|
||||
// Verify all uploaded files appear in the list
|
||||
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
||||
|
||||
// PDF navigation and viewer
|
||||
await clickDocumentLink(page, 'sample.pdf');
|
||||
await expectViewerForFile(page, 'sample.pdf');
|
||||
await page.goBack();
|
||||
await expect(page.getByText('Local Documents')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// EPUB navigation and viewer
|
||||
await clickDocumentLink(page, 'sample.epub');
|
||||
await expectViewerForFile(page, 'sample.epub');
|
||||
await page.goBack();
|
||||
await expect(page.getByText('Local Documents')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// TXT navigation and viewer (HTML viewer)
|
||||
await clickDocumentLink(page, 'sample.txt');
|
||||
await expectViewerForFile(page, 'sample.txt');
|
||||
});
|
||||
|
||||
test('renders Markdown via ReactMarkdown and keeps TXT preformatted', async ({ page }) => {
|
||||
// Upload MD and TXT
|
||||
await uploadFiles(page, 'sample.md', 'sample.txt');
|
||||
await ensureDocumentsListed(page, ['sample.md', 'sample.txt']);
|
||||
|
||||
// Open MD and verify rendered markdown
|
||||
await clickDocumentLink(page, 'sample.md');
|
||||
await expectViewerForFile(page, 'sample.md');
|
||||
const mdContainer = page.locator('.html-container');
|
||||
await expect(mdContainer).toBeVisible();
|
||||
// Should have prose classes (not monospace)
|
||||
await expect(mdContainer).toHaveClass(/prose/);
|
||||
await expect(mdContainer).not.toHaveClass(/font-mono/);
|
||||
// Heading and link rendered
|
||||
await expect(page.getByRole('heading', { name: 'Sample Markdown' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'OpenAI' })).toBeVisible();
|
||||
|
||||
// Go back and open TXT, verify monospace preformatted
|
||||
await page.goBack();
|
||||
await clickDocumentLink(page, 'sample.txt');
|
||||
await expectViewerForFile(page, 'sample.txt');
|
||||
const txtContainer = page.locator('.html-container');
|
||||
await expect(txtContainer).toHaveClass(/font-mono/);
|
||||
});
|
||||
|
||||
test('unsupported file type is ignored and no new document is created', async ({ page }) => {
|
||||
// Capture initial list of names
|
||||
const before = await page.locator('.document-link').count();
|
||||
|
||||
// Try to upload unsupported file
|
||||
await uploadFile(page, 'unsupported.xyz');
|
||||
// Give the UI a moment just in case
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Assert no new document entries created
|
||||
const after = await page.locator('.document-link').count();
|
||||
expect(after).toBe(before);
|
||||
// Also ensure no link with that filename exists
|
||||
await expect(page.getByRole('link', { name: /unsupported\.xyz/i })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue