CompileManagerTests.js 18 KB

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