import Path from 'node:path' import sinon from 'sinon' import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest' const MODULE_PATH = Path.join( import.meta.dirname, '../../../app/js/ConversionManager' ) const CONVERT_TO_LATEX_CASES = [ { type: 'docx', inputFilename: 'input.docx', pandocArgs: [ 'pandoc', 'input.docx', '--output', 'main.tex', '--to', 'latex', '--standalone', '--extract-media=.', '--from', 'docx+citations', '--citeproc', ], }, { type: 'markdown', inputFilename: 'input.md', pandocArgs: [ 'pandoc', 'input.md', '--output', 'main.tex', '--to', 'latex', '--standalone', '--from', 'markdown', ], }, ] const LATEX_TO_DOCUMENT_CASES = [ { type: 'docx', extension: 'docx', compressOutput: false, pandocArgs: outputId => [ 'pandoc', 'main.tex', '--output', `${outputId}.docx`, '--from', 'latex', '--to', 'docx', '--citeproc', '--number-sections', '--resource-path=.', ], }, { type: 'markdown', extension: 'md', compressOutput: true, pandocArgs: outputId => [ 'pandoc', Path.join('..', 'main.tex'), '--output', 'main.md', '--from', 'latex', '--to', 'markdown', '--resource-path=..', '--extract-media=.', ], }, ] describe('ConversionManager', function () { beforeEach(async function (ctx) { ctx.CommandRunner = { promises: { run: sinon.stub().resolves({ stdout: '', stderr: '', exitCode: 0 }), }, } ctx.lock = { release: sinon.stub(), } ctx.LockManager = { acquire: sinon.stub().returns(ctx.lock), } ctx.Settings = { pandocImage: 'mock-pandoc-image', conversionTimeoutSeconds: 60, path: { compilesDir: '/compiles' }, } ctx.fs = { mkdir: sinon.stub().resolves(), copyFile: sinon.stub().resolves(), rm: sinon.stub().resolves(), unlink: sinon.stub().resolves(), } ctx.conversionId = 'test-conversion-id' ctx.conversionDir = '/compiles/test-conversion-id' ctx.outputPath = '/compiles/test-conversion-id/output-uuid.zip' ctx.uuidStub = sinon .stub(globalThis.crypto, 'randomUUID') .returns('output-uuid') vi.doMock('../../../app/js/LockManager', () => ({ default: ctx.LockManager, })) vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings, })) vi.doMock('../../../app/js/CommandRunner', () => ({ default: ctx.CommandRunner, })) vi.doMock('node:fs/promises', () => ({ default: ctx.fs })) ctx.ConversionManager = (await import(MODULE_PATH)).default }) afterEach(function (ctx) { ctx.uuidStub.restore() }) describe('convertToLaTeXWithLock', function () { describe('per conversion type', function () { CONVERT_TO_LATEX_CASES.forEach(({ type, inputFilename, pandocArgs }) => { describe(`type=${type}`, function () { beforeEach(async function (ctx) { ctx.inputPath = `/path/to/${inputFilename}` await ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, ctx.inputPath, type ) }) it('should copy the input file to the conversion directory under the type-specific filename', function (ctx) { sinon.assert.calledWith( ctx.fs.copyFile, ctx.inputPath, Path.join(ctx.conversionDir, inputFilename) ) }) it('should run pandoc with the type-specific args', function (ctx) { expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual( pandocArgs ) }) }) }) }) describe('with conversionType=docx (representative)', function () { beforeEach(function (ctx) { ctx.inputPath = '/path/to/input.docx' }) describe('successful conversion', function () { beforeEach(async function (ctx) { ctx.result = await ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, ctx.inputPath, 'docx' ) }) it('should acquire a lock on the conversion directory', function (ctx) { sinon.assert.calledWith(ctx.LockManager.acquire, ctx.conversionDir) }) it('should create the conversion directory', function (ctx) { sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, { recursive: true, }) }) it('should run pandoc then zip with the conversion timeout in milliseconds', function (ctx) { expect(ctx.CommandRunner.promises.run.callCount).toBe(2) expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([ 'zip', '-r', 'output-uuid.zip', '.', ]) expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000) expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000) }) it('should remove the source document after conversion', function (ctx) { sinon.assert.calledWith( ctx.fs.unlink, Path.join(ctx.conversionDir, 'input.docx') ) }) it('should return the output zip path', function (ctx) { expect(ctx.result).toBe(ctx.outputPath) }) it('should release the lock', function (ctx) { sinon.assert.called(ctx.lock.release) }) }) describe('unsuccessful conversion (pandoc exit code)', function () { beforeEach(async function (ctx) { ctx.CommandRunner.promises.run.resolves({ stdout: '', stderr: '', exitCode: 63, }) await expect( ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, ctx.inputPath, 'docx' ) ).to.be.rejectedWith('pandoc conversion failed') }) it('should remove the entire conversion directory', function (ctx) { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { force: true, recursive: true, }) }) it('should release the lock', function (ctx) { sinon.assert.called(ctx.lock.release) }) }) describe('unsuccessful compression (zip exit code)', function () { beforeEach(async function (ctx) { ctx.CommandRunner.promises.run .onFirstCall() .resolves({ stdout: '', stderr: '', exitCode: 0 }) .onSecondCall() .resolves({ stdout: '', stderr: '', exitCode: 12 }) await expect( ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, ctx.inputPath, 'docx' ) ).to.be.rejectedWith('pandoc conversion failed') }) it('should remove the entire conversion directory', function (ctx) { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { force: true, recursive: true, }) }) it('should release the lock', function (ctx) { sinon.assert.called(ctx.lock.release) }) }) describe('unsuccessful conversion (throws)', function () { beforeEach(async function (ctx) { ctx.CommandRunner.promises.run.rejects( new Error('mock conversion error') ) await expect( ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, ctx.inputPath, 'docx' ) ).to.be.rejectedWith('pandoc conversion failed') }) it('should remove the entire conversion directory', function (ctx) { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { force: true, recursive: true, }) }) it('should release the lock', function (ctx) { sinon.assert.called(ctx.lock.release) }) }) }) describe('with an unsupported conversion type', function () { it('should reject with an unsupported conversion type error', async function (ctx) { await expect( ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.conversionId, '/path/to/input.txt', 'not-a-real-type' ) ).to.be.rejectedWith('unsupported conversion type') }) }) }) describe('convertLaTeXToDocumentInDirWithLock', function () { beforeEach(function (ctx) { ctx.compileDir = '/compiles/test-compile-dir' ctx.rootDocPath = 'main.tex' }) describe('pandoc args per conversion type', function () { LATEX_TO_DOCUMENT_CASES.forEach(({ type, pandocArgs }) => { it(`should run pandoc with the type-specific args for type=${type}`, async function (ctx) { await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.conversionId, ctx.compileDir, ctx.rootDocPath, type ) expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual( pandocArgs('output-uuid') ) }) }) }) describe('with type=docx (representative non-compressing type)', function () { describe('successful conversion', function () { beforeEach(async function (ctx) { ctx.result = await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.conversionId, ctx.compileDir, ctx.rootDocPath, 'docx' ) }) it('should acquire a lock on the compile dir', function (ctx) { sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir) }) it('should release the lock', function (ctx) { sinon.assert.called(ctx.lock.release) }) it('should pass the conversion timeout in milliseconds', function (ctx) { expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000) }) it('should not create a subdirectory or run zip and should return the document path directly', function (ctx) { sinon.assert.notCalled(ctx.fs.mkdir) expect(ctx.CommandRunner.promises.run.callCount).toBe(1) expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.docx')) }) }) describe('when pandoc fails (non-zero exit code)', function () { it('should reject with an error and release the lock', async function (ctx) { ctx.CommandRunner.promises.run.resolves({ stdout: '', stderr: '', exitCode: 1, }) await expect( ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.conversionId, ctx.compileDir, ctx.rootDocPath, 'docx' ) ).to.be.rejectedWith('pandoc latex-to-document conversion failed') sinon.assert.called(ctx.lock.release) }) }) }) describe('with type=markdown (representative compressing type)', function () { describe('successful conversion', function () { beforeEach(async function (ctx) { ctx.result = await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.conversionId, ctx.compileDir, ctx.rootDocPath, 'markdown' ) }) it('should create a UUID-named subdirectory for the output', function (ctx) { sinon.assert.calledWith( ctx.fs.mkdir, Path.join(ctx.compileDir, 'output-uuid'), { recursive: true } ) }) it('should run the conversion in the uuid-named subdirectory', function (ctx) { expect(ctx.CommandRunner.promises.run.firstCall.args[7]).toBe( 'output-uuid' ) }) it('should run pandoc then zip the subdirectory and return the zip path', function (ctx) { expect(ctx.CommandRunner.promises.run.callCount).toBe(2) expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([ 'zip', '-r', Path.join('..', 'output-uuid.zip'), '.', ]) expect(ctx.CommandRunner.promises.run.secondCall.args[7]).toBe( 'output-uuid' ) expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.zip')) }) }) describe('when zip fails (non-zero exit code)', function () { it('should reject with an error and release the lock', async function (ctx) { ctx.CommandRunner.promises.run .onFirstCall() .resolves({ stdout: '', stderr: '', exitCode: 0 }) .onSecondCall() .resolves({ stdout: '', stderr: 'zip error', exitCode: 1 }) await expect( ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.conversionId, ctx.compileDir, ctx.rootDocPath, 'markdown' ) ).to.be.rejectedWith('zip compression of export failed') sinon.assert.called(ctx.lock.release) }) }) }) describe('with an unsupported conversion type', function () { it('should reject with an unsupported conversion type error', async function (ctx) { await expect( ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.conversionId, ctx.compileDir, ctx.rootDocPath, 'not-a-real-type' ) ).to.be.rejectedWith('unsupported conversion type') }) }) }) })