From 21870ed576f2a8d7a320eebea0b01bcd6f0d6ce5 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Wed, 12 Nov 2025 16:21:11 -0700 Subject: [PATCH] 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 --- .github/workflows/playwright.yml | 2 +- src/app/api/documents/docx-to-pdf/route.ts | 72 ++++-- src/components/ConfirmDialog.tsx | 3 +- src/components/HTMLViewer.tsx | 2 +- src/components/doclist/DocumentFolder.tsx | 14 +- tests/accessibility.spec.ts | 88 +++++++ tests/api.spec.ts | 20 ++ tests/delete.spec.ts | 48 ++++ tests/files/sample.md | 20 ++ tests/files/sample.pdf | Bin 18810 -> 74469 bytes tests/files/unsupported.xyz | 1 + tests/folders.spec.ts | 96 +++++++ tests/helpers.ts | 288 ++++++++++++++++++++- tests/navigation.spec.ts | 107 ++++++++ tests/play.spec.ts | 61 ++++- tests/upload.spec.ts | 98 ++++++- 16 files changed, 868 insertions(+), 52 deletions(-) create mode 100644 tests/accessibility.spec.ts create mode 100644 tests/api.spec.ts create mode 100644 tests/delete.spec.ts create mode 100644 tests/files/sample.md create mode 100644 tests/files/unsupported.xyz create mode 100644 tests/folders.spec.ts create mode 100644 tests/navigation.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 6c2f3e8..233ae88 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,7 +1,7 @@ name: Playwright Tests on: push: - branches: [ main, master ] + branches: [ main, master, version1.0.0 ] pull_request: branches: [ main, master ] jobs: diff --git a/src/app/api/documents/docx-to-pdf/route.ts b/src/app/api/documents/docx-to-pdf/route.ts index df3a382..de61165 100644 --- a/src/app/api/documents/docx-to-pdf/route.ts +++ b/src/app/api/documents/docx-to-pdf/route.ts @@ -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 { +async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise { 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 { + 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) { diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index b8c5090..1672165 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -33,6 +33,7 @@ export function ConfirmDialog({ - +
-
+
{isTxtFile ? ( currDocData ) : ( diff --git a/src/components/doclist/DocumentFolder.tsx b/src/components/doclist/DocumentFolder.tsx index aad8c22..5784811 100644 --- a/src/components/doclist/DocumentFolder.tsx +++ b/src/components/doclist/DocumentFolder.tsx @@ -70,11 +70,15 @@ export function DocumentFolder({

{folder.name}