CompileManagerTests.js 18 KB

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