Files
overleaf-cep/services/clsi/test/unit/js/CompileManager.test.js
Jakob Ackermann 37cc65ec7e [web] consolidate clsi downloads and add zod validation (#33069)
* [web] consolidate clsi downloads and add zod validation

* [validation-tools] make prettier happy

* [web] make clsiServerId optional

* [web] fix type of buildId

* [web] gracefully handle ObjectId

* [web] fix type of buildId

* [monorepo] address review feedback

- cjs export
- update module path in comments
- skip adding ?clsiserverid if not set
- allow nested output file download for submissions and add tests

* [web] address review feedback

* [web] cache one more zod schema

* [web] fix unit tests

GitOrigin-RevId: 0a1e618955983e035defd6d3c0528b81e0e85c95
2026-05-05 08:06:05 +00:00

774 lines
22 KiB
JavaScript

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: '',
})
})
})
})