ClsiManager.test.mjs 38 KB

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