project-snapshot.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  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. 'empty.png': {
  43. contents: '',
  44. hash: 'ffffffffffffffffffffffffffffffffffffffff',
  45. },
  46. }
  47. const chunk = {
  48. history: {
  49. snapshot: {
  50. files: {},
  51. },
  52. changes: [
  53. {
  54. operations: [
  55. {
  56. pathname: 'hello.txt',
  57. file: {
  58. hash: files['hello.txt'].hash,
  59. stringLength: files['hello.txt'].contents.length,
  60. },
  61. },
  62. {
  63. pathname: 'main.tex',
  64. file: {
  65. hash: files['main.tex'].hash,
  66. stringLength: files['main.tex'].contents.length,
  67. },
  68. },
  69. {
  70. pathname: 'frog.jpg',
  71. file: {
  72. hash: 'cccccccccccccccccccccccccccccccccccccccc',
  73. byteLength: 97080,
  74. },
  75. },
  76. {
  77. pathname: 'bibliography.bib',
  78. file: {
  79. hash: files['bibliography.bib'].hash,
  80. byteLength: files['bibliography.bib'].contents.length,
  81. },
  82. },
  83. {
  84. pathname: 'empty.png',
  85. file: {
  86. hash: files['empty.png'].hash,
  87. byteLength: files['empty.png'].contents.length,
  88. },
  89. },
  90. ],
  91. timestamp: '2025-01-01T12:00:00.000Z',
  92. },
  93. ],
  94. },
  95. startVersion: 0,
  96. }
  97. const changes = [
  98. {
  99. operations: [
  100. {
  101. pathname: 'hello.txt',
  102. textOperation: ['Quote: ', files['hello.txt'].contents.length],
  103. },
  104. {
  105. pathname: 'goodbye.txt',
  106. file: {
  107. hash: files['goodbye.txt'].hash,
  108. stringLength: files['goodbye.txt'].contents.length,
  109. },
  110. },
  111. ],
  112. timestamp: '2025-01-01T13:00:00.000Z',
  113. },
  114. ]
  115. function mockFlush(
  116. opts: { repeat?: number; failOnCall?: (call: number) => boolean } = {}
  117. ) {
  118. let currentCall = 0
  119. const getResponse = () => {
  120. currentCall += 1
  121. return opts.failOnCall?.(currentCall) ? 500 : 200
  122. }
  123. fetchMock.post(`/project/${projectId}/flush`, getResponse, {
  124. name: 'flush',
  125. repeat: opts.repeat ?? 1,
  126. })
  127. }
  128. function mockLatestChunk() {
  129. fetchMock.getOnce(
  130. `/project/${projectId}/latest/history`,
  131. { chunk },
  132. { name: 'latest-chunk' }
  133. )
  134. }
  135. function mockChanges() {
  136. fetchMock.getOnce(
  137. `/project/${projectId}/changes?since=1&paginated=true`,
  138. changes,
  139. {
  140. name: 'changes-1',
  141. }
  142. )
  143. fetchMock.get(`/project/${projectId}/changes?since=2&paginated=true`, [], {
  144. name: 'changes-2',
  145. })
  146. }
  147. // fetch-mock doesn't seem to expose the header to the response function,
  148. // so we just use a constant here
  149. const MOCKED_MAX_SIZE = 100
  150. function mockBlobs(paths = Object.keys(files) as (keyof typeof files)[]) {
  151. for (const path of paths) {
  152. const file = files[path]
  153. fetchMock
  154. .get({
  155. url: `/project/${projectId}/blob/${file.hash}`,
  156. missingHeaders: ['Range'],
  157. response: file.contents,
  158. })
  159. .get({
  160. url: `/project/${projectId}/blob/${file.hash}`,
  161. headers: { Range: `bytes=0-${MOCKED_MAX_SIZE - 1}` },
  162. response: file.contents.slice(0, MOCKED_MAX_SIZE),
  163. })
  164. }
  165. }
  166. async function initializeSnapshot() {
  167. mockFlush()
  168. mockLatestChunk()
  169. mockBlobs(['main.tex', 'hello.txt'])
  170. await snapshot.refresh()
  171. fetchMock.removeRoutes().clearHistory()
  172. }
  173. describe('after initialization', function () {
  174. beforeEach(initializeSnapshot)
  175. describe('getDocPaths()', function () {
  176. it('returns the editable docs', function () {
  177. expect(snapshot.getDocPaths()).to.have.members([
  178. 'main.tex',
  179. 'hello.txt',
  180. ])
  181. })
  182. })
  183. describe('getDocContents()', function () {
  184. it('returns the doc contents', function () {
  185. expect(snapshot.getDocContents('main.tex')).to.equal(
  186. files['main.tex'].contents
  187. )
  188. })
  189. it('returns null for binary files', function () {
  190. expect(snapshot.getDocContents('frog.jpg')).to.be.null
  191. })
  192. it('returns null for inexistent files', function () {
  193. expect(snapshot.getDocContents('does-not-exist.txt')).to.be.null
  194. })
  195. })
  196. })
  197. async function refreshSnapshot() {
  198. mockFlush()
  199. mockChanges()
  200. mockBlobs(['goodbye.txt'])
  201. await snapshot.refresh()
  202. fetchMock.removeRoutes().clearHistory()
  203. }
  204. describe('after refresh', function () {
  205. beforeEach(initializeSnapshot)
  206. beforeEach(refreshSnapshot)
  207. afterEach(function () {
  208. fetchMock.removeRoutes().clearHistory()
  209. })
  210. describe('getDocPaths()', function () {
  211. it('returns the editable docs', function () {
  212. expect(snapshot.getDocPaths()).to.have.members([
  213. 'main.tex',
  214. 'hello.txt',
  215. 'goodbye.txt',
  216. ])
  217. })
  218. })
  219. describe('getDocContents()', function () {
  220. it('returns the up to date content', function () {
  221. expect(snapshot.getDocContents('hello.txt')).to.equal(
  222. `Quote: ${files['hello.txt'].contents}`
  223. )
  224. })
  225. it('returns contents of new files', function () {
  226. expect(snapshot.getDocContents('goodbye.txt')).to.equal(
  227. files['goodbye.txt'].contents
  228. )
  229. })
  230. })
  231. describe('getBinaryFilePathsWithHash()', function () {
  232. it('returns the binary files', function () {
  233. const binaries = snapshot.getBinaryFilePathsWithHash()
  234. expect(binaries).to.deep.equal([
  235. {
  236. path: 'frog.jpg',
  237. hash: 'cccccccccccccccccccccccccccccccccccccccc',
  238. size: 97080,
  239. },
  240. {
  241. path: 'bibliography.bib',
  242. hash: files['bibliography.bib'].hash,
  243. size: files['bibliography.bib'].contents.length,
  244. },
  245. {
  246. path: 'empty.png',
  247. hash: 'ffffffffffffffffffffffffffffffffffffffff',
  248. size: 0,
  249. },
  250. ])
  251. })
  252. })
  253. describe('getBinaryFileContents', function () {
  254. beforeEach(function () {
  255. mockBlobs(['bibliography.bib', 'empty.png'])
  256. })
  257. it('can fetch whole file', async function () {
  258. const blob = await snapshot.getBinaryFileContents('bibliography.bib')
  259. expect(blob).to.equal(files['bibliography.bib'].contents)
  260. })
  261. it('can fetch part of file', async function () {
  262. const blob = await snapshot.getBinaryFileContents('bibliography.bib', {
  263. maxSize: MOCKED_MAX_SIZE,
  264. })
  265. expect(blob).to.equal(
  266. files['bibliography.bib'].contents.slice(0, MOCKED_MAX_SIZE)
  267. )
  268. })
  269. it('can fetch empty file with maxSize', async function () {
  270. const blob = await snapshot.getBinaryFileContents('empty.png', {
  271. maxSize: 200,
  272. })
  273. expect(blob).to.equal(files['empty.png'].contents)
  274. })
  275. })
  276. })
  277. describe('concurrency', function () {
  278. afterEach(function () {
  279. fetchMock.removeRoutes().clearHistory()
  280. })
  281. specify('two concurrent inits', async function () {
  282. mockFlush({ repeat: 2 })
  283. mockLatestChunk()
  284. mockChanges()
  285. mockBlobs()
  286. await Promise.all([snapshot.refresh(), snapshot.refresh()])
  287. // The first request initializes, the second request loads changes
  288. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  289. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  290. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  291. })
  292. specify('three concurrent inits', async function () {
  293. mockFlush({ repeat: 2 })
  294. mockLatestChunk()
  295. mockChanges()
  296. mockBlobs()
  297. await Promise.all([
  298. snapshot.refresh(),
  299. snapshot.refresh(),
  300. snapshot.refresh(),
  301. ])
  302. // The first request initializes, the second and third are combined and
  303. // load changes
  304. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  305. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  306. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  307. })
  308. specify('two concurrent inits - first fails', async function () {
  309. mockFlush({ repeat: 2, failOnCall: call => call === 1 })
  310. mockLatestChunk()
  311. mockBlobs()
  312. const results = await Promise.allSettled([
  313. snapshot.refresh(),
  314. snapshot.refresh(),
  315. ])
  316. // The first init fails, but the second succeeds
  317. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  318. expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
  319. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  320. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(0)
  321. })
  322. specify('three concurrent inits - second fails', async function () {
  323. mockFlush({ repeat: 4, failOnCall: call => call === 2 })
  324. mockLatestChunk()
  325. mockChanges()
  326. mockBlobs()
  327. const results = await Promise.allSettled([
  328. snapshot.refresh(),
  329. snapshot.refresh(),
  330. snapshot.refresh(),
  331. ])
  332. // Another request afterwards
  333. await snapshot.refresh()
  334. // The first init succeeds, the two queued requests fail, the last request
  335. // succeeds
  336. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  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(0)
  341. })
  342. specify('two concurrent load changes', async function () {
  343. mockFlush({ repeat: 3 })
  344. mockLatestChunk()
  345. mockChanges()
  346. mockBlobs()
  347. // Initialize
  348. await snapshot.refresh()
  349. // Two concurrent load changes
  350. await Promise.all([snapshot.refresh(), snapshot.refresh()])
  351. // One init, two load changes
  352. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  353. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  354. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  355. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  356. })
  357. specify('three concurrent load changes', async function () {
  358. mockFlush({ repeat: 3 })
  359. mockLatestChunk()
  360. mockChanges()
  361. mockBlobs()
  362. // Initialize
  363. await snapshot.refresh()
  364. // Three concurrent load changes
  365. await Promise.all([
  366. snapshot.refresh(),
  367. snapshot.refresh(),
  368. snapshot.refresh(),
  369. ])
  370. // One init, two load changes (the two last are queued and combined)
  371. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  372. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  373. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  374. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  375. })
  376. specify('two concurrent load changes - first fails', async function () {
  377. mockFlush({ repeat: 3, failOnCall: call => call === 2 })
  378. mockLatestChunk()
  379. mockChanges()
  380. mockBlobs()
  381. // Initialize
  382. await snapshot.refresh()
  383. // Two concurrent load changes
  384. const results = await Promise.allSettled([
  385. snapshot.refresh(),
  386. snapshot.refresh(),
  387. ])
  388. // One init, one load changes fails, the second succeeds
  389. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  390. expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
  391. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  392. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  393. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
  394. })
  395. specify('three concurrent load changes - second fails', async function () {
  396. mockFlush({ repeat: 4, failOnCall: call => call === 3 })
  397. mockLatestChunk()
  398. mockChanges()
  399. mockBlobs()
  400. // Initialize
  401. await snapshot.refresh()
  402. // Two concurrent load changes
  403. const results = await Promise.allSettled([
  404. snapshot.refresh(),
  405. snapshot.refresh(),
  406. snapshot.refresh(),
  407. ])
  408. // Another request afterwards
  409. await snapshot.refresh()
  410. // One init, one load changes succeeds, the second and third are combined
  411. // and fail, the last request succeeds
  412. expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
  413. expect(fetchMock.callHistory.calls('flush')).to.have.length(4)
  414. expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
  415. expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
  416. expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
  417. })
  418. })
  419. })