| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490 |
- 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=.',
- ],
- },
- {
- type: 'html',
- extension: 'html',
- compressOutput: true,
- pandocArgs: outputId => [
- 'pandoc',
- Path.join('..', 'main.tex'),
- '--output',
- 'main.html',
- '--from',
- 'latex',
- '--to',
- 'html',
- '--standalone',
- '--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('Non-zero exit code from pandoc')
- })
- 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 the conversion and set the TEXINPUTS environment variable', function (ctx) {
- expect(
- ctx.CommandRunner.promises.run.firstCall.args[5]
- ).toMatchObject({ TEXINPUTS: '..:' })
- })
- 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')
- })
- })
- })
- })
|