/** @jest-environment node */ const mockExecFile = jest.fn() const mockReadFile = jest.fn() const mockUnlink = jest.fn() const mockSharp = jest.fn() const mockEnsureEnoughDiskSpace = jest.fn() jest.mock('child_process', () => ({ ...jest.requireActual('child_process'), execFile: mockExecFile, })) jest.mock('fs/promises', () => ({ readFile: mockReadFile, unlink: mockUnlink, writeFile: jest.fn(), open: jest.fn(), })) jest.mock('sharp', () => ({ __esModule: true, default: mockSharp, })) jest.mock('@/lib/sharp-runtime', () => ({ configureSharpRuntime: jest.fn(), })) jest.mock('@/lib/disk-space', () => ({ ensureEnoughDiskSpace: mockEnsureEnoughDiskSpace, })) async function waitUntil(condition: () => boolean): Promise { for (let attempt = 0; attempt < 20; attempt += 1) { if (condition()) { return } await new Promise((resolve) => setImmediate(resolve)) } throw new Error('等待并发测试状态超时') } describe('generatePdfPreview image-processing limiter', () => { afterEach(() => { delete process.env.IMAGE_PROCESSING_MAX_CONCURRENT }) test('两个 PDF 预览的 pdftoppm 与 Sharp 完整重处理段最大并发为 1', async () => { process.env.IMAGE_PROCESSING_MAX_CONCURRENT = '1' jest.resetModules() jest.clearAllMocks() const events: string[] = [] const execReleases: Array<() => void> = [] const sharpWriteReleases: Array<() => void> = [] let activeHeavyJobs = 0 let peakHeavyJobs = 0 let sourceImageCount = 0 let finalImageCount = 0 mockEnsureEnoughDiskSpace.mockResolvedValue(undefined) mockReadFile.mockResolvedValue(Buffer.from('pdf-preview-image')) mockUnlink.mockResolvedValue(undefined) mockExecFile.mockImplementation((...args: unknown[]) => { const callback = args[args.length - 1] as ( error: Error | null, result: { stdout: string; stderr: string } ) => void const jobNumber = execReleases.length + 1 activeHeavyJobs += 1 peakHeavyJobs = Math.max(peakHeavyJobs, activeHeavyJobs) events.push(`pdftoppm:start:${jobNumber}`) execReleases.push(() => callback(null, { stdout: '', stderr: '' })) return {} as never }) mockSharp.mockImplementation((input: Buffer | string) => { if (Buffer.isBuffer(input)) { const jobIndex = sourceImageCount sourceImageCount += 1 const instance: any = {} instance.metadata = jest.fn().mockResolvedValue({ width: 800, height: 600 }) instance.extract = jest.fn().mockReturnValue(instance) instance.resize = jest.fn().mockReturnValue(instance) instance.jpeg = jest.fn().mockReturnValue(instance) instance.toFile = jest.fn(() => { events.push(`sharp:write:${jobIndex + 1}`) return new Promise((resolve) => { sharpWriteReleases[jobIndex] = resolve }) }) return instance } const jobNumber = finalImageCount + 1 finalImageCount += 1 return { metadata: jest.fn().mockImplementation(async () => { events.push(`sharp:complete:${jobNumber}`) activeHeavyJobs -= 1 return { width: 800, height: 600 } }), } }) const { generatePdfPreview } = await import('@/lib/pdf-processor') const firstPreview = generatePdfPreview('first.pdf', 'first-preview.jpg') const secondPreview = generatePdfPreview('second.pdf', 'second-preview.jpg') await waitUntil(() => execReleases.length === 1) expect(mockExecFile).toHaveBeenCalledTimes(1) expect(activeHeavyJobs).toBe(1) execReleases[0]() await waitUntil(() => sharpWriteReleases.length === 1) // 第一个 pdftoppm 已结束,但 Sharp 仍在写盘;第二个 PDF 仍必须排队。 expect(mockExecFile).toHaveBeenCalledTimes(1) expect(activeHeavyJobs).toBe(1) sharpWriteReleases[0]() await expect(firstPreview).resolves.toEqual({ success: true, previewPath: 'first-preview.jpg', width: 800, height: 600, }) await waitUntil(() => execReleases.length === 2) expect(activeHeavyJobs).toBe(1) execReleases[1]() await waitUntil(() => sharpWriteReleases.length === 2) sharpWriteReleases[1]() await expect(secondPreview).resolves.toEqual({ success: true, previewPath: 'second-preview.jpg', width: 800, height: 600, }) expect(peakHeavyJobs).toBe(1) expect(activeHeavyJobs).toBe(0) expect(events).toEqual([ 'pdftoppm:start:1', 'sharp:write:1', 'sharp:complete:1', 'pdftoppm:start:2', 'sharp:write:2', 'sharp:complete:2', ]) }) })