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