import { vi, expect, describe, beforeEach, it } from 'vitest' import Path from 'node:path' import sinon from 'sinon' import Metrics from '../../../app/js/Metrics.js' const MODULE_PATH = Path.join( import.meta.dirname, '../../../app/js/CompileManager' ) describe('CompileManager', () => { beforeEach(async ctx => { ctx.projectId = 'project-id-123' ctx.userId = '1234' ctx.resources = 'mock-resources' ctx.outputFiles = [ { path: 'output.log', type: 'log', }, { path: 'output.pdf', type: 'pdf', }, ] ctx.buildFiles = [ { path: 'output.log', type: 'log', build: '1234-5678', }, { path: 'output.pdf', type: 'pdf', build: '1234-5678', }, ] ctx.buildId = '00000000000-0000000000000000' ctx.commandOutput = 'Dummy output' ctx.compileBaseDir = '/compile/dir' ctx.outputBaseDir = '/output/dir' ctx.compileDir = `${ctx.compileBaseDir}/${ctx.projectId}-${ctx.userId}` ctx.outputDir = `${ctx.outputBaseDir}/${ctx.projectId}-${ctx.userId}` ctx.LatexRunner = { promises: { runLatex: sinon.stub().resolves({}), }, } ctx.ResourceWriter = { promises: { syncResourcesToDisk: sinon.stub().resolves(ctx.resources), }, } ctx.OutputFileFinder = { promises: { findOutputFiles: sinon.stub().resolves({ outputFiles: ctx.outputFiles, allEntries: ctx.outputFiles.map(f => f.path).concat(['main.tex']), }), }, } ctx.OutputCacheManager = { BUILD_REGEX: /^[0-9a-f]+-[0-9a-f]+$/, CACHE_SUBDIR: 'generated-files', promises: { queueDirOperation: sinon.stub().callsArg(1), saveOutputFiles: sinon .stub() .resolves({ outputFiles: ctx.buildFiles, buildId: ctx.buildId }), }, } ctx.Settings = { path: { compilesDir: ctx.compileBaseDir, outputDir: ctx.outputBaseDir, synctexBaseDir: sinon.stub(), }, clsi: { docker: { image: 'SOMEIMAGE', }, }, } ctx.Settings.path.synctexBaseDir .withArgs(`${ctx.projectId}-${ctx.userId}`) .returns(ctx.compileDir) ctx.child_process = { exec: sinon.stub(), execFile: sinon.stub().yields(), } ctx.CommandRunner = { canRunSyncTeXInOutputDir: sinon.stub().returns(false), promises: { run: sinon.stub().callsFake((_1, _2, _3, _4, _5, _6, compileGroup) => { if (compileGroup === 'synctex' || compileGroup === 'synctex-output') { return Promise.resolve({ stdout: ctx.commandOutput }) } else { return Promise.resolve({ stdout: 'Encoding: ascii\nWords in text: 2', }) } }), }, } ctx.DraftModeManager = { promises: { injectDraftMode: sinon.stub().resolves(), }, } ctx.TikzManager = { promises: { checkMainFile: sinon.stub().resolves(false), }, } ctx.lock = { release: sinon.stub(), } ctx.LockManager = { acquire: sinon.stub().returns(ctx.lock), } ctx.SynctexOutputParser = { parseViewOutput: sinon.stub(), parseEditOutput: sinon.stub(), } ctx.dirStats = { isDirectory: sinon.stub().returns(true), } ctx.fileStats = { isFile: sinon.stub().returns(true), } ctx.fsPromises = { lstat: sinon.stub(), stat: sinon.stub(), readFile: sinon.stub(), mkdir: sinon.stub().resolves(), rm: sinon.stub().resolves(), unlink: sinon.stub().resolves(), rmdir: sinon.stub().resolves(), } ctx.fsPromises.lstat.withArgs(ctx.compileDir).resolves(ctx.dirStats) ctx.fsPromises.stat .withArgs(Path.join(ctx.compileDir, 'output.synctex.gz')) .resolves(ctx.fileStats) ctx.CLSICacheHandler = { notifyCLSICacheAboutBuild: sinon.stub(), downloadLatestCompileCache: sinon.stub().resolves(), downloadOutputDotSynctexFromCompileCache: sinon.stub().resolves(), } ctx.LatexMetrics = { enableLatexMkMetrics: sinon.stub() } ctx.StatsManager = { sampleRequest: sinon.stub().returns(false) } vi.doMock('../../../app/js/LatexRunner', () => ({ default: ctx.LatexRunner, })) vi.doMock('../../../app/js/ResourceWriter', () => ({ default: ctx.ResourceWriter, })) vi.doMock('../../../app/js/OutputFileFinder', () => ({ default: ctx.OutputFileFinder, })) vi.doMock('../../../app/js/OutputCacheManager', () => ({ default: ctx.OutputCacheManager, })) vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings, })) vi.doMock('@overleaf/metrics', () => ({ default: { inc: sinon.stub(), timing: sinon.stub(), gauge: sinon.stub(), Timer: sinon.stub().returns({ done: sinon.stub() }), }, })) vi.doMock('child_process', () => ({ default: ctx.child_process, })) vi.doMock('../../../app/js/CommandRunner', () => ({ default: ctx.CommandRunner, })) vi.doMock('../../../app/js/DraftModeManager', () => ({ default: ctx.DraftModeManager, })) vi.doMock('../../../app/js/TikzManager', () => ({ default: ctx.TikzManager, })) vi.doMock('../../../app/js/LockManager', () => ({ default: ctx.LockManager, })) vi.doMock('../../../app/js/SynctexOutputParser', () => ({ default: ctx.SynctexOutputParser, })) vi.doMock('fs/promises', () => ({ default: ctx.fsPromises, })) vi.doMock('../../../app/js/CLSICacheHandler', () => ({ default: ctx.CLSICacheHandler, })) vi.doMock('../../../app/js/LatexMetrics', () => ({ default: ctx.LatexMetrics, })) vi.doMock('../../../app/js/StatsManager', () => ({ default: ctx.StatsManager, })) vi.doMock('../../../app/js/Metrics', () => ({ default: Metrics, })) ctx.CompileManager = (await import(MODULE_PATH)).default }) describe('doCompileWithLock', () => { beforeEach(ctx => { ctx.request = { resources: ctx.resources, rootResourcePath: (ctx.rootResourcePath = 'main.tex'), project_id: ctx.projectId, user_id: ctx.userId, compiler: (ctx.compiler = 'pdflatex'), timeout: (ctx.timeout = 42000), imageName: (ctx.image = 'example.com/image'), flags: (ctx.flags = ['-file-line-error']), compileGroup: (ctx.compileGroup = 'compile-group'), stopOnFirstError: false, metricsOpts: { path: 'clsi-perf', method: 'minimal', compile: 'initial', }, } ctx.env = { OVERLEAF_PROJECT_ID: ctx.projectId, } }) describe('when the project is locked', () => { beforeEach(async ctx => { const error = new Error('locked') ctx.LockManager.acquire.throws(error) await expect( ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) ).to.be.rejectedWith(error) }) it('should ensure that the compile directory exists', ctx => { expect(ctx.fsPromises.mkdir).to.have.been.calledWith(ctx.compileDir, { recursive: true, }) }) it('should not run LaTeX', ctx => { expect(ctx.LatexRunner.promises.runLatex).not.to.have.been.called }) }) describe('normally', () => { beforeEach(async ctx => { ctx.result = await ctx.CompileManager.promises.doCompileWithLock( ctx.request, {}, {} ) }) it('should ensure that the compile directory exists', ctx => { expect(ctx.fsPromises.mkdir).to.have.been.calledWith(ctx.compileDir, { recursive: true, }) }) it('should write the resources to disk', ctx => { expect( ctx.ResourceWriter.promises.syncResourcesToDisk ).to.have.been.calledWith(ctx.request, ctx.compileDir) }) it('should run LaTeX', ctx => { expect(ctx.LatexRunner.promises.runLatex).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, { directory: ctx.compileDir, mainFile: ctx.rootResourcePath, compiler: ctx.compiler, timeout: ctx.timeout, image: ctx.image, flags: ctx.flags, environment: ctx.env, compileGroup: ctx.compileGroup, stopOnFirstError: ctx.request.stopOnFirstError, stats: sinon.match.object, timings: sinon.match.object, } ) }) it('should find the output files', ctx => { expect( ctx.OutputFileFinder.promises.findOutputFiles ).to.have.been.calledWith(ctx.resources, ctx.compileDir) }) it('should return the output files', ctx => { expect(ctx.result.outputFiles).to.equal(ctx.buildFiles) }) it('should not inject draft mode by default', ctx => { expect(ctx.DraftModeManager.promises.injectDraftMode).not.to.have.been .called }) }) describe('with performance metric collection', () => { it('should enable latexmk metrics when sampleRequest returns true', async ctx => { ctx.StatsManager.sampleRequest.returns(true) await ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) expect(ctx.LatexMetrics.enableLatexMkMetrics).to.have.been.calledWith( sinon.match.object ) }) it('should enable latexmk metrics when sampleRequest returns false', async ctx => { ctx.StatsManager.sampleRequest.returns(false) await ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) expect(ctx.LatexMetrics.enableLatexMkMetrics).to.have.been.calledWith( sinon.match.object ) }) it('should enable latexmk metrics when sampleRequest returns undefined', async ctx => { ctx.StatsManager.sampleRequest.returns(undefined) await ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) expect(ctx.LatexMetrics.enableLatexMkMetrics).to.have.been.calledWith( sinon.match.object ) }) }) describe('with draft mode', () => { beforeEach(async ctx => { ctx.request.draft = true await ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) }) it('should inject the draft mode header', ctx => { expect( ctx.DraftModeManager.promises.injectDraftMode ).to.have.been.calledWith(ctx.compileDir + '/' + ctx.rootResourcePath) }) }) describe('with a check option', () => { beforeEach(async ctx => { ctx.request.check = 'error' await ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) }) it('should run chktex', ctx => { expect(ctx.LatexRunner.promises.runLatex).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, { directory: ctx.compileDir, mainFile: ctx.rootResourcePath, compiler: ctx.compiler, timeout: ctx.timeout, image: ctx.image, flags: ctx.flags, environment: { CHKTEX_OPTIONS: '-nall -e9 -e10 -w15 -w16', CHKTEX_EXIT_ON_ERROR: 1, CHKTEX_ULIMIT_OPTIONS: '-t 5 -v 64000', OVERLEAF_PROJECT_ID: ctx.projectId, }, compileGroup: ctx.compileGroup, stopOnFirstError: ctx.request.stopOnFirstError, stats: sinon.match.object, timings: sinon.match.object, } ) }) }) describe('with a knitr file and check options', () => { beforeEach(async ctx => { ctx.request.rootResourcePath = 'main.Rtex' ctx.request.check = 'error' await ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) }) it('should not run chktex', ctx => { expect(ctx.LatexRunner.promises.runLatex).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, { directory: ctx.compileDir, mainFile: 'main.Rtex', compiler: ctx.compiler, timeout: ctx.timeout, image: ctx.image, flags: ctx.flags, environment: ctx.env, compileGroup: ctx.compileGroup, stopOnFirstError: ctx.request.stopOnFirstError, stats: sinon.match.object, timings: sinon.match.object, } ) }) }) describe('when the compile times out', () => { beforeEach(async ctx => { const error = new Error('timed out!') error.timedout = true ctx.LatexRunner.promises.runLatex.rejects(error) await expect( ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) ).to.be.rejected }) it('should clear the compile directory', ctx => { for (const { path } of ctx.buildFiles) { expect(ctx.fsPromises.unlink).to.have.been.calledWith( ctx.compileDir + '/' + path ) } expect(ctx.fsPromises.unlink).to.have.been.calledWith( ctx.compileDir + '/main.tex' ) expect(ctx.fsPromises.rmdir).to.have.been.calledWith(ctx.compileDir) }) }) describe('when the compile is manually stopped', () => { beforeEach(async ctx => { const error = new Error('terminated!') error.terminated = true ctx.LatexRunner.promises.runLatex.rejects(error) await expect( ctx.CompileManager.promises.doCompileWithLock(ctx.request, {}, {}) ).to.be.rejected }) it('should clear the compile directory', ctx => { for (const { path } of ctx.buildFiles) { expect(ctx.fsPromises.unlink).to.have.been.calledWith( ctx.compileDir + '/' + path ) } expect(ctx.fsPromises.unlink).to.have.been.calledWith( ctx.compileDir + '/main.tex' ) expect(ctx.fsPromises.rmdir).to.have.been.calledWith(ctx.compileDir) }) }) }) describe('clearProject', () => { it('should clear the compile directory', async ctx => { await ctx.CompileManager.promises.clearProject(ctx.projectId, ctx.userId) expect(ctx.fsPromises.rm).to.have.been.calledWith(ctx.compileDir, { force: true, recursive: true, }) }) }) describe('syncing', () => { beforeEach(ctx => { ctx.page = 1 ctx.h = 42.23 ctx.v = 87.56 ctx.width = 100.01 ctx.height = 234.56 ctx.line = 5 ctx.column = 3 ctx.filename = 'main.tex' }) describe('syncFromCode', () => { beforeEach(ctx => { ctx.records = [{ page: 1, h: 2, v: 3, width: 4, height: 5 }] ctx.SynctexOutputParser.parseViewOutput .withArgs(ctx.commandOutput) .returns(ctx.records) }) describe('normal case', () => { beforeEach(async ctx => { ctx.result = await ctx.CompileManager.promises.syncFromCode( ctx.projectId, ctx.userId, ctx.filename, ctx.line, ctx.column, '' ) }) it('should execute the synctex binary', ctx => { const outputFilePath = `${ctx.compileDir}/output.pdf` const inputFilePath = `${ctx.compileDir}/${ctx.filename}` expect(ctx.CommandRunner.promises.run).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, [ 'synctex', 'view', '-i', `${ctx.line}:${ctx.column}:${inputFilePath}`, '-o', outputFilePath, ], ctx.compileDir, ctx.Settings.clsi.docker.image, 60000, {}, 'synctex' ) }) it('should return the parsed output', ctx => { expect(ctx.result).to.deep.equal({ codePositions: ctx.records, downloadedFromCache: false, }) }) }) describe('from cache in docker', () => { beforeEach(async ctx => { ctx.CommandRunner.canRunSyncTeXInOutputDir.returns(true) ctx.Settings.path.synctexBaseDir .withArgs(`${ctx.projectId}-${ctx.userId}`) .returns('/compile') const errNotFound = new Error() errNotFound.code = 'ENOENT' ctx.outputDir = `${ctx.outputBaseDir}/${ctx.projectId}-${ctx.userId}/${ctx.OutputCacheManager.CACHE_SUBDIR}/${ctx.buildId}` const filename = Path.join(ctx.outputDir, 'output.synctex.gz') ctx.fsPromises.stat .withArgs(ctx.outputDir) .onFirstCall() .rejects(errNotFound) ctx.fsPromises.stat .withArgs(ctx.outputDir) .onSecondCall() .resolves(ctx.dirStats) ctx.fsPromises.stat.withArgs(filename).resolves(ctx.fileStats) ctx.CLSICacheHandler.downloadOutputDotSynctexFromCompileCache.resolves( true ) ctx.result = await ctx.CompileManager.promises.syncFromCode( ctx.projectId, ctx.userId, ctx.filename, ctx.line, ctx.column, { imageName: 'image', editorId: '00000000-0000-0000-0000-000000000000', buildId: ctx.buildId, compileFromClsiCache: true, } ) }) it('should run in output dir', ctx => { const outputFilePath = '/compile/output.pdf' const inputFilePath = `/compile/${ctx.filename}` expect(ctx.CommandRunner.promises.run).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, [ 'synctex', 'view', '-i', `${ctx.line}:${ctx.column}:${inputFilePath}`, '-o', outputFilePath, ], ctx.outputDir, 'image', 60000, {}, 'synctex-output' ) }) it('should return the parsed output', ctx => { expect(ctx.result).to.deep.equal({ codePositions: ctx.records, downloadedFromCache: true, }) }) }) describe('with a custom imageName', () => { const customImageName = 'foo/bar:tag-0' beforeEach(async ctx => { await ctx.CompileManager.promises.syncFromCode( ctx.projectId, ctx.userId, ctx.filename, ctx.line, ctx.column, { imageName: customImageName } ) }) it('should execute the synctex binary in a custom docker image', ctx => { const outputFilePath = `${ctx.compileDir}/output.pdf` const inputFilePath = `${ctx.compileDir}/${ctx.filename}` expect(ctx.CommandRunner.promises.run).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, [ 'synctex', 'view', '-i', `${ctx.line}:${ctx.column}:${inputFilePath}`, '-o', outputFilePath, ], ctx.compileDir, customImageName, 60000, {}, 'synctex' ) }) }) }) describe('syncFromPdf', () => { beforeEach(ctx => { ctx.records = [{ file: 'main.tex', line: 1, column: 1 }] ctx.SynctexOutputParser.parseEditOutput .withArgs(ctx.commandOutput, ctx.compileDir) .returns(ctx.records) }) describe('normal case', () => { beforeEach(async ctx => { ctx.result = await ctx.CompileManager.promises.syncFromPdf( ctx.projectId, ctx.userId, ctx.page, ctx.h, ctx.v, { imageName: '' } ) }) it('should execute the synctex binary', ctx => { const outputFilePath = `${ctx.compileDir}/output.pdf` expect(ctx.CommandRunner.promises.run).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, [ 'synctex', 'edit', '-o', `${ctx.page}:${ctx.h}:${ctx.v}:${outputFilePath}`, ], ctx.compileDir, ctx.Settings.clsi.docker.image, 60000, {} ) }) it('should return the parsed output', ctx => { expect(ctx.result).to.deep.equal({ pdfPositions: ctx.records, downloadedFromCache: false, }) }) }) describe('with a custom imageName', () => { const customImageName = 'foo/bar:tag-1' beforeEach(async ctx => { await ctx.CompileManager.promises.syncFromPdf( ctx.projectId, ctx.userId, ctx.page, ctx.h, ctx.v, { imageName: customImageName } ) }) it('should execute the synctex binary in a custom docker image', ctx => { const outputFilePath = `${ctx.compileDir}/output.pdf` expect(ctx.CommandRunner.promises.run).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, [ 'synctex', 'edit', '-o', `${ctx.page}:${ctx.h}:${ctx.v}:${outputFilePath}`, ], ctx.compileDir, customImageName, 60000, {} ) }) }) }) }) describe('wordcount', () => { beforeEach(async ctx => { ctx.timeout = 60 * 1000 ctx.filename = 'main.tex' ctx.image = 'example.com/image' ctx.result = await ctx.CompileManager.promises.wordcount( ctx.projectId, ctx.userId, ctx.filename, ctx.image ) }) it('should run the texcount command', ctx => { ctx.filePath = `$COMPILE_DIR/${ctx.filename}` ctx.command = ['texcount', '-nocol', '-inc', ctx.filePath] expect(ctx.CommandRunner.promises.run).to.have.been.calledWith( `${ctx.projectId}-${ctx.userId}`, ctx.command, ctx.compileDir, ctx.image, ctx.timeout, {} ) }) it('should return the parsed output', ctx => { expect(ctx.result).to.deep.equal({ encode: 'ascii', textWords: 2, headWords: 0, outside: 0, headers: 0, elements: 0, mathInline: 0, mathDisplay: 0, errors: 0, messages: '', }) }) }) })