CompileController.test.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. import { vi, describe, beforeEach, it } from 'vitest'
  2. import sinon from 'sinon'
  3. import Errors from '../../../app/js/Errors.js'
  4. import path from 'node:path'
  5. const modulePath = path.join(
  6. import.meta.dirname,
  7. '../../../app/js/CompileController'
  8. )
  9. describe('CompileController', () => {
  10. beforeEach(async ctx => {
  11. ctx.buildId = 'build-id-123'
  12. vi.doMock('../../../app/js/CompileManager', () => ({
  13. default: (ctx.CompileManager = {}),
  14. }))
  15. vi.doMock('../../../app/js/RequestParser', () => ({
  16. default: (ctx.RequestParser = {}),
  17. }))
  18. vi.doMock('@overleaf/settings', () => ({
  19. default: (ctx.Settings = {
  20. apis: {
  21. clsi: {
  22. url: 'http://clsi.example.com',
  23. outputUrlPrefix: '/zone/b',
  24. instanceType: 'c4d',
  25. zone: 'b',
  26. isSpotInstance: true,
  27. downloadHost: 'http://localhost:3013',
  28. },
  29. clsiCache: {
  30. enabled: false,
  31. url: 'http://localhost:3044',
  32. },
  33. },
  34. }),
  35. }))
  36. vi.doMock('@overleaf/metrics', () => ({
  37. default: {
  38. Timer: sinon.stub().returns({ done: sinon.stub() }),
  39. },
  40. }))
  41. vi.doMock('../../../app/js/ProjectPersistenceManager', () => ({
  42. default: (ctx.ProjectPersistenceManager = {}),
  43. }))
  44. vi.doMock('../../../app/js/CLSICacheHandler', () => ({
  45. default: {
  46. notifyCLSICacheAboutBuild: sinon.stub(),
  47. downloadLatestCompileCache: sinon.stub().resolves(),
  48. downloadOutputDotSynctexFromCompileCache: sinon.stub().resolves(),
  49. },
  50. }))
  51. vi.doMock('../../../app/js/Errors', () => ({
  52. default: (ctx.Errors = Errors),
  53. }))
  54. ctx.CompileController = (await import(modulePath)).default
  55. ctx.Settings.externalUrl = 'http://www.example.com'
  56. ctx.req = {}
  57. ctx.res = {}
  58. ctx.next = sinon.stub()
  59. })
  60. describe('compile', () => {
  61. beforeEach(ctx => {
  62. ctx.req.body = {
  63. compile: 'mock-body',
  64. }
  65. ctx.req.params = { project_id: (ctx.project_id = 'project-id-123') }
  66. ctx.request = {
  67. compile: 'mock-parsed-request',
  68. }
  69. ctx.request_with_project_id = {
  70. compile: ctx.request.compile,
  71. project_id: ctx.project_id,
  72. }
  73. ctx.output_files = [
  74. {
  75. path: 'output.pdf',
  76. type: 'pdf',
  77. size: 1337,
  78. build: '1234-5678',
  79. },
  80. {
  81. path: 'output.log',
  82. type: 'log',
  83. build: '1234-5678',
  84. },
  85. ]
  86. ctx.RequestParser.parse = sinon.stub().callsArgWith(1, null, ctx.request)
  87. ctx.ProjectPersistenceManager.markProjectAsJustAccessed = sinon
  88. .stub()
  89. .callsArg(1)
  90. ctx.stats = { foo: 1 }
  91. ctx.timings = { bar: 2 }
  92. ctx.res.status = sinon.stub().returnsThis()
  93. ctx.res.send = sinon.stub()
  94. ctx.CompileManager.doCompileWithLock = sinon
  95. .stub()
  96. .callsFake((_req, stats, timings, cb) => {
  97. Object.assign(stats, ctx.stats)
  98. Object.assign(timings, ctx.timings)
  99. cb(null, {
  100. outputFiles: ctx.output_files,
  101. buildId: ctx.buildId,
  102. })
  103. })
  104. })
  105. describe('successfully', () => {
  106. beforeEach(ctx => {
  107. ctx.CompileController.compile(ctx.req, ctx.res)
  108. })
  109. it('should parse the request', ctx => {
  110. ctx.RequestParser.parse.calledWith(ctx.req.body).should.equal(true)
  111. })
  112. it('should run the compile for the specified project', ctx => {
  113. ctx.CompileManager.doCompileWithLock
  114. .calledWith(ctx.request_with_project_id)
  115. .should.equal(true)
  116. })
  117. it('should mark the project as accessed', ctx => {
  118. ctx.ProjectPersistenceManager.markProjectAsJustAccessed
  119. .calledWith(ctx.project_id)
  120. .should.equal(true)
  121. })
  122. it('should return the JSON response', ctx => {
  123. ctx.res.status.calledWith(200).should.equal(true)
  124. ctx.res.send
  125. .calledWith({
  126. compile: {
  127. status: 'success',
  128. error: null,
  129. stats: ctx.stats,
  130. timings: ctx.timings,
  131. buildId: ctx.buildId,
  132. outputUrlPrefix: '/zone/b',
  133. outputFiles: ctx.output_files.map(file => ({
  134. url: `${ctx.Settings.apis.clsi.downloadHost}/project/${ctx.project_id}/build/${file.build}/output/${file.path}`,
  135. ...file,
  136. })),
  137. clsiCacheShard: undefined,
  138. baseHistoryVersion: undefined,
  139. instanceType: 'c4d',
  140. zone: 'b',
  141. isSpotInstance: true,
  142. },
  143. })
  144. .should.equal(true)
  145. })
  146. })
  147. describe('without a outputUrlPrefix', () => {
  148. beforeEach(ctx => {
  149. ctx.Settings.apis.clsi.outputUrlPrefix = ''
  150. ctx.CompileController.compile(ctx.req, ctx.res)
  151. })
  152. it('should return the JSON response with empty outputUrlPrefix', ctx => {
  153. ctx.res.status.calledWith(200).should.equal(true)
  154. ctx.res.send
  155. .calledWith({
  156. compile: {
  157. status: 'success',
  158. error: null,
  159. stats: ctx.stats,
  160. timings: ctx.timings,
  161. buildId: ctx.buildId,
  162. outputUrlPrefix: '',
  163. outputFiles: ctx.output_files.map(file => ({
  164. url: `${ctx.Settings.apis.clsi.downloadHost}/project/${ctx.project_id}/build/${file.build}/output/${file.path}`,
  165. ...file,
  166. })),
  167. clsiCacheShard: undefined,
  168. baseHistoryVersion: undefined,
  169. instanceType: 'c4d',
  170. zone: 'b',
  171. isSpotInstance: true,
  172. },
  173. })
  174. .should.equal(true)
  175. })
  176. })
  177. describe('with user provided fake_output.pdf', () => {
  178. beforeEach(ctx => {
  179. ctx.output_files = [
  180. {
  181. path: 'fake_output.pdf',
  182. type: 'pdf',
  183. build: '1234-5678',
  184. },
  185. {
  186. path: 'output.log',
  187. type: 'log',
  188. build: '1234-5678',
  189. },
  190. ]
  191. ctx.CompileManager.doCompileWithLock = sinon
  192. .stub()
  193. .callsFake((_req, stats, timings, cb) => {
  194. Object.assign(stats, ctx.stats)
  195. Object.assign(timings, ctx.timings)
  196. cb(null, {
  197. outputFiles: ctx.output_files,
  198. buildId: ctx.buildId,
  199. })
  200. })
  201. ctx.CompileController.compile(ctx.req, ctx.res)
  202. })
  203. it('should return the JSON response with status failure', ctx => {
  204. ctx.res.status.calledWith(200).should.equal(true)
  205. ctx.res.send.should.have.been.calledWith({
  206. compile: {
  207. status: 'failure',
  208. error: null,
  209. stats: ctx.stats,
  210. timings: ctx.timings,
  211. outputUrlPrefix: '/zone/b',
  212. buildId: ctx.buildId,
  213. outputFiles: ctx.output_files.map(file => ({
  214. url: `${ctx.Settings.apis.clsi.downloadHost}/project/${ctx.project_id}/build/${file.build}/output/${file.path}`,
  215. ...file,
  216. })),
  217. clsiCacheShard: undefined,
  218. baseHistoryVersion: undefined,
  219. instanceType: 'c4d',
  220. zone: 'b',
  221. isSpotInstance: true,
  222. },
  223. })
  224. })
  225. })
  226. describe('with an empty output.pdf', () => {
  227. beforeEach(ctx => {
  228. ctx.output_files = [
  229. {
  230. path: 'output.pdf',
  231. type: 'pdf',
  232. size: 0,
  233. build: '1234-5678',
  234. },
  235. {
  236. path: 'output.log',
  237. type: 'log',
  238. build: '1234-5678',
  239. },
  240. ]
  241. ctx.CompileManager.doCompileWithLock = sinon
  242. .stub()
  243. .callsFake((_req, stats, timings, cb) => {
  244. Object.assign(stats, ctx.stats)
  245. Object.assign(timings, ctx.timings)
  246. cb(null, {
  247. outputFiles: ctx.output_files,
  248. buildId: ctx.buildId,
  249. })
  250. })
  251. ctx.CompileController.compile(ctx.req, ctx.res)
  252. })
  253. it('should return the JSON response with status failure', ctx => {
  254. ctx.res.status.calledWith(200).should.equal(true)
  255. ctx.res.send.should.have.been.calledWith({
  256. compile: {
  257. status: 'failure',
  258. error: null,
  259. stats: ctx.stats,
  260. buildId: ctx.buildId,
  261. timings: ctx.timings,
  262. outputUrlPrefix: '/zone/b',
  263. outputFiles: ctx.output_files.map(file => ({
  264. url: `${ctx.Settings.apis.clsi.downloadHost}/project/${ctx.project_id}/build/${file.build}/output/${file.path}`,
  265. ...file,
  266. })),
  267. clsiCacheShard: undefined,
  268. baseHistoryVersion: undefined,
  269. instanceType: 'c4d',
  270. zone: 'b',
  271. isSpotInstance: true,
  272. },
  273. })
  274. })
  275. })
  276. describe('with an error', () => {
  277. beforeEach(ctx => {
  278. const error = new Error((ctx.message = 'error message'))
  279. error.buildId = ctx.buildId
  280. ctx.CompileManager.doCompileWithLock = sinon
  281. .stub()
  282. .callsFake((_req, stats, timings, cb) => {
  283. Object.assign(stats, ctx.stats)
  284. Object.assign(timings, ctx.timings)
  285. cb(error)
  286. })
  287. ctx.CompileController.compile(ctx.req, ctx.res)
  288. })
  289. it('should return the JSON response with the error', ctx => {
  290. ctx.res.status.calledWith(500).should.equal(true)
  291. ctx.res.send
  292. .calledWith({
  293. compile: {
  294. status: 'error',
  295. error: ctx.message,
  296. outputUrlPrefix: '/zone/b',
  297. outputFiles: [],
  298. buildId: ctx.buildId,
  299. stats: ctx.stats,
  300. timings: ctx.timings,
  301. clsiCacheShard: undefined,
  302. baseHistoryVersion: undefined,
  303. instanceType: 'c4d',
  304. zone: 'b',
  305. isSpotInstance: true,
  306. },
  307. })
  308. .should.equal(true)
  309. })
  310. })
  311. describe('with too many compile requests error', () => {
  312. beforeEach(ctx => {
  313. const error = new Errors.TooManyCompileRequestsError(
  314. 'too many concurrent compile requests'
  315. )
  316. ctx.CompileManager.doCompileWithLock = sinon
  317. .stub()
  318. .callsFake((_req, stats, timings, cb) => {
  319. Object.assign(stats, ctx.stats)
  320. Object.assign(timings, ctx.timings)
  321. cb(error)
  322. })
  323. ctx.CompileController.compile(ctx.req, ctx.res)
  324. })
  325. it('should return the JSON response with the error', ctx => {
  326. ctx.res.status.calledWith(503).should.equal(true)
  327. ctx.res.send
  328. .calledWith({
  329. compile: {
  330. status: 'unavailable',
  331. error: 'too many concurrent compile requests',
  332. outputUrlPrefix: '/zone/b',
  333. outputFiles: [],
  334. stats: ctx.stats,
  335. timings: ctx.timings,
  336. // JSON.stringify will omit these undefined values
  337. buildId: undefined,
  338. clsiCacheShard: undefined,
  339. baseHistoryVersion: undefined,
  340. instanceType: 'c4d',
  341. zone: 'b',
  342. isSpotInstance: true,
  343. },
  344. })
  345. .should.equal(true)
  346. })
  347. })
  348. describe('when the request times out', () => {
  349. beforeEach(ctx => {
  350. ctx.error = new Error((ctx.message = 'container timed out'))
  351. ctx.error.timedout = true
  352. ctx.CompileManager.doCompileWithLock = sinon
  353. .stub()
  354. .callsFake((_req, stats, timings, cb) => {
  355. Object.assign(stats, ctx.stats)
  356. Object.assign(timings, ctx.timings)
  357. cb(ctx.error)
  358. })
  359. ctx.CompileController.compile(ctx.req, ctx.res)
  360. })
  361. it('should return the JSON response with the timeout status', ctx => {
  362. ctx.res.status.calledWith(200).should.equal(true)
  363. ctx.res.send
  364. .calledWith({
  365. compile: {
  366. status: 'timedout',
  367. error: ctx.message,
  368. outputUrlPrefix: '/zone/b',
  369. outputFiles: [],
  370. stats: ctx.stats,
  371. timings: ctx.timings,
  372. // JSON.stringify will omit these undefined values
  373. buildId: undefined,
  374. clsiCacheShard: undefined,
  375. baseHistoryVersion: undefined,
  376. instanceType: 'c4d',
  377. zone: 'b',
  378. isSpotInstance: true,
  379. },
  380. })
  381. .should.equal(true)
  382. })
  383. })
  384. describe('when the request returns no output files', () => {
  385. beforeEach(ctx => {
  386. ctx.CompileManager.doCompileWithLock = sinon
  387. .stub()
  388. .callsFake((_req, stats, timings, cb) => {
  389. Object.assign(stats, ctx.stats)
  390. Object.assign(timings, ctx.timings)
  391. cb(null, {})
  392. })
  393. ctx.CompileController.compile(ctx.req, ctx.res)
  394. })
  395. it('should return the JSON response with the failure status', ctx => {
  396. ctx.res.status.calledWith(200).should.equal(true)
  397. ctx.res.send
  398. .calledWith({
  399. compile: {
  400. error: null,
  401. status: 'failure',
  402. outputUrlPrefix: '/zone/b',
  403. outputFiles: [],
  404. stats: ctx.stats,
  405. timings: ctx.timings,
  406. // JSON.stringify will omit these undefined values
  407. buildId: undefined,
  408. clsiCacheShard: undefined,
  409. baseHistoryVersion: undefined,
  410. instanceType: 'c4d',
  411. zone: 'b',
  412. isSpotInstance: true,
  413. },
  414. })
  415. .should.equal(true)
  416. })
  417. })
  418. })
  419. describe('syncFromCode', () => {
  420. beforeEach(ctx => {
  421. ctx.file = 'main.tex'
  422. ctx.line = 42
  423. ctx.column = 5
  424. ctx.project_id = 'mock-project-id'
  425. ctx.req.params = { project_id: ctx.project_id }
  426. ctx.req.query = {
  427. file: ctx.file,
  428. line: ctx.line.toString(),
  429. column: ctx.column.toString(),
  430. }
  431. ctx.res.json = sinon.stub()
  432. ctx.CompileManager.syncFromCode = sinon
  433. .stub()
  434. .yields(null, (ctx.pdfPositions = ['mock-positions']), true)
  435. ctx.CompileController.syncFromCode(ctx.req, ctx.res, ctx.next)
  436. })
  437. it('should find the corresponding location in the PDF', ctx => {
  438. ctx.CompileManager.syncFromCode
  439. .calledWith(ctx.project_id, undefined, ctx.file, ctx.line, ctx.column)
  440. .should.equal(true)
  441. })
  442. it('should return the positions', ctx => {
  443. ctx.res.json
  444. .calledWith({
  445. pdf: ctx.pdfPositions,
  446. downloadedFromCache: true,
  447. })
  448. .should.equal(true)
  449. })
  450. })
  451. describe('syncFromPdf', () => {
  452. beforeEach(ctx => {
  453. ctx.page = 5
  454. ctx.h = 100.23
  455. ctx.v = 45.67
  456. ctx.project_id = 'mock-project-id'
  457. ctx.req.params = { project_id: ctx.project_id }
  458. ctx.req.query = {
  459. page: ctx.page.toString(),
  460. h: ctx.h.toString(),
  461. v: ctx.v.toString(),
  462. }
  463. ctx.res.json = sinon.stub()
  464. ctx.CompileManager.syncFromPdf = sinon
  465. .stub()
  466. .yields(null, (ctx.codePositions = ['mock-positions']), true)
  467. ctx.CompileController.syncFromPdf(ctx.req, ctx.res, ctx.next)
  468. })
  469. it('should find the corresponding location in the code', ctx => {
  470. ctx.CompileManager.syncFromPdf
  471. .calledWith(ctx.project_id, undefined, ctx.page, ctx.h, ctx.v)
  472. .should.equal(true)
  473. })
  474. it('should return the positions', ctx => {
  475. ctx.res.json
  476. .calledWith({
  477. code: ctx.codePositions,
  478. downloadedFromCache: true,
  479. })
  480. .should.equal(true)
  481. })
  482. })
  483. describe('wordcount', () => {
  484. beforeEach(ctx => {
  485. ctx.file = 'main.tex'
  486. ctx.project_id = 'mock-project-id'
  487. ctx.req.params = { project_id: ctx.project_id }
  488. ctx.req.query = {
  489. file: ctx.file,
  490. image: (ctx.image = 'example.com/image'),
  491. }
  492. ctx.res.json = sinon.stub()
  493. ctx.CompileManager.wordcount = sinon
  494. .stub()
  495. .callsArgWith(4, null, (ctx.texcount = ['mock-texcount']))
  496. })
  497. it('should return the word count of a file', ctx => {
  498. ctx.CompileController.wordcount(ctx.req, ctx.res, ctx.next)
  499. ctx.CompileManager.wordcount
  500. .calledWith(ctx.project_id, undefined, ctx.file, ctx.image)
  501. .should.equal(true)
  502. })
  503. it('should return the texcount info', ctx => {
  504. ctx.CompileController.wordcount(ctx.req, ctx.res, ctx.next)
  505. ctx.res.json
  506. .calledWith({
  507. texcount: ctx.texcount,
  508. })
  509. .should.equal(true)
  510. })
  511. })
  512. })