CompileManagerTests.js 18 KB

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