CompileManagerTests.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. const SandboxedModule = require('sandboxed-module')
  2. const sinon = require('sinon')
  3. const modulePath = require('path').join(
  4. __dirname,
  5. '../../../app/js/CompileManager'
  6. )
  7. const { EventEmitter } = require('events')
  8. describe('CompileManager', function () {
  9. beforeEach(function () {
  10. this.callback = sinon.stub()
  11. this.projectId = 'project-id-123'
  12. this.userId = '1234'
  13. this.resources = 'mock-resources'
  14. this.outputFiles = [
  15. {
  16. path: 'output.log',
  17. type: 'log',
  18. },
  19. {
  20. path: 'output.pdf',
  21. type: 'pdf',
  22. },
  23. ]
  24. this.buildFiles = [
  25. {
  26. path: 'output.log',
  27. type: 'log',
  28. build: 1234,
  29. },
  30. {
  31. path: 'output.pdf',
  32. type: 'pdf',
  33. build: 1234,
  34. },
  35. ]
  36. this.commandOutput = 'Dummy output'
  37. this.compileBaseDir = '/compile/dir'
  38. this.outputBaseDir = '/output/dir'
  39. this.compileDir = `${this.compileBaseDir}/${this.projectId}-${this.userId}`
  40. this.outputDir = `${this.outputBaseDir}/${this.projectId}-${this.userId}`
  41. this.proc = new EventEmitter()
  42. this.proc.stdout = new EventEmitter()
  43. this.proc.stderr = new EventEmitter()
  44. this.proc.stderr.setEncoding = sinon.stub().returns(this.proc.stderr)
  45. this.LatexRunner = {
  46. runLatex: sinon.stub().yields(),
  47. }
  48. this.ResourceWriter = {
  49. syncResourcesToDisk: sinon.stub().yields(null, this.resources),
  50. }
  51. this.OutputFileFinder = {
  52. findOutputFiles: sinon.stub().yields(null, this.outputFiles),
  53. }
  54. this.OutputCacheManager = {
  55. saveOutputFiles: sinon.stub().yields(null, this.buildFiles),
  56. }
  57. this.Settings = {
  58. path: {
  59. compilesDir: this.compileBaseDir,
  60. outputDir: this.outputBaseDir,
  61. synctexBaseDir: sinon.stub(),
  62. },
  63. clsi: {
  64. docker: {
  65. image: 'SOMEIMAGE',
  66. },
  67. },
  68. }
  69. this.Settings.path.synctexBaseDir
  70. .withArgs(`${this.projectId}-${this.userId}`)
  71. .returns(this.compileDir)
  72. this.child_process = {
  73. exec: sinon.stub(),
  74. spawn: sinon.stub().returns(this.proc),
  75. }
  76. this.CommandRunner = {
  77. run: sinon.stub().yields(null, { stdout: this.commandOutput }),
  78. }
  79. this.DraftModeManager = {
  80. injectDraftMode: sinon.stub().yields(),
  81. }
  82. this.TikzManager = {
  83. checkMainFile: sinon.stub().yields(null, false),
  84. }
  85. this.LockManager = {
  86. runWithLock: sinon.stub().callsFake((lockFile, runner, callback) => {
  87. runner((err, ...result) => callback(err, ...result))
  88. }),
  89. }
  90. this.SynctexOutputParser = {
  91. parseViewOutput: sinon.stub(),
  92. parseEditOutput: sinon.stub(),
  93. }
  94. this.fs = {
  95. lstat: sinon.stub(),
  96. stat: sinon.stub(),
  97. readFile: sinon.stub(),
  98. }
  99. this.fse = {
  100. ensureDir: sinon.stub().yields(),
  101. }
  102. this.CompileManager = SandboxedModule.require(modulePath, {
  103. requires: {
  104. './LatexRunner': this.LatexRunner,
  105. './ResourceWriter': this.ResourceWriter,
  106. './OutputFileFinder': this.OutputFileFinder,
  107. './OutputCacheManager': this.OutputCacheManager,
  108. '@overleaf/settings': this.Settings,
  109. child_process: this.child_process,
  110. './CommandRunner': this.CommandRunner,
  111. './DraftModeManager': this.DraftModeManager,
  112. './TikzManager': this.TikzManager,
  113. './LockManager': this.LockManager,
  114. './SynctexOutputParser': this.SynctexOutputParser,
  115. fs: this.fs,
  116. 'fs-extra': this.fse,
  117. },
  118. })
  119. })
  120. describe('doCompileWithLock', function () {
  121. beforeEach(function () {
  122. this.request = {
  123. resources: this.resources,
  124. rootResourcePath: (this.rootResourcePath = 'main.tex'),
  125. project_id: this.projectId,
  126. user_id: this.userId,
  127. compiler: (this.compiler = 'pdflatex'),
  128. timeout: (this.timeout = 42000),
  129. imageName: (this.image = 'example.com/image'),
  130. flags: (this.flags = ['-file-line-error']),
  131. compileGroup: (this.compileGroup = 'compile-group'),
  132. stopOnFirstError: false,
  133. }
  134. this.env = {}
  135. })
  136. describe('when the project is locked', function () {
  137. beforeEach(function () {
  138. this.error = new Error('locked')
  139. this.LockManager.runWithLock.callsFake((lockFile, runner, callback) => {
  140. callback(this.error)
  141. })
  142. this.CompileManager.doCompileWithLock(this.request, this.callback)
  143. })
  144. it('should ensure that the compile directory exists', function () {
  145. this.fse.ensureDir.calledWith(this.compileDir).should.equal(true)
  146. })
  147. it('should not run LaTeX', function () {
  148. this.LatexRunner.runLatex.called.should.equal(false)
  149. })
  150. it('should call the callback with the error', function () {
  151. this.callback.calledWithExactly(this.error).should.equal(true)
  152. })
  153. })
  154. describe('normally', function () {
  155. beforeEach(function () {
  156. this.CompileManager.doCompileWithLock(this.request, this.callback)
  157. })
  158. it('should ensure that the compile directory exists', function () {
  159. this.fse.ensureDir.calledWith(this.compileDir).should.equal(true)
  160. })
  161. it('should write the resources to disk', function () {
  162. this.ResourceWriter.syncResourcesToDisk
  163. .calledWith(this.request, this.compileDir)
  164. .should.equal(true)
  165. })
  166. it('should run LaTeX', function () {
  167. this.LatexRunner.runLatex
  168. .calledWith(`${this.projectId}-${this.userId}`, {
  169. directory: this.compileDir,
  170. mainFile: this.rootResourcePath,
  171. compiler: this.compiler,
  172. timeout: this.timeout,
  173. image: this.image,
  174. flags: this.flags,
  175. environment: this.env,
  176. compileGroup: this.compileGroup,
  177. stopOnFirstError: this.request.stopOnFirstError,
  178. })
  179. .should.equal(true)
  180. })
  181. it('should find the output files', function () {
  182. this.OutputFileFinder.findOutputFiles
  183. .calledWith(this.resources, this.compileDir)
  184. .should.equal(true)
  185. })
  186. it('should return the output files', function () {
  187. this.callback.calledWith(null, this.buildFiles).should.equal(true)
  188. })
  189. it('should not inject draft mode by default', function () {
  190. this.DraftModeManager.injectDraftMode.called.should.equal(false)
  191. })
  192. })
  193. describe('with draft mode', function () {
  194. beforeEach(function () {
  195. this.request.draft = true
  196. this.CompileManager.doCompileWithLock(this.request, this.callback)
  197. })
  198. it('should inject the draft mode header', function () {
  199. this.DraftModeManager.injectDraftMode
  200. .calledWith(this.compileDir + '/' + this.rootResourcePath)
  201. .should.equal(true)
  202. })
  203. })
  204. describe('with a check option', function () {
  205. beforeEach(function () {
  206. this.request.check = 'error'
  207. this.CompileManager.doCompileWithLock(this.request, this.callback)
  208. })
  209. it('should run chktex', function () {
  210. this.LatexRunner.runLatex
  211. .calledWith(`${this.projectId}-${this.userId}`, {
  212. directory: this.compileDir,
  213. mainFile: this.rootResourcePath,
  214. compiler: this.compiler,
  215. timeout: this.timeout,
  216. image: this.image,
  217. flags: this.flags,
  218. environment: {
  219. CHKTEX_OPTIONS: '-nall -e9 -e10 -w15 -w16',
  220. CHKTEX_EXIT_ON_ERROR: 1,
  221. CHKTEX_ULIMIT_OPTIONS: '-t 5 -v 64000',
  222. },
  223. compileGroup: this.compileGroup,
  224. stopOnFirstError: this.request.stopOnFirstError,
  225. })
  226. .should.equal(true)
  227. })
  228. })
  229. describe('with a knitr file and check options', function () {
  230. beforeEach(function () {
  231. this.request.rootResourcePath = 'main.Rtex'
  232. this.request.check = 'error'
  233. this.CompileManager.doCompileWithLock(this.request, this.callback)
  234. })
  235. it('should not run chktex', function () {
  236. this.LatexRunner.runLatex
  237. .calledWith(`${this.projectId}-${this.userId}`, {
  238. directory: this.compileDir,
  239. mainFile: 'main.Rtex',
  240. compiler: this.compiler,
  241. timeout: this.timeout,
  242. image: this.image,
  243. flags: this.flags,
  244. environment: this.env,
  245. compileGroup: this.compileGroup,
  246. stopOnFirstError: this.request.stopOnFirstError,
  247. })
  248. .should.equal(true)
  249. })
  250. })
  251. })
  252. describe('clearProject', function () {
  253. describe('succesfully', function () {
  254. beforeEach(function () {
  255. this.Settings.compileDir = 'compiles'
  256. this.fs.lstat.yields(null, {
  257. isDirectory() {
  258. return true
  259. },
  260. })
  261. this.CompileManager.clearProject(
  262. this.projectId,
  263. this.userId,
  264. this.callback
  265. )
  266. this.proc.emit('close', 0)
  267. })
  268. it('should remove the project directory', function () {
  269. this.child_process.spawn
  270. .calledWith('rm', ['-r', '-f', '--', this.compileDir])
  271. .should.equal(true)
  272. })
  273. it('should call the callback', function () {
  274. this.callback.called.should.equal(true)
  275. })
  276. })
  277. describe('with a non-success status code', function () {
  278. beforeEach(function () {
  279. this.Settings.compileDir = 'compiles'
  280. this.fs.lstat.yields(null, {
  281. isDirectory() {
  282. return true
  283. },
  284. })
  285. this.CompileManager.clearProject(
  286. this.projectId,
  287. this.userId,
  288. this.callback
  289. )
  290. this.proc.stderr.emit('data', (this.error = 'oops'))
  291. this.proc.emit('close', 1)
  292. })
  293. it('should remove the project directory', function () {
  294. this.child_process.spawn
  295. .calledWith('rm', ['-r', '-f', '--', this.compileDir])
  296. .should.equal(true)
  297. })
  298. it('should call the callback with an error from the stderr', function () {
  299. this.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
  300. this.callback.args[0][0].message.should.equal(
  301. `rm -r ${this.compileDir} failed: ${this.error}`
  302. )
  303. })
  304. })
  305. })
  306. describe('syncing', function () {
  307. beforeEach(function () {
  308. this.page = 1
  309. this.h = 42.23
  310. this.v = 87.56
  311. this.width = 100.01
  312. this.height = 234.56
  313. this.line = 5
  314. this.column = 3
  315. this.filename = 'main.tex'
  316. })
  317. describe('syncFromCode', function () {
  318. beforeEach(function () {
  319. this.fs.stat.yields(null, {
  320. isFile() {
  321. return true
  322. },
  323. })
  324. this.records = [{ page: 1, h: 2, v: 3, width: 4, height: 5 }]
  325. this.SynctexOutputParser.parseViewOutput
  326. .withArgs(this.commandOutput)
  327. .returns(this.records)
  328. this.CompileManager.syncFromCode(
  329. this.projectId,
  330. this.userId,
  331. this.filename,
  332. this.line,
  333. this.column,
  334. '',
  335. this.callback
  336. )
  337. })
  338. it('should execute the synctex binary', function () {
  339. const outputFilePath = `${this.compileDir}/output.pdf`
  340. const inputFilePath = `${this.compileDir}/${this.filename}`
  341. this.CommandRunner.run.should.have.been.calledWith(
  342. `${this.projectId}-${this.userId}`,
  343. [
  344. 'synctex',
  345. 'view',
  346. '-i',
  347. `${this.line}:${this.column}:${inputFilePath}`,
  348. '-o',
  349. outputFilePath,
  350. ],
  351. this.compileDir,
  352. this.Settings.clsi.docker.image,
  353. 60000,
  354. {}
  355. )
  356. })
  357. it('should call the callback with the parsed output', function () {
  358. this.callback.should.have.been.calledWith(
  359. null,
  360. sinon.match.array.deepEquals(this.records)
  361. )
  362. })
  363. describe('with a custom imageName', function () {
  364. const customImageName = 'foo/bar:tag-0'
  365. beforeEach(function () {
  366. this.CommandRunner.run.reset()
  367. this.CompileManager.syncFromCode(
  368. this.projectId,
  369. this.userId,
  370. this.filename,
  371. this.line,
  372. this.column,
  373. customImageName,
  374. this.callback
  375. )
  376. })
  377. it('should execute the synctex binary in a custom docker image', function () {
  378. const outputFilePath = `${this.compileDir}/output.pdf`
  379. const inputFilePath = `${this.compileDir}/${this.filename}`
  380. this.CommandRunner.run.should.have.been.calledWith(
  381. `${this.projectId}-${this.userId}`,
  382. [
  383. 'synctex',
  384. 'view',
  385. '-i',
  386. `${this.line}:${this.column}:${inputFilePath}`,
  387. '-o',
  388. outputFilePath,
  389. ],
  390. this.compileDir,
  391. customImageName,
  392. 60000,
  393. {}
  394. )
  395. })
  396. })
  397. })
  398. describe('syncFromPdf', function () {
  399. beforeEach(function () {
  400. this.fs.stat.yields(null, {
  401. isFile() {
  402. return true
  403. },
  404. })
  405. this.records = [{ file: 'main.tex', line: 1, column: 1 }]
  406. this.SynctexOutputParser.parseEditOutput
  407. .withArgs(this.commandOutput, this.compileDir)
  408. .returns(this.records)
  409. this.CompileManager.syncFromPdf(
  410. this.projectId,
  411. this.userId,
  412. this.page,
  413. this.h,
  414. this.v,
  415. '',
  416. this.callback
  417. )
  418. })
  419. it('should execute the synctex binary', function () {
  420. const outputFilePath = `${this.compileDir}/output.pdf`
  421. this.CommandRunner.run.should.have.been.calledWith(
  422. `${this.projectId}-${this.userId}`,
  423. [
  424. 'synctex',
  425. 'edit',
  426. '-o',
  427. `${this.page}:${this.h}:${this.v}:${outputFilePath}`,
  428. ],
  429. this.compileDir,
  430. this.Settings.clsi.docker.image,
  431. 60000,
  432. {}
  433. )
  434. })
  435. it('should call the callback with the parsed output', function () {
  436. this.callback.should.have.been.calledWith(
  437. null,
  438. sinon.match.array.deepEquals(this.records)
  439. )
  440. })
  441. describe('with a custom imageName', function () {
  442. const customImageName = 'foo/bar:tag-1'
  443. beforeEach(function () {
  444. this.CommandRunner.run.reset()
  445. this.CompileManager.syncFromPdf(
  446. this.projectId,
  447. this.userId,
  448. this.page,
  449. this.h,
  450. this.v,
  451. customImageName,
  452. this.callback
  453. )
  454. })
  455. it('should execute the synctex binary in a custom docker image', function () {
  456. const outputFilePath = `${this.compileDir}/output.pdf`
  457. this.CommandRunner.run
  458. .calledWith(
  459. `${this.projectId}-${this.userId}`,
  460. [
  461. 'synctex',
  462. 'edit',
  463. '-o',
  464. `${this.page}:${this.h}:${this.v}:${outputFilePath}`,
  465. ],
  466. this.compileDir,
  467. customImageName,
  468. 60000,
  469. {}
  470. )
  471. .should.equal(true)
  472. })
  473. })
  474. })
  475. })
  476. describe('wordcount', function () {
  477. beforeEach(function () {
  478. this.stdout = 'Encoding: ascii\nWords in text: 2'
  479. this.fs.readFile.yields(null, this.stdout)
  480. this.timeout = 60 * 1000
  481. this.filename = 'main.tex'
  482. this.image = 'example.com/image'
  483. this.CompileManager.wordcount(
  484. this.projectId,
  485. this.userId,
  486. this.filename,
  487. this.image,
  488. this.callback
  489. )
  490. })
  491. it('should run the texcount command', function () {
  492. this.filePath = `$COMPILE_DIR/${this.filename}`
  493. this.command = [
  494. 'texcount',
  495. '-nocol',
  496. '-inc',
  497. this.filePath,
  498. `-out=${this.filePath}.wc`,
  499. ]
  500. this.CommandRunner.run
  501. .calledWith(
  502. `${this.projectId}-${this.userId}`,
  503. this.command,
  504. this.compileDir,
  505. this.image,
  506. this.timeout,
  507. {}
  508. )
  509. .should.equal(true)
  510. })
  511. it('should call the callback with the parsed output', function () {
  512. this.callback
  513. .calledWith(null, {
  514. encode: 'ascii',
  515. textWords: 2,
  516. headWords: 0,
  517. outside: 0,
  518. headers: 0,
  519. elements: 0,
  520. mathInline: 0,
  521. mathDisplay: 0,
  522. errors: 0,
  523. messages: '',
  524. })
  525. .should.equal(true)
  526. })
  527. })
  528. })