ClsiManagerTests.js 32 KB

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