project-snapshot.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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. }
  36. const chunk = {
  37. history: {
  38. snapshot: {
  39. files: {},
  40. },
  41. changes: [
  42. {
  43. operations: [
  44. {
  45. pathname: 'hello.txt',
  46. file: {
  47. hash: files['hello.txt'].hash,
  48. stringLength: files['hello.txt'].contents.length,
  49. },
  50. },
  51. {
  52. pathname: 'main.tex',
  53. file: {
  54. hash: files['main.tex'].hash,
  55. stringLength: files['main.tex'].contents.length,
  56. },
  57. },
  58. {
  59. pathname: 'frog.jpg',
  60. file: {
  61. hash: 'cccccccccccccccccccccccccccccccccccccccc',
  62. byteLength: 97080,
  63. },
  64. },
  65. ],
  66. timestamp: '2025-01-01T12:00:00.000Z',
  67. },
  68. ],
  69. },
  70. startVersion: 0,
  71. }
  72. const changes = [
  73. {
  74. operations: [
  75. {
  76. pathname: 'hello.txt',
  77. textOperation: ['Quote: ', files['hello.txt'].contents.length],
  78. },
  79. {
  80. pathname: 'goodbye.txt',
  81. file: {
  82. hash: files['goodbye.txt'].hash,
  83. stringLength: files['goodbye.txt'].contents.length,
  84. },
  85. },
  86. ],
  87. timestamp: '2025-01-01T13:00:00.000Z',
  88. },
  89. ]
  90. function mockFlush(
  91. opts: { repeat?: number; failOnCall?: (call: number) => boolean } = {}
  92. ) {
  93. let currentCall = 0
  94. const getResponse = () => {
  95. currentCall += 1
  96. return opts.failOnCall?.(currentCall) ? 500 : 200
  97. }
  98. fetchMock.post(`/project/${projectId}/flush`, getResponse, {
  99. name: 'flush',
  100. repeat: opts.repeat ?? 1,
  101. })
  102. }
  103. function mockLatestChunk() {
  104. fetchMock.getOnce(
  105. `/project/${projectId}/latest/history`,
  106. { chunk },
  107. { name: 'latest-chunk' }
  108. )
  109. }
  110. function mockChanges() {
  111. fetchMock.getOnce(
  112. `/project/${projectId}/changes?since=1&paginated=true`,
  113. changes,
  114. {
  115. name: 'changes-1',
  116. }
  117. )
  118. fetchMock.get(`/project/${projectId}/changes?since=2&paginated=true`, [], {
  119. name: 'changes-2',
  120. })
  121. }
  122. function mockBlobs(paths = Object.keys(files) as (keyof typeof files)[]) {
  123. for (const path of paths) {
  124. const file = files[path]
  125. fetchMock.get(`/project/${projectId}/blob/${file.hash}`, file.contents)
  126. }
  127. }
  128. async function initializeSnapshot() {
  129. mockFlush()
  130. mockLatestChunk()
  131. mockBlobs(['main.tex', 'hello.txt'])
  132. await snapshot.refresh()
  133. fetchMock.removeRoutes().clearHistory()
  134. }
  135. describe('after initialization', function () {
  136. beforeEach(initializeSnapshot)
  137. describe('getDocPaths()', function () {
  138. it('returns the editable docs', function () {
  139. expect(snapshot.getDocPaths()).to.have.members([
  140. 'main.tex',
  141. 'hello.txt',
  142. ])
  143. })
  144. })
  145. describe('getDocContents()', function () {
  146. it('returns the doc contents', function () {
  147. expect(snapshot.getDocContents('main.tex')).to.equal(
  148. files['main.tex'].contents
  149. )
  150. })
  151. it('returns null for binary files', function () {
  152. expect(snapshot.getDocContents('frog.jpg')).to.be.null
  153. })
  154. it('returns null for inexistent files', function () {
  155. expect(snapshot.getDocContents('does-not-exist.txt')).to.be.null
  156. })
  157. })
  158. })
  159. async function refreshSnapshot() {
  160. mockFlush()
  161. mockChanges()
  162. mockBlobs(['goodbye.txt'])
  163. await snapshot.refresh()
  164. fetchMock.removeRoutes().clearHistory()
  165. }
  166. describe('after refresh', function () {
  167. beforeEach(initializeSnapshot)
  168. beforeEach(refreshSnapshot)
  169. afterEach(function () {
  170. fetchMock.removeRoutes().clearHistory()
  171. })
  172. describe('getDocPaths()', function () {
  173. it('returns the editable docs', function () {
  174. expect(snapshot.getDocPaths()).to.have.members([
  175. 'main.tex',
  176. 'hello.txt',
  177. 'goodbye.txt',
  178. ])
  179. })
  180. })
  181. describe('getDocContents()', function () {
  182. it('returns the up to date content', function () {
  183. expect(snapshot.getDocContents('hello.txt')).to.equal(
  184. `Quote: ${files['hello.txt'].contents}`
  185. )
  186. })
  187. it('returns contents of new files', function () {
  188. expect(snapshot.getDocContents('goodbye.txt')).to.equal(
  189. files['goodbye.txt'].contents
  190. )
  191. })
  192. })
  193. })
  194. describe('concurrency', function () {
  195. afterEach(function () {
  196. fetchMock.removeRoutes().clearHistory()
  197. })
  198. specify('two concurrent inits', async function () {
  199. mockFlush({ repeat: 2 })
  200. mockLatestChunk()
  201. mockChanges()
  202. mockBlobs()
  203. await Promise.all([snapshot.refresh(), snapshot.refresh()])
  204. // The first request initializes, the second request loads changes
  205. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  206. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  207. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  208. })
  209. specify('three concurrent inits', async function () {
  210. mockFlush({ repeat: 2 })
  211. mockLatestChunk()
  212. mockChanges()
  213. mockBlobs()
  214. await Promise.all([
  215. snapshot.refresh(),
  216. snapshot.refresh(),
  217. snapshot.refresh(),
  218. ])
  219. // The first request initializes, the second and third are combined and
  220. // load changes
  221. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  222. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  223. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  224. })
  225. specify('two concurrent inits - first fails', async function () {
  226. mockFlush({ repeat: 2, failOnCall: call => call === 1 })
  227. mockLatestChunk()
  228. mockBlobs()
  229. const results = await Promise.allSettled([
  230. snapshot.refresh(),
  231. snapshot.refresh(),
  232. ])
  233. // The first init fails, but the second succeeds
  234. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  235. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  236. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  237. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(0)
  238. })
  239. specify('three concurrent inits - second fails', async function () {
  240. mockFlush({ repeat: 4, failOnCall: call => call === 2 })
  241. mockLatestChunk()
  242. mockChanges()
  243. mockBlobs()
  244. const results = await Promise.allSettled([
  245. snapshot.refresh(),
  246. snapshot.refresh(),
  247. snapshot.refresh(),
  248. ])
  249. // Another request afterwards
  250. await snapshot.refresh()
  251. // The first init succeeds, the two queued requests fail, the last request
  252. // succeeds
  253. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  254. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  255. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  256. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  257. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
  258. })
  259. specify('two concurrent load changes', async function () {
  260. mockFlush({ repeat: 3 })
  261. mockLatestChunk()
  262. mockChanges()
  263. mockBlobs()
  264. // Initialize
  265. await snapshot.refresh()
  266. // Two concurrent load changes
  267. await Promise.all([snapshot.refresh(), snapshot.refresh()])
  268. // One init, two load changes
  269. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  270. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  271. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  272. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  273. })
  274. specify('three concurrent load changes', async function () {
  275. mockFlush({ repeat: 3 })
  276. mockLatestChunk()
  277. mockChanges()
  278. mockBlobs()
  279. // Initialize
  280. await snapshot.refresh()
  281. // Three concurrent load changes
  282. await Promise.all([
  283. snapshot.refresh(),
  284. snapshot.refresh(),
  285. snapshot.refresh(),
  286. ])
  287. // One init, two load changes (the two last are queued and combined)
  288. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  289. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  290. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  291. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  292. })
  293. specify('two concurrent load changes - first fails', async function () {
  294. mockFlush({ repeat: 3, failOnCall: call => call === 2 })
  295. mockLatestChunk()
  296. mockChanges()
  297. mockBlobs()
  298. // Initialize
  299. await snapshot.refresh()
  300. // Two concurrent load changes
  301. const results = await Promise.allSettled([
  302. snapshot.refresh(),
  303. snapshot.refresh(),
  304. ])
  305. // One init, one load changes fails, the second succeeds
  306. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  307. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  308. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  309. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  310. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
  311. })
  312. specify('three concurrent load changes - second fails', async function () {
  313. mockFlush({ repeat: 4, failOnCall: call => call === 3 })
  314. mockLatestChunk()
  315. mockChanges()
  316. mockBlobs()
  317. // Initialize
  318. await snapshot.refresh()
  319. // Two concurrent load changes
  320. const results = await Promise.allSettled([
  321. snapshot.refresh(),
  322. snapshot.refresh(),
  323. snapshot.refresh(),
  324. ])
  325. // Another request afterwards
  326. await snapshot.refresh()
  327. // One init, one load changes succeeds, the second and third are combined
  328. // and fail, the last request succeeds
  329. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  330. expect(fetchMock.callHistory.calls('flush')).to.have.length(4)
  331. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  332. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  333. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  334. })
  335. })
  336. })