From 2e5502a6449630d01dd0a972038c2a61dce5d680 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 22:35:48 +0200 Subject: [PATCH 1/3] feat(pdf): convert invoices to PDF via WordCraft, fall back to LibreOffice A stale soffice wrapper left behind by an uninstalled LibreOffice cask passed the `which soffice` check and then hung forever, even for --dry-run. When conversion did fail, the CLI still wrote history with a PDF path that did not exist and bumped the invoice number. - New src/lib/pdf-converter.ts: prefer WordCraft, driven over its local --control channel (file.open, file.exportPdf, file.close); use LibreOffice only if `soffice --version` answers within 15s. soffice calls time out with SIGKILL; the WordCraft child is always stopped and the control key never appears in errors. - The CLI, generateDocuments and the API routes share this path. - A failed PDF now exits 1 before history and numbering are touched. --- .../editors/BatchInvoiceGenerator.tsx | 2 +- gui/src/components/editors/InvoiceForm.tsx | 2 +- src/api/routes/invoices.ts | 4 +- src/api/routes/system.ts | 9 +- src/invoice.ts | 12 +- src/lib/document-generator.ts | 58 +---- src/lib/index.ts | 9 +- src/lib/pdf-converter.ts | 218 ++++++++++++++++++ tests/lib-document-generator.test.ts | 88 ++++--- tests/lib-pdf-converter-wordcraft.test.ts | 110 +++++++++ tests/lib-pdf-converter.test.ts | 76 ++++++ 11 files changed, 470 insertions(+), 118 deletions(-) create mode 100644 src/lib/pdf-converter.ts create mode 100644 tests/lib-pdf-converter-wordcraft.test.ts create mode 100644 tests/lib-pdf-converter.test.ts diff --git a/gui/src/components/editors/BatchInvoiceGenerator.tsx b/gui/src/components/editors/BatchInvoiceGenerator.tsx index 5e298da..1774b25 100644 --- a/gui/src/components/editors/BatchInvoiceGenerator.tsx +++ b/gui/src/components/editors/BatchInvoiceGenerator.tsx @@ -299,7 +299,7 @@ export function BatchInvoiceGenerator({ persona, onBack }: BatchInvoiceGenerator diff --git a/gui/src/components/editors/InvoiceForm.tsx b/gui/src/components/editors/InvoiceForm.tsx index 623e332..049430f 100644 --- a/gui/src/components/editors/InvoiceForm.tsx +++ b/gui/src/components/editors/InvoiceForm.tsx @@ -256,7 +256,7 @@ export function InvoiceForm({ persona, clientName, onBack }: InvoiceFormProps) { diff --git a/src/api/routes/invoices.ts b/src/api/routes/invoices.ts index 6bd2d80..9c17469 100644 --- a/src/api/routes/invoices.ts +++ b/src/api/routes/invoices.ts @@ -12,7 +12,7 @@ import { applyCurrencyConversion, generateDocx, generateDocuments, - isLibreOfficeAvailable, + detectPdfConverter, addHistoryEntry, parseMonthArg, getDefaultBillingMonth, @@ -136,7 +136,7 @@ export function generateInvoice(ctx: ServerContext) { currency: string; }; - if (generatePdf && isLibreOfficeAvailable()) { + if (generatePdf && detectPdfConverter() !== null) { const docs = await generateDocuments(context, clientInfo.directory, { template: effectiveTemplate, personaDir diff --git a/src/api/routes/system.ts b/src/api/routes/system.ts index 66764bc..63b0f89 100644 --- a/src/api/routes/system.ts +++ b/src/api/routes/system.ts @@ -1,9 +1,9 @@ /** - * System routes - health check, LibreOffice status, and countries + * System routes - health check, PDF converter status, and countries */ import type { ApiRequest, ApiResponse, ServerContext } from '../types.js'; -import { isLibreOfficeAvailable, getLibreOfficeVersion } from '../../lib/index.js'; +import { detectPdfConverter } from '../../lib/index.js'; import { getAllCountries } from '../../data/countries.js'; export function healthCheck(ctx: ServerContext) { @@ -19,9 +19,8 @@ export function healthCheck(ctx: ServerContext) { export function libreOfficeStatus() { return async (_req: ApiRequest, res: ApiResponse): Promise => { - const available = isLibreOfficeAvailable(); - const version = available ? getLibreOfficeVersion() : null; - res.json({ available, version }); + const version = detectPdfConverter(); + res.json({ available: version !== null, version }); }; } diff --git a/src/invoice.ts b/src/invoice.ts index cd6a190..564b47b 100644 --- a/src/invoice.ts +++ b/src/invoice.ts @@ -1,13 +1,13 @@ #!/usr/bin/env node import * as fs from 'fs'; import * as path from 'path'; -import { execSync } from 'child_process'; import { fileURLToPath } from 'url'; import { Provider, Client, Translations, InvoiceContext, ResolvedLineItem, EInvoiceFormat, CountryCode } from './types.js'; import { formatDate, formatCurrency, getServiceDescription, calculateDueDate, sanitizeFilenamePart } from './utils.js'; import { generateInvoiceFromTemplate } from './lib/template-generator.js'; import { applyCurrencyConversion } from './lib/invoice-builder.js'; import { createEmail } from './email.js'; +import { convertDocxToPdf } from './lib/pdf-converter.js'; import { validateProvider, validateClient } from './schemas/index.js'; import { saveToHistory } from './history.js'; import { canGenerateEInvoice, getDefaultFormat, validateForEInvoice, generateEInvoice, saveEInvoice } from './einvoice/index.js'; @@ -375,17 +375,17 @@ async function runInvoice() { } // Generate DOCX and PDF - await generateInvoiceFromTemplate(ctx, effectiveTemplateName, cwd).then(buffer => { + await generateInvoiceFromTemplate(ctx, effectiveTemplateName, cwd).then(async buffer => { fs.writeFileSync(docxPath, buffer); console.log(`DOCX created: ${docxPath}`); - // Convert to PDF using LibreOffice + // Convert to PDF; abort before history/numbering so a failure leaves them untouched try { - execSync(`soffice --headless --convert-to pdf --outdir "${outputDir}" "${docxPath}"`, { stdio: 'inherit' }); + await convertDocxToPdf(docxPath); console.log(`PDF created: ${pdfPath}`); } catch (err) { - console.error('PDF conversion failed. Make sure LibreOffice is installed.'); - console.error('Install with: brew install --cask libreoffice'); + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); } // Save to history diff --git a/src/lib/document-generator.ts b/src/lib/document-generator.ts index 42b19aa..c93ad7f 100644 --- a/src/lib/document-generator.ts +++ b/src/lib/document-generator.ts @@ -4,13 +4,13 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import { execSync } from 'child_process'; import { InvoiceContext } from '../types.js'; import { generateInvoiceFromTemplate, getBuiltInTemplates, } from './template-generator.js'; import { sanitizeFilenamePart } from '../utils.js'; +import { convertDocxToPdf } from './pdf-converter.js'; export interface GeneratedDocument { docxPath: string; @@ -64,34 +64,10 @@ export async function generateDocx( } /** - * Convert DOCX to PDF using LibreOffice + * Convert DOCX to PDF using WordCraft or LibreOffice */ -export function convertToPdf(docxPath: string): string { - const outputDir = path.dirname(docxPath); - const baseName = path.basename(docxPath, '.docx'); - const pdfPath = path.join(outputDir, `${baseName}.pdf`); - - try { - execSync( - `soffice --headless --convert-to pdf --outdir "${outputDir}" "${docxPath}"`, - { stdio: 'pipe' } - ); - - if (!fs.existsSync(pdfPath)) { - throw new Error('PDF file was not created'); - } - - return pdfPath; - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - throw new Error( - `PDF conversion failed: ${message}\n` + - 'Make sure LibreOffice is installed:\n' + - ' macOS: brew install --cask libreoffice\n' + - ' Linux: sudo apt install libreoffice\n' + - ' Windows: https://www.libreoffice.org/download/' - ); - } +export async function convertToPdf(docxPath: string): Promise { + return convertDocxToPdf(docxPath); } /** @@ -111,7 +87,7 @@ export async function generateDocuments( const { docxPath, docxBuffer } = await generateDocx(ctx, outputDir, opts); // Convert to PDF - const pdfPath = convertToPdf(docxPath); + const pdfPath = await convertToPdf(docxPath); return { docxPath, @@ -120,30 +96,6 @@ export async function generateDocuments( }; } -/** - * Check if LibreOffice is available - */ -export function isLibreOfficeAvailable(): boolean { - try { - execSync('which soffice', { stdio: 'pipe' }); - return true; - } catch { - return false; - } -} - -/** - * Get LibreOffice version if available - */ -export function getLibreOfficeVersion(): string | null { - try { - const output = execSync('soffice --version', { encoding: 'utf8' }); - return output.trim(); - } catch { - return null; - } -} - /** * Generate output paths for invoice */ diff --git a/src/lib/index.ts b/src/lib/index.ts index 8dc2f33..d6533bf 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -51,13 +51,18 @@ export { generateDocx, convertToPdf, generateDocuments, - isLibreOfficeAvailable, - getLibreOfficeVersion, generateOutputPaths, type GeneratedDocument, type GeneratedPDF } from './document-generator.js'; +// PDF Conversion +export { + detectPdfConverter, + convertDocxToPdf, + type PdfConverter +} from './pdf-converter.js'; + // History Management export { getHistoryPath, diff --git a/src/lib/pdf-converter.ts b/src/lib/pdf-converter.ts new file mode 100644 index 0000000..f98a014 --- /dev/null +++ b/src/lib/pdf-converter.ts @@ -0,0 +1,218 @@ +/** + * DOCX to PDF conversion via WordCraft (preferred) or LibreOffice + */ +import * as fs from 'fs'; +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync, spawn, type ChildProcess } from 'child_process'; + +export type PdfConverter = 'wordcraft' | 'libreoffice'; + +const WORDCRAFT_BINARY = '/Applications/WordCraft.app/Contents/MacOS/WordCraft'; +const STARTUP_TIMEOUT_MS = 30_000; +const REQUEST_TIMEOUT_MS = 60_000; +const SOFFICE_PROBE_TIMEOUT_MS = 15_000; +const SOFFICE_CONVERT_TIMEOUT_MS = 120_000; +const KILL_GRACE_MS = 5_000; + +export interface WordCraftControlOptions { + port: number; + keyPath: string; + docxPath: string; + pdfPath: string; +} + +/** + * Detect which PDF converter is usable. The soffice probe is time-limited + * because a stale symlink to an uninstalled LibreOffice hangs forever. + */ +export function detectPdfConverter(): PdfConverter | null { + if (fs.existsSync(WORDCRAFT_BINARY)) return 'wordcraft'; + try { + execFileSync('soffice', ['--version'], { stdio: 'pipe', timeout: SOFFICE_PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' }); + return 'libreoffice'; + } catch { + return null; + } +} + +/** + * Drive a running WordCraft control channel: open the DOCX, export the PDF, close. + * Requests are newline-delimited JSON over one connection, one reply line each. + */ +export async function runWordCraftControl(opts: WordCraftControlOptions): Promise { + const key = fs.readFileSync(opts.keyPath, 'utf8').trim(); + const socket = net.createConnection({ host: '127.0.0.1', port: opts.port }); + const lines: string[] = []; + let buffer = ''; + let wake: (() => void) | null = null; + let socketError: Error | null = null; + let closed = false; + + socket.setEncoding('utf8'); + socket.on('data', chunk => { + buffer += chunk; + let idx: number; + while ((idx = buffer.indexOf('\n')) >= 0) { + lines.push(buffer.slice(0, idx)); + buffer = buffer.slice(idx + 1); + } + wake?.(); + }); + socket.on('error', err => { socketError = err; wake?.(); }); + socket.on('close', () => { closed = true; wake?.(); }); + + const nextLine = (method: string): Promise => new Promise((resolve, reject) => { + const timer = setTimeout(() => { + wake = null; + reject(new Error(`WordCraft ${method} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`)); + }, REQUEST_TIMEOUT_MS); + const check = (): void => { + if (lines.length > 0) { + clearTimeout(timer); + wake = null; + resolve(lines.shift() as string); + } else if (socketError || closed) { + clearTimeout(timer); + wake = null; + reject(new Error(`WordCraft ${method} failed: ${socketError?.message ?? 'connection closed'}`)); + } else { + wake = check; + } + }; + check(); + }); + + let id = 0; + const request = async (method: string, params: Record): Promise => { + id++; + socket.write(JSON.stringify({ key, id, method, params }) + '\n'); + const reply = JSON.parse(await nextLine(method)) as { ok?: boolean; error?: string }; + if (reply.ok !== true) { + throw new Error(`WordCraft ${method} failed: ${reply.error ?? 'unknown error'}`); + } + }; + + try { + await request('file.open', { path: opts.docxPath }); + await request('file.exportPdf', { path: opts.pdfPath }); + await request('file.close', {}); + } finally { + socket.destroy(); + } +} + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + }); +} + +function canConnect(port: number): Promise { + return new Promise(resolve => { + const probe = net.createConnection({ host: '127.0.0.1', port }); + probe.once('connect', () => { probe.destroy(); resolve(true); }); + probe.once('error', () => { probe.destroy(); resolve(false); }); + }); +} + +/** + * Wait for a control server started after `spawnedAt`: a key file older than + * that belongs to an earlier run on the same port. + */ +async function waitForWordCraft( + port: number, + keyPath: string, + spawnedAt: number, + childFailure: () => Error | null +): Promise { + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + while (Date.now() < deadline) { + const failure = childFailure(); + if (failure) throw failure; + if (fs.existsSync(keyPath) && fs.statSync(keyPath).mtimeMs >= spawnedAt && await canConnect(port)) return; + await new Promise(r => setTimeout(r, 250)); + } + throw new Error(`WordCraft did not start its control channel within ${STARTUP_TIMEOUT_MS / 1000}s`); +} + +function stopChild(child: ChildProcess, alreadyGone: boolean): Promise { + if (alreadyGone || child.pid === undefined) { + child.kill('SIGTERM'); + return Promise.resolve(); + } + return new Promise(resolve => { + const timer = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + child.kill('SIGTERM'); + }); +} + +async function convertWithWordCraft(docxPath: string, pdfPath: string): Promise { + const port = await findFreePort(); + const keyPath = path.join( + os.homedir(), 'Library', 'Application Support', 'WordCraft', `control-key.local-${port}` + ); + const spawnedAt = Date.now(); + const child = spawn(WORDCRAFT_BINARY, ['--control', String(port)], { stdio: 'ignore' }); + let failure: Error | null = null; + let gone = false; + child.once('error', err => { failure = err; gone = true; }); + child.once('exit', code => { + gone = true; + failure ??= new Error(`WordCraft exited before its control server started (code ${code})`); + }); + try { + await waitForWordCraft(port, keyPath, spawnedAt, () => failure); + await runWordCraftControl({ port, keyPath, docxPath, pdfPath }); + } finally { + await stopChild(child, gone); + } +} + +/** + * Convert a DOCX to a PDF next to it and return the PDF path + */ +export async function convertDocxToPdf(docxPath: string): Promise { + if (!fs.existsSync(docxPath)) { + throw new Error(`DOCX file not found: ${docxPath}`); + } + const converter = detectPdfConverter(); + if (!converter) { + throw new Error( + 'No PDF converter available. Install WordCraft (/Applications/WordCraft.app) or LibreOffice:\n' + + ' macOS: brew install --cask libreoffice\n' + + ' Linux: sudo apt install libreoffice\n' + + ' Windows: https://www.libreoffice.org/download/' + ); + } + + const outputDir = path.dirname(docxPath); + const pdfPath = path.join(outputDir, `${path.basename(docxPath, '.docx')}.pdf`); + + try { + if (converter === 'wordcraft') { + await convertWithWordCraft(docxPath, pdfPath); + } else { + execFileSync( + 'soffice', + ['--headless', '--convert-to', 'pdf', '--outdir', outputDir, docxPath], + { stdio: 'pipe', timeout: SOFFICE_CONVERT_TIMEOUT_MS, killSignal: 'SIGKILL' } + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`PDF conversion failed (${converter}): ${message}`); + } + + if (!fs.existsSync(pdfPath)) { + throw new Error(`PDF conversion failed (${converter}): PDF file was not created`); + } + return pdfPath; +} diff --git a/tests/lib-document-generator.test.ts b/tests/lib-document-generator.test.ts index 3f7d28d..e8342b8 100644 --- a/tests/lib-document-generator.test.ts +++ b/tests/lib-document-generator.test.ts @@ -1,4 +1,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { convertDocxToPdf, detectPdfConverter } from '../src/lib/pdf-converter.js'; + +// Never launch the real WordCraft or soffice from tests +vi.mock('../src/lib/pdf-converter.js', async importOriginal => ({ + ...(await importOriginal()), + convertDocxToPdf: vi.fn() +})); import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -6,9 +13,7 @@ import { generateDocx, generateDocuments, convertToPdf, - generateOutputPaths, - isLibreOfficeAvailable, - getLibreOfficeVersion + generateOutputPaths } from '../src/lib/document-generator.js'; import type { InvoiceContext } from '../src/types.js'; @@ -193,69 +198,56 @@ describe('document-generator', () => { }); }); - describe('isLibreOfficeAvailable', () => { - it('should return a boolean', () => { - const result = isLibreOfficeAvailable(); - expect(typeof result).toBe('boolean'); - }); - }); - - describe('getLibreOfficeVersion', () => { - it('should return string or null', () => { - const result = getLibreOfficeVersion(); - expect(result === null || typeof result === 'string').toBe(true); + describe('detectPdfConverter', () => { + it('should return a known converter or null', () => { + expect([null, 'wordcraft', 'libreoffice']).toContain(detectPdfConverter()); }); }); describe('convertToPdf', () => { - it('should throw error when LibreOffice not available or conversion fails', async () => { - const ctx = createValidContext(); - const { docxPath } = await generateDocx(ctx, tempDir); - - // This may throw if LibreOffice isn't installed, which is expected behavior - try { - const pdfPath = convertToPdf(docxPath); - // If LibreOffice is installed, check that PDF was created - expect(fs.existsSync(pdfPath)).toBe(true); - } catch (error) { - // Expected if LibreOffice not installed - expect((error as Error).message).toContain('PDF conversion failed'); - } + it('should delegate to convertDocxToPdf and return its path', async () => { + vi.mocked(convertDocxToPdf).mockResolvedValueOnce('/out/a.pdf'); + + await expect(convertToPdf('/out/a.docx')).resolves.toBe('/out/a.pdf'); + expect(convertDocxToPdf).toHaveBeenCalledWith('/out/a.docx'); }); - it('should throw for non-existent docx file', () => { - expect(() => convertToPdf('/nonexistent/file.docx')).toThrow(); + it('should reject when conversion fails', async () => { + vi.mocked(convertDocxToPdf).mockRejectedValueOnce(new Error('PDF conversion failed (x): boom')); + + await expect(convertToPdf('/nonexistent/file.docx')).rejects.toThrow('PDF conversion failed'); }); }); describe('generateDocuments', () => { + beforeEach(() => { + vi.mocked(convertDocxToPdf).mockImplementation(async docxPath => { + const pdfPath = docxPath.replace(/\.docx$/, '.pdf'); + fs.writeFileSync(pdfPath, '%PDF'); + return pdfPath; + }); + }); + it('should generate both DOCX and PDF', async () => { const ctx = createValidContext(); + const result = await generateDocuments(ctx, tempDir); - try { - const result = await generateDocuments(ctx, tempDir); - - expect(result.docxPath).toContain('.docx'); - expect(result.pdfPath).toContain('.pdf'); - expect(result.docxBuffer).toBeInstanceOf(Buffer); - expect(fs.existsSync(result.docxPath)).toBe(true); - expect(fs.existsSync(result.pdfPath)).toBe(true); - } catch (error) { - // Expected if LibreOffice not installed - expect((error as Error).message).toContain('PDF conversion failed'); - } + expect(result.docxPath).toContain('.docx'); + expect(result.pdfPath).toContain('.pdf'); + expect(result.docxBuffer).toBeInstanceOf(Buffer); + expect(fs.existsSync(result.docxPath)).toBe(true); + expect(fs.existsSync(result.pdfPath)).toBe(true); }); it('should use specified template for document generation', async () => { const ctx = createValidContext(); + const result = await generateDocuments(ctx, tempDir, 'detailed'); + expect(result.docxBuffer.length).toBeGreaterThan(0); + }); - try { - const result = await generateDocuments(ctx, tempDir, 'detailed'); - expect(result.docxBuffer.length).toBeGreaterThan(0); - } catch (error) { - // Expected if LibreOffice not installed - expect((error as Error).message).toContain('PDF conversion failed'); - } + it('should propagate conversion failures', async () => { + vi.mocked(convertDocxToPdf).mockRejectedValueOnce(new Error('PDF conversion failed (x): boom')); + await expect(generateDocuments(createValidContext(), tempDir)).rejects.toThrow('PDF conversion failed'); }); }); diff --git a/tests/lib-pdf-converter-wordcraft.test.ts b/tests/lib-pdf-converter-wordcraft.test.ts new file mode 100644 index 0000000..73e5ce1 --- /dev/null +++ b/tests/lib-pdf-converter-wordcraft.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; + +const state = vi.hoisted(() => ({ home: '', spawn: null as null | ((args: string[]) => unknown) })); + +// The real app must never launch and the real key dir must never be read +vi.mock('child_process', () => ({ + spawn: (_bin: string, args: string[]) => state.spawn!(args), + execFileSync: vi.fn(() => { throw new Error('soffice must not run'); }) +})); +vi.mock('os', async importOriginal => ({ + ...(await importOriginal()), + homedir: () => state.home +})); +vi.mock('fs', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (p: string) => p.endsWith('/WordCraft.app/Contents/MacOS/WordCraft') || actual.existsSync(p) + }; +}); + +import { convertDocxToPdf } from '../src/lib/pdf-converter.js'; + +class FakeChild extends EventEmitter { + pid: number | undefined = 4242; + kill = vi.fn((_sig?: string) => { queueMicrotask(() => this.emit('exit', null, 'SIGTERM')); return true; }); +} + +describe('convertDocxToPdf with WordCraft', () => { + let tempDir: string; + let docx: string; + let servers: net.Server[]; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'invoicr-wc-test-')); + state.home = tempDir; + docx = path.join(tempDir, 'a.docx'); + fs.writeFileSync(docx, 'x'); + servers = []; + }); + + afterEach(() => { + servers.forEach(s => s.close()); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + // Fake app: writes the key file and serves the control port named in the args + function fakeApp(child: FakeChild, exportReply: object) { + return (args: string[]) => { + const port = Number(args[1]); + const dir = path.join(tempDir, 'Library', 'Application Support', 'WordCraft'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `control-key.local-${port}`), 'k\n'); + const server = net.createServer(socket => { + socket.on('data', chunk => { + for (const line of chunk.toString().split('\n').filter(Boolean)) { + const req = JSON.parse(line); + let reply: object = { ok: true }; + if (req.method === 'file.exportPdf') { + reply = exportReply; + if ((reply as { ok: boolean }).ok) fs.writeFileSync(req.params.path, '%PDF'); + } + socket.write(JSON.stringify({ id: req.id, ...reply }) + '\n'); + } + }); + }); + server.listen(port, '127.0.0.1'); + servers.push(server); + return child; + }; + } + + it('rejects with the wordcraft message when spawn emits error', async () => { + const child = new FakeChild(); + child.pid = undefined; + state.spawn = () => { queueMicrotask(() => child.emit('error', new Error('spawn EACCES'))); return child; }; + + await expect(convertDocxToPdf(docx)).rejects.toThrow('PDF conversion failed (wordcraft): spawn EACCES'); + }); + + it('rejects fast when the child exits before the control server is ready', async () => { + const child = new FakeChild(); + state.spawn = () => { queueMicrotask(() => child.emit('exit', 1, null)); return child; }; + + const start = Date.now(); + await expect(convertDocxToPdf(docx)).rejects.toThrow('WordCraft exited before its control server started (code 1)'); + expect(Date.now() - start).toBeLessThan(5000); + }); + + it('converts and kills the child on success', async () => { + const child = new FakeChild(); + state.spawn = fakeApp(child, { ok: true }); + + await expect(convertDocxToPdf(docx)).resolves.toBe(path.join(tempDir, 'a.pdf')); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('kills the child when a control request fails', async () => { + const child = new FakeChild(); + state.spawn = fakeApp(child, { ok: false, error: 'bad' }); + + await expect(convertDocxToPdf(docx)).rejects.toThrow('WordCraft file.exportPdf failed: bad'); + expect(child.kill).toHaveBeenCalled(); + }); +}); diff --git a/tests/lib-pdf-converter.test.ts b/tests/lib-pdf-converter.test.ts new file mode 100644 index 0000000..00e6db7 --- /dev/null +++ b/tests/lib-pdf-converter.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import { runWordCraftControl, convertDocxToPdf } from '../src/lib/pdf-converter.js'; + +const KEY = 'sekrit-key-123'; + +describe('pdf-converter', () => { + let tempDir: string; + let keyPath: string; + let server: net.Server; + let port: number; + let received: unknown[]; + let replyFor: (req: { method: string }) => object; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'invoicr-pdfconv-test-')); + keyPath = path.join(tempDir, 'control-key.local-test'); + fs.writeFileSync(keyPath, `${KEY}\n`); + received = []; + replyFor = () => ({ ok: true }); + server = net.createServer(socket => { + let buf = ''; + socket.on('data', chunk => { + buf += chunk.toString(); + let idx: number; + while ((idx = buf.indexOf('\n')) >= 0) { + const req = JSON.parse(buf.slice(0, idx)); + buf = buf.slice(idx + 1); + received.push(req); + socket.write(JSON.stringify({ id: req.id, ...replyFor(req) }) + '\n'); + } + }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as net.AddressInfo).port; + }); + + afterEach(async () => { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('runWordCraftControl', () => { + it('sends open, exportPdf and close with key, incrementing ids and paths', async () => { + await runWordCraftControl({ port, keyPath, docxPath: '/in/a.docx', pdfPath: '/in/a.pdf' }); + + expect(received).toEqual([ + { key: KEY, id: 1, method: 'file.open', params: { path: '/in/a.docx' } }, + { key: KEY, id: 2, method: 'file.exportPdf', params: { path: '/in/a.pdf' } }, + { key: KEY, id: 3, method: 'file.close', params: {} } + ]); + }); + + it('throws with the method and error when a request fails, without leaking the key', async () => { + replyFor = req => (req.method === 'file.exportPdf' ? { ok: false, error: 'x' } : { ok: true }); + + const err = await runWordCraftControl({ + port, keyPath, docxPath: '/in/a.docx', pdfPath: '/in/a.pdf' + }).catch(e => e as Error); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toBe('WordCraft file.exportPdf failed: x'); + expect((err as Error).message).not.toContain(KEY); + expect(received).toHaveLength(2); + }); + }); + + describe('convertDocxToPdf', () => { + it('rejects for a missing docx without starting any converter', async () => { + await expect(convertDocxToPdf(path.join(tempDir, 'missing.docx'))).rejects.toThrow('DOCX file not found'); + }); + }); +}); From ab0cf65e19a174ceacf52be9c7d10b0fda7928e2 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 22:45:10 +0200 Subject: [PATCH 2/3] fix(pdf): tolerate coarse file mtimes when waiting for the WordCraft key file On Linux the kernel stamps mtimes from a coarse clock that can trail Date.now(), so a fresh key file was rejected as stale and the wait ran to its 30s timeout. --- src/lib/pdf-converter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/pdf-converter.ts b/src/lib/pdf-converter.ts index f98a014..19978f6 100644 --- a/src/lib/pdf-converter.ts +++ b/src/lib/pdf-converter.ts @@ -15,6 +15,8 @@ const REQUEST_TIMEOUT_MS = 60_000; const SOFFICE_PROBE_TIMEOUT_MS = 15_000; const SOFFICE_CONVERT_TIMEOUT_MS = 120_000; const KILL_GRACE_MS = 5_000; +// Linux stamps file mtimes from a coarse clock that can trail Date.now() by several ms +const MTIME_SLACK_MS = 2_000; export interface WordCraftControlOptions { port: number; @@ -159,7 +161,7 @@ async function convertWithWordCraft(docxPath: string, pdfPath: string): Promise< const keyPath = path.join( os.homedir(), 'Library', 'Application Support', 'WordCraft', `control-key.local-${port}` ); - const spawnedAt = Date.now(); + const spawnedAt = Date.now() - MTIME_SLACK_MS; const child = spawn(WORDCRAFT_BINARY, ['--control', String(port)], { stdio: 'ignore' }); let failure: Error | null = null; let gone = false; From 9c43e0f378fb583ec7914f816fe4d2ef8f3ca98c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 19 Oct 2026 22:46:39 +0200 Subject: [PATCH 3/3] fix(pdf): clear stale WordCraft key file before spawn; mock converter detection in tests --- src/lib/pdf-converter.ts | 18 +++++----- tests/lib-document-generator.test.ts | 8 +---- tests/lib-pdf-converter-detect.test.ts | 42 +++++++++++++++++++++++ tests/lib-pdf-converter-wordcraft.test.ts | 35 +++++++++++++++++-- 4 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 tests/lib-pdf-converter-detect.test.ts diff --git a/src/lib/pdf-converter.ts b/src/lib/pdf-converter.ts index 19978f6..2b67082 100644 --- a/src/lib/pdf-converter.ts +++ b/src/lib/pdf-converter.ts @@ -15,8 +15,6 @@ const REQUEST_TIMEOUT_MS = 60_000; const SOFFICE_PROBE_TIMEOUT_MS = 15_000; const SOFFICE_CONVERT_TIMEOUT_MS = 120_000; const KILL_GRACE_MS = 5_000; -// Linux stamps file mtimes from a coarse clock that can trail Date.now() by several ms -const MTIME_SLACK_MS = 2_000; export interface WordCraftControlOptions { port: number; @@ -124,21 +122,16 @@ function canConnect(port: number): Promise { }); } -/** - * Wait for a control server started after `spawnedAt`: a key file older than - * that belongs to an earlier run on the same port. - */ async function waitForWordCraft( port: number, keyPath: string, - spawnedAt: number, childFailure: () => Error | null ): Promise { const deadline = Date.now() + STARTUP_TIMEOUT_MS; while (Date.now() < deadline) { const failure = childFailure(); if (failure) throw failure; - if (fs.existsSync(keyPath) && fs.statSync(keyPath).mtimeMs >= spawnedAt && await canConnect(port)) return; + if (fs.existsSync(keyPath) && await canConnect(port)) return; await new Promise(r => setTimeout(r, 250)); } throw new Error(`WordCraft did not start its control channel within ${STARTUP_TIMEOUT_MS / 1000}s`); @@ -161,7 +154,12 @@ async function convertWithWordCraft(docxPath: string, pdfPath: string): Promise< const keyPath = path.join( os.homedir(), 'Library', 'Application Support', 'WordCraft', `control-key.local-${port}` ); - const spawnedAt = Date.now() - MTIME_SLACK_MS; + // A leftover key from an earlier run on this port must not be read as the new app's key + try { + fs.unlinkSync(keyPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } const child = spawn(WORDCRAFT_BINARY, ['--control', String(port)], { stdio: 'ignore' }); let failure: Error | null = null; let gone = false; @@ -171,7 +169,7 @@ async function convertWithWordCraft(docxPath: string, pdfPath: string): Promise< failure ??= new Error(`WordCraft exited before its control server started (code ${code})`); }); try { - await waitForWordCraft(port, keyPath, spawnedAt, () => failure); + await waitForWordCraft(port, keyPath, () => failure); await runWordCraftControl({ port, keyPath, docxPath, pdfPath }); } finally { await stopChild(child, gone); diff --git a/tests/lib-document-generator.test.ts b/tests/lib-document-generator.test.ts index e8342b8..76fce4a 100644 --- a/tests/lib-document-generator.test.ts +++ b/tests/lib-document-generator.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { convertDocxToPdf, detectPdfConverter } from '../src/lib/pdf-converter.js'; +import { convertDocxToPdf } from '../src/lib/pdf-converter.js'; // Never launch the real WordCraft or soffice from tests vi.mock('../src/lib/pdf-converter.js', async importOriginal => ({ @@ -198,12 +198,6 @@ describe('document-generator', () => { }); }); - describe('detectPdfConverter', () => { - it('should return a known converter or null', () => { - expect([null, 'wordcraft', 'libreoffice']).toContain(detectPdfConverter()); - }); - }); - describe('convertToPdf', () => { it('should delegate to convertDocxToPdf and return its path', async () => { vi.mocked(convertDocxToPdf).mockResolvedValueOnce('/out/a.pdf'); diff --git a/tests/lib-pdf-converter-detect.test.ts b/tests/lib-pdf-converter-detect.test.ts new file mode 100644 index 0000000..349ffcc --- /dev/null +++ b/tests/lib-pdf-converter-detect.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ wordcraft: false, execFileSync: vi.fn() })); + +vi.mock('child_process', () => ({ spawn: vi.fn(), execFileSync: state.execFileSync })); +vi.mock('fs', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (p: string) => + p.endsWith('/WordCraft.app/Contents/MacOS/WordCraft') ? state.wordcraft : actual.existsSync(p) + }; +}); + +import { detectPdfConverter } from '../src/lib/pdf-converter.js'; + +describe('detectPdfConverter', () => { + beforeEach(() => { + state.execFileSync.mockReset(); + }); + + it('returns wordcraft when the binary exists without probing soffice', () => { + state.wordcraft = true; + expect(detectPdfConverter()).toBe('wordcraft'); + expect(state.execFileSync).not.toHaveBeenCalled(); + }); + + it('returns libreoffice when soffice --version succeeds, with a hard kill timeout', () => { + state.wordcraft = false; + state.execFileSync.mockReturnValue(Buffer.from('LibreOffice 7')); + expect(detectPdfConverter()).toBe('libreoffice'); + expect(state.execFileSync).toHaveBeenCalledWith( + 'soffice', ['--version'], expect.objectContaining({ timeout: 15_000, killSignal: 'SIGKILL' }) + ); + }); + + it('returns null when soffice fails or times out', () => { + state.wordcraft = false; + state.execFileSync.mockImplementation(() => { throw new Error('ETIMEDOUT'); }); + expect(detectPdfConverter()).toBeNull(); + }); +}); diff --git a/tests/lib-pdf-converter-wordcraft.test.ts b/tests/lib-pdf-converter-wordcraft.test.ts index 73e5ce1..bf5998c 100644 --- a/tests/lib-pdf-converter-wordcraft.test.ts +++ b/tests/lib-pdf-converter-wordcraft.test.ts @@ -5,7 +5,12 @@ import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; -const state = vi.hoisted(() => ({ home: '', spawn: null as null | ((args: string[]) => unknown) })); +const state = vi.hoisted(() => ({ + home: '', + spawn: null as null | ((args: string[]) => unknown), + unlinked: [] as string[], + seedStale: false +})); // The real app must never launch and the real key dir must never be read vi.mock('child_process', () => ({ @@ -20,6 +25,12 @@ vi.mock('fs', async importOriginal => { const actual = await importOriginal(); return { ...actual, + // Seeds a stale key just before the real unlink so the removal is observable + unlinkSync: (p: string) => { + state.unlinked.push(p); + if (state.seedStale) actual.writeFileSync(p, 'stale\n'); + actual.unlinkSync(p); + }, existsSync: (p: string) => p.endsWith('/WordCraft.app/Contents/MacOS/WordCraft') || actual.existsSync(p) }; }); @@ -42,6 +53,8 @@ describe('convertDocxToPdf with WordCraft', () => { docx = path.join(tempDir, 'a.docx'); fs.writeFileSync(docx, 'x'); servers = []; + state.unlinked = []; + state.seedStale = false; }); afterEach(() => { @@ -50,16 +63,19 @@ describe('convertDocxToPdf with WordCraft', () => { }); // Fake app: writes the key file and serves the control port named in the args - function fakeApp(child: FakeChild, exportReply: object) { + function fakeApp(child: FakeChild, exportReply: object, keysSeen: string[] = []) { return (args: string[]) => { const port = Number(args[1]); const dir = path.join(tempDir, 'Library', 'Application Support', 'WordCraft'); + const keyFile = path.join(dir, `control-key.local-${port}`); + expect(fs.existsSync(keyFile)).toBe(false); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, `control-key.local-${port}`), 'k\n'); + fs.writeFileSync(keyFile, 'k\n'); const server = net.createServer(socket => { socket.on('data', chunk => { for (const line of chunk.toString().split('\n').filter(Boolean)) { const req = JSON.parse(line); + keysSeen.push(req.key); let reply: object = { ok: true }; if (req.method === 'file.exportPdf') { reply = exportReply; @@ -75,6 +91,19 @@ describe('convertDocxToPdf with WordCraft', () => { }; } + it('removes a stale key file for the port before spawn and uses the fresh key', async () => { + const child = new FakeChild(); + const keysSeen: string[] = []; + state.seedStale = true; + state.spawn = fakeApp(child, { ok: true }, keysSeen); + + await convertDocxToPdf(docx); + + expect(state.unlinked).toHaveLength(1); + expect(state.unlinked[0]).toMatch(/control-key\.local-\d+$/); + expect(new Set(keysSeen)).toEqual(new Set(['k'])); + }); + it('rejects with the wordcraft message when spawn emits error', async () => { const child = new FakeChild(); child.pid = undefined;