diff --git a/services/web/app/src/Features/Compile/ClsiManager.mjs b/services/web/app/src/Features/Compile/ClsiManager.mjs index b2370efdae..d310d95f79 100644 --- a/services/web/app/src/Features/Compile/ClsiManager.mjs +++ b/services/web/app/src/Features/Compile/ClsiManager.mjs @@ -768,6 +768,10 @@ async function _buildRequest(projectId, userId, options) { 'failed to compose history-full request' ) // fall back to old compile mode + return await _buildRequest(projectId, userId, { + ...options, + compileFromHistory: false, + }) } } else if (options.compileFromHistory) { // incremental sync @@ -785,6 +789,10 @@ async function _buildRequest(projectId, userId, options) { 'failed to compose history-incremental request' ) // fall back to old compile mode + return await _buildRequest(projectId, userId, { + ...options, + compileFromHistory: false, + }) } } diff --git a/services/web/test/unit/src/Compile/ClsiManager.test.mjs b/services/web/test/unit/src/Compile/ClsiManager.test.mjs index a317abd153..c4b0d451a7 100644 --- a/services/web/test/unit/src/Compile/ClsiManager.test.mjs +++ b/services/web/test/unit/src/Compile/ClsiManager.test.mjs @@ -3,6 +3,7 @@ import { setTimeout } from 'node:timers/promises' import sinon from 'sinon' import tk from 'timekeeper' import { RequestFailedError } from '@overleaf/fetch-utils' +import _ from 'lodash' const FILESTORE_URL = 'http://filestore.example.com' const CLSI_HOST = 'clsi.example.com' @@ -15,13 +16,6 @@ describe('ClsiManager', function () { tk.freeze(Date.now()) ctx.user_id = 'user-id' - ctx.project = { - _id: 'project-id', - compiler: 'latex', - rootDoc_id: 'mock-doc-id-1', - imageName: 'mock-image-name', - overleaf: { history: { id: 42 } }, - } ctx.docs = { '/main.tex': { name: 'main.tex', @@ -53,6 +47,20 @@ describe('ClsiManager', function () { created: new Date(), }, } + ctx.project = { + _id: 'project-id', + compiler: 'latex', + rootDoc_id: 'mock-doc-id-1', + imageName: 'mock-image-name', + overleaf: { history: { id: 42 } }, + rootFolder: [ + { + docs: [], + files: [], + folders: [], + }, + ], + } ctx.clsiCookieKey = 'clsiserver' ctx.clsiServerId = 'clsi-server-id' ctx.newClsiServerId = 'newserver' @@ -121,7 +129,17 @@ describe('ClsiManager', function () { ctx.ProjectGetter = { promises: { findById: sinon.stub().resolves(ctx.project), - getProject: sinon.stub().resolves(ctx.project), + getProject: sinon.stub().callsFake((projectId, projection) => { + const result = { _id: ctx.project._id } + for (const [field, v] of Object.entries(projection)) { + if (v) { + _.set(result, field, _.get(ctx.project, field)) + } else { + _.unset(result, field) + } + } + return result + }), }, } ctx.DocumentUpdaterHandler = { @@ -162,6 +180,9 @@ describe('ClsiManager', function () { clearCache: sinon.stub().resolves(), } ctx.HistoryManager = { + promises: { + flushProject: sinon.stub().resolves(), + }, getFilestoreBlobURL: sinon.stub().callsFake((historyId, hash) => { if (hash === GLOBAL_BLOB_HASH) { return `${FILESTORE_URL}/history/global/hash/${hash}` @@ -392,6 +413,155 @@ describe('ClsiManager', function () { }) }) + describe('with compile from history fallback to incremental', function () { + const buildId = '18fbe9e7564-30dcb2f71250c690' + + beforeEach(async function (ctx) { + ctx.outputFiles = [ + { + url: `/project/${ctx.project_id}/user/${ctx.user_id}/build/1234/output/output.pdf`, + path: 'output.pdf', + type: 'pdf', + build: buildId, + }, + { + url: `/project/${ctx.project_id}/user/${ctx.user_id}/build/1234/output/output.log`, + path: 'output.log', + type: 'log', + build: buildId, + }, + ] + ctx.responseBody.compile.outputFiles = ctx.outputFiles.map( + outputFile => ({ + ...outputFile, + url: `http://${CLSI_HOST}${outputFile.url}`, + }) + ) + ctx.responseBody.compile.buildId = buildId + ctx.timeout = 100 + ctx.HistoryManager.promises.flushProject.rejects() + const doc = ctx.docs['/main.tex'] + ctx.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.resolves([ + { _id: doc._id, lines: doc.lines, v: 123 }, + ]) + ctx.ProjectEntityHandler.getAllDocPathsFromProject.returns({ + 'mock-doc-id-1': 'main.tex', + }) + ctx.result = await ctx.ClsiManager.promises.sendRequest( + ctx.project._id, + ctx.user_id, + { + compileBackendClass: 'c3d', + compileGroup: 'standard', + timeout: ctx.timeout, + compileFromHistory: true, + incrementalCompilesEnabled: true, + } + ) + }) + + it('should send the request to the CLSI', function (ctx) { + const doc = ctx.docs['/main.tex'] + ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith( + sinon.match( + url => + url.host === CLSI_HOST && + url.pathname === + `/project/${ctx.project._id}/user/${ctx.user_id}/compile` && + url.searchParams.get('compileBackendClass') === 'c3d' && + url.searchParams.get('compileGroup') === 'standard' + ), + { + method: 'POST', + json: sinon.match({ + compile: { + options: { + compiler: ctx.project.compiler, + imageName: ctx.project.imageName, + timeout: ctx.timeout, + draft: false, + compileGroup: 'standard', + metricsMethod: 'standard', + stopOnFirstError: false, + syncType: 'incremental', + syncState: '01234567890abcdef', + }, + rootResourcePath: 'main.tex', + resources: [ + { path: 'main.tex', content: doc.lines.join('\n') }, + ], + }, + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cookie: `${ctx.clsiCookieKey}=${ctx.clsiServerId}`, + }, + signal: sinon.match.instanceOf(AbortSignal), + } + ) + }) + + it('should compute the hash with the full project', function (ctx) { + ctx.ClsiStateManager.computeHash.should.have.been.calledWithMatch({ + rootFolder: ctx.project.rootFolder, + }) + }) + + it('should get the project with the required fields', function (ctx) { + ctx.ProjectGetter.promises.getProject.should.have.been.calledWith( + ctx.project._id, + { + compiler: 1, + rootDoc_id: 1, + imageName: 1, + rootFolder: 1, + 'overleaf.history.id': 1, + } + ) + }) + + it('should get only the live docs from the docupdater with a background flush in docupdater', function (ctx) { + ctx.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.should.have.been.calledWith( + ctx.project._id + ) + }) + + it('should not get any docs from mongo', function (ctx) { + ctx.ProjectEntityHandler.promises.getAllDocs.should.not.have.been.calledWith( + ctx.project._id + ) + }) + + it('should not get any of the files', function (ctx) { + ctx.ProjectEntityHandler.promises.getAllFiles.should.not.have.been + .called + }) + + it('should return the status and output files', function (ctx) { + expect(ctx.result.status).to.equal('success') + expect(ctx.result.outputFiles.map(f => f.path)).to.have.members( + ctx.outputFiles.map(f => f.path) + ) + }) + + it('should return the buildId', function (ctx) { + expect(ctx.result.buildId).to.equal(buildId) + }) + + it('should persist the cookie from the response', function (ctx) { + expect( + ctx.ClsiCookieManager.promises.setServerId + ).to.have.been.calledWith( + ctx.project._id, + ctx.user_id, + 'standard', + 'c3d', + ctx.newClsiServerId + ) + }) + }) + describe('with ranges on the pdf and stats/timings details', function () { beforeEach(async function (ctx) { ctx.ranges = [{ start: 1, end: 42, hash: 'foo' }] @@ -499,6 +669,12 @@ describe('ClsiManager', function () { ) }) + it('should not get any docs from mongo', function (ctx) { + ctx.ProjectEntityHandler.promises.getAllDocs.should.not.have.been.calledWith( + ctx.project._id + ) + }) + it('should not get any of the files', function (ctx) { ctx.ProjectEntityHandler.promises.getAllFiles.should.not.have.been .called