From 037c002a2df98ac7ef18feb45508f7c03b128700 Mon Sep 17 00:00:00 2001 From: CyberWolf Date: Wed, 4 Nov 2026 10:34:30 +0800 Subject: [PATCH] fix(split): write split pages without cwd-relative outputFolder (#75, #20) path.relative(cwd, taskDir) yields an absolute path across Windows drives, which pdf-to-png-converter then joins onto cwd (D:\...\C:\...). Render to buffers and write to the task dir ourselves; only report 'source file not found' when the source really is missing. Adds win32 cross-drive/UNC tests. Fixes #75 --- docs/SPLITTER_WORKER_DESIGN.md | 2 + docs/TESTING_GUIDE.md | 5 +- .../adapters/split/PDFSplitter.ts | 141 ++++++++------ .../__tests__/PDFSplitter.integration.test.ts | 84 +++++++++ .../split/__tests__/PDFSplitter.test.ts | 135 ++++++++++++-- .../split/__tests__/PDFSplitter.win32.test.ts | 174 ++++++++++++++++++ 6 files changed, 465 insertions(+), 76 deletions(-) create mode 100644 src/core/infrastructure/adapters/split/__tests__/PDFSplitter.integration.test.ts create mode 100644 src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts diff --git a/docs/SPLITTER_WORKER_DESIGN.md b/docs/SPLITTER_WORKER_DESIGN.md index f8045ba..0cd582c 100644 --- a/docs/SPLITTER_WORKER_DESIGN.md +++ b/docs/SPLITTER_WORKER_DESIGN.md @@ -94,6 +94,8 @@ npm install pdf-to-png-converter **基本用法**: +> ⚠️ `outputFolder` 会被库按 `path.join(process.cwd(), outputFolder)` 解析,无法指向 Windows 上其他盘符或 UNC 路径下的目录(见 #75)。实际实现(`PDFSplitter.ts`)不传 `outputFolder`,而是取返回的 `content` 自行写入 `{uploadsDir}/{taskId}/split/`。本文档中传 `outputFolder` 的示例仅用于说明库的 API 或早期设计。 + ```typescript import { pdfToPng } from "pdf-to-png-converter"; diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index e327423..e17f858 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -75,14 +75,17 @@ npm run test:coverage | `src/core/infrastructure/adapters/llm/__tests__/LLMClient.test.ts` | 15 | Base class backward compatibility | | `src/core/infrastructure/adapters/llm/__tests__/LLMClientFactory.test.ts` | 8 | Client factory creation | -#### Splitter Adapters (4 files) +#### Splitter Adapters (7 files) | File | Description | |------|-------------| | `src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts` | PDF splitting functionality | +| `src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts` | PDF output location with Windows path semantics (cross-drive, UNC, same drive) on any host OS | +| `src/core/infrastructure/adapters/split/__tests__/PDFSplitter.integration.test.ts` | PDF splitting with the real converter and file system | | `src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts` | Image splitting functionality | | `src/core/infrastructure/adapters/split/__tests__/SplitterFactory.test.ts` | Splitter factory creation | | `src/core/infrastructure/adapters/split/__tests__/ImagePathUtil.test.ts` | Image path utilities | +| `src/core/infrastructure/adapters/split/__tests__/FileWaitUtil.test.ts` | Waiting for uploaded source files to become available | #### Infrastructure Services (2 files) diff --git a/src/core/infrastructure/adapters/split/PDFSplitter.ts b/src/core/infrastructure/adapters/split/PDFSplitter.ts index 6653039..39f2e79 100644 --- a/src/core/infrastructure/adapters/split/PDFSplitter.ts +++ b/src/core/infrastructure/adapters/split/PDFSplitter.ts @@ -9,6 +9,9 @@ import { ImagePathUtil } from './ImagePathUtil.js'; import { FileWaitUtil } from './FileWaitUtil.js'; import { WORKER_CONFIG } from '../../config/worker.config.js'; +/** A rendered page, held in memory until it is written to the split directory. */ +type RenderedPage = Omit & { content: Buffer }; + /** * PDF splitter implementation using pdf-to-png-converter. * @@ -17,6 +20,8 @@ import { WORKER_CONFIG } from '../../config/worker.config.js'; * - Retry logic for transient errors (3 attempts with exponential backoff) * - Graceful handling of non-retryable errors (password-protected PDFs) * - Sequential page numbering (page-1.png, page-2.png, ...) + * - Output location independent of process.cwd() (pages are written by this class, + * see renderPagesWithRetry) */ export class PDFSplitter implements ISplitter { private readonly uploadsDir: string; @@ -48,6 +53,7 @@ export class PDFSplitter implements ISplitter { // Retry with short delays to handle antivirus scanning or filesystem sync delays await FileWaitUtil.waitForFile(sourcePath, this.uploadsDir, taskId, filename, 'PDFSplitter'); + let renderedPages: RenderedPage[]; try { // Step 1: Get total page count with retry const totalPages = await this.getPDFPageCountWithRetry(sourcePath); @@ -55,22 +61,21 @@ export class PDFSplitter implements ISplitter { // Step 2: Parse page range (defaults to all pages if not specified) const pageNumbers = PageRangeParser.parse(task.page_range, totalPages); - // Step 3: Convert specified pages with retry + // Step 3: Render specified pages with retry // Pass the page_range string to determine if user explicitly specified pages - const pages = await this.convertPagesWithRetry( - sourcePath, - taskId, - pageNumbers, - task.page_range - ); - - return { - pages, - totalPages: pages.length, - }; + renderedPages = await this.renderPagesWithRetry(sourcePath, pageNumbers, task.page_range); } catch (error) { - throw this.wrapError(error, taskId, filename); + throw await this.wrapError(error, sourcePath, filename); } + + // Step 4: Write the images. Kept apart from the steps above so that an output + // failure is never reported as a problem with the source PDF. + const pages = await this.writePagesWithRetry(taskId, renderedPages); + + return { + pages, + totalPages: pages.length, + }; } /** @@ -99,28 +104,19 @@ export class PDFSplitter implements ISplitter { } /** - * Convert specified pages with retry logic. + * Render specified pages to in-memory PNGs with retry logic. */ - private async convertPagesWithRetry( + private async renderPagesWithRetry( pdfPath: string, - taskId: string, pageNumbers: number[], pageRange?: string | null - ): Promise { - const taskDir = ImagePathUtil.getTaskDir(taskId); - - // Ensure task directory exists - await fs.mkdir(taskDir, { recursive: true }); - + ): Promise { return this.withRetry(async () => { - // Convert all specified pages - // IMPORTANT: pdf-to-png-converter expects a RELATIVE path - // It will internally do: path.join(process.cwd(), outputFolder) - // So we must convert absolute path to relative path - const relativeOutputFolder = path.relative(process.cwd(), taskDir); - + // IMPORTANT: do not pass outputFolder to pdf-to-png-converter. It resolves it with + // path.join(process.cwd(), outputFolder), which cannot address a directory on another + // Windows drive or UNC share (#75). We take the PNG buffers and write them ourselves. const options: any = { - outputFolder: relativeOutputFolder, + returnPageContent: true, viewportScale: WORKER_CONFIG.splitter.viewportScale, strictPagesToProcess: false, verbosityLevel: 0, @@ -128,7 +124,8 @@ export class PDFSplitter implements ISplitter { // Only add pagesToProcess if user explicitly specified a page range // If pageRange is empty/null, let pdf-to-png-converter process all pages - if (pageRange && pageRange.trim() !== '') { + const hasPageRange = Boolean(pageRange && pageRange.trim() !== ''); + if (hasPageRange) { options.pagesToProcess = pageNumbers; // Array of page numbers } // Otherwise, don't specify pagesToProcess to convert all pages @@ -139,29 +136,45 @@ export class PDFSplitter implements ISplitter { throw new Error('PDF conversion produced no output'); } - // Rename files to page-{N}.png format and build PageInfo array - const pages: PageInfo[] = []; - - for (let i = 0; i < result.length; i++) { - const pageNum = i + 1; // Sequential numbering - // If page range was specified, use the mapped page numbers; otherwise use sequential numbering - const sourcePageNum = pageRange && pageRange.trim() !== '' ? pageNumbers[i] : pageNum; - const targetPath = ImagePathUtil.getPath(taskId, pageNum); - - // Rename from temporary name to standard format - if (result[i].path !== targetPath) { - await fs.rename(result[i].path, targetPath); + return result.map((output, i) => { + const page = i + 1; // Sequential numbering + if (!output.content) { + throw new Error(`PDF conversion produced no image for page ${page}`); } + return { + page, + // If page range was specified, use the mapped page numbers; otherwise use sequential numbering + pageSource: hasPageRange ? pageNumbers[i] : page, + content: output.content, + }; + }); + }, 'convert PDF pages'); + } - pages.push({ - page: pageNum, - pageSource: sourcePageNum, - imagePath: targetPath, - }); - } + /** + * Write rendered pages to the task split directory as page-{N}.png with retry logic. + */ + private async writePagesWithRetry( + taskId: string, + renderedPages: RenderedPage[] + ): Promise { + const taskDir = ImagePathUtil.getTaskDir(taskId); - return pages; - }, 'convert PDF pages'); + try { + return await this.withRetry(async () => { + await fs.mkdir(taskDir, { recursive: true }); + + const pages: PageInfo[] = []; + for (const { page, pageSource, content } of renderedPages) { + const imagePath = ImagePathUtil.getPath(taskId, page); + await fs.writeFile(imagePath, content); + pages.push({ page, pageSource, imagePath }); + } + return pages; + }, 'write page images'); + } catch (error) { + throw new Error(`Failed to write split images to ${taskDir}: ${(error as Error).message}`); + } } /** @@ -215,10 +228,19 @@ export class PDFSplitter implements ISplitter { } /** - * Wrap errors with friendly, actionable messages. + * Wrap source read/render errors with friendly, actionable messages. */ - private wrapError(error: unknown, _taskId: string, filename: string): Error { + private async wrapError(error: unknown, sourcePath: string, filename: string): Promise { const err = error as Error; + + // Ask the file system instead of matching ENOENT in the message: an ENOENT can + // also come from other files, and the message embeds paths with arbitrary words. + if (await this.isFileMissing(sourcePath)) { + return new Error( + `PDF file not found: ${filename}. The file may have been moved or deleted.` + ); + } + const message = err.message.toLowerCase(); if (message.includes('password') || message.includes('encrypted')) { @@ -233,16 +255,19 @@ export class PDFSplitter implements ISplitter { ); } - if (message.includes('enoent') || message.includes('file not found')) { - return new Error( - `PDF file not found: ${filename}. The file may have been moved or deleted.` - ); - } - // Generic error with context return new Error(`Failed to split PDF ${filename}: ${err.message}`); } + private async isFileMissing(filePath: string): Promise { + try { + await fs.access(filePath); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; + } + } + /** * Clean up temporary files for a task. * diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.integration.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.integration.test.ts new file mode 100644 index 0000000..3237028 --- /dev/null +++ b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.integration.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; +import { PDFDocument } from 'pdf-lib'; +import { PDFSplitter } from '../PDFSplitter.js'; +import { ImagePathUtil } from '../ImagePathUtil.js'; + +/** + * Runs against the real pdf-to-png-converter, pdf-lib and file system, because + * the unit tests mock the library whose path handling caused issue #75. + */ +describe('PDFSplitter with the real converter', () => { + const TASK_ID = 'task-1'; + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const PAGE_SIZE_PT = 100; + + let workDir: string; + let uploadsDir: string; + let cwd: string; + let splitDir: string; + let splitter: PDFSplitter; + + beforeEach(async () => { + workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-splitter-')); + uploadsDir = path.join(workDir, 'userData', 'files'); + cwd = path.join(workDir, 'install-dir'); + splitDir = path.join(uploadsDir, TASK_ID, 'split'); + await fs.mkdir(path.join(uploadsDir, TASK_ID), { recursive: true }); + await fs.mkdir(cwd); + + const pdf = await PDFDocument.create(); + pdf.addPage([PAGE_SIZE_PT, PAGE_SIZE_PT]); + pdf.addPage([PAGE_SIZE_PT, PAGE_SIZE_PT]); + await fs.writeFile(path.join(uploadsDir, TASK_ID, 'doc.pdf'), await pdf.save()); + + vi.spyOn(process, 'cwd').mockReturnValue(cwd); + ImagePathUtil.init(uploadsDir); + splitter = new PDFSplitter(uploadsDir); + }); + + afterEach(async () => { + ImagePathUtil.reset(); + vi.restoreAllMocks(); + await fs.rm(workDir, { recursive: true, force: true }); + }); + + it('should render every page to the split dir and leave nothing in cwd', async () => { + const result = await splitter.split({ id: TASK_ID, filename: 'doc.pdf', page_range: '' }); + + expect(result.pages).toEqual([ + { page: 1, pageSource: 1, imagePath: path.join(splitDir, 'page-1.png') }, + { page: 2, pageSource: 2, imagePath: path.join(splitDir, 'page-2.png') }, + ]); + expect((await fs.readdir(splitDir)).sort()).toEqual(['page-1.png', 'page-2.png']); + expect(await fs.readdir(cwd)).toEqual([]); + + const png = await fs.readFile(path.join(splitDir, 'page-1.png')); + expect(png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)).toBe(true); + // IHDR width: the configured viewport scale (2.0) must still be applied. + expect(png.readUInt32BE(16)).toBe(PAGE_SIZE_PT * 2); + }); + + it('should number pages sequentially and keep the source page for a page range', async () => { + const result = await splitter.split({ id: TASK_ID, filename: 'doc.pdf', page_range: '2' }); + + expect(result.pages).toEqual([ + { page: 1, pageSource: 2, imagePath: path.join(splitDir, 'page-1.png') }, + ]); + expect(await fs.readdir(splitDir)).toEqual(['page-1.png']); + }); + + it('should not blame the source PDF when the split dir cannot be created', async () => { + vi.spyOn(splitter as any, 'sleep').mockResolvedValue(undefined); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + // A regular file in place of the split dir makes the real mkdir fail. + await fs.writeFile(splitDir, ''); + + const failure = splitter.split({ id: TASK_ID, filename: 'doc.pdf', page_range: '' }); + + await expect(failure).rejects.toThrow(`Failed to write split images to ${splitDir}`); + await expect(failure).rejects.not.toThrow(/moved or deleted/); + }); +}); diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts index fcec091..c0b1aa0 100644 --- a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts @@ -27,7 +27,7 @@ vi.mock('../FileWaitUtil.js', () => ({ vi.mock('fs', () => ({ promises: { mkdir: vi.fn(), - rename: vi.fn(), + writeFile: vi.fn(), rm: vi.fn(), unlink: vi.fn(), readFile: vi.fn(), @@ -60,6 +60,9 @@ describe('PDFSplitter', () => { // Setup default mocks for PDFDocument vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('mock-pdf-bytes')); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(PDFDocument.load).mockResolvedValue({ getPageCount: () => 3, } as any); @@ -85,7 +88,6 @@ describe('PDFSplitter', () => { ]); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.rename).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -115,7 +117,6 @@ describe('PDFSplitter', () => { ]); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.rename).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -125,7 +126,7 @@ describe('PDFSplitter', () => { expect(result.pages[1].pageSource).toBe(3); }); - it('should rename files to page-{N}.png format', async () => { + it('should write page images as page-{N}.png in the task split dir', async () => { const task = { id: 'task789', filename: 'document.pdf', @@ -138,26 +139,37 @@ describe('PDFSplitter', () => { } as any); vi.mocked(pdfToPng).mockResolvedValueOnce([ - { path: '/tmp/temp-1.png', name: 'temp-1.png', page: 1, content: Buffer.from('') }, - { path: '/tmp/temp-2.png', name: 'temp-2.png', page: 2, content: Buffer.from('') }, + { path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') }, + { path: '', name: 'temp-2.png', pageNumber: 2, content: Buffer.from('png-2') }, ]); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.rename).mockResolvedValue(undefined); await splitter.split(task); - // Verify rename was called with correct paths - expect(fs.rename).toHaveBeenCalledWith( - '/tmp/temp-1.png', - path.join(uploadsDir, 'task789', 'split', 'page-1.png') + expect(fs.mkdir).toHaveBeenCalledWith(path.join(uploadsDir, 'task789', 'split'), { + recursive: true, + }); + expect(fs.writeFile).toHaveBeenCalledWith( + path.join(uploadsDir, 'task789', 'split', 'page-1.png'), + Buffer.from('png-1') ); - expect(fs.rename).toHaveBeenCalledWith( - '/tmp/temp-2.png', - path.join(uploadsDir, 'task789', 'split', 'page-2.png') + expect(fs.writeFile).toHaveBeenCalledWith( + path.join(uploadsDir, 'task789', 'split', 'page-2.png'), + Buffer.from('png-2') ); }); + it('should not hand an output folder to the converter', async () => { + vi.mocked(pdfToPng).mockResolvedValueOnce([ + { path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') }, + ] as any); + + await splitter.split({ id: 'task789', filename: 'document.pdf', page_range: '' }); + + expect(vi.mocked(pdfToPng).mock.calls[0][1]).not.toHaveProperty('outputFolder'); + }); + it('should throw error for missing task ID', async () => { const task = { filename: 'document.pdf', @@ -214,6 +226,98 @@ describe('PDFSplitter', () => { }); }); + describe('error reporting', () => { + const task = { id: 'task123', filename: 'document.pdf', page_range: '' }; + const splitDir = path.join(uploadsDir, 'task123', 'split'); + const sourcePath = path.join(uploadsDir, 'task123', 'document.pdf'); + const MISSING_FILE_HINT = /moved or deleted/; + + const systemError = (code: string, syscall: string, target: string) => + Object.assign(new Error(`${code}: no such file or directory, ${syscall} '${target}'`), { + code, + path: target, + }); + + beforeEach(() => { + vi.spyOn(splitter as any, 'sleep').mockResolvedValue(undefined); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(pdfToPng).mockResolvedValue([ + { path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') }, + ] as any); + }); + + afterEach(() => { + vi.mocked(pdfToPng).mockReset(); + vi.restoreAllMocks(); + }); + + it('should report a failure to create the output dir as an output problem', async () => { + const error = systemError('ENOENT', 'mkdir', splitDir); + vi.mocked(fs.mkdir).mockRejectedValue(error); + + const failure = splitter.split(task); + + await expect(failure).rejects.toThrow(`Failed to write split images to ${splitDir}`); + await expect(failure).rejects.toThrow(error.message); + await expect(failure).rejects.not.toThrow(MISSING_FILE_HINT); + }); + + it('should retry a failed write without rendering the PDF again', async () => { + vi.mocked(fs.writeFile).mockRejectedValueOnce(systemError('EBUSY', 'open', splitDir)); + + const result = await splitter.split(task); + + expect(result.totalPages).toBe(1); + expect(fs.writeFile).toHaveBeenCalledTimes(2); + expect(pdfToPng).toHaveBeenCalledTimes(1); + }); + + it('should not blame the source when writing a page keeps failing, even if it is ENOENT', async () => { + const target = path.join(splitDir, 'page-1.png'); + vi.mocked(fs.writeFile).mockRejectedValue(systemError('ENOENT', 'open', target)); + + const failure = splitter.split(task); + + await expect(failure).rejects.toThrow(`Failed to write split images to ${splitDir}`); + await expect(failure).rejects.toThrow(target); + await expect(failure).rejects.not.toThrow(MISSING_FILE_HINT); + expect(fs.writeFile).toHaveBeenCalledTimes(3); + }); + + it('should not blame the source for an ENOENT on another file while it still exists', async () => { + const fontPath = '/resources/standard_fonts/missing.pfb'; + vi.mocked(pdfToPng).mockRejectedValue(systemError('ENOENT', 'open', fontPath)); + + const failure = splitter.split(task); + + await expect(failure).rejects.toThrow(`Failed to split PDF document.pdf`); + await expect(failure).rejects.toThrow(fontPath); + await expect(failure).rejects.not.toThrow(MISSING_FILE_HINT); + expect(fs.access).toHaveBeenCalledWith(sourcePath); + }); + + it('should report a missing source when the PDF disappears after the pre-check', async () => { + const error = systemError('ENOENT', 'open', sourcePath); + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.access).mockRejectedValue(error); + + await expect(splitter.split(task)).rejects.toThrow( + 'PDF file not found: document.pdf. The file may have been moved or deleted.' + ); + }); + + it('should not call an unreadable source missing', async () => { + const error = systemError('EACCES', 'open', sourcePath); + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.access).mockRejectedValue(error); + + const failure = splitter.split(task); + + await expect(failure).rejects.toThrow(`Failed to split PDF document.pdf`); + await expect(failure).rejects.not.toThrow(MISSING_FILE_HINT); + }); + }); + describe('cleanup()', () => { it('should remove task directory', async () => { vi.mocked(fs.rm).mockResolvedValue(undefined); @@ -260,7 +364,6 @@ describe('PDFSplitter', () => { ]); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.rename).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -318,7 +421,6 @@ describe('PDFSplitter', () => { ]); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.rename).mockResolvedValue(undefined); const result = await splitter.split(task); @@ -348,7 +450,6 @@ describe('PDFSplitter', () => { vi.mocked(pdfToPng).mockResolvedValueOnce(pages); vi.mocked(fs.mkdir).mockResolvedValue(undefined); - vi.mocked(fs.rename).mockResolvedValue(undefined); const result = await splitter.split(task); diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts new file mode 100644 index 0000000..87adb6d --- /dev/null +++ b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Run the path logic of the modules under test with Windows semantics on any host OS. +vi.mock('path', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual.win32, default: actual.win32 }; +}); + +vi.mock('pdf-to-png-converter', () => ({ + pdfToPng: vi.fn(), +})); + +vi.mock('pdf-lib', () => ({ + PDFDocument: { + load: vi.fn(), + }, +})); + +vi.mock('../FileWaitUtil.js', () => ({ + FileWaitUtil: { + waitForFile: vi.fn(), + }, +})); + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + rename: vi.fn(), + readFile: vi.fn(), + }, +})); + +import { promises as fs } from 'fs'; +import path from 'path'; +import { pdfToPng } from 'pdf-to-png-converter'; +import { PDFDocument } from 'pdf-lib'; +import { PDFSplitter } from '../PDFSplitter.js'; +import { ImagePathUtil } from '../ImagePathUtil.js'; + +const USER_DATA_FILES = 'C:\\Users\\u\\AppData\\Roaming\\MarkPDFdown\\files'; +const NON_SYSTEM_DRIVE_CWD = 'D:\\Apps\\MarkPDFdown'; +const TASK_ID = 'task-1'; + +function systemError(code: string, syscall: string, target: string): NodeJS.ErrnoException { + return Object.assign(new Error(`${code}: ${syscall} '${target}'`), { code, syscall, path: target }); +} + +/** + * Minimal Windows file system: ':' is illegal after the root, and libuv reports + * the resulting ERROR_INVALID_NAME as ENOENT. + */ +class FakeWindowsFs { + readonly dirs = new Set(); + readonly files = new Map(); + + private assertValidName(target: string, syscall: string): void { + if (target.slice(path.parse(target).root.length).includes(':')) { + throw systemError('ENOENT', syscall, target); + } + } + + mkdir = async (target: string): Promise => { + this.assertValidName(target, 'mkdir'); + this.dirs.add(target); + return undefined; + }; + + writeFile = async (target: string, content: Buffer): Promise => { + this.assertValidName(target, 'open'); + this.files.set(target, content); + }; + + rename = async (from: string, to: string): Promise => { + if (path.parse(from).root.toLowerCase() !== path.parse(to).root.toLowerCase()) { + throw systemError('EXDEV', 'rename', from); + } + this.files.set(to, this.files.get(from)!); + this.files.delete(from); + }; + + /** Every directory and file the code under test created below `dir`. */ + entriesUnder(dir: string): string[] { + const prefix = `${dir.toLowerCase()}\\`; + return [...this.dirs, ...this.files.keys()].filter((entry) => + entry.toLowerCase().startsWith(prefix) + ); + } +} + +/** Same output handling as pdf-to-png-converter 3.11.0 (out/pdfToPng.js). */ +function fakePdfToPng(pageCount: number): typeof pdfToPng { + return (async (_pdf: unknown, props?: { outputFolder?: string }) => { + const pages = Array.from({ length: pageCount }, (_, i) => ({ + pageNumber: i + 1, + name: `doc_page_${i + 1}.png`, + content: Buffer.from(`png-${i + 1}`), + path: '', + width: 100, + height: 100, + })); + + if (props?.outputFolder !== undefined) { + const outputFolder = path.join(process.cwd(), props.outputFolder); + await fs.mkdir(outputFolder, { recursive: true }); + for (const page of pages) { + page.path = path.join(outputFolder, page.name); + await fs.writeFile(page.path, page.content); + } + } + + return pages; + }) as unknown as typeof pdfToPng; +} + +describe('PDFSplitter output location on Windows', () => { + let fakeFs: FakeWindowsFs; + + async function splitTwoPages(uploadsDir: string, cwd: string) { + vi.spyOn(process, 'cwd').mockReturnValue(cwd); + ImagePathUtil.init(uploadsDir); + + const splitter = new PDFSplitter(uploadsDir); + vi.spyOn(splitter as any, 'sleep').mockResolvedValue(undefined); + + return splitter.split({ id: TASK_ID, filename: 'doc.pdf', page_range: '' }); + } + + beforeEach(() => { + fakeFs = new FakeWindowsFs(); + + vi.mocked(fs.mkdir).mockImplementation(fakeFs.mkdir as any); + vi.mocked(fs.writeFile).mockImplementation(fakeFs.writeFile as any); + vi.mocked(fs.rename).mockImplementation(fakeFs.rename as any); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('pdf-bytes')); + vi.mocked(PDFDocument.load).mockResolvedValue({ getPageCount: () => 2 } as any); + vi.mocked(pdfToPng).mockImplementation(fakePdfToPng(2)); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + ImagePathUtil.reset(); + vi.restoreAllMocks(); + }); + + it.each([ + { + scenario: 'cwd on a different drive than userData', + uploadsDir: USER_DATA_FILES, + cwd: NON_SYSTEM_DRIVE_CWD, + }, + { + scenario: 'userData on a UNC share', + uploadsDir: '\\\\server\\share\\MarkPDFdown\\files', + cwd: NON_SYSTEM_DRIVE_CWD, + }, + { + scenario: 'cwd on the same drive as userData', + uploadsDir: USER_DATA_FILES, + cwd: 'C:\\Program Files\\MarkPDFdown', + }, + ])('should write page images only to the task split dir with $scenario', async ({ uploadsDir, cwd }) => { + const result = await splitTwoPages(uploadsDir, cwd); + + const splitDir = `${uploadsDir}\\${TASK_ID}\\split`; + const expectedPaths = [`${splitDir}\\page-1.png`, `${splitDir}\\page-2.png`]; + + expect(result.pages.map((page) => page.imagePath)).toEqual(expectedPaths); + expect([...fakeFs.files.keys()].sort()).toEqual(expectedPaths); + expect(fakeFs.files.get(expectedPaths[0])?.toString()).toBe('png-1'); + expect(fakeFs.files.get(expectedPaths[1])?.toString()).toBe('png-2'); + expect(fakeFs.entriesUnder(cwd)).toEqual([]); + }); +});