project-snapshot.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import { expect } from 'chai'
  2. import fetchMock from 'fetch-mock'
  3. import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
  4. describe('ProjectSnapshot', function () {
  5. let snapshot: ProjectSnapshot
  6. const projectId = 'project-id'
  7. beforeEach(function () {
  8. snapshot = new ProjectSnapshot(projectId)
  9. })
  10. describe('before initialization', function () {
  11. describe('getDocPaths()', function () {
  12. it('returns an empty string', function () {
  13. expect(snapshot.getDocPaths()).to.deep.equal([])
  14. })
  15. })
  16. describe('getDocContents()', function () {
  17. it('returns null', function () {
  18. expect(snapshot.getDocContents('main.tex')).to.be.null
  19. })
  20. })
  21. })
  22. const files = {
  23. 'main.tex': {
  24. contents: '\\documentclass{article}\netc.',
  25. hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
  26. },
  27. 'hello.txt': {
  28. contents: 'Hello history!',
  29. hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
  30. },
  31. 'goodbye.txt': {
  32. contents: "We're done here",
  33. hash: 'dddddddddddddddddddddddddddddddddddddddd',
  34. },
  35. 'bibliography.bib': {
  36. contents:
  37. '@book{example2020,\n title={An example book},\n author={Doe, John},\n year={2020},\n publisher={Publisher}\n}\n'.repeat(
  38. 60_000
  39. ), // 6.5MB
  40. hash: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
  41. },
  42. }
  43. const chunk = {
  44. history: {
  45. snapshot: {
  46. files: {},
  47. },
  48. changes: [
  49. {
  50. operations: [
  51. {
  52. pathname: 'hello.txt',
  53. file: {
  54. hash: files['hello.txt'].hash,
  55. stringLength: files['hello.txt'].contents.length,
  56. },
  57. },
  58. {
  59. pathname: 'main.tex',
  60. file: {
  61. hash: files['main.tex'].hash,
  62. stringLength: files['main.tex'].contents.length,
  63. },
  64. },
  65. {
  66. pathname: 'frog.jpg',
  67. file: {
  68. hash: 'cccccccccccccccccccccccccccccccccccccccc',
  69. byteLength: 97080,
  70. },
  71. },
  72. {
  73. pathname: 'bibliography.bib',
  74. file: {
  75. hash: files['bibliography.bib'].hash,
  76. byteLength: files['bibliography.bib'].contents.length,
  77. },
  78. },
  79. ],
  80. timestamp: '2025-01-01T12:00:00.000Z',
  81. },
  82. ],
  83. },
  84. startVersion: 0,
  85. }
  86. const changes = [
  87. {
  88. operations: [
  89. {
  90. pathname: 'hello.txt',
  91. textOperation: ['Quote: ', files['hello.txt'].contents.length],
  92. },
  93. {
  94. pathname: 'goodbye.txt',
  95. file: {
  96. hash: files['goodbye.txt'].hash,
  97. stringLength: files['goodbye.txt'].contents.length,
  98. },
  99. },
  100. ],
  101. timestamp: '2025-01-01T13:00:00.000Z',
  102. },
  103. ]
  104. function mockFlush(
  105. opts: { repeat?: number; failOnCall?: (call: number) => boolean } = {}
  106. ) {
  107. let currentCall = 0
  108. const getResponse = () => {
  109. currentCall += 1
  110. return opts.failOnCall?.(currentCall) ? 500 : 200
  111. }
  112. fetchMock.post(`/project/${projectId}/flush`, getResponse, {
  113. name: 'flush',
  114. repeat: opts.repeat ?? 1,
  115. })
  116. }
  117. function mockLatestChunk() {
  118. fetchMock.getOnce(
  119. `/project/${projectId}/latest/history`,
  120. { chunk },
  121. { name: 'latest-chunk' }
  122. )
  123. }
  124. function mockChanges() {
  125. fetchMock.getOnce(
  126. `/project/${projectId}/changes?since=1&paginated=true`,
  127. changes,
  128. {
  129. name: 'changes-1',
  130. }
  131. )
  132. fetchMock.get(`/project/${projectId}/changes?since=2&paginated=true`, [], {
  133. name: 'changes-2',
  134. })
  135. }
  136. function mockBlobs(paths = Object.keys(files) as (keyof typeof files)[]) {
  137. for (const path of paths) {
  138. const file = files[path]
  139. fetchMock.get(`/project/${projectId}/blob/${file.hash}`, file.contents)
  140. }
  141. }
  142. async function initializeSnapshot() {
  143. mockFlush()
  144. mockLatestChunk()
  145. mockBlobs(['main.tex', 'hello.txt'])
  146. await snapshot.refresh()
  147. fetchMock.removeRoutes().clearHistory()
  148. }
  149. describe('after initialization', function () {
  150. beforeEach(initializeSnapshot)
  151. describe('getDocPaths()', function () {
  152. it('returns the editable docs', function () {
  153. expect(snapshot.getDocPaths()).to.have.members([
  154. 'main.tex',
  155. 'hello.txt',
  156. ])
  157. })
  158. })
  159. describe('getDocContents()', function () {
  160. it('returns the doc contents', function () {
  161. expect(snapshot.getDocContents('main.tex')).to.equal(
  162. files['main.tex'].contents
  163. )
  164. })
  165. it('returns null for binary files', function () {
  166. expect(snapshot.getDocContents('frog.jpg')).to.be.null
  167. })
  168. it('returns null for inexistent files', function () {
  169. expect(snapshot.getDocContents('does-not-exist.txt')).to.be.null
  170. })
  171. })
  172. })
  173. async function refreshSnapshot() {
  174. mockFlush()
  175. mockChanges()
  176. mockBlobs(['goodbye.txt'])
  177. await snapshot.refresh()
  178. fetchMock.removeRoutes().clearHistory()
  179. }
  180. describe('after refresh', function () {
  181. beforeEach(initializeSnapshot)
  182. beforeEach(refreshSnapshot)
  183. afterEach(function () {
  184. fetchMock.removeRoutes().clearHistory()
  185. })
  186. describe('getDocPaths()', function () {
  187. it('returns the editable docs', function () {
  188. expect(snapshot.getDocPaths()).to.have.members([
  189. 'main.tex',
  190. 'hello.txt',
  191. 'goodbye.txt',
  192. ])
  193. })
  194. })
  195. describe('getDocContents()', function () {
  196. it('returns the up to date content', function () {
  197. expect(snapshot.getDocContents('hello.txt')).to.equal(
  198. `Quote: ${files['hello.txt'].contents}`
  199. )
  200. })
  201. it('returns contents of new files', function () {
  202. expect(snapshot.getDocContents('goodbye.txt')).to.equal(
  203. files['goodbye.txt'].contents
  204. )
  205. })
  206. })
  207. describe('getBinaryFilePathsWithHash()', function () {
  208. it('returns the binary files', function () {
  209. const binaries = snapshot.getBinaryFilePathsWithHash()
  210. expect(binaries).to.deep.equal([
  211. {
  212. path: 'frog.jpg',
  213. hash: 'cccccccccccccccccccccccccccccccccccccccc',
  214. size: 97080,
  215. },
  216. {
  217. path: 'bibliography.bib',
  218. hash: files['bibliography.bib'].hash,
  219. size: files['bibliography.bib'].contents.length,
  220. },
  221. ])
  222. })
  223. })
  224. describe('getBinaryFileContents', function () {
  225. beforeEach(function () {
  226. mockBlobs(['bibliography.bib'])
  227. })
  228. it('can fetch whole file', async function () {
  229. const blob = await snapshot.getBinaryFileContents('bibliography.bib')
  230. expect(blob).to.equal(files['bibliography.bib'].contents)
  231. })
  232. // NOTE: fetch-mock does not support the .response.body.pipeThrough API,
  233. // so this test is skipped for now.
  234. // eslint-disable-next-line mocha/no-skipped-tests
  235. it.skip('can fetch part of file', async function () {
  236. const blob = await snapshot.getBinaryFileContents('bibliography.bib', {
  237. maxSize: 100,
  238. })
  239. expect(blob).to.equal(files['bibliography.bib'].contents.slice(0, 100))
  240. })
  241. })
  242. })
  243. describe('concurrency', function () {
  244. afterEach(function () {
  245. fetchMock.removeRoutes().clearHistory()
  246. })
  247. specify('two concurrent inits', async function () {
  248. mockFlush({ repeat: 2 })
  249. mockLatestChunk()
  250. mockChanges()
  251. mockBlobs()
  252. await Promise.all([snapshot.refresh(), snapshot.refresh()])
  253. // The first request initializes, the second request loads changes
  254. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  255. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  256. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  257. })
  258. specify('three concurrent inits', async function () {
  259. mockFlush({ repeat: 2 })
  260. mockLatestChunk()
  261. mockChanges()
  262. mockBlobs()
  263. await Promise.all([
  264. snapshot.refresh(),
  265. snapshot.refresh(),
  266. snapshot.refresh(),
  267. ])
  268. // The first request initializes, the second and third are combined and
  269. // load changes
  270. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  271. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  272. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  273. })
  274. specify('two concurrent inits - first fails', async function () {
  275. mockFlush({ repeat: 2, failOnCall: call => call === 1 })
  276. mockLatestChunk()
  277. mockBlobs()
  278. const results = await Promise.allSettled([
  279. snapshot.refresh(),
  280. snapshot.refresh(),
  281. ])
  282. // The first init fails, but the second succeeds
  283. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  284. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  285. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  286. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(0)
  287. })
  288. specify('three concurrent inits - second fails', async function () {
  289. mockFlush({ repeat: 4, failOnCall: call => call === 2 })
  290. mockLatestChunk()
  291. mockChanges()
  292. mockBlobs()
  293. const results = await Promise.allSettled([
  294. snapshot.refresh(),
  295. snapshot.refresh(),
  296. snapshot.refresh(),
  297. ])
  298. // Another request afterwards
  299. await snapshot.refresh()
  300. // The first init succeeds, the two queued requests fail, the last request
  301. // succeeds
  302. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  303. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  304. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  305. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  306. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
  307. })
  308. specify('two concurrent load changes', async function () {
  309. mockFlush({ repeat: 3 })
  310. mockLatestChunk()
  311. mockChanges()
  312. mockBlobs()
  313. // Initialize
  314. await snapshot.refresh()
  315. // Two concurrent load changes
  316. await Promise.all([snapshot.refresh(), snapshot.refresh()])
  317. // One init, two load changes
  318. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  319. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  320. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  321. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  322. })
  323. specify('three concurrent load changes', async function () {
  324. mockFlush({ repeat: 3 })
  325. mockLatestChunk()
  326. mockChanges()
  327. mockBlobs()
  328. // Initialize
  329. await snapshot.refresh()
  330. // Three concurrent load changes
  331. await Promise.all([
  332. snapshot.refresh(),
  333. snapshot.refresh(),
  334. snapshot.refresh(),
  335. ])
  336. // One init, two load changes (the two last are queued and combined)
  337. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  338. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  339. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  340. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  341. })
  342. specify('two concurrent load changes - first fails', async function () {
  343. mockFlush({ repeat: 3, failOnCall: call => call === 2 })
  344. mockLatestChunk()
  345. mockChanges()
  346. mockBlobs()
  347. // Initialize
  348. await snapshot.refresh()
  349. // Two concurrent load changes
  350. const results = await Promise.allSettled([
  351. snapshot.refresh(),
  352. snapshot.refresh(),
  353. ])
  354. // One init, one load changes fails, the second succeeds
  355. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  356. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  357. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  358. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  359. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
  360. })
  361. specify('three concurrent load changes - second fails', async function () {
  362. mockFlush({ repeat: 4, failOnCall: call => call === 3 })
  363. mockLatestChunk()
  364. mockChanges()
  365. mockBlobs()
  366. // Initialize
  367. await snapshot.refresh()
  368. // Two concurrent load changes
  369. const results = await Promise.allSettled([
  370. snapshot.refresh(),
  371. snapshot.refresh(),
  372. snapshot.refresh(),
  373. ])
  374. // Another request afterwards
  375. await snapshot.refresh()
  376. // One init, one load changes succeeds, the second and third are combined
  377. // and fail, the last request succeeds
  378. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  379. expect(fetchMock.callHistory.calls('flush')).to.have.length(4)
  380. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  381. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  382. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  383. })
  384. })
  385. })