CompileManagerTests.js 18 KB

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