ConversionManager.test.js 14 KB

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