CompileManagerTests.js 17 KB

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