CompileController.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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. },
  136. })
  137. .should.equal(true)
  138. })
  139. })
  140. describe('without a outputUrlPrefix', () => {
  141. beforeEach(ctx => {
  142. ctx.Settings.apis.clsi.outputUrlPrefix = ''
  143. ctx.CompileController.compile(ctx.req, ctx.res)
  144. })
  145. it('should return the JSON response with empty outputUrlPrefix', ctx => {
  146. ctx.res.status.calledWith(200).should.equal(true)
  147. ctx.res.send
  148. .calledWith({
  149. compile: {
  150. status: 'success',
  151. error: null,
  152. stats: ctx.stats,
  153. timings: ctx.timings,
  154. buildId: ctx.buildId,
  155. outputUrlPrefix: '',
  156. outputFiles: ctx.output_files.map(file => ({
  157. url: `${ctx.Settings.apis.clsi.downloadHost}/project/${ctx.project_id}/build/${file.build}/output/${file.path}`,
  158. ...file,
  159. })),
  160. clsiCacheShard: undefined,
  161. },
  162. })
  163. .should.equal(true)
  164. })
  165. })
  166. describe('with user provided fake_output.pdf', () => {
  167. beforeEach(ctx => {
  168. ctx.output_files = [
  169. {
  170. path: 'fake_output.pdf',
  171. type: 'pdf',
  172. build: 1234,
  173. },
  174. {
  175. path: 'output.log',
  176. type: 'log',
  177. build: 1234,
  178. },
  179. ]
  180. ctx.CompileManager.doCompileWithLock = sinon
  181. .stub()
  182. .callsFake((_req, stats, timings, cb) => {
  183. Object.assign(stats, ctx.stats)
  184. Object.assign(timings, ctx.timings)
  185. cb(null, {
  186. outputFiles: ctx.output_files,
  187. buildId: ctx.buildId,
  188. })
  189. })
  190. ctx.CompileController.compile(ctx.req, ctx.res)
  191. })
  192. it('should return the JSON response with status failure', ctx => {
  193. ctx.res.status.calledWith(200).should.equal(true)
  194. ctx.res.send.should.have.been.calledWith({
  195. compile: {
  196. status: 'failure',
  197. error: null,
  198. stats: ctx.stats,
  199. timings: ctx.timings,
  200. outputUrlPrefix: '/zone/b',
  201. buildId: ctx.buildId,
  202. outputFiles: ctx.output_files.map(file => ({
  203. url: `${ctx.Settings.apis.clsi.downloadHost}/project/${ctx.project_id}/build/${file.build}/output/${file.path}`,
  204. ...file,
  205. })),
  206. clsiCacheShard: undefined,
  207. },
  208. })
  209. })
  210. })
  211. describe('with an empty output.pdf', () => {
  212. beforeEach(ctx => {
  213. ctx.output_files = [
  214. {
  215. path: 'output.pdf',
  216. type: 'pdf',
  217. size: 0,
  218. build: 1234,
  219. },
  220. {
  221. path: 'output.log',
  222. type: 'log',
  223. build: 1234,
  224. },
  225. ]
  226. ctx.CompileManager.doCompileWithLock = sinon
  227. .stub()
  228. .callsFake((_req, stats, timings, cb) => {
  229. Object.assign(stats, ctx.stats)
  230. Object.assign(timings, ctx.timings)
  231. cb(null, {
  232. outputFiles: ctx.output_files,
  233. buildId: ctx.buildId,
  234. })
  235. })
  236. ctx.CompileController.compile(ctx.req, ctx.res)
  237. })
  238. it('should return the JSON response with status failure', ctx => {
  239. ctx.res.status.calledWith(200).should.equal(true)
  240. ctx.res.send.should.have.been.calledWith({
  241. compile: {
  242. status: 'failure',
  243. error: null,
  244. stats: ctx.stats,
  245. buildId: ctx.buildId,
  246. timings: ctx.timings,
  247. outputUrlPrefix: '/zone/b',
  248. outputFiles: ctx.output_files.map(file => ({
  249. url: `${ctx.Settings.apis.clsi.downloadHost}/project/${ctx.project_id}/build/${file.build}/output/${file.path}`,
  250. ...file,
  251. })),
  252. clsiCacheShard: undefined,
  253. },
  254. })
  255. })
  256. })
  257. describe('with an error', () => {
  258. beforeEach(ctx => {
  259. const error = new Error((ctx.message = 'error message'))
  260. error.buildId = ctx.buildId
  261. ctx.CompileManager.doCompileWithLock = sinon
  262. .stub()
  263. .callsFake((_req, stats, timings, cb) => {
  264. Object.assign(stats, ctx.stats)
  265. Object.assign(timings, ctx.timings)
  266. cb(error)
  267. })
  268. ctx.CompileController.compile(ctx.req, ctx.res)
  269. })
  270. it('should return the JSON response with the error', ctx => {
  271. ctx.res.status.calledWith(500).should.equal(true)
  272. ctx.res.send
  273. .calledWith({
  274. compile: {
  275. status: 'error',
  276. error: ctx.message,
  277. outputUrlPrefix: '/zone/b',
  278. outputFiles: [],
  279. buildId: ctx.buildId,
  280. stats: ctx.stats,
  281. timings: ctx.timings,
  282. clsiCacheShard: undefined,
  283. },
  284. })
  285. .should.equal(true)
  286. })
  287. })
  288. describe('with too many compile requests error', () => {
  289. beforeEach(ctx => {
  290. const error = new Errors.TooManyCompileRequestsError(
  291. 'too many concurrent compile requests'
  292. )
  293. ctx.CompileManager.doCompileWithLock = sinon
  294. .stub()
  295. .callsFake((_req, stats, timings, cb) => {
  296. Object.assign(stats, ctx.stats)
  297. Object.assign(timings, ctx.timings)
  298. cb(error)
  299. })
  300. ctx.CompileController.compile(ctx.req, ctx.res)
  301. })
  302. it('should return the JSON response with the error', ctx => {
  303. ctx.res.status.calledWith(503).should.equal(true)
  304. ctx.res.send
  305. .calledWith({
  306. compile: {
  307. status: 'unavailable',
  308. error: 'too many concurrent compile requests',
  309. outputUrlPrefix: '/zone/b',
  310. outputFiles: [],
  311. stats: ctx.stats,
  312. timings: ctx.timings,
  313. // JSON.stringify will omit these undefined values
  314. buildId: undefined,
  315. clsiCacheShard: undefined,
  316. },
  317. })
  318. .should.equal(true)
  319. })
  320. })
  321. describe('when the request times out', () => {
  322. beforeEach(ctx => {
  323. ctx.error = new Error((ctx.message = 'container timed out'))
  324. ctx.error.timedout = true
  325. ctx.CompileManager.doCompileWithLock = sinon
  326. .stub()
  327. .callsFake((_req, stats, timings, cb) => {
  328. Object.assign(stats, ctx.stats)
  329. Object.assign(timings, ctx.timings)
  330. cb(ctx.error)
  331. })
  332. ctx.CompileController.compile(ctx.req, ctx.res)
  333. })
  334. it('should return the JSON response with the timeout status', ctx => {
  335. ctx.res.status.calledWith(200).should.equal(true)
  336. ctx.res.send
  337. .calledWith({
  338. compile: {
  339. status: 'timedout',
  340. error: ctx.message,
  341. outputUrlPrefix: '/zone/b',
  342. outputFiles: [],
  343. stats: ctx.stats,
  344. timings: ctx.timings,
  345. // JSON.stringify will omit these undefined values
  346. buildId: undefined,
  347. clsiCacheShard: undefined,
  348. },
  349. })
  350. .should.equal(true)
  351. })
  352. })
  353. describe('when the request returns no output files', () => {
  354. beforeEach(ctx => {
  355. ctx.CompileManager.doCompileWithLock = sinon
  356. .stub()
  357. .callsFake((_req, stats, timings, cb) => {
  358. Object.assign(stats, ctx.stats)
  359. Object.assign(timings, ctx.timings)
  360. cb(null, {})
  361. })
  362. ctx.CompileController.compile(ctx.req, ctx.res)
  363. })
  364. it('should return the JSON response with the failure status', ctx => {
  365. ctx.res.status.calledWith(200).should.equal(true)
  366. ctx.res.send
  367. .calledWith({
  368. compile: {
  369. error: null,
  370. status: 'failure',
  371. outputUrlPrefix: '/zone/b',
  372. outputFiles: [],
  373. stats: ctx.stats,
  374. timings: ctx.timings,
  375. // JSON.stringify will omit these undefined values
  376. buildId: undefined,
  377. clsiCacheShard: undefined,
  378. },
  379. })
  380. .should.equal(true)
  381. })
  382. })
  383. })
  384. describe('syncFromCode', () => {
  385. beforeEach(ctx => {
  386. ctx.file = 'main.tex'
  387. ctx.line = 42
  388. ctx.column = 5
  389. ctx.project_id = 'mock-project-id'
  390. ctx.req.params = { project_id: ctx.project_id }
  391. ctx.req.query = {
  392. file: ctx.file,
  393. line: ctx.line.toString(),
  394. column: ctx.column.toString(),
  395. }
  396. ctx.res.json = sinon.stub()
  397. ctx.CompileManager.syncFromCode = sinon
  398. .stub()
  399. .yields(null, (ctx.pdfPositions = ['mock-positions']), true)
  400. ctx.CompileController.syncFromCode(ctx.req, ctx.res, ctx.next)
  401. })
  402. it('should find the corresponding location in the PDF', ctx => {
  403. ctx.CompileManager.syncFromCode
  404. .calledWith(ctx.project_id, undefined, ctx.file, ctx.line, ctx.column)
  405. .should.equal(true)
  406. })
  407. it('should return the positions', ctx => {
  408. ctx.res.json
  409. .calledWith({
  410. pdf: ctx.pdfPositions,
  411. downloadedFromCache: true,
  412. })
  413. .should.equal(true)
  414. })
  415. })
  416. describe('syncFromPdf', () => {
  417. beforeEach(ctx => {
  418. ctx.page = 5
  419. ctx.h = 100.23
  420. ctx.v = 45.67
  421. ctx.project_id = 'mock-project-id'
  422. ctx.req.params = { project_id: ctx.project_id }
  423. ctx.req.query = {
  424. page: ctx.page.toString(),
  425. h: ctx.h.toString(),
  426. v: ctx.v.toString(),
  427. }
  428. ctx.res.json = sinon.stub()
  429. ctx.CompileManager.syncFromPdf = sinon
  430. .stub()
  431. .yields(null, (ctx.codePositions = ['mock-positions']), true)
  432. ctx.CompileController.syncFromPdf(ctx.req, ctx.res, ctx.next)
  433. })
  434. it('should find the corresponding location in the code', ctx => {
  435. ctx.CompileManager.syncFromPdf
  436. .calledWith(ctx.project_id, undefined, ctx.page, ctx.h, ctx.v)
  437. .should.equal(true)
  438. })
  439. it('should return the positions', ctx => {
  440. ctx.res.json
  441. .calledWith({
  442. code: ctx.codePositions,
  443. downloadedFromCache: true,
  444. })
  445. .should.equal(true)
  446. })
  447. })
  448. describe('wordcount', () => {
  449. beforeEach(ctx => {
  450. ctx.file = 'main.tex'
  451. ctx.project_id = 'mock-project-id'
  452. ctx.req.params = { project_id: ctx.project_id }
  453. ctx.req.query = {
  454. file: ctx.file,
  455. image: (ctx.image = 'example.com/image'),
  456. }
  457. ctx.res.json = sinon.stub()
  458. ctx.CompileManager.wordcount = sinon
  459. .stub()
  460. .callsArgWith(4, null, (ctx.texcount = ['mock-texcount']))
  461. })
  462. it('should return the word count of a file', ctx => {
  463. ctx.CompileController.wordcount(ctx.req, ctx.res, ctx.next)
  464. ctx.CompileManager.wordcount
  465. .calledWith(ctx.project_id, undefined, ctx.file, ctx.image)
  466. .should.equal(true)
  467. })
  468. it('should return the texcount info', ctx => {
  469. ctx.CompileController.wordcount(ctx.req, ctx.res, ctx.next)
  470. ctx.res.json
  471. .calledWith({
  472. texcount: ctx.texcount,
  473. })
  474. .should.equal(true)
  475. })
  476. })
  477. })