CompileManager.test.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import { vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. const MODULE_PATH = '../../../../app/src/Features/Compile/CompileManager.mjs'
  4. describe('CompileManager', function () {
  5. beforeEach(async function (ctx) {
  6. ctx.rateLimiter = {
  7. consume: sinon.stub().resolves(),
  8. }
  9. ctx.timer = {
  10. done: sinon.stub(),
  11. }
  12. ctx.Metrics = {
  13. Timer: sinon.stub().returns(ctx.timer),
  14. inc: sinon.stub(),
  15. }
  16. vi.doMock('@overleaf/settings', () => ({
  17. default: (ctx.settings = {
  18. apis: {
  19. clsi: { submissionBackendClass: 'c3d' },
  20. },
  21. redis: { web: { host: '127.0.0.1', port: 42 } },
  22. rateLimit: { autoCompile: {} },
  23. }),
  24. }))
  25. vi.doMock('../../../../app/src/infrastructure/RedisWrapper', () => ({
  26. default: {
  27. client: () =>
  28. (ctx.rclient = {
  29. auth() {},
  30. }),
  31. },
  32. }))
  33. vi.doMock(
  34. '../../../../app/src/Features/Project/ProjectRootDocManager',
  35. () => ({
  36. default: (ctx.ProjectRootDocManager = {
  37. promises: {},
  38. }),
  39. })
  40. )
  41. vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
  42. default: (ctx.ProjectGetter = { promises: {} }),
  43. }))
  44. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  45. default: (ctx.UserGetter = { promises: {} }),
  46. }))
  47. vi.doMock('../../../../app/src/Features/Compile/ClsiManager', () => ({
  48. default: (ctx.ClsiManager = { promises: {} }),
  49. }))
  50. vi.doMock('../../../../app/src/infrastructure/RateLimiter.mjs', () => ({
  51. RateLimiter: sinon.stub().returns(ctx.rateLimiter),
  52. }))
  53. vi.doMock('@overleaf/metrics', () => ({
  54. default: ctx.Metrics,
  55. }))
  56. vi.doMock(
  57. '../../../../app/src/Features/Analytics/UserAnalyticsDataCache',
  58. () => ({
  59. default: (ctx.UserAnalyticsDataCache = {
  60. getAnalyticsId: sinon.stub().resolves('abc'),
  61. }),
  62. })
  63. )
  64. vi.doMock(
  65. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  66. () => ({
  67. default: (ctx.SplitTestHandler = {
  68. promises: {},
  69. }),
  70. })
  71. )
  72. ctx.CompileManager = (await import(MODULE_PATH)).default
  73. ctx.project_id = 'mock-project-id-123'
  74. ctx.user_id = 'mock-user-id-123'
  75. ctx.callback = sinon.stub()
  76. ctx.limits = {
  77. timeout: 42,
  78. compileGroup: 'standard',
  79. }
  80. })
  81. describe('compile', function () {
  82. beforeEach(function (ctx) {
  83. ctx.CompileManager._checkIfRecentlyCompiled = sinon.stub().resolves(false)
  84. ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid = sinon
  85. .stub()
  86. .resolves({
  87. rootDocId: 'mock-root-doc-id-123',
  88. rootResourcePath: '/main.tex',
  89. })
  90. ctx.CompileManager.promises.getProjectCompileLimits = sinon
  91. .stub()
  92. .resolves(ctx.limits)
  93. ctx.ClsiManager.promises.sendRequest = sinon.stub().resolves({
  94. status: (ctx.status = 'mock-status'),
  95. outputFiles: (ctx.outputFiles = []),
  96. clsiServerId: (ctx.output = 'mock output'),
  97. })
  98. })
  99. describe('successfully', function () {
  100. let result
  101. beforeEach(async function (ctx) {
  102. ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
  103. isAutoCompile,
  104. compileGroup
  105. ) => true
  106. ctx.ProjectGetter.promises.getProject = sinon
  107. .stub()
  108. .resolves(
  109. (ctx.project = { owner_ref: (ctx.owner_id = 'owner-id-123') })
  110. )
  111. ctx.UserGetter.promises.getUser = sinon.stub().resolves(
  112. (ctx.user = {
  113. features: { compileTimeout: '20s', compileGroup: 'standard' },
  114. analyticsId: 'abc',
  115. })
  116. )
  117. result = await ctx.CompileManager.promises.compile(
  118. ctx.project_id,
  119. ctx.user_id,
  120. {}
  121. )
  122. })
  123. it('should check the project has not been recently compiled', function (ctx) {
  124. ctx.CompileManager._checkIfRecentlyCompiled
  125. .calledWith(ctx.project_id, ctx.user_id)
  126. .should.equal(true)
  127. })
  128. it('should ensure that the root document is set', function (ctx) {
  129. ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid
  130. .calledWith(ctx.project_id)
  131. .should.equal(true)
  132. })
  133. it('should get the project compile limits', function (ctx) {
  134. ctx.CompileManager.promises.getProjectCompileLimits
  135. .calledWith(ctx.project_id)
  136. .should.equal(true)
  137. })
  138. it('should run the compile with the compile limits', function (ctx) {
  139. ctx.ClsiManager.promises.sendRequest.should.have.been.calledWith(
  140. ctx.project_id,
  141. ctx.user_id,
  142. {
  143. timeout: ctx.limits.timeout,
  144. compileGroup: 'standard',
  145. buildId: sinon.match(/[a-f0-9]+-[a-f0-9]+/),
  146. rootResourcePath: 'main.tex',
  147. rootDoc_id: 'mock-root-doc-id-123',
  148. }
  149. )
  150. })
  151. it('should resolve with the output', function (ctx) {
  152. expect(result).to.haveOwnProperty('status', ctx.status)
  153. expect(result).to.haveOwnProperty('clsiServerId', ctx.output)
  154. expect(result).to.haveOwnProperty('outputFiles', ctx.outputFiles)
  155. })
  156. it('should time the compile', function (ctx) {
  157. ctx.timer.done.called.should.equal(true)
  158. })
  159. })
  160. describe('with rootResourcePath given', function () {
  161. it('should not check the root doc', async function (ctx) {
  162. const { status } = await ctx.CompileManager.promises.compile(
  163. ctx.project_id,
  164. ctx.user_id,
  165. { rootResourcePath: 'main.tex' }
  166. )
  167. ctx.ClsiManager.promises.sendRequest.should.have.been.called
  168. status.should.equal('mock-status')
  169. ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid.should.not
  170. .have.been.called
  171. })
  172. })
  173. describe('without a root doc', function () {
  174. it('should return early', async function (ctx) {
  175. ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid.resolves()
  176. const { status, validationProblems } =
  177. await ctx.CompileManager.promises.compile(
  178. ctx.project_id,
  179. ctx.user_id,
  180. {}
  181. )
  182. status.should.equal('validation-problems')
  183. validationProblems.should.deep.equal({
  184. mainFile: 'no main file specified',
  185. })
  186. })
  187. })
  188. describe('when the project has been recently compiled', function () {
  189. it('should return', async function (ctx) {
  190. ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
  191. isAutoCompile,
  192. compileGroup
  193. ) => true
  194. ctx.CompileManager._checkIfRecentlyCompiled = sinon
  195. .stub()
  196. .resolves(true)
  197. const { status } = await ctx.CompileManager.promises.compile(
  198. ctx.project_id,
  199. ctx.user_id,
  200. {}
  201. )
  202. status.should.equal('too-recently-compiled')
  203. })
  204. })
  205. describe('should check the rate limit', function () {
  206. it('should return', async function (ctx) {
  207. ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon
  208. .stub()
  209. .resolves(false)
  210. const { status } = await ctx.CompileManager.promises.compile(
  211. ctx.project_id,
  212. ctx.user_id,
  213. {}
  214. )
  215. expect(status).to.equal('autocompile-backoff')
  216. })
  217. })
  218. })
  219. describe('getProjectCompileLimits', function () {
  220. beforeEach(async function (ctx) {
  221. ctx.features = {
  222. compileTimeout: (ctx.timeout = 42),
  223. compileGroup: (ctx.group = 'priority'),
  224. }
  225. ctx.ProjectGetter.promises.getProject = sinon
  226. .stub()
  227. .resolves(
  228. (ctx.project = { owner_ref: (ctx.owner_id = 'owner-id-123') })
  229. )
  230. ctx.UserGetter.promises.getUser = sinon
  231. .stub()
  232. .resolves((ctx.user = { features: ctx.features, analyticsId: 'abc' }))
  233. try {
  234. const result =
  235. await ctx.CompileManager.promises.getProjectCompileLimits(
  236. ctx.project_id
  237. )
  238. ctx.callback(null, result)
  239. } catch (error) {
  240. ctx.callback(error)
  241. }
  242. })
  243. it('should look up the owner of the project', function (ctx) {
  244. ctx.ProjectGetter.promises.getProject
  245. .calledWith(ctx.project_id, { owner_ref: 1, fromV1TemplateId: 1 })
  246. .should.equal(true)
  247. })
  248. it("should look up the owner's features", function (ctx) {
  249. ctx.UserGetter.promises.getUser
  250. .calledWith(ctx.project.owner_ref, {
  251. _id: 1,
  252. alphaProgram: 1,
  253. analyticsId: 1,
  254. betaProgram: 1,
  255. features: 1,
  256. })
  257. .should.equal(true)
  258. })
  259. it('should return the limits', function (ctx) {
  260. ctx.callback
  261. .calledWith(null, {
  262. timeout: ctx.timeout,
  263. compileGroup: ctx.group,
  264. compileBackendClass: 'c4d',
  265. ownerAnalyticsId: 'abc',
  266. })
  267. .should.equal(true)
  268. })
  269. })
  270. describe('compileBackendClass', function () {
  271. beforeEach(function (ctx) {
  272. ctx.features = {
  273. compileTimeout: 42,
  274. compileGroup: 'standard',
  275. }
  276. ctx.ProjectGetter.promises.getProject = sinon
  277. .stub()
  278. .resolves({ owner_ref: 'owner-id-123' })
  279. ctx.UserGetter.promises.getUser = sinon
  280. .stub()
  281. .resolves({ features: ctx.features, analyticsId: 'abc' })
  282. })
  283. describe('with priority compile', function () {
  284. beforeEach(function (ctx) {
  285. ctx.features.compileGroup = 'priority'
  286. })
  287. it('should return the default class', async function (ctx) {
  288. const { compileBackendClass } =
  289. await ctx.CompileManager.promises.getProjectCompileLimits(
  290. ctx.project_id
  291. )
  292. expect(compileBackendClass).to.equal('c4d')
  293. })
  294. })
  295. })
  296. describe('deleteAuxFiles', function () {
  297. let result
  298. beforeEach(async function (ctx) {
  299. ctx.CompileManager.promises.getProjectCompileLimits = sinon
  300. .stub()
  301. .resolves((ctx.limits = { compileGroup: 'mock-compile-group' }))
  302. ctx.ClsiManager.promises.deleteAuxFiles = sinon.stub().resolves('test')
  303. result = await ctx.CompileManager.promises.deleteAuxFiles(
  304. ctx.project_id,
  305. ctx.user_id
  306. )
  307. })
  308. it('should look up the compile group to use', function (ctx) {
  309. ctx.CompileManager.promises.getProjectCompileLimits
  310. .calledWith(ctx.project_id)
  311. .should.equal(true)
  312. })
  313. it('should delete the aux files', function (ctx) {
  314. ctx.ClsiManager.promises.deleteAuxFiles
  315. .calledWith(ctx.project_id, ctx.user_id, ctx.limits)
  316. .should.equal(true)
  317. })
  318. it('should resolve', function () {
  319. expect(result).not.to.be.undefined
  320. })
  321. })
  322. describe('_checkIfRecentlyCompiled', function () {
  323. describe('when the key exists in redis', function () {
  324. let result
  325. beforeEach(async function (ctx) {
  326. ctx.rclient.set = sinon.stub().resolves(null)
  327. result = await ctx.CompileManager._checkIfRecentlyCompiled(
  328. ctx.project_id,
  329. ctx.user_id
  330. )
  331. })
  332. it('should try to set the key', function (ctx) {
  333. ctx.rclient.set
  334. .calledWith(
  335. `compile:${ctx.project_id}:${ctx.user_id}`,
  336. true,
  337. 'EX',
  338. ctx.CompileManager.COMPILE_DELAY,
  339. 'NX'
  340. )
  341. .should.equal(true)
  342. })
  343. it('should resolve with true', function () {
  344. result.should.equal(true)
  345. })
  346. })
  347. describe('when the key does not exist in redis', function () {
  348. let result
  349. beforeEach(async function (ctx) {
  350. ctx.rclient.set = sinon.stub().resolves('OK')
  351. result = await ctx.CompileManager._checkIfRecentlyCompiled(
  352. ctx.project_id,
  353. ctx.user_id
  354. )
  355. })
  356. it('should try to set the key', function (ctx) {
  357. ctx.rclient.set
  358. .calledWith(
  359. `compile:${ctx.project_id}:${ctx.user_id}`,
  360. true,
  361. 'EX',
  362. ctx.CompileManager.COMPILE_DELAY,
  363. 'NX'
  364. )
  365. .should.equal(true)
  366. })
  367. it('should resolve with false', function () {
  368. result.should.equal(false)
  369. })
  370. })
  371. })
  372. describe('_checkIfAutoCompileLimitHasBeenHit', function () {
  373. it('should be able to compile if it is not an autocompile', async function (ctx) {
  374. const canCompile =
  375. await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  376. false,
  377. 'everyone'
  378. )
  379. expect(canCompile).to.equal(true)
  380. })
  381. it('should be able to compile if rate limit has remaining', async function (ctx) {
  382. const canCompile =
  383. await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  384. true,
  385. 'everyone'
  386. )
  387. expect(ctx.rateLimiter.consume).to.have.been.calledWith('global')
  388. expect(canCompile).to.equal(true)
  389. })
  390. it('should be not able to compile if rate limit has no remianing', async function (ctx) {
  391. ctx.rateLimiter.consume.rejects({ remainingPoints: 0 })
  392. const canCompile =
  393. await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  394. true,
  395. 'everyone'
  396. )
  397. expect(canCompile).to.equal(false)
  398. })
  399. it('should return false if there is an error in the rate limit', async function (ctx) {
  400. ctx.rateLimiter.consume.rejects(new Error('BOOM!'))
  401. const canCompile =
  402. await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit(
  403. true,
  404. 'everyone'
  405. )
  406. expect(canCompile).to.equal(false)
  407. })
  408. })
  409. describe('wordCount', function () {
  410. let result
  411. const wordCount = 1
  412. beforeEach(async function (ctx) {
  413. ctx.CompileManager.promises.getProjectCompileLimits = sinon
  414. .stub()
  415. .resolves((ctx.limits = { compileGroup: 'mock-compile-group' }))
  416. ctx.ClsiManager.promises.wordCount = sinon.stub().resolves(wordCount)
  417. result = await ctx.CompileManager.promises.wordCount(
  418. ctx.project_id,
  419. ctx.user_id,
  420. false
  421. )
  422. })
  423. it('should look up the compile group to use', function (ctx) {
  424. ctx.CompileManager.promises.getProjectCompileLimits
  425. .calledWith(ctx.project_id)
  426. .should.equal(true)
  427. })
  428. it('should call wordCount for project', function (ctx) {
  429. ctx.ClsiManager.promises.wordCount
  430. .calledWith(ctx.project_id, ctx.user_id, false, ctx.limits)
  431. .should.equal(true)
  432. })
  433. it('should resolve with the wordCount from the ClsiManager', function () {
  434. expect(result).to.equal(wordCount)
  435. })
  436. })
  437. })