ConversionManager.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import Path from 'node:path'
  2. import sinon from 'sinon'
  3. import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest'
  4. const MODULE_PATH = Path.join(
  5. import.meta.dirname,
  6. '../../../app/js/ConversionManager'
  7. )
  8. const CONVERT_TO_LATEX_CASES = [
  9. {
  10. type: 'docx',
  11. inputFilename: 'input.docx',
  12. pandocArgs: [
  13. 'pandoc',
  14. 'input.docx',
  15. '--output',
  16. 'main.tex',
  17. '--to',
  18. 'latex',
  19. '--standalone',
  20. '--extract-media=.',
  21. '--from',
  22. 'docx+citations',
  23. '--citeproc',
  24. ],
  25. },
  26. {
  27. type: 'markdown',
  28. inputFilename: 'input.md',
  29. pandocArgs: [
  30. 'pandoc',
  31. 'input.md',
  32. '--output',
  33. 'main.tex',
  34. '--to',
  35. 'latex',
  36. '--standalone',
  37. '--from',
  38. 'markdown',
  39. ],
  40. },
  41. ]
  42. const LATEX_TO_DOCUMENT_CASES = [
  43. {
  44. type: 'docx',
  45. extension: 'docx',
  46. compressOutput: false,
  47. pandocArgs: outputId => [
  48. 'pandoc',
  49. 'main.tex',
  50. '--output',
  51. `${outputId}.docx`,
  52. '--from',
  53. 'latex',
  54. '--to',
  55. 'docx',
  56. '--citeproc',
  57. '--number-sections',
  58. '--resource-path=.',
  59. ],
  60. },
  61. {
  62. type: 'markdown',
  63. extension: 'md',
  64. compressOutput: true,
  65. pandocArgs: outputId => [
  66. 'pandoc',
  67. Path.join('..', 'main.tex'),
  68. '--output',
  69. 'main.md',
  70. '--from',
  71. 'latex',
  72. '--to',
  73. 'markdown',
  74. '--resource-path=..',
  75. '--extract-media=.',
  76. ],
  77. },
  78. ]
  79. describe('ConversionManager', function () {
  80. beforeEach(async function (ctx) {
  81. ctx.CommandRunner = {
  82. promises: {
  83. run: sinon.stub().resolves({ stdout: '', stderr: '', exitCode: 0 }),
  84. },
  85. }
  86. ctx.lock = {
  87. release: sinon.stub(),
  88. }
  89. ctx.LockManager = {
  90. acquire: sinon.stub().returns(ctx.lock),
  91. }
  92. ctx.Settings = {
  93. pandocImage: 'mock-pandoc-image',
  94. conversionTimeoutSeconds: 60,
  95. path: { compilesDir: '/compiles' },
  96. }
  97. ctx.fs = {
  98. mkdir: sinon.stub().resolves(),
  99. copyFile: sinon.stub().resolves(),
  100. rm: sinon.stub().resolves(),
  101. unlink: sinon.stub().resolves(),
  102. }
  103. ctx.conversionId = 'test-conversion-id'
  104. ctx.conversionDir = '/compiles/test-conversion-id'
  105. ctx.outputPath = '/compiles/test-conversion-id/output-uuid.zip'
  106. ctx.uuidStub = sinon
  107. .stub(globalThis.crypto, 'randomUUID')
  108. .returns('output-uuid')
  109. vi.doMock('../../../app/js/LockManager', () => ({
  110. default: ctx.LockManager,
  111. }))
  112. vi.doMock('@overleaf/settings', () => ({
  113. default: ctx.Settings,
  114. }))
  115. vi.doMock('../../../app/js/CommandRunner', () => ({
  116. default: ctx.CommandRunner,
  117. }))
  118. vi.doMock('node:fs/promises', () => ({ default: ctx.fs }))
  119. ctx.ConversionManager = (await import(MODULE_PATH)).default
  120. })
  121. afterEach(function (ctx) {
  122. ctx.uuidStub.restore()
  123. })
  124. describe('convertToLaTeXWithLock', function () {
  125. describe('per conversion type', function () {
  126. CONVERT_TO_LATEX_CASES.forEach(({ type, inputFilename, pandocArgs }) => {
  127. describe(`type=${type}`, function () {
  128. beforeEach(async function (ctx) {
  129. ctx.inputPath = `/path/to/${inputFilename}`
  130. await ctx.ConversionManager.promises.convertToLaTeXWithLock(
  131. ctx.conversionId,
  132. ctx.inputPath,
  133. type
  134. )
  135. })
  136. it('should copy the input file to the conversion directory under the type-specific filename', function (ctx) {
  137. sinon.assert.calledWith(
  138. ctx.fs.copyFile,
  139. ctx.inputPath,
  140. Path.join(ctx.conversionDir, inputFilename)
  141. )
  142. })
  143. it('should run pandoc with the type-specific args', function (ctx) {
  144. expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual(
  145. pandocArgs
  146. )
  147. })
  148. })
  149. })
  150. })
  151. describe('with conversionType=docx (representative)', function () {
  152. beforeEach(function (ctx) {
  153. ctx.inputPath = '/path/to/input.docx'
  154. })
  155. describe('successful conversion', function () {
  156. beforeEach(async function (ctx) {
  157. ctx.result =
  158. await ctx.ConversionManager.promises.convertToLaTeXWithLock(
  159. ctx.conversionId,
  160. ctx.inputPath,
  161. 'docx'
  162. )
  163. })
  164. it('should acquire a lock on the conversion directory', function (ctx) {
  165. sinon.assert.calledWith(ctx.LockManager.acquire, ctx.conversionDir)
  166. })
  167. it('should create the conversion directory', function (ctx) {
  168. sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, {
  169. recursive: true,
  170. })
  171. })
  172. it('should run pandoc then zip with the conversion timeout in milliseconds', function (ctx) {
  173. expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
  174. expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([
  175. 'zip',
  176. '-r',
  177. 'output-uuid.zip',
  178. '.',
  179. ])
  180. expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
  181. expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000)
  182. })
  183. it('should remove the source document after conversion', function (ctx) {
  184. sinon.assert.calledWith(
  185. ctx.fs.unlink,
  186. Path.join(ctx.conversionDir, 'input.docx')
  187. )
  188. })
  189. it('should return the output zip path', function (ctx) {
  190. expect(ctx.result).toBe(ctx.outputPath)
  191. })
  192. it('should release the lock', function (ctx) {
  193. sinon.assert.called(ctx.lock.release)
  194. })
  195. })
  196. describe('unsuccessful conversion (pandoc exit code)', function () {
  197. beforeEach(async function (ctx) {
  198. ctx.CommandRunner.promises.run.resolves({
  199. stdout: '',
  200. stderr: '',
  201. exitCode: 63,
  202. })
  203. await expect(
  204. ctx.ConversionManager.promises.convertToLaTeXWithLock(
  205. ctx.conversionId,
  206. ctx.inputPath,
  207. 'docx'
  208. )
  209. ).to.be.rejectedWith('Non-zero exit code from pandoc')
  210. })
  211. it('should remove the entire conversion directory', function (ctx) {
  212. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
  213. force: true,
  214. recursive: true,
  215. })
  216. })
  217. it('should release the lock', function (ctx) {
  218. sinon.assert.called(ctx.lock.release)
  219. })
  220. })
  221. describe('unsuccessful compression (zip exit code)', function () {
  222. beforeEach(async function (ctx) {
  223. ctx.CommandRunner.promises.run
  224. .onFirstCall()
  225. .resolves({ stdout: '', stderr: '', exitCode: 0 })
  226. .onSecondCall()
  227. .resolves({ stdout: '', stderr: '', exitCode: 12 })
  228. await expect(
  229. ctx.ConversionManager.promises.convertToLaTeXWithLock(
  230. ctx.conversionId,
  231. ctx.inputPath,
  232. 'docx'
  233. )
  234. ).to.be.rejectedWith('pandoc conversion failed')
  235. })
  236. it('should remove the entire conversion directory', function (ctx) {
  237. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
  238. force: true,
  239. recursive: true,
  240. })
  241. })
  242. it('should release the lock', function (ctx) {
  243. sinon.assert.called(ctx.lock.release)
  244. })
  245. })
  246. describe('unsuccessful conversion (throws)', function () {
  247. beforeEach(async function (ctx) {
  248. ctx.CommandRunner.promises.run.rejects(
  249. new Error('mock conversion error')
  250. )
  251. await expect(
  252. ctx.ConversionManager.promises.convertToLaTeXWithLock(
  253. ctx.conversionId,
  254. ctx.inputPath,
  255. 'docx'
  256. )
  257. ).to.be.rejectedWith('pandoc conversion failed')
  258. })
  259. it('should remove the entire conversion directory', function (ctx) {
  260. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
  261. force: true,
  262. recursive: true,
  263. })
  264. })
  265. it('should release the lock', function (ctx) {
  266. sinon.assert.called(ctx.lock.release)
  267. })
  268. })
  269. })
  270. describe('with an unsupported conversion type', function () {
  271. it('should reject with an unsupported conversion type error', async function (ctx) {
  272. await expect(
  273. ctx.ConversionManager.promises.convertToLaTeXWithLock(
  274. ctx.conversionId,
  275. '/path/to/input.txt',
  276. 'not-a-real-type'
  277. )
  278. ).to.be.rejectedWith('unsupported conversion type')
  279. })
  280. })
  281. })
  282. describe('convertLaTeXToDocumentInDirWithLock', function () {
  283. beforeEach(function (ctx) {
  284. ctx.compileDir = '/compiles/test-compile-dir'
  285. ctx.rootDocPath = 'main.tex'
  286. })
  287. describe('pandoc args per conversion type', function () {
  288. LATEX_TO_DOCUMENT_CASES.forEach(({ type, pandocArgs }) => {
  289. it(`should run pandoc with the type-specific args for type=${type}`, async function (ctx) {
  290. await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  291. ctx.conversionId,
  292. ctx.compileDir,
  293. ctx.rootDocPath,
  294. type
  295. )
  296. expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual(
  297. pandocArgs('output-uuid')
  298. )
  299. })
  300. })
  301. })
  302. describe('with type=docx (representative non-compressing type)', function () {
  303. describe('successful conversion', function () {
  304. beforeEach(async function (ctx) {
  305. ctx.result =
  306. await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  307. ctx.conversionId,
  308. ctx.compileDir,
  309. ctx.rootDocPath,
  310. 'docx'
  311. )
  312. })
  313. it('should acquire a lock on the compile dir', function (ctx) {
  314. sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir)
  315. })
  316. it('should release the lock', function (ctx) {
  317. sinon.assert.called(ctx.lock.release)
  318. })
  319. it('should pass the conversion timeout in milliseconds', function (ctx) {
  320. expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
  321. })
  322. it('should not create a subdirectory or run zip and should return the document path directly', function (ctx) {
  323. sinon.assert.notCalled(ctx.fs.mkdir)
  324. expect(ctx.CommandRunner.promises.run.callCount).toBe(1)
  325. expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.docx'))
  326. })
  327. })
  328. describe('when pandoc fails (non-zero exit code)', function () {
  329. it('should reject with an error and release the lock', async function (ctx) {
  330. ctx.CommandRunner.promises.run.resolves({
  331. stdout: '',
  332. stderr: '',
  333. exitCode: 1,
  334. })
  335. await expect(
  336. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  337. ctx.conversionId,
  338. ctx.compileDir,
  339. ctx.rootDocPath,
  340. 'docx'
  341. )
  342. ).to.be.rejectedWith('pandoc latex-to-document conversion failed')
  343. sinon.assert.called(ctx.lock.release)
  344. })
  345. })
  346. })
  347. describe('with type=markdown (representative compressing type)', function () {
  348. describe('successful conversion', function () {
  349. beforeEach(async function (ctx) {
  350. ctx.result =
  351. await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  352. ctx.conversionId,
  353. ctx.compileDir,
  354. ctx.rootDocPath,
  355. 'markdown'
  356. )
  357. })
  358. it('should create a UUID-named subdirectory for the output', function (ctx) {
  359. sinon.assert.calledWith(
  360. ctx.fs.mkdir,
  361. Path.join(ctx.compileDir, 'output-uuid'),
  362. { recursive: true }
  363. )
  364. })
  365. it('should run the conversion in the uuid-named subdirectory', function (ctx) {
  366. expect(ctx.CommandRunner.promises.run.firstCall.args[7]).toBe(
  367. 'output-uuid'
  368. )
  369. })
  370. it('should run pandoc then zip the subdirectory and return the zip path', function (ctx) {
  371. expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
  372. expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([
  373. 'zip',
  374. '-r',
  375. Path.join('..', 'output-uuid.zip'),
  376. '.',
  377. ])
  378. expect(ctx.CommandRunner.promises.run.secondCall.args[7]).toBe(
  379. 'output-uuid'
  380. )
  381. expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.zip'))
  382. })
  383. })
  384. describe('when zip fails (non-zero exit code)', function () {
  385. it('should reject with an error and release the lock', async function (ctx) {
  386. ctx.CommandRunner.promises.run
  387. .onFirstCall()
  388. .resolves({ stdout: '', stderr: '', exitCode: 0 })
  389. .onSecondCall()
  390. .resolves({ stdout: '', stderr: 'zip error', exitCode: 1 })
  391. await expect(
  392. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  393. ctx.conversionId,
  394. ctx.compileDir,
  395. ctx.rootDocPath,
  396. 'markdown'
  397. )
  398. ).to.be.rejectedWith('zip compression of export failed')
  399. sinon.assert.called(ctx.lock.release)
  400. })
  401. })
  402. })
  403. describe('with an unsupported conversion type', function () {
  404. it('should reject with an unsupported conversion type error', async function (ctx) {
  405. await expect(
  406. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  407. ctx.conversionId,
  408. ctx.compileDir,
  409. ctx.rootDocPath,
  410. 'not-a-real-type'
  411. )
  412. ).to.be.rejectedWith('unsupported conversion type')
  413. })
  414. })
  415. })
  416. })