CompileManagerTests.js 16 KB

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