ClsiManager.test.mjs 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220
  1. import { vi, expect } from 'vitest'
  2. import { setTimeout } from 'timers/promises'
  3. import sinon from 'sinon'
  4. import tk from 'timekeeper'
  5. import { RequestFailedError } from '@overleaf/fetch-utils'
  6. const FILESTORE_URL = 'http://filestore.example.com'
  7. const CLSI_HOST = 'clsi.example.com'
  8. const MODULE_PATH = '../../../../app/src/Features/Compile/ClsiManager.mjs'
  9. const GLOBAL_BLOB_HASH = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  10. describe('ClsiManager', function () {
  11. beforeEach(async function (ctx) {
  12. tk.freeze(Date.now())
  13. ctx.user_id = 'user-id'
  14. ctx.project = {
  15. _id: 'project-id',
  16. compiler: 'latex',
  17. rootDoc_id: 'mock-doc-id-1',
  18. imageName: 'mock-image-name',
  19. overleaf: { history: { id: 42 } },
  20. }
  21. ctx.docs = {
  22. '/main.tex': {
  23. name: 'main.tex',
  24. _id: 'mock-doc-id-1',
  25. lines: ['Hello', 'world'],
  26. },
  27. '/chapters/chapter1.tex': {
  28. name: 'chapter1.tex',
  29. _id: 'mock-doc-id-2',
  30. lines: ['Chapter 1'],
  31. },
  32. }
  33. ctx.files = {
  34. '/images/frog.png': {
  35. name: 'frog.png',
  36. _id: 'mock-file-id-1',
  37. created: new Date(),
  38. hash: GLOBAL_BLOB_HASH,
  39. },
  40. '/images/image.png': {
  41. name: 'image.png',
  42. _id: 'mock-file-id-2',
  43. created: new Date(),
  44. hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
  45. },
  46. '/images/no-hash.png': {
  47. name: 'no-hash.png',
  48. _id: 'mock-file-id-3',
  49. created: new Date(),
  50. },
  51. }
  52. ctx.clsiCookieKey = 'clsiserver'
  53. ctx.clsiServerId = 'clsi-server-id'
  54. ctx.newClsiServerId = 'newserver'
  55. ctx.rawOutputFiles = {}
  56. ctx.responseBody = {
  57. compile: {
  58. status: 'success',
  59. stats: {
  60. isInitialCompile: 1,
  61. restoredClsiCache: 1,
  62. },
  63. timings: { compileE2E: 1337 },
  64. outputFiles: [
  65. {
  66. path: 'output.pdf',
  67. size: 42,
  68. url: 'http://localhost:3013/snip/output.pdf',
  69. },
  70. ],
  71. },
  72. }
  73. ctx.response = {
  74. ok: true,
  75. status: 200,
  76. headers: {
  77. raw: sinon.stub().returns({
  78. 'set-cookie': [`${ctx.clsiCookieKey}=${ctx.newClsiServerId}`],
  79. }),
  80. },
  81. }
  82. ctx.FetchUtils = {
  83. fetchString: sinon
  84. .stub()
  85. .callsFake(() => Promise.resolve(JSON.stringify(ctx.responseBody))),
  86. fetchStringWithResponse: sinon.stub().callsFake(() =>
  87. Promise.resolve({
  88. body: JSON.stringify(ctx.responseBody),
  89. response: ctx.response,
  90. })
  91. ),
  92. fetchStream: sinon.stub(),
  93. RequestFailedError,
  94. }
  95. ctx.ClsiCookieManager = {
  96. promises: {
  97. clearServerId: sinon.stub().resolves(),
  98. getServerId: sinon.stub().resolves('clsi-server-id'),
  99. setServerId: sinon.stub().resolves(),
  100. },
  101. }
  102. ctx.ClsiStateManager = {
  103. computeHash: sinon.stub().returns('01234567890abcdef'),
  104. }
  105. ctx.ClsiFormatChecker = {
  106. checkRecoursesForProblems: sinon.stub().returns(),
  107. }
  108. ctx.Project = {}
  109. ctx.ProjectEntityHandler = {
  110. getAllDocPathsFromProject: sinon.stub(),
  111. promises: {
  112. getAllDocs: sinon.stub().resolves(ctx.docs),
  113. getAllFiles: sinon.stub().resolves(ctx.files),
  114. },
  115. }
  116. ctx.ProjectGetter = {
  117. promises: {
  118. findById: sinon.stub().resolves(ctx.project),
  119. getProject: sinon.stub().resolves(ctx.project),
  120. },
  121. }
  122. ctx.DocumentUpdaterHandler = {
  123. promises: {
  124. clearProjectState: sinon.stub().resolves(),
  125. flushProjectToMongo: sinon.stub().resolves(),
  126. getProjectDocsIfMatch: sinon.stub().resolves(),
  127. },
  128. }
  129. ctx.Metrics = {
  130. Timer: class Metrics {
  131. constructor() {
  132. this.done = sinon.stub()
  133. }
  134. },
  135. inc: sinon.stub(),
  136. count: sinon.stub(),
  137. histogram: sinon.stub(),
  138. }
  139. ctx.Settings = {
  140. apis: {
  141. filestore: {
  142. url: FILESTORE_URL,
  143. secret: 'secret',
  144. },
  145. clsi: {
  146. url: `http://${CLSI_HOST}`,
  147. submissionBackendClass: 'n2d',
  148. },
  149. clsi_new: {
  150. sample: 100,
  151. },
  152. },
  153. enablePdfCaching: true,
  154. clsiCookie: { key: 'clsiserver' },
  155. }
  156. ctx.ClsiCacheHandler = {
  157. clearCache: sinon.stub().resolves(),
  158. }
  159. ctx.HistoryManager = {
  160. getFilestoreBlobURL: sinon.stub().callsFake((historyId, hash) => {
  161. if (hash === GLOBAL_BLOB_HASH) {
  162. return `${FILESTORE_URL}/history/global/hash/${hash}`
  163. }
  164. return `${FILESTORE_URL}/history/project/${historyId}/hash/${hash}`
  165. }),
  166. }
  167. ctx.SplitTestHandler = {
  168. getPercentile: sinon.stub().returns(42),
  169. }
  170. ctx.AnalyticsManager = {
  171. recordEventForUserInBackground: sinon.stub(),
  172. }
  173. vi.doMock('@overleaf/settings', () => ({
  174. default: ctx.Settings,
  175. }))
  176. vi.doMock(
  177. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  178. () => ({
  179. default: ctx.SplitTestHandler,
  180. })
  181. )
  182. vi.doMock('../../../../app/src/models/Project', () => ({
  183. Project: ctx.Project,
  184. }))
  185. vi.doMock(
  186. '../../../../app/src/Features/Project/ProjectEntityHandler',
  187. () => ({
  188. default: ctx.ProjectEntityHandler,
  189. })
  190. )
  191. vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
  192. default: ctx.ProjectGetter,
  193. }))
  194. vi.doMock(
  195. '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
  196. () => ({
  197. default: ctx.DocumentUpdaterHandler,
  198. })
  199. )
  200. vi.doMock('../../../../app/src/Features/Compile/ClsiCookieManager', () => ({
  201. default: () => ctx.ClsiCookieManager,
  202. }))
  203. vi.doMock('../../../../app/src/Features/Compile/ClsiStateManager', () => ({
  204. default: ctx.ClsiStateManager,
  205. }))
  206. vi.doMock('../../../../app/src/Features/Compile/ClsiCacheHandler', () => ({
  207. default: ctx.ClsiCacheHandler,
  208. }))
  209. vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils)
  210. vi.doMock('../../../../app/src/Features/Compile/ClsiFormatChecker', () => ({
  211. default: ctx.ClsiFormatChecker,
  212. }))
  213. vi.doMock('@overleaf/metrics', () => ({
  214. default: ctx.Metrics,
  215. }))
  216. vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({
  217. default: ctx.HistoryManager,
  218. }))
  219. vi.doMock(
  220. '../../../../app/src/Features/Analytics/AnalyticsManager',
  221. () => ({
  222. default: ctx.AnalyticsManager,
  223. })
  224. )
  225. ctx.ClsiManager = (await import(MODULE_PATH)).default
  226. })
  227. afterAll(function () {
  228. tk.reset()
  229. })
  230. describe('sendRequest', function () {
  231. describe('with a successful compile', function () {
  232. const buildId = '18fbe9e7564-30dcb2f71250c690'
  233. beforeEach(async function (ctx) {
  234. ctx.outputFiles = [
  235. {
  236. url: `/project/${ctx.project_id}/user/${ctx.user_id}/build/1234/output/output.pdf`,
  237. path: 'output.pdf',
  238. type: 'pdf',
  239. build: buildId,
  240. },
  241. {
  242. url: `/project/${ctx.project_id}/user/${ctx.user_id}/build/1234/output/output.log`,
  243. path: 'output.log',
  244. type: 'log',
  245. build: buildId,
  246. },
  247. ]
  248. ctx.responseBody.compile.outputFiles = ctx.outputFiles.map(
  249. outputFile => ({
  250. ...outputFile,
  251. url: `http://${CLSI_HOST}${outputFile.url}`,
  252. })
  253. )
  254. ctx.responseBody.compile.buildId = buildId
  255. ctx.timeout = 100
  256. ctx.result = await ctx.ClsiManager.promises.sendRequest(
  257. ctx.project._id,
  258. ctx.user_id,
  259. {
  260. compileBackendClass: 'n2d',
  261. compileGroup: 'standard',
  262. timeout: ctx.timeout,
  263. }
  264. )
  265. })
  266. it('should send the request to the CLSI', function (ctx) {
  267. ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith(
  268. sinon.match(
  269. url =>
  270. url.host === CLSI_HOST &&
  271. url.pathname ===
  272. `/project/${ctx.project._id}/user/${ctx.user_id}/compile` &&
  273. url.searchParams.get('compileBackendClass') === 'n2d' &&
  274. url.searchParams.get('compileGroup') === 'standard'
  275. ),
  276. {
  277. method: 'POST',
  278. json: sinon.match({
  279. compile: {
  280. options: {
  281. compiler: ctx.project.compiler,
  282. imageName: ctx.project.imageName,
  283. timeout: ctx.timeout,
  284. draft: false,
  285. compileGroup: 'standard',
  286. metricsMethod: 'standard',
  287. stopOnFirstError: false,
  288. syncType: undefined,
  289. },
  290. rootResourcePath: 'main.tex',
  291. resources: _makeResources(ctx.project, ctx.docs, ctx.files),
  292. },
  293. }),
  294. headers: {
  295. Accept: 'application/json',
  296. 'Content-Type': 'application/json',
  297. Cookie: `${ctx.clsiCookieKey}=${ctx.clsiServerId}`,
  298. },
  299. signal: sinon.match.instanceOf(AbortSignal),
  300. }
  301. )
  302. })
  303. it('should get the project with the required fields', function (ctx) {
  304. ctx.ProjectGetter.promises.getProject.should.have.been.calledWith(
  305. ctx.project._id,
  306. {
  307. compiler: 1,
  308. rootDoc_id: 1,
  309. imageName: 1,
  310. rootFolder: 1,
  311. 'overleaf.history.id': 1,
  312. }
  313. )
  314. })
  315. it('should flush the project to the database', function (ctx) {
  316. ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith(
  317. ctx.project._id
  318. )
  319. })
  320. it('should get all the docs', function (ctx) {
  321. ctx.ProjectEntityHandler.promises.getAllDocs.should.have.been.calledWith(
  322. ctx.project._id
  323. )
  324. })
  325. it('should get all the files', function (ctx) {
  326. ctx.ProjectEntityHandler.promises.getAllFiles.should.have.been.calledWith(
  327. ctx.project._id
  328. )
  329. })
  330. it('should return the status and output files', function (ctx) {
  331. expect(ctx.result.status).to.equal('success')
  332. expect(ctx.result.outputFiles.map(f => f.path)).to.have.members(
  333. ctx.outputFiles.map(f => f.path)
  334. )
  335. })
  336. it('should return the buildId', function (ctx) {
  337. expect(ctx.result.buildId).to.equal(buildId)
  338. })
  339. it('should persist the cookie from the response', function (ctx) {
  340. expect(
  341. ctx.ClsiCookieManager.promises.setServerId
  342. ).to.have.been.calledWith(
  343. ctx.project._id,
  344. ctx.user_id,
  345. 'standard',
  346. 'n2d',
  347. ctx.newClsiServerId
  348. )
  349. })
  350. })
  351. describe('with ranges on the pdf and stats/timings details', function () {
  352. beforeEach(async function (ctx) {
  353. ctx.ranges = [{ start: 1, end: 42, hash: 'foo' }]
  354. ctx.startXRefTable = 123
  355. ctx.size = 456
  356. ctx.contentId = '123-321'
  357. ctx.outputFiles = [
  358. {
  359. url: `/project/${ctx.project._id}/user/${ctx.user_id}/build/1234/output/output.pdf`,
  360. path: 'output.pdf',
  361. type: 'pdf',
  362. build: 1234,
  363. contentId: ctx.contentId,
  364. ranges: ctx.ranges,
  365. startXRefTable: ctx.startXRefTable,
  366. size: ctx.size,
  367. },
  368. {
  369. url: `/project/${ctx.project._id}/user/${ctx.user_id}/build/1234/output/output.log`,
  370. path: 'output.log',
  371. type: 'log',
  372. build: 1234,
  373. },
  374. ]
  375. ctx.stats = { fooStat: 1 }
  376. ctx.timings = { barTiming: 2 }
  377. ctx.responseBody.compile.outputFiles = ctx.outputFiles.map(
  378. outputFile => ({
  379. ...outputFile,
  380. url: `http://${CLSI_HOST}${outputFile.url}`,
  381. })
  382. )
  383. ctx.responseBody.compile.stats = ctx.stats
  384. ctx.responseBody.compile.timings = ctx.timings
  385. ctx.result = await ctx.ClsiManager.promises.sendRequest(
  386. ctx.project._id,
  387. ctx.user_id,
  388. { compileBackendClass: 'n2d', compileGroup: 'standard' }
  389. )
  390. })
  391. it('should emit the caching details and stats/timings', function (ctx) {
  392. expect(ctx.result.status).to.equal('success')
  393. expect(ctx.result.clsiServerId).to.equal(ctx.newClsiServerId)
  394. expect(ctx.result.validationError).to.be.undefined
  395. expect(ctx.result.stats).to.deep.equal(ctx.stats)
  396. expect(ctx.result.timings).to.deep.equal(ctx.timings)
  397. const outputPdf = ctx.result.outputFiles.find(
  398. f => f.path === 'output.pdf'
  399. )
  400. expect(outputPdf.ranges).to.deep.equal(ctx.ranges)
  401. expect(outputPdf.startXRefTable).to.equal(ctx.startXRefTable)
  402. expect(outputPdf.contentId).to.equal(ctx.contentId)
  403. expect(outputPdf.size).to.equal(ctx.size)
  404. })
  405. })
  406. describe('with the incremental compile option', function () {
  407. beforeEach(async function (ctx) {
  408. const doc = ctx.docs['/main.tex']
  409. ctx.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.resolves([
  410. { _id: doc._id, lines: doc.lines, v: 123 },
  411. ])
  412. ctx.ProjectEntityHandler.getAllDocPathsFromProject.returns({
  413. 'mock-doc-id-1': 'main.tex',
  414. })
  415. ctx.result = await ctx.ClsiManager.promises.sendRequest(
  416. ctx.project._id,
  417. ctx.user_id,
  418. {
  419. timeout: 100,
  420. incrementalCompilesEnabled: true,
  421. compileBackendClass: 'n2d',
  422. compileGroup: 'priority',
  423. compileFromClsiCache: true,
  424. populateClsiCache: true,
  425. enablePdfCaching: true,
  426. pdfCachingMinChunkSize: 1337,
  427. }
  428. )
  429. })
  430. it('should get the project with the required fields', function (ctx) {
  431. ctx.ProjectGetter.promises.getProject.should.have.been.calledWith(
  432. ctx.project._id,
  433. {
  434. compiler: 1,
  435. rootDoc_id: 1,
  436. imageName: 1,
  437. rootFolder: 1,
  438. 'overleaf.history.id': 1,
  439. }
  440. )
  441. })
  442. it('should not explicitly flush the project to the database', function (ctx) {
  443. ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.not.have.been.calledWith(
  444. ctx.project._id
  445. )
  446. })
  447. it('should get only the live docs from the docupdater with a background flush in docupdater', function (ctx) {
  448. ctx.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.should.have.been.calledWith(
  449. ctx.project._id
  450. )
  451. })
  452. it('should not get any of the files', function (ctx) {
  453. ctx.ProjectEntityHandler.promises.getAllFiles.should.not.have.been
  454. .called
  455. })
  456. it('should build up the CLSI request', function (ctx) {
  457. ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith(
  458. sinon.match(
  459. url =>
  460. url.hostname === CLSI_HOST &&
  461. url.pathname ===
  462. `/project/${ctx.project._id}/user/${ctx.user_id}/compile` &&
  463. url.searchParams.get('compileBackendClass') === 'n2d' &&
  464. url.searchParams.get('compileGroup') === 'priority'
  465. ),
  466. {
  467. method: 'POST',
  468. json: sinon.match({
  469. compile: {
  470. options: {
  471. compiler: ctx.project.compiler,
  472. timeout: 100,
  473. imageName: ctx.project.imageName,
  474. draft: false,
  475. syncType: 'incremental',
  476. syncState: '01234567890abcdef',
  477. compileGroup: 'priority',
  478. compileFromClsiCache: true,
  479. populateClsiCache: true,
  480. enablePdfCaching: true,
  481. pdfCachingMinChunkSize: 1337,
  482. metricsMethod: 'priority',
  483. stopOnFirstError: false,
  484. },
  485. rootResourcePath: 'main.tex',
  486. resources: [
  487. {
  488. path: 'main.tex',
  489. content: ctx.docs['/main.tex'].lines.join('\n'),
  490. },
  491. ],
  492. },
  493. }),
  494. headers: {
  495. Accept: 'application/json',
  496. 'Content-Type': 'application/json',
  497. Cookie: `${ctx.clsiCookieKey}=${ctx.clsiServerId}`,
  498. },
  499. signal: sinon.match.instanceOf(AbortSignal),
  500. }
  501. )
  502. })
  503. })
  504. describe('when the root doc is set and not in the docupdater', function () {
  505. beforeEach(async function (ctx) {
  506. const doc = ctx.docs['/main.tex']
  507. ctx.DocumentUpdaterHandler.promises.getProjectDocsIfMatch.resolves([
  508. { _id: doc._id, lines: doc.lines, v: 123 },
  509. ])
  510. ctx.ProjectEntityHandler.getAllDocPathsFromProject.returns({
  511. 'mock-doc-id-1': 'main.tex',
  512. 'mock-doc-id-2': '/chapters/chapter1.tex',
  513. })
  514. await ctx.ClsiManager.promises.sendRequest(
  515. ctx.project._id,
  516. ctx.user_id,
  517. {
  518. timeout: 100,
  519. incrementalCompilesEnabled: true,
  520. rootDoc_id: 'mock-doc-id-2',
  521. }
  522. )
  523. })
  524. it('should still change the root path', function (ctx) {
  525. ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith(
  526. sinon.match.any,
  527. sinon.match({
  528. json: { compile: { rootResourcePath: 'chapters/chapter1.tex' } },
  529. })
  530. )
  531. })
  532. })
  533. describe('when root doc override is valid', function () {
  534. beforeEach(async function (ctx) {
  535. await ctx.ClsiManager.promises.sendRequest(
  536. ctx.project._id,
  537. ctx.user_id,
  538. { rootDoc_id: 'mock-doc-id-2' }
  539. )
  540. })
  541. it('should change root path', function (ctx) {
  542. ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith(
  543. sinon.match.any,
  544. sinon.match({
  545. json: { compile: { rootResourcePath: 'chapters/chapter1.tex' } },
  546. })
  547. )
  548. })
  549. })
  550. describe('when root doc override is invalid', function () {
  551. beforeEach(async function (ctx) {
  552. await ctx.ClsiManager.promises.sendRequest(
  553. ctx.project._id,
  554. ctx.user_id,
  555. { rootDoc_id: 'invalid-id' }
  556. )
  557. })
  558. it('should fallback to default root doc', function (ctx) {
  559. ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith(
  560. sinon.match.any,
  561. sinon.match({
  562. json: { compile: { rootResourcePath: 'main.tex' } },
  563. })
  564. )
  565. })
  566. })
  567. describe('when the project has an invalid compiler', function () {
  568. beforeEach(async function (ctx) {
  569. ctx.project.compiler = 'context'
  570. await ctx.ClsiManager.promises.sendRequest(
  571. ctx.project._id,
  572. ctx.user_id,
  573. {}
  574. )
  575. })
  576. it('should set the compiler to pdflatex', function (ctx) {
  577. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(
  578. sinon.match.any,
  579. sinon.match({
  580. json: { compile: { options: { compiler: 'pdflatex' } } },
  581. })
  582. )
  583. })
  584. })
  585. describe('when there is no valid root document', function () {
  586. beforeEach(async function (ctx) {
  587. ctx.project.rootDoc_id = 'not-valid'
  588. await ctx.ClsiManager.promises.sendRequest(
  589. ctx.project._id,
  590. ctx.user_id,
  591. {}
  592. )
  593. })
  594. it('should set to main.tex', function (ctx) {
  595. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(
  596. sinon.match.any,
  597. sinon.match({
  598. json: { compile: { rootResourcePath: 'main.tex' } },
  599. })
  600. )
  601. })
  602. })
  603. describe('when there is no valid root document and no main.tex document', function () {
  604. beforeEach(async function (ctx) {
  605. ctx.project.rootDoc_id = 'not-valid'
  606. ctx.docs = {
  607. '/other.tex': {
  608. name: 'other.tex',
  609. _id: 'mock-doc-id-1',
  610. lines: ['Hello', 'world'],
  611. },
  612. '/chapters/chapter1.tex': {
  613. name: 'chapter1.tex',
  614. _id: 'mock-doc-id-2',
  615. lines: ['Chapter 1'],
  616. },
  617. }
  618. ctx.ProjectEntityHandler.promises.getAllDocs.resolves(ctx.docs)
  619. ctx.result = await ctx.ClsiManager.promises.sendRequest(
  620. ctx.project._id,
  621. ctx.user_id,
  622. {}
  623. )
  624. })
  625. it('should report a validation problem', function (ctx) {
  626. expect(ctx.result.status).to.equal('validation-problems')
  627. })
  628. })
  629. describe('when there is no valid root document and a single document which is not main.tex', function () {
  630. beforeEach(async function (ctx) {
  631. ctx.project.rootDoc_id = 'not-valid'
  632. ctx.docs = {
  633. '/other.tex': {
  634. name: 'other.tex',
  635. _id: 'mock-doc-id-1',
  636. lines: ['Hello', 'world'],
  637. },
  638. }
  639. ctx.ProjectEntityHandler.promises.getAllDocs.resolves(ctx.docs)
  640. await ctx.ClsiManager.promises.sendRequest(
  641. ctx.project._id,
  642. ctx.user_id,
  643. {}
  644. )
  645. })
  646. it('should set io to the only file', function (ctx) {
  647. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(
  648. sinon.match.any,
  649. sinon.match({
  650. json: { compile: { rootResourcePath: 'other.tex' } },
  651. })
  652. )
  653. })
  654. })
  655. describe('with the draft option', function () {
  656. beforeEach(async function (ctx) {
  657. await ctx.ClsiManager.promises.sendRequest(
  658. ctx.project._id,
  659. ctx.user_id,
  660. {
  661. timeout: 100,
  662. draft: true,
  663. }
  664. )
  665. })
  666. it('should add the draft option into the request', function (ctx) {
  667. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(
  668. sinon.match.any,
  669. sinon.match({
  670. json: { compile: { options: { draft: true } } },
  671. })
  672. )
  673. })
  674. })
  675. describe('with a failed compile', function () {
  676. beforeEach(async function (ctx) {
  677. ctx.responseBody.compile.status = 'failure'
  678. ctx.result = await ctx.ClsiManager.promises.sendRequest(
  679. ctx.project._id,
  680. ctx.user_id,
  681. {}
  682. )
  683. })
  684. it('should return a failure status', function (ctx) {
  685. expect(ctx.result.status).to.equal('failure')
  686. })
  687. })
  688. describe('with a sync conflict', function () {
  689. beforeEach(async function (ctx) {
  690. const conflictResponseBody = { compile: { status: 'conflict' } }
  691. ctx.FetchUtils.fetchStringWithResponse
  692. .withArgs(
  693. sinon.match.any,
  694. sinon.match({
  695. json: sinon.match(
  696. json => json.compile.options.syncType !== 'full'
  697. ),
  698. })
  699. )
  700. .resolves({
  701. body: JSON.stringify(conflictResponseBody),
  702. response: ctx.response,
  703. })
  704. ctx.result = await ctx.ClsiManager.promises.sendRequest(
  705. ctx.project._id,
  706. ctx.user_id,
  707. {}
  708. )
  709. })
  710. it('should send two requests to CLSI', function (ctx) {
  711. ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledTwice
  712. })
  713. it('should call the CLSI first without syncType:full', function (ctx) {
  714. const compileOptions =
  715. ctx.FetchUtils.fetchStringWithResponse.getCall(0).args[1].json.compile
  716. .options
  717. expect(compileOptions.syncType).to.be.undefined
  718. })
  719. it('should call the CLSI a second time with syncType:full', function (ctx) {
  720. const compileOptions =
  721. ctx.FetchUtils.fetchStringWithResponse.getCall(1).args[1].json.compile
  722. .options
  723. expect(compileOptions.syncType).to.equal('full')
  724. })
  725. it('should return a success status', function (ctx) {
  726. ctx.result.status.should.equal('success')
  727. })
  728. })
  729. describe('with an unavailable response', function () {
  730. beforeEach(async function (ctx) {
  731. ctx.FetchUtils.fetchStringWithResponse.onCall(0).resolves({
  732. body: JSON.stringify({ compile: { status: 'unavailable' } }),
  733. response: ctx.response,
  734. })
  735. ctx.result = await ctx.ClsiManager.promises.sendRequest(
  736. ctx.project._id,
  737. ctx.user_id,
  738. { compileBackendClass: 'n2d' }
  739. )
  740. })
  741. it('should send two requests to CLSI', function (ctx) {
  742. ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledTwice
  743. })
  744. it('should call the CLSI first without syncType:full', function (ctx) {
  745. const compileOptions =
  746. ctx.FetchUtils.fetchStringWithResponse.getCall(0).args[1].json.compile
  747. .options
  748. expect(compileOptions.syncType).to.be.undefined
  749. })
  750. it('should call the CLSI a second time with syncType:full', function (ctx) {
  751. const compileOptions =
  752. ctx.FetchUtils.fetchStringWithResponse.getCall(1).args[1].json.compile
  753. .options
  754. expect(compileOptions.syncType).to.equal('full')
  755. })
  756. it('should clear the CLSI server id cookie', function (ctx) {
  757. expect(
  758. ctx.ClsiCookieManager.promises.clearServerId
  759. ).to.have.been.calledWith(ctx.project._id, ctx.user_id, 'n2d')
  760. })
  761. it('should return a success status', function (ctx) {
  762. expect(ctx.result.status).to.equal('success')
  763. })
  764. })
  765. describe('when the resources fail the precompile check', function () {
  766. beforeEach(function (ctx) {
  767. ctx.ClsiFormatChecker.checkRecoursesForProblems.throws(
  768. new Error('failed')
  769. )
  770. })
  771. it('should throw an error', async function (ctx) {
  772. await expect(
  773. ctx.ClsiManager.promises.sendRequest(ctx.project._id, ctx.user_id, {})
  774. ).to.be.rejected
  775. })
  776. })
  777. describe('when a new backend is configured', function () {
  778. beforeEach(async function (ctx) {
  779. ctx.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' }
  780. await ctx.ClsiManager.promises.sendRequest(
  781. ctx.project._id,
  782. ctx.user_id,
  783. {
  784. compileBackendClass: 'c2d',
  785. compileGroup: 'priority',
  786. }
  787. )
  788. // wait for the background task to finish
  789. await setTimeout(0)
  790. })
  791. it('makes a request to the new backend', function (ctx) {
  792. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledTwice
  793. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(
  794. sinon.match(
  795. url =>
  796. url.host === CLSI_HOST &&
  797. url.pathname ===
  798. `/project/${ctx.project._id}/user/${ctx.user_id}/compile` &&
  799. url.searchParams.get('compileBackendClass') === 'c2d' &&
  800. url.searchParams.get('compileGroup') === 'priority'
  801. )
  802. )
  803. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(
  804. sinon.match(
  805. url =>
  806. url.toString() ===
  807. `${ctx.Settings.apis.clsi_new.url}/project/${ctx.project._id}/user/${ctx.user_id}/compile?compileBackendClass=n4&compileGroup=priority`
  808. )
  809. )
  810. })
  811. it('should record an event', function (ctx) {
  812. expect(
  813. ctx.AnalyticsManager.recordEventForUserInBackground
  814. ).to.have.been.calledWith(ctx.user_id, 'double-compile-result', {
  815. projectId: 'project-id',
  816. compileBackendClass: 'c2d',
  817. newCompileBackendClass: 'n4',
  818. status: 'success',
  819. compileTime: 1337,
  820. newCompileTime: 1337,
  821. clsiServerId: 'newserver',
  822. newClsiServerId: 'clsi-server-id',
  823. pdfSize: 42,
  824. newPdfSize: 42,
  825. })
  826. })
  827. })
  828. })
  829. describe('sendExternalRequest', function () {
  830. beforeEach(function (ctx) {
  831. ctx.submissionId = 'submission-id'
  832. ctx.clsiRequest = 'mock-request'
  833. })
  834. describe('with a successful compile', function () {
  835. beforeEach(async function (ctx) {
  836. ctx.outputFiles = [
  837. {
  838. url: `/project/${ctx.submissionId}/build/1234/output/output.pdf`,
  839. path: 'output.pdf',
  840. type: 'pdf',
  841. build: 1234,
  842. },
  843. {
  844. url: `/project/${ctx.submissionId}/build/1234/output/output.log`,
  845. path: 'output.log',
  846. type: 'log',
  847. build: 1234,
  848. },
  849. ]
  850. ctx.responseBody.compile.outputFiles = ctx.outputFiles.map(
  851. outputFile => ({
  852. ...outputFile,
  853. url: `http://${CLSI_HOST}${outputFile.url}`,
  854. })
  855. )
  856. ctx.result = await ctx.ClsiManager.promises.sendExternalRequest(
  857. ctx.submissionId,
  858. ctx.clsiRequest,
  859. { compileBackendClass: 'n2d', compileGroup: 'standard' }
  860. )
  861. })
  862. it('should send the request to the CLSI', function (ctx) {
  863. ctx.FetchUtils.fetchStringWithResponse.should.have.been.calledWith(
  864. sinon.match(
  865. url =>
  866. url.host === CLSI_HOST &&
  867. url.pathname === `/project/${ctx.submissionId}/compile` &&
  868. url.searchParams.get('compileBackendClass') === 'n2d' &&
  869. url.searchParams.get('compileGroup') === 'standard'
  870. ),
  871. {
  872. method: 'POST',
  873. json: ctx.clsiRequest,
  874. headers: {
  875. Accept: 'application/json',
  876. 'Content-Type': 'application/json',
  877. Cookie: `${ctx.clsiCookieKey}=${ctx.clsiServerId}`,
  878. },
  879. signal: sinon.match.instanceOf(AbortSignal),
  880. }
  881. )
  882. })
  883. it('should return the status and output files', function (ctx) {
  884. expect(ctx.result.status).to.equal('success')
  885. expect(ctx.result.outputFiles.map(f => f.path)).to.have.members(
  886. ctx.outputFiles.map(f => f.path)
  887. )
  888. })
  889. })
  890. describe('with a failed compile', function () {
  891. beforeEach(async function (ctx) {
  892. ctx.responseBody.compile.status = 'failure'
  893. ctx.result = await ctx.ClsiManager.promises.sendExternalRequest(
  894. ctx.submissionId,
  895. ctx.clsiRequest,
  896. {}
  897. )
  898. })
  899. it('should return a failure status', function (ctx) {
  900. expect(ctx.result.status).to.equal('failure')
  901. })
  902. })
  903. describe('when the resources fail the precompile check', function () {
  904. beforeEach(async function (ctx) {
  905. ctx.ClsiFormatChecker.checkRecoursesForProblems.throws(
  906. new Error('failed')
  907. )
  908. ctx.responseBody.compile.status = 'failure'
  909. })
  910. it('should throw an error', async function (ctx) {
  911. await expect(
  912. ctx.ClsiManager.promises.sendExternalRequest(
  913. ctx.submissionId,
  914. ctx.clsiRequest,
  915. {}
  916. )
  917. ).to.be.rejected
  918. })
  919. })
  920. })
  921. describe('deleteAuxFiles', function () {
  922. describe('with the standard compileGroup', function () {
  923. beforeEach(async function (ctx) {
  924. await ctx.ClsiManager.promises.deleteAuxFiles(
  925. ctx.project._id,
  926. ctx.user_id,
  927. { compileBackendClass: 'n2d', compileGroup: 'standard' },
  928. 'node-1'
  929. )
  930. })
  931. it('should call the delete method in the standard CLSI', function (ctx) {
  932. ctx.FetchUtils.fetchString.should.have.been.calledWith(
  933. sinon.match(
  934. url =>
  935. url.host === CLSI_HOST &&
  936. url.pathname ===
  937. `/project/${ctx.project._id}/user/${ctx.user_id}` &&
  938. url.searchParams.get('compileBackendClass') === 'n2d' &&
  939. url.searchParams.get('compileGroup') === 'standard' &&
  940. url.searchParams.get('clsiserverid') === 'node-1'
  941. ),
  942. { method: 'DELETE' }
  943. )
  944. })
  945. it('should clear the output.tar.gz files in clsi-cache', function (ctx) {
  946. ctx.ClsiCacheHandler.clearCache
  947. .calledWith(ctx.project._id, ctx.user_id)
  948. .should.equal(true)
  949. })
  950. it('should clear the project state from the docupdater', function (ctx) {
  951. ctx.DocumentUpdaterHandler.promises.clearProjectState
  952. .calledWith(ctx.project._id)
  953. .should.equal(true)
  954. })
  955. it('should clear the clsi persistance', function (ctx) {
  956. ctx.ClsiCookieManager.promises.clearServerId
  957. .calledWith(ctx.project._id, ctx.user_id, 'n2d')
  958. .should.equal(true)
  959. })
  960. it('should not persist a cookie on response', function (ctx) {
  961. expect(ctx.ClsiCookieManager.promises.setServerId).not.to.have.been
  962. .called
  963. })
  964. })
  965. describe('when a new backend is configured', function () {
  966. beforeEach(async function (ctx) {
  967. ctx.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' }
  968. await ctx.ClsiManager.promises.deleteAuxFiles(
  969. ctx.project._id,
  970. ctx.user_id,
  971. { compileBackendClass: 'c2d', compileGroup: 'priority' },
  972. 'node-1'
  973. )
  974. // wait for the background task to finish
  975. await setTimeout(0)
  976. })
  977. it('should clear both cookies', function (ctx) {
  978. expect(
  979. ctx.ClsiCookieManager.promises.clearServerId
  980. ).to.have.been.calledWith(ctx.project._id, ctx.user_id, 'c2d')
  981. expect(
  982. ctx.ClsiCookieManager.promises.clearServerId
  983. ).to.have.been.calledWith(ctx.project._id, ctx.user_id, 'n4')
  984. })
  985. it('should forward delete request', function (ctx) {
  986. expect(ctx.FetchUtils.fetchString).to.have.been.calledWith(
  987. sinon.match(
  988. url =>
  989. url.host === CLSI_HOST &&
  990. url.pathname ===
  991. `/project/${ctx.project._id}/user/${ctx.user_id}` &&
  992. url.searchParams.get('compileBackendClass') === 'c2d' &&
  993. url.searchParams.get('compileGroup') === 'priority' &&
  994. url.searchParams.get('clsiserverid') === 'node-1'
  995. ),
  996. { method: 'DELETE' }
  997. )
  998. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(
  999. sinon.match(
  1000. url =>
  1001. url.host === 'compiles.somewhere.test' &&
  1002. url.pathname ===
  1003. `/project/${ctx.project._id}/user/${ctx.user_id}` &&
  1004. url.searchParams.get('compileBackendClass') === 'n4' &&
  1005. url.searchParams.get('compileGroup') === 'priority' &&
  1006. !url.searchParams.has('clsiserverid')
  1007. ),
  1008. sinon.match({ method: 'DELETE' })
  1009. )
  1010. })
  1011. })
  1012. })
  1013. describe('wordCount', function () {
  1014. describe('with root file', function () {
  1015. beforeEach(async function (ctx) {
  1016. await ctx.ClsiManager.promises.wordCount(
  1017. ctx.project._id,
  1018. ctx.user_id,
  1019. false,
  1020. { compileBackendClass: 'n2d', compileGroup: 'standard' },
  1021. 'node-1'
  1022. )
  1023. })
  1024. it('should call wordCount with root file', function (ctx) {
  1025. expect(ctx.FetchUtils.fetchString).to.have.been.calledWith(
  1026. sinon.match(
  1027. url =>
  1028. url.toString() ===
  1029. `http://clsi.example.com/project/${ctx.project._id}/user/${ctx.user_id}/wordcount?compileBackendClass=n2d&compileGroup=standard&file=main.tex&image=mock-image-name&clsiserverid=node-1`
  1030. )
  1031. )
  1032. })
  1033. it('should not persist a cookie on response', function (ctx) {
  1034. expect(ctx.ClsiCookieManager.promises.setServerId).not.to.have.been
  1035. .called
  1036. })
  1037. })
  1038. describe('with param file', function () {
  1039. beforeEach(async function (ctx) {
  1040. await ctx.ClsiManager.promises.wordCount(
  1041. ctx.project._id,
  1042. ctx.user_id,
  1043. 'other.tex',
  1044. { compileBackendClass: 'n2d', compileGroup: 'standard' },
  1045. 'node-2'
  1046. )
  1047. })
  1048. it('should call wordCount with param file', function (ctx) {
  1049. expect(ctx.FetchUtils.fetchString).to.have.been.calledWith(
  1050. sinon.match(
  1051. url =>
  1052. url.host === CLSI_HOST &&
  1053. url.pathname ===
  1054. `/project/${ctx.project._id}/user/${ctx.user_id}/wordcount` &&
  1055. url.searchParams.get('compileBackendClass') === 'n2d' &&
  1056. url.searchParams.get('compileGroup') === 'standard' &&
  1057. url.searchParams.get('clsiserverid') === 'node-2' &&
  1058. url.searchParams.get('file') === 'other.tex' &&
  1059. url.searchParams.get('image') === 'mock-image-name'
  1060. )
  1061. )
  1062. })
  1063. it('should not persist a cookie on response', function (ctx) {
  1064. expect(ctx.ClsiCookieManager.promises.setServerId).not.to.have.been
  1065. .called
  1066. })
  1067. })
  1068. describe('when a new backend is configured', function () {
  1069. beforeEach(async function (ctx) {
  1070. ctx.Settings.apis.clsi_new = { url: 'https://compiles.somewhere.test' }
  1071. await ctx.ClsiManager.promises.wordCount(
  1072. ctx.project._id,
  1073. ctx.user_id,
  1074. false,
  1075. { compileBackendClass: 'c2d', compileGroup: 'priority' },
  1076. 'node-1'
  1077. )
  1078. // wait for the background task to finish
  1079. await setTimeout(0)
  1080. })
  1081. it('should forward wordcount request', function (ctx) {
  1082. expect(ctx.FetchUtils.fetchString).to.have.been.calledWith(
  1083. sinon.match(
  1084. url =>
  1085. url.toString() ===
  1086. `http://clsi.example.com/project/${ctx.project._id}/user/${ctx.user_id}/wordcount?compileBackendClass=c2d&compileGroup=priority&file=main.tex&image=mock-image-name&clsiserverid=node-1`
  1087. )
  1088. )
  1089. expect(ctx.FetchUtils.fetchStringWithResponse).to.have.been.calledWith(
  1090. sinon.match(
  1091. url =>
  1092. url.toString() ===
  1093. `${ctx.Settings.apis.clsi_new.url}/project/${ctx.project._id}/user/${ctx.user_id}/wordcount?compileBackendClass=n4&compileGroup=priority&file=main.tex&image=mock-image-name`
  1094. )
  1095. )
  1096. })
  1097. })
  1098. })
  1099. })
  1100. function _makeResources(project, docs, files) {
  1101. const resources = []
  1102. for (const [path, doc] of Object.entries(docs)) {
  1103. resources.push({
  1104. path: path.replace(/^\//, ''),
  1105. content: doc.lines.join('\n'),
  1106. })
  1107. }
  1108. for (const [path, file] of Object.entries(files)) {
  1109. let url
  1110. if (file.hash === GLOBAL_BLOB_HASH) {
  1111. url = `${FILESTORE_URL}/history/global/hash/${file.hash}`
  1112. } else {
  1113. url = `${FILESTORE_URL}/history/project/${project.overleaf.history.id}/hash/${file.hash}`
  1114. }
  1115. resources.push({
  1116. path: path.replace(/^\//, ''),
  1117. url,
  1118. modified: file.created.getTime(),
  1119. })
  1120. }
  1121. return resources
  1122. }