CompileManagerTests.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  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. }
  133. this.env = {}
  134. })
  135. describe('when the project is locked', function () {
  136. beforeEach(function () {
  137. this.error = new Error('locked')
  138. this.LockManager.runWithLock.callsFake((lockFile, runner, callback) => {
  139. callback(this.error)
  140. })
  141. this.CompileManager.doCompileWithLock(this.request, this.callback)
  142. })
  143. it('should ensure that the compile directory exists', function () {
  144. this.fse.ensureDir.calledWith(this.compileDir).should.equal(true)
  145. })
  146. it('should not run LaTeX', function () {
  147. this.LatexRunner.runLatex.called.should.equal(false)
  148. })
  149. it('should call the callback with the error', function () {
  150. this.callback.calledWithExactly(this.error).should.equal(true)
  151. })
  152. })
  153. describe('normally', function () {
  154. beforeEach(function () {
  155. this.CompileManager.doCompileWithLock(this.request, this.callback)
  156. })
  157. it('should ensure that the compile directory exists', function () {
  158. this.fse.ensureDir.calledWith(this.compileDir).should.equal(true)
  159. })
  160. it('should write the resources to disk', function () {
  161. this.ResourceWriter.syncResourcesToDisk
  162. .calledWith(this.request, this.compileDir)
  163. .should.equal(true)
  164. })
  165. it('should run LaTeX', function () {
  166. this.LatexRunner.runLatex
  167. .calledWith(`${this.projectId}-${this.userId}`, {
  168. directory: this.compileDir,
  169. mainFile: this.rootResourcePath,
  170. compiler: this.compiler,
  171. timeout: this.timeout,
  172. image: this.image,
  173. flags: this.flags,
  174. environment: this.env,
  175. compileGroup: this.compileGroup,
  176. })
  177. .should.equal(true)
  178. })
  179. it('should find the output files', function () {
  180. this.OutputFileFinder.findOutputFiles
  181. .calledWith(this.resources, this.compileDir)
  182. .should.equal(true)
  183. })
  184. it('should return the output files', function () {
  185. this.callback.calledWith(null, this.buildFiles).should.equal(true)
  186. })
  187. it('should not inject draft mode by default', function () {
  188. this.DraftModeManager.injectDraftMode.called.should.equal(false)
  189. })
  190. })
  191. describe('with draft mode', function () {
  192. beforeEach(function () {
  193. this.request.draft = true
  194. this.CompileManager.doCompileWithLock(this.request, this.callback)
  195. })
  196. it('should inject the draft mode header', function () {
  197. this.DraftModeManager.injectDraftMode
  198. .calledWith(this.compileDir + '/' + this.rootResourcePath)
  199. .should.equal(true)
  200. })
  201. })
  202. describe('with a check option', function () {
  203. beforeEach(function () {
  204. this.request.check = 'error'
  205. this.CompileManager.doCompileWithLock(this.request, this.callback)
  206. })
  207. it('should run chktex', function () {
  208. this.LatexRunner.runLatex
  209. .calledWith(`${this.projectId}-${this.userId}`, {
  210. directory: this.compileDir,
  211. mainFile: this.rootResourcePath,
  212. compiler: this.compiler,
  213. timeout: this.timeout,
  214. image: this.image,
  215. flags: this.flags,
  216. environment: {
  217. CHKTEX_OPTIONS: '-nall -e9 -e10 -w15 -w16',
  218. CHKTEX_EXIT_ON_ERROR: 1,
  219. CHKTEX_ULIMIT_OPTIONS: '-t 5 -v 64000',
  220. },
  221. compileGroup: this.compileGroup,
  222. })
  223. .should.equal(true)
  224. })
  225. })
  226. describe('with a knitr file and check options', function () {
  227. beforeEach(function () {
  228. this.request.rootResourcePath = 'main.Rtex'
  229. this.request.check = 'error'
  230. this.CompileManager.doCompileWithLock(this.request, this.callback)
  231. })
  232. it('should not run chktex', function () {
  233. this.LatexRunner.runLatex
  234. .calledWith(`${this.projectId}-${this.userId}`, {
  235. directory: this.compileDir,
  236. mainFile: 'main.Rtex',
  237. compiler: this.compiler,
  238. timeout: this.timeout,
  239. image: this.image,
  240. flags: this.flags,
  241. environment: this.env,
  242. compileGroup: this.compileGroup,
  243. })
  244. .should.equal(true)
  245. })
  246. })
  247. })
  248. describe('clearProject', function () {
  249. describe('succesfully', function () {
  250. beforeEach(function () {
  251. this.Settings.compileDir = 'compiles'
  252. this.fs.lstat.yields(null, {
  253. isDirectory() {
  254. return true
  255. },
  256. })
  257. this.CompileManager.clearProject(
  258. this.projectId,
  259. this.userId,
  260. this.callback
  261. )
  262. this.proc.emit('close', 0)
  263. })
  264. it('should remove the project directory', function () {
  265. this.child_process.spawn
  266. .calledWith('rm', ['-r', '-f', '--', this.compileDir, this.outputDir])
  267. .should.equal(true)
  268. })
  269. it('should call the callback', function () {
  270. this.callback.called.should.equal(true)
  271. })
  272. })
  273. describe('with a non-success status code', function () {
  274. beforeEach(function () {
  275. this.Settings.compileDir = 'compiles'
  276. this.fs.lstat.yields(null, {
  277. isDirectory() {
  278. return true
  279. },
  280. })
  281. this.CompileManager.clearProject(
  282. this.projectId,
  283. this.userId,
  284. this.callback
  285. )
  286. this.proc.stderr.emit('data', (this.error = 'oops'))
  287. this.proc.emit('close', 1)
  288. })
  289. it('should remove the project directory', function () {
  290. this.child_process.spawn
  291. .calledWith('rm', ['-r', '-f', '--', this.compileDir, this.outputDir])
  292. .should.equal(true)
  293. })
  294. it('should call the callback with an error from the stderr', function () {
  295. this.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
  296. this.callback.args[0][0].message.should.equal(
  297. `rm -r ${this.compileDir} ${this.outputDir} failed: ${this.error}`
  298. )
  299. })
  300. })
  301. })
  302. describe('syncing', function () {
  303. beforeEach(function () {
  304. this.page = 1
  305. this.h = 42.23
  306. this.v = 87.56
  307. this.width = 100.01
  308. this.height = 234.56
  309. this.line = 5
  310. this.column = 3
  311. this.filename = 'main.tex'
  312. })
  313. describe('syncFromCode', function () {
  314. beforeEach(function () {
  315. this.fs.stat.yields(null, {
  316. isFile() {
  317. return true
  318. },
  319. })
  320. this.records = [{ page: 1, h: 2, v: 3, width: 4, height: 5 }]
  321. this.SynctexOutputParser.parseViewOutput
  322. .withArgs(this.commandOutput)
  323. .returns(this.records)
  324. this.CompileManager.syncFromCode(
  325. this.projectId,
  326. this.userId,
  327. this.filename,
  328. this.line,
  329. this.column,
  330. '',
  331. this.callback
  332. )
  333. })
  334. it('should execute the synctex binary', function () {
  335. const outputFilePath = `${this.compileDir}/output.pdf`
  336. const inputFilePath = `${this.compileDir}/${this.filename}`
  337. this.CommandRunner.run.should.have.been.calledWith(
  338. `${this.projectId}-${this.userId}`,
  339. [
  340. 'synctex',
  341. 'view',
  342. '-i',
  343. `${this.line}:${this.column}:${inputFilePath}`,
  344. '-o',
  345. outputFilePath,
  346. ],
  347. this.compileDir,
  348. this.Settings.clsi.docker.image,
  349. 60000,
  350. {}
  351. )
  352. })
  353. it('should call the callback with the parsed output', function () {
  354. this.callback.should.have.been.calledWith(
  355. null,
  356. sinon.match.array.deepEquals(this.records)
  357. )
  358. })
  359. describe('with a custom imageName', function () {
  360. const customImageName = 'foo/bar:tag-0'
  361. beforeEach(function () {
  362. this.CommandRunner.run.reset()
  363. this.CompileManager.syncFromCode(
  364. this.projectId,
  365. this.userId,
  366. this.filename,
  367. this.line,
  368. this.column,
  369. customImageName,
  370. this.callback
  371. )
  372. })
  373. it('should execute the synctex binary in a custom docker image', function () {
  374. const outputFilePath = `${this.compileDir}/output.pdf`
  375. const inputFilePath = `${this.compileDir}/${this.filename}`
  376. this.CommandRunner.run.should.have.been.calledWith(
  377. `${this.projectId}-${this.userId}`,
  378. [
  379. 'synctex',
  380. 'view',
  381. '-i',
  382. `${this.line}:${this.column}:${inputFilePath}`,
  383. '-o',
  384. outputFilePath,
  385. ],
  386. this.compileDir,
  387. customImageName,
  388. 60000,
  389. {}
  390. )
  391. })
  392. })
  393. })
  394. describe('syncFromPdf', function () {
  395. beforeEach(function () {
  396. this.fs.stat.yields(null, {
  397. isFile() {
  398. return true
  399. },
  400. })
  401. this.records = [{ file: 'main.tex', line: 1, column: 1 }]
  402. this.SynctexOutputParser.parseEditOutput
  403. .withArgs(this.commandOutput, this.compileDir)
  404. .returns(this.records)
  405. this.CompileManager.syncFromPdf(
  406. this.projectId,
  407. this.userId,
  408. this.page,
  409. this.h,
  410. this.v,
  411. '',
  412. this.callback
  413. )
  414. })
  415. it('should execute the synctex binary', function () {
  416. const outputFilePath = `${this.compileDir}/output.pdf`
  417. this.CommandRunner.run.should.have.been.calledWith(
  418. `${this.projectId}-${this.userId}`,
  419. [
  420. 'synctex',
  421. 'edit',
  422. '-o',
  423. `${this.page}:${this.h}:${this.v}:${outputFilePath}`,
  424. ],
  425. this.compileDir,
  426. this.Settings.clsi.docker.image,
  427. 60000,
  428. {}
  429. )
  430. })
  431. it('should call the callback with the parsed output', function () {
  432. this.callback.should.have.been.calledWith(
  433. null,
  434. sinon.match.array.deepEquals(this.records)
  435. )
  436. })
  437. describe('with a custom imageName', function () {
  438. const customImageName = 'foo/bar:tag-1'
  439. beforeEach(function () {
  440. this.CommandRunner.run.reset()
  441. this.CompileManager.syncFromPdf(
  442. this.projectId,
  443. this.userId,
  444. this.page,
  445. this.h,
  446. this.v,
  447. customImageName,
  448. this.callback
  449. )
  450. })
  451. it('should execute the synctex binary in a custom docker image', function () {
  452. const outputFilePath = `${this.compileDir}/output.pdf`
  453. this.CommandRunner.run
  454. .calledWith(
  455. `${this.projectId}-${this.userId}`,
  456. [
  457. 'synctex',
  458. 'edit',
  459. '-o',
  460. `${this.page}:${this.h}:${this.v}:${outputFilePath}`,
  461. ],
  462. this.compileDir,
  463. customImageName,
  464. 60000,
  465. {}
  466. )
  467. .should.equal(true)
  468. })
  469. })
  470. })
  471. })
  472. describe('wordcount', function () {
  473. beforeEach(function () {
  474. this.stdout = 'Encoding: ascii\nWords in text: 2'
  475. this.fs.readFile.yields(null, this.stdout)
  476. this.timeout = 60 * 1000
  477. this.filename = 'main.tex'
  478. this.image = 'example.com/image'
  479. this.CompileManager.wordcount(
  480. this.projectId,
  481. this.userId,
  482. this.filename,
  483. this.image,
  484. this.callback
  485. )
  486. })
  487. it('should run the texcount command', function () {
  488. this.filePath = `$COMPILE_DIR/${this.filename}`
  489. this.command = [
  490. 'texcount',
  491. '-nocol',
  492. '-inc',
  493. this.filePath,
  494. `-out=${this.filePath}.wc`,
  495. ]
  496. this.CommandRunner.run
  497. .calledWith(
  498. `${this.projectId}-${this.userId}`,
  499. this.command,
  500. this.compileDir,
  501. this.image,
  502. this.timeout,
  503. {}
  504. )
  505. .should.equal(true)
  506. })
  507. it('should call the callback with the parsed output', function () {
  508. this.callback
  509. .calledWith(null, {
  510. encode: 'ascii',
  511. textWords: 2,
  512. headWords: 0,
  513. outside: 0,
  514. headers: 0,
  515. elements: 0,
  516. mathInline: 0,
  517. mathDisplay: 0,
  518. errors: 0,
  519. messages: '',
  520. })
  521. .should.equal(true)
  522. })
  523. })
  524. })