ClsiManagerTests.js 32 KB

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