CompileManagerTests.js 18 KB

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