ClsiManagerTests.js 34 KB

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