ClsiManagerTests.js 34 KB

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