CompileManagerTests.js 18 KB

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