ClsiManagerTests.js 33 KB

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