ConversionManager.test.js 14 KB

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