Skip to content

Commit 21870ed

Browse files
committed
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
1 parent 4266588 commit 21870ed

File tree

16 files changed

+867
-51
lines changed

16 files changed

+867
-51
lines changed

.github/workflows/playwright.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Playwright Tests
22
on:
33
push:
4-
branches: [ main, master ]
4+
branches: [ main, master, version1.0.0 ]
55
pull_request:
66
branches: [ main, master ]
77
jobs:

src/app/api/documents/docx-to-pdf/route.ts

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,40 @@
11
import { NextRequest, NextResponse } from 'next/server';
2-
import { writeFile, mkdir, unlink, readFile } from 'fs/promises';
2+
import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises';
33
import { spawn } from 'child_process';
44
import path from 'path';
55
import { existsSync } from 'fs';
66
import { randomUUID } from 'crypto';
7+
import { pathToFileURL } from 'url';
78

8-
const TEMP_DIR = path.join(process.cwd(), 'temp');
9+
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
10+
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
911

1012
async function ensureTempDir() {
13+
if (!existsSync(DOCSTORE_DIR)) {
14+
await mkdir(DOCSTORE_DIR, { recursive: true });
15+
}
1116
if (!existsSync(TEMP_DIR)) {
1217
await mkdir(TEMP_DIR, { recursive: true });
1318
}
1419
}
1520

16-
async function convertDocxToPdf(inputPath: string, outputDir: string): Promise<void> {
21+
async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise<void> {
1722
return new Promise((resolve, reject) => {
18-
const process = spawn('soffice', [
23+
const args: string[] = [];
24+
if (profileDir) {
25+
// Ensure a per-job profile to isolate concurrent soffice instances
26+
// Note: mkdir is async; we prepare the directory before calling this in POST, but safe to include here too
27+
// (we avoid awaiting here; POST ensures creation)
28+
args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`);
29+
}
30+
args.push(
1931
'--headless',
32+
'--nologo',
2033
'--convert-to', 'pdf',
2134
'--outdir', outputDir,
2235
inputPath
23-
]);
36+
);
37+
const process = spawn('soffice', args);
2438

2539
process.on('error', (error) => {
2640
reject(error);
@@ -36,6 +50,29 @@ async function convertDocxToPdf(inputPath: string, outputDir: string): Promise<v
3650
});
3751
}
3852

53+
async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100): Promise<string> {
54+
const end = Date.now() + timeoutMs;
55+
while (Date.now() < end) {
56+
const files = await readdir(dir);
57+
const pdf = files.find(f => f.toLowerCase().endsWith('.pdf'));
58+
if (pdf) {
59+
const pdfPath = path.join(dir, pdf);
60+
try {
61+
const first = await stat(pdfPath);
62+
await new Promise((res) => setTimeout(res, intervalMs));
63+
const second = await stat(pdfPath);
64+
if (second.size > 0 && second.size === first.size) {
65+
return pdfPath;
66+
}
67+
} catch {
68+
// If stat fails (transient), continue polling
69+
}
70+
}
71+
await new Promise((res) => setTimeout(res, intervalMs));
72+
}
73+
throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`);
74+
}
75+
3976
export async function POST(req: NextRequest) {
4077
try {
4178
await ensureTempDir();
@@ -59,24 +96,25 @@ export async function POST(req: NextRequest) {
5996

6097
const buffer = Buffer.from(await file.arrayBuffer());
6198
const tempId = randomUUID();
62-
const inputPath = path.join(TEMP_DIR, `${tempId}.docx`);
63-
const outputPath = path.join(TEMP_DIR, `${tempId}.pdf`);
99+
const jobDir = path.join(TEMP_DIR, tempId);
100+
await mkdir(jobDir, { recursive: true });
101+
const profileDir = path.join(jobDir, 'lo-profile');
102+
await mkdir(profileDir, { recursive: true });
103+
const inputPath = path.join(jobDir, 'input.docx');
64104

65105
// Write the uploaded file
66106
await writeFile(inputPath, buffer);
67107

68108
try {
69109
// Convert the file
70-
await convertDocxToPdf(inputPath, TEMP_DIR);
110+
await convertDocxToPdf(inputPath, jobDir, profileDir);
71111

72112
// Return the PDF file
73-
const pdfContent = await readFile(outputPath);
74-
113+
const pdfPath = await waitForPdfReady(jobDir);
114+
const pdfContent = await readFile(pdfPath);
115+
75116
// Clean up temp files
76-
await Promise.all([
77-
unlink(inputPath),
78-
unlink(outputPath)
79-
]).catch(console.error);
117+
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
80118

81119
return new NextResponse(pdfContent, {
82120
headers: {
@@ -86,11 +124,7 @@ export async function POST(req: NextRequest) {
86124
});
87125
} catch (error) {
88126
// Clean up temp files on error
89-
await Promise.all([
90-
unlink(inputPath),
91-
unlink(outputPath)
92-
]).catch(console.error);
93-
127+
await rm(jobDir, { recursive: true, force: true }).catch(console.error);
94128
throw error;
95129
}
96130
} catch (error) {

src/components/ConfirmDialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export function ConfirmDialog({
3333
<Transition appear show={isOpen} as={Fragment}>
3434
<Dialog
3535
as="div"
36+
role={undefined}
3637
className="relative z-50"
3738
onClose={onClose}
3839
onKeyDown={handleKeyDown}
@@ -60,7 +61,7 @@ export function ConfirmDialog({
6061
leaveFrom="opacity-100 scale-100"
6162
leaveTo="opacity-0 scale-95"
6263
>
63-
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
64+
<DialogPanel role='dialog' className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
6465
<DialogTitle
6566
as="h3"
6667
className="text-lg font-semibold leading-6 text-foreground"

src/components/HTMLViewer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function HTMLViewer({ className = '' }: HTMLViewerProps) {
2424
return (
2525
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
2626
<div className="flex-1 overflow-auto">
27-
<div className={`min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
27+
<div className={`html-container min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
2828
{isTxtFile ? (
2929
currDocData
3030
) : (

src/components/doclist/DocumentFolder.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,15 @@ export function DocumentFolder({
7070
<div className="flex items-center">
7171
<h3 className="text-sm px-1 font-semibold leading-tight">{folder.name}</h3>
7272
<Button
73-
onClick={() => onToggleCollapse(folder.id)}
74-
className="ml-0.5 transform transition-transform duration-200 ease-in-out hover:scale-[1.08] hover:text-accent"
75-
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
73+
type="button"
74+
onClick={() => onToggleCollapse(folder.id)}
75+
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"
76+
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
77+
aria-expanded={!isCollapsed}
78+
aria-controls={`folder-panel-${folder.id}`}
79+
title={isCollapsed ? "Expand folder" : "Collapse folder"}
7680
>
77-
<ChevronIcon className={`transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
81+
<ChevronIcon className={`w-4 h-4 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
7882
</Button>
7983
</div>
8084
<Button
@@ -98,7 +102,7 @@ export function DocumentFolder({
98102
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
99103
leaveTo="transform scale-y-0 opacity-0 max-h-0"
100104
>
101-
<div className="space-y-1 origin-top">
105+
<div id={`folder-panel-${folder.id}`} className="space-y-1 origin-top">
102106
{sortedDocuments.map(doc => (
103107
<DocumentListItem
104108
key={`${doc.type}-${doc.id}`}

tests/accessibility.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { test, expect } from '@playwright/test';
2+
import {
3+
setupTest,
4+
uploadFiles,
5+
ensureDocumentsListed,
6+
uploadAndDisplay,
7+
expectProcessingTransition,
8+
} from './helpers';
9+
10+
test.describe('Accessibility smoke', () => {
11+
test.beforeEach(async ({ page }) => {
12+
await setupTest(page);
13+
});
14+
15+
test('dropzone input and hint text are accessible', async ({ page }) => {
16+
// Input is present and visible
17+
await expect(page.locator('input[type="file"]')).toBeVisible({ timeout: 10000 });
18+
19+
// Hint text present (supports compact or default variants)
20+
await expect(
21+
page.getByText(/Drop your file\(s\) here|Drop files or click|Drop your file\(s\) here, or click to select/i)
22+
).toBeVisible();
23+
});
24+
25+
test('document links have roles and accessible names', async ({ page }) => {
26+
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
27+
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
28+
29+
await expect(page.getByRole('link', { name: /sample\.pdf/i })).toBeVisible();
30+
await expect(page.getByRole('link', { name: /sample\.epub/i })).toBeVisible();
31+
await expect(page.getByRole('link', { name: /sample\.txt/i })).toBeVisible();
32+
});
33+
34+
test('ConfirmDialog exposes role=dialog with title and actions', async ({ page }) => {
35+
await uploadFiles(page, 'sample.pdf');
36+
await ensureDocumentsListed(page, ['sample.pdf']);
37+
38+
// Open the confirm dialog by clicking the row delete button
39+
await page.getByRole('button', { name: 'Delete document' }).first().click();
40+
41+
// Title and dialog role visible
42+
const heading = page.getByRole('heading', { name: 'Delete Document' });
43+
await expect(heading).toBeVisible({ timeout: 10000 });
44+
const dialog = heading.locator('xpath=ancestor::*[@role="dialog"][1]');
45+
await expect(dialog).toBeVisible();
46+
47+
// Has a destructive action (Delete)
48+
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
49+
50+
// Close with Escape to avoid deleting test data
51+
await page.keyboard.press('Escape');
52+
});
53+
54+
test('TTS controls expose aria labels and are keyboard focusable', async ({ page }) => {
55+
await uploadAndDisplay(page, 'sample.pdf');
56+
57+
// TTS bar present
58+
const ttsbar = page.locator('[data-app-ttsbar]');
59+
await expect(ttsbar).toBeVisible();
60+
61+
// Verify control labels
62+
const backBtn = page.getByRole('button', { name: 'Skip backward' });
63+
const playBtn = page.getByRole('button', { name: 'Play' });
64+
const fwdBtn = page.getByRole('button', { name: 'Skip forward' });
65+
66+
await expect(backBtn).toBeVisible();
67+
await expect(playBtn).toBeVisible();
68+
await expect(fwdBtn).toBeVisible();
69+
70+
// Keyboard focus checks
71+
await page.focus('button[aria-label="Skip backward"]');
72+
await expect(backBtn).toBeFocused();
73+
74+
await page.focus('button[aria-label="Play"]');
75+
await expect(playBtn).toBeFocused();
76+
77+
await page.focus('button[aria-label="Skip forward"]');
78+
await expect(fwdBtn).toBeFocused();
79+
80+
// Toggle play and verify aria-label swap to Pause, then back to Play
81+
await playBtn.click();
82+
await expectProcessingTransition(page);
83+
await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible();
84+
85+
await page.getByRole('button', { name: 'Pause' }).click();
86+
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
87+
});
88+
});

tests/api.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('API health checks', () => {
4+
test('GET /api/tts/voices returns 200 and a non-empty voices array', async ({ request }) => {
5+
const res = await request.get('/api/tts/voices');
6+
expect(res.ok()).toBeTruthy();
7+
const json = await res.json();
8+
expect(Array.isArray(json.voices)).toBeTruthy();
9+
expect(json.voices.length).toBeGreaterThan(0);
10+
});
11+
12+
test('GET /api/audio/convert/chapters returns 200 with exists flag and chapters array', async ({ request }) => {
13+
const bookId = `healthcheck-${Date.now()}`;
14+
const res = await request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
15+
expect(res.ok()).toBeTruthy();
16+
const json = await res.json();
17+
expect(json).toHaveProperty('exists');
18+
expect(Array.isArray(json.chapters)).toBeTruthy();
19+
});
20+
});

tests/delete.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { test, expect } from '@playwright/test';
2+
import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName, deleteAllLocalDocuments, ensureDocumentsListed } from './helpers';
3+
4+
test.describe('Document deletion flow', () => {
5+
test.beforeEach(async ({ page }) => {
6+
await setupTest(page);
7+
});
8+
9+
test('deletes a document and updates list', async ({ page }) => {
10+
// Upload two documents
11+
await uploadFile(page, 'sample.pdf');
12+
await uploadFile(page, 'sample.txt');
13+
14+
// Verify both appear
15+
await expectDocumentListed(page, 'sample.pdf');
16+
await expectDocumentListed(page, 'sample.txt');
17+
18+
// Delete the TXT document via row action
19+
await deleteDocumentByName(page, 'sample.txt');
20+
21+
// Assert the TXT document is removed, PDF remains
22+
await expectNoDocumentLink(page, 'sample.txt');
23+
await expectDocumentListed(page, 'sample.pdf');
24+
25+
// Optional: summary exists (best-effort)
26+
const summary = page.locator('[data-doc-summary]');
27+
await expect(summary).toBeVisible();
28+
});
29+
30+
test('deletes all local documents from Settings modal', async ({ page }) => {
31+
// Upload multiple docs (PDF + EPUB)
32+
await uploadFile(page, 'sample.pdf');
33+
await uploadFile(page, 'sample.epub');
34+
35+
// Verify both appear
36+
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']);
37+
38+
// Delete all local documents via Settings
39+
await deleteAllLocalDocuments(page);
40+
41+
// Assert both documents are removed
42+
await expectNoDocumentLink(page, 'sample.pdf');
43+
await expectNoDocumentLink(page, 'sample.epub');
44+
45+
// Uploader should be visible when no docs remain
46+
await expect(page.locator('input[type=file]')).toBeVisible({ timeout: 10000 });
47+
});
48+
});

tests/files/sample.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Sample Markdown
2+
3+
This is a test document with basic Markdown elements.
4+
5+
## Section One
6+
7+
- Item 1
8+
- Item 2
9+
10+
Visit [OpenAI](https://www.openai.com) for more information.
11+
12+
### Subsection
13+
14+
Bold text: **strong**; Italic text: _emphasis_.
15+
16+
Code:
17+
18+
```js
19+
console.log('hello markdown');
20+
```

tests/files/sample.pdf

54.4 KB
Binary file not shown.

0 commit comments

Comments
 (0)