const Path = require('path') const SandboxedModule = require('sandboxed-module') const { expect } = require('chai') const sinon = require('sinon') const MODULE_PATH = require('path').join( __dirname, '../../../app/js/CompileManager' ) describe('CompileManager', function () { beforeEach(function () { this.projectId = 'project-id-123' this.userId = '1234' this.resources = 'mock-resources' this.outputFiles = [ { path: 'output.log', type: 'log', }, { path: 'output.pdf', type: 'pdf', }, ] this.buildFiles = [ { path: 'output.log', type: 'log', build: 1234, }, { path: 'output.pdf', type: 'pdf', build: 1234, }, ] this.commandOutput = 'Dummy output' this.compileBaseDir = '/compile/dir' this.outputBaseDir = '/output/dir' this.compileDir = `${this.compileBaseDir}/${this.projectId}-${this.userId}` this.outputDir = `${this.outputBaseDir}/${this.projectId}-${this.userId}` this.LatexRunner = { promises: { runLatex: sinon.stub().resolves({}), }, } this.ResourceWriter = { promises: { syncResourcesToDisk: sinon.stub().resolves(this.resources), }, } this.OutputFileFinder = { promises: { findOutputFiles: sinon.stub().resolves(this.outputFiles), }, } this.OutputCacheManager = { promises: { saveOutputFiles: sinon.stub().resolves(this.buildFiles), }, } this.Settings = { path: { compilesDir: this.compileBaseDir, outputDir: this.outputBaseDir, synctexBaseDir: sinon.stub(), }, clsi: { docker: { image: 'SOMEIMAGE', }, }, } this.Settings.path.synctexBaseDir .withArgs(`${this.projectId}-${this.userId}`) .returns(this.compileDir) this.child_process = { exec: sinon.stub(), execFile: sinon.stub().yields(), } this.CommandRunner = { promises: { run: sinon.stub().resolves({ stdout: this.commandOutput }), }, } this.DraftModeManager = { promises: { injectDraftMode: sinon.stub().resolves(), }, } this.TikzManager = { promises: { checkMainFile: sinon.stub().resolves(false), }, } this.lock = { release: sinon.stub().resolves(), } this.LockManager = { acquire: sinon.stub().resolves(this.lock), } this.SynctexOutputParser = { parseViewOutput: sinon.stub(), parseEditOutput: sinon.stub(), } this.dirStats = { isDirectory: sinon.stub().returns(true), } this.fileStats = { isFile: sinon.stub().returns(true), } this.fsPromises = { lstat: sinon.stub(), stat: sinon.stub(), readFile: sinon.stub(), } this.fsPromises.lstat.withArgs(this.compileDir).resolves(this.dirStats) this.fsPromises.stat .withArgs(Path.join(this.compileDir, 'output.synctex.gz')) .resolves(this.fileStats) this.fse = { ensureDir: sinon.stub().resolves(), } this.CompileManager = SandboxedModule.require(MODULE_PATH, { requires: { './LatexRunner': this.LatexRunner, './ResourceWriter': this.ResourceWriter, './OutputFileFinder': this.OutputFileFinder, './OutputCacheManager': this.OutputCacheManager, '@overleaf/settings': this.Settings, '@overleaf/metrics': { inc: sinon.stub(), timing: sinon.stub(), gauge: sinon.stub(), Timer: sinon.stub().returns({ done: sinon.stub() }), }, child_process: this.child_process, './CommandRunner': this.CommandRunner, './DraftModeManager': this.DraftModeManager, './TikzManager': this.TikzManager, './LockManager': this.LockManager, './SynctexOutputParser': this.SynctexOutputParser, 'fs/promises': this.fsPromises, 'fs-extra': this.fse, }, }) }) describe('doCompileWithLock', function () { beforeEach(function () { this.request = { resources: this.resources, rootResourcePath: (this.rootResourcePath = 'main.tex'), project_id: this.projectId, user_id: this.userId, compiler: (this.compiler = 'pdflatex'), timeout: (this.timeout = 42000), imageName: (this.image = 'example.com/image'), flags: (this.flags = ['-file-line-error']), compileGroup: (this.compileGroup = 'compile-group'), stopOnFirstError: false, } this.env = {} }) describe('when the project is locked', function () { beforeEach(async function () { const error = new Error('locked') this.LockManager.acquire.rejects(error) await expect( this.CompileManager.promises.doCompileWithLock(this.request) ).to.be.rejectedWith(error) }) it('should ensure that the compile directory exists', function () { expect(this.fse.ensureDir).to.have.been.calledWith(this.compileDir) }) it('should not run LaTeX', function () { expect(this.LatexRunner.promises.runLatex).not.to.have.been.called }) }) describe('normally', function () { beforeEach(async function () { this.result = await this.CompileManager.promises.doCompileWithLock( this.request ) }) it('should ensure that the compile directory exists', function () { expect(this.fse.ensureDir).to.have.been.calledWith(this.compileDir) }) it('should write the resources to disk', function () { expect( this.ResourceWriter.promises.syncResourcesToDisk ).to.have.been.calledWith(this.request, this.compileDir) }) it('should run LaTeX', function () { expect(this.LatexRunner.promises.runLatex).to.have.been.calledWith( `${this.projectId}-${this.userId}`, { directory: this.compileDir, mainFile: this.rootResourcePath, compiler: this.compiler, timeout: this.timeout, image: this.image, flags: this.flags, environment: this.env, compileGroup: this.compileGroup, stopOnFirstError: this.request.stopOnFirstError, stats: sinon.match.object, timings: sinon.match.object, } ) }) it('should find the output files', function () { expect( this.OutputFileFinder.promises.findOutputFiles ).to.have.been.calledWith(this.resources, this.compileDir) }) it('should return the output files', function () { expect(this.result.outputFiles).to.equal(this.buildFiles) }) it('should not inject draft mode by default', function () { expect(this.DraftModeManager.promises.injectDraftMode).not.to.have.been .called }) }) describe('with draft mode', function () { beforeEach(async function () { this.request.draft = true await this.CompileManager.promises.doCompileWithLock(this.request) }) it('should inject the draft mode header', function () { expect( this.DraftModeManager.promises.injectDraftMode ).to.have.been.calledWith(this.compileDir + '/' + this.rootResourcePath) }) }) describe('with a check option', function () { beforeEach(async function () { this.request.check = 'error' await this.CompileManager.promises.doCompileWithLock(this.request) }) it('should run chktex', function () { expect(this.LatexRunner.promises.runLatex).to.have.been.calledWith( `${this.projectId}-${this.userId}`, { directory: this.compileDir, mainFile: this.rootResourcePath, compiler: this.compiler, timeout: this.timeout, image: this.image, flags: this.flags, environment: { CHKTEX_OPTIONS: '-nall -e9 -e10 -w15 -w16', CHKTEX_EXIT_ON_ERROR: 1, CHKTEX_ULIMIT_OPTIONS: '-t 5 -v 64000', }, compileGroup: this.compileGroup, stopOnFirstError: this.request.stopOnFirstError, stats: sinon.match.object, timings: sinon.match.object, } ) }) }) describe('with a knitr file and check options', function () { beforeEach(async function () { this.request.rootResourcePath = 'main.Rtex' this.request.check = 'error' await this.CompileManager.promises.doCompileWithLock(this.request) }) it('should not run chktex', function () { expect(this.LatexRunner.promises.runLatex).to.have.been.calledWith( `${this.projectId}-${this.userId}`, { directory: this.compileDir, mainFile: 'main.Rtex', compiler: this.compiler, timeout: this.timeout, image: this.image, flags: this.flags, environment: this.env, compileGroup: this.compileGroup, stopOnFirstError: this.request.stopOnFirstError, stats: sinon.match.object, timings: sinon.match.object, } ) }) }) describe('when the compile times out', function () { beforeEach(async function () { const error = new Error('timed out!') error.timedout = true this.LatexRunner.promises.runLatex.rejects(error) await expect( this.CompileManager.promises.doCompileWithLock(this.request) ).to.be.rejected }) it('should clear the compile directory', function () { expect(this.child_process.execFile).to.have.been.calledWith('rm', [ '-r', '-f', '--', this.compileDir, ]) }) }) describe('when the compile is manually stopped', function () { beforeEach(async function () { const error = new Error('terminated!') error.terminated = true this.LatexRunner.promises.runLatex.rejects(error) await expect( this.CompileManager.promises.doCompileWithLock(this.request) ).to.be.rejected }) it('should clear the compile directory', function () { expect(this.child_process.execFile).to.have.been.calledWith('rm', [ '-r', '-f', '--', this.compileDir, ]) }) }) }) describe('clearProject', function () { describe('successfully', function () { beforeEach(async function () { await this.CompileManager.promises.clearProject( this.projectId, this.userId ) }) it('should remove the project directory', function () { expect(this.child_process.execFile).to.have.been.calledWith('rm', [ '-r', '-f', '--', this.compileDir, ]) }) }) describe('with a non-success status code', function () { beforeEach(async function () { this.child_process.execFile.yields(new Error('oops')) await expect( this.CompileManager.promises.clearProject(this.projectId, this.userId) ).to.be.rejected }) it('should remove the project directory', function () { expect(this.child_process.execFile).to.have.been.calledWith('rm', [ '-r', '-f', '--', this.compileDir, ]) }) }) }) describe('syncing', function () { beforeEach(function () { this.page = 1 this.h = 42.23 this.v = 87.56 this.width = 100.01 this.height = 234.56 this.line = 5 this.column = 3 this.filename = 'main.tex' }) describe('syncFromCode', function () { beforeEach(function () { this.records = [{ page: 1, h: 2, v: 3, width: 4, height: 5 }] this.SynctexOutputParser.parseViewOutput .withArgs(this.commandOutput) .returns(this.records) }) describe('normal case', function () { beforeEach(async function () { this.result = await this.CompileManager.promises.syncFromCode( this.projectId, this.userId, this.filename, this.line, this.column, '' ) }) it('should execute the synctex binary', function () { const outputFilePath = `${this.compileDir}/output.pdf` const inputFilePath = `${this.compileDir}/${this.filename}` expect(this.CommandRunner.promises.run).to.have.been.calledWith( `${this.projectId}-${this.userId}`, [ 'synctex', 'view', '-i', `${this.line}:${this.column}:${inputFilePath}`, '-o', outputFilePath, ], this.compileDir, this.Settings.clsi.docker.image, 60000, {} ) }) it('should return the parsed output', function () { expect(this.result).to.deep.equal(this.records) }) }) describe('with a custom imageName', function () { const customImageName = 'foo/bar:tag-0' beforeEach(async function () { await this.CompileManager.promises.syncFromCode( this.projectId, this.userId, this.filename, this.line, this.column, customImageName ) }) it('should execute the synctex binary in a custom docker image', function () { const outputFilePath = `${this.compileDir}/output.pdf` const inputFilePath = `${this.compileDir}/${this.filename}` expect(this.CommandRunner.promises.run).to.have.been.calledWith( `${this.projectId}-${this.userId}`, [ 'synctex', 'view', '-i', `${this.line}:${this.column}:${inputFilePath}`, '-o', outputFilePath, ], this.compileDir, customImageName, 60000, {} ) }) }) }) describe('syncFromPdf', function () { beforeEach(function () { this.records = [{ file: 'main.tex', line: 1, column: 1 }] this.SynctexOutputParser.parseEditOutput .withArgs(this.commandOutput, this.compileDir) .returns(this.records) }) describe('normal case', function () { beforeEach(async function () { this.result = await this.CompileManager.promises.syncFromPdf( this.projectId, this.userId, this.page, this.h, this.v, '' ) }) it('should execute the synctex binary', function () { const outputFilePath = `${this.compileDir}/output.pdf` expect(this.CommandRunner.promises.run).to.have.been.calledWith( `${this.projectId}-${this.userId}`, [ 'synctex', 'edit', '-o', `${this.page}:${this.h}:${this.v}:${outputFilePath}`, ], this.compileDir, this.Settings.clsi.docker.image, 60000, {} ) }) it('should return the parsed output', function () { expect(this.result).to.deep.equal(this.records) }) }) describe('with a custom imageName', function () { const customImageName = 'foo/bar:tag-1' beforeEach(async function () { await this.CompileManager.promises.syncFromPdf( this.projectId, this.userId, this.page, this.h, this.v, customImageName ) }) it('should execute the synctex binary in a custom docker image', function () { const outputFilePath = `${this.compileDir}/output.pdf` expect(this.CommandRunner.promises.run).to.have.been.calledWith( `${this.projectId}-${this.userId}`, [ 'synctex', 'edit', '-o', `${this.page}:${this.h}:${this.v}:${outputFilePath}`, ], this.compileDir, customImageName, 60000, {} ) }) }) }) }) describe('wordcount', function () { beforeEach(async function () { this.stdout = 'Encoding: ascii\nWords in text: 2' this.fsPromises.readFile .withArgs(Path.join(this.compileDir, 'main.tex.wc')) .resolves(this.stdout) this.timeout = 60 * 1000 this.filename = 'main.tex' this.image = 'example.com/image' this.result = await this.CompileManager.promises.wordcount( this.projectId, this.userId, this.filename, this.image ) }) it('should run the texcount command', function () { this.filePath = `$COMPILE_DIR/${this.filename}` this.command = [ 'texcount', '-nocol', '-inc', this.filePath, `-out=${this.filePath}.wc`, ] expect(this.CommandRunner.promises.run).to.have.been.calledWith( `${this.projectId}-${this.userId}`, this.command, this.compileDir, this.image, this.timeout, {} ) }) it('should return the parsed output', function () { expect(this.result).to.deep.equal({ encode: 'ascii', textWords: 2, headWords: 0, outside: 0, headers: 0, elements: 0, mathInline: 0, mathDisplay: 0, errors: 0, messages: '', }) }) }) })