mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
[clsi+web] Small cleanups and improvements to conversions / exports GitOrigin-RevId: 300adfbb91e89f754ee7f835db792ccb50b27613
457 lines
14 KiB
JavaScript
457 lines
14 KiB
JavaScript
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',
|
|
'main.tex',
|
|
'--output',
|
|
Path.join(outputId, 'main.md'),
|
|
'--from',
|
|
'latex',
|
|
'--to',
|
|
'markdown',
|
|
'--resource-path=.',
|
|
`--extract-media=${outputId}`,
|
|
],
|
|
},
|
|
]
|
|
|
|
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 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([
|
|
'sh',
|
|
'-c',
|
|
'cd output-uuid && zip -r ../output-uuid.zip .',
|
|
])
|
|
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')
|
|
})
|
|
})
|
|
})
|
|
})
|