ConversionManager.test.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  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.inputPath = '/path/to/input.docx'
  34. ctx.conversionDir = '/compiles/test-conversion-id'
  35. ctx.outputPath = '/compiles/test-conversion-id/output-uuid.zip'
  36. ctx.uuidStub = sinon
  37. .stub(globalThis.crypto, 'randomUUID')
  38. .returns('output-uuid')
  39. vi.doMock('../../../app/js/LockManager', () => ({
  40. default: ctx.LockManager,
  41. }))
  42. vi.doMock('@overleaf/settings', () => ({
  43. default: ctx.Settings,
  44. }))
  45. vi.doMock('../../../app/js/CommandRunner', () => ({
  46. default: ctx.CommandRunner,
  47. }))
  48. vi.doMock('node:fs/promises', () => ({ default: ctx.fs }))
  49. ctx.ConversionManager = (await import(MODULE_PATH)).default
  50. })
  51. afterEach(function (ctx) {
  52. ctx.uuidStub.restore()
  53. })
  54. describe('convertDocxToLaTeXWithLock', function () {
  55. describe('general behavior', function () {
  56. beforeEach(async function (ctx) {
  57. ctx.result =
  58. await ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
  59. ctx.conversionId,
  60. ctx.inputPath
  61. )
  62. })
  63. it('should acquire a lock', async function (ctx) {
  64. sinon.assert.calledWith(ctx.LockManager.acquire, ctx.conversionDir)
  65. })
  66. it('should copy the input file to the conversion directory', async function (ctx) {
  67. sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, {
  68. recursive: true,
  69. })
  70. sinon.assert.calledWith(
  71. ctx.fs.copyFile,
  72. ctx.inputPath,
  73. Path.join(ctx.conversionDir, 'input.docx')
  74. )
  75. })
  76. it('should convert conversion timeout to milliseconds', async function (ctx) {
  77. expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
  78. expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000)
  79. })
  80. it('should run pandoc followed by zip in the conversion directory', function (ctx) {
  81. expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
  82. expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([
  83. ctx.conversionId,
  84. [
  85. 'pandoc',
  86. 'input.docx',
  87. '--output',
  88. 'main.tex',
  89. '--extract-media=.',
  90. '--from',
  91. 'docx+citations',
  92. '--to',
  93. 'latex',
  94. '--citeproc',
  95. '--standalone',
  96. ],
  97. ctx.conversionDir,
  98. ctx.Settings.pandocImage,
  99. 60_000,
  100. {},
  101. 'conversions',
  102. ])
  103. expect(ctx.CommandRunner.promises.run.secondCall.args).toEqual([
  104. ctx.conversionId,
  105. ['zip', '-r', 'output-uuid.zip', '.'],
  106. ctx.conversionDir,
  107. ctx.Settings.pandocImage,
  108. 60_000,
  109. {},
  110. 'conversions',
  111. ])
  112. })
  113. })
  114. describe('successful conversion', function () {
  115. beforeEach(async function (ctx) {
  116. ctx.CommandRunner.promises.run.resolves({
  117. stdout: 'mock-stdout',
  118. stderr: 'mock-stderr',
  119. exitCode: 0,
  120. })
  121. ctx.result =
  122. await ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
  123. ctx.conversionId,
  124. ctx.inputPath
  125. )
  126. })
  127. it('should remove the source document after conversion', async function (ctx) {
  128. sinon.assert.calledWith(
  129. ctx.fs.unlink,
  130. Path.join(ctx.conversionDir, 'input.docx')
  131. )
  132. })
  133. it('should return the conversion directory', function (ctx) {
  134. expect(ctx.result).toBe(ctx.outputPath)
  135. })
  136. it('should release the lock', function (ctx) {
  137. sinon.assert.called(ctx.lock.release)
  138. })
  139. })
  140. describe('unsuccessful conversion (exitcode)', function () {
  141. beforeEach(async function (ctx) {
  142. ctx.CommandRunner.promises.run.resolves({
  143. stdout: 'mock-stdout',
  144. stderr: 'mock-stderr',
  145. exitCode: 63,
  146. })
  147. await expect(
  148. ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
  149. ctx.conversionId,
  150. ctx.inputPath
  151. )
  152. ).to.be.rejectedWith('pandoc conversion failed')
  153. })
  154. it('should remove the entire conversion directory', async function (ctx) {
  155. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
  156. force: true,
  157. recursive: true,
  158. })
  159. })
  160. it('should release the lock', function (ctx) {
  161. sinon.assert.called(ctx.lock.release)
  162. })
  163. })
  164. describe('unsuccessful compression (exitcode)', function () {
  165. beforeEach(async function (ctx) {
  166. ctx.CommandRunner.promises.run
  167. .onFirstCall()
  168. .resolves({
  169. stdout: 'mock-pandoc-stdout',
  170. stderr: 'mock-pandoc-stderr',
  171. exitCode: 0,
  172. })
  173. .onSecondCall()
  174. .resolves({
  175. stdout: 'mock-zip-stdout',
  176. stderr: 'mock-zip-stderr',
  177. exitCode: 12,
  178. })
  179. await expect(
  180. ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
  181. ctx.conversionId,
  182. ctx.inputPath
  183. )
  184. ).to.be.rejectedWith('pandoc conversion failed')
  185. })
  186. it('should remove the entire conversion directory', async function (ctx) {
  187. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
  188. force: true,
  189. recursive: true,
  190. })
  191. })
  192. it('should release the lock', function (ctx) {
  193. sinon.assert.called(ctx.lock.release)
  194. })
  195. })
  196. describe('unsuccessful conversion (throws)', function () {
  197. beforeEach(async function (ctx) {
  198. ctx.CommandRunner.promises.run.rejects(
  199. new Error('mock conversion error')
  200. )
  201. await expect(
  202. ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
  203. ctx.conversionId,
  204. ctx.inputPath
  205. )
  206. ).to.be.rejectedWith('pandoc conversion failed')
  207. })
  208. it('should remove the entire conversion directory', async function (ctx) {
  209. sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
  210. force: true,
  211. recursive: true,
  212. })
  213. })
  214. it('should release the lock', function (ctx) {
  215. sinon.assert.called(ctx.lock.release)
  216. })
  217. })
  218. })
  219. describe('convertLaTeXToDocumentInDirWithLock', function () {
  220. describe('successfully', function () {
  221. beforeEach(async function (ctx) {
  222. ctx.compileDir = '/compiles/test-compile-dir'
  223. ctx.rootDocPath = 'main.tex'
  224. ctx.type = 'docx'
  225. ctx.extension = 'docx'
  226. ctx.result =
  227. await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  228. ctx.conversionId,
  229. ctx.compileDir,
  230. ctx.rootDocPath,
  231. ctx.type,
  232. ctx.extension
  233. )
  234. })
  235. it('should acquire a lock on the compile dir', function (ctx) {
  236. sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir)
  237. })
  238. it('should release the lock', function (ctx) {
  239. sinon.assert.called(ctx.lock.release)
  240. })
  241. it('should run pandoc with correct arguments', function (ctx) {
  242. expect(ctx.CommandRunner.promises.run.callCount).toBe(1)
  243. expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([
  244. ctx.conversionId,
  245. [
  246. 'pandoc',
  247. ctx.rootDocPath,
  248. '--output',
  249. `output-uuid.${ctx.extension}`,
  250. '--from',
  251. 'latex',
  252. '--to',
  253. ctx.type,
  254. '--resource-path=.',
  255. ],
  256. ctx.compileDir,
  257. ctx.Settings.pandocImage,
  258. 60_000,
  259. {},
  260. 'conversions',
  261. ])
  262. })
  263. it('should convert conversion timeout to milliseconds', function (ctx) {
  264. expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
  265. })
  266. it('should return path to the output document', function (ctx) {
  267. expect(ctx.result).toBe(
  268. Path.join(ctx.compileDir, `output-uuid.${ctx.extension}`)
  269. )
  270. })
  271. })
  272. describe('when pandoc fails (non-zero exit code)', function () {
  273. it('should reject with an error and release the lock', async function (ctx) {
  274. ctx.compileDir = '/compiles/test-compile-dir'
  275. ctx.CommandRunner.promises.run.resolves({
  276. stdout: 'mock-stdout',
  277. stderr: 'mock-stderr',
  278. exitCode: 1,
  279. })
  280. await expect(
  281. ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
  282. ctx.conversionId,
  283. ctx.compileDir,
  284. 'main.tex',
  285. 'docx',
  286. 'docx'
  287. )
  288. ).to.be.rejectedWith('pandoc latex-to-document conversion failed')
  289. sinon.assert.called(ctx.lock.release)
  290. })
  291. })
  292. })
  293. })