CompileController.test.js 16 KB

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