ConversionManager.test.js 13 KB

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