CompileManagerTests.js 17 KB

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