project-snapshot.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  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. describe('blob with UTF-8 BOM', function () {
  420. // Files uploaded from Windows editors often have a UTF-8 BOM (U+FEFF) at
  421. // the start. The server stores the blob as-is and counts the BOM in
  422. // stringLength. TextOperations are built against that length.
  423. //
  424. // Response.text() strips the BOM per the Encoding spec, making the content
  425. // 1 char shorter than expected — causing ApplyError on every page load.
  426. // The fix uses arrayBuffer() + TextDecoder({ ignoreBOM: true }) to preserve
  427. // the BOM, matching how the server counts stringLength.
  428. const bomHash = '1111111111111111111111111111111111111111'
  429. const bomHash2 = '2222222222222222222222222222222222222222'
  430. const noBomHash = '3333333333333333333333333333333333333333'
  431. const bomContent = '\uFEFF@article{Test2020,\n author = {Smith, J},\n}\n'
  432. const bomContent2 = '\uFEFF@article{Other2021,\n author = {Jones, A},\n}\n'
  433. const noBomContent = '@article{NoBom2022,\n author = {Lee, B},\n}\n'
  434. afterEach(function () {
  435. fetchMock.removeRoutes().clearHistory()
  436. })
  437. it('loads a doc whose blob starts with a UTF-8 BOM', async function () {
  438. // The main production bug: upload a BOM file, make one edit, reload.
  439. const insertedText = '% comment\n'
  440. const bomChunk = {
  441. history: {
  442. snapshot: { files: {} },
  443. changes: [
  444. {
  445. operations: [
  446. {
  447. pathname: 'refs.bib',
  448. file: { hash: bomHash, stringLength: bomContent.length },
  449. },
  450. ],
  451. timestamp: '2025-01-01T12:00:00.000Z',
  452. },
  453. {
  454. operations: [
  455. {
  456. pathname: 'refs.bib',
  457. // baseLength includes BOM — matches server stringLength
  458. textOperation: [bomContent.length, insertedText],
  459. },
  460. ],
  461. timestamp: '2025-01-01T12:01:00.000Z',
  462. },
  463. ],
  464. },
  465. startVersion: 0,
  466. }
  467. fetchMock.post(`/project/${projectId}/flush`, 200)
  468. fetchMock.getOnce(`/project/${projectId}/latest/history`, {
  469. chunk: bomChunk,
  470. })
  471. fetchMock.get(`/project/${projectId}/blob/${bomHash}`, bomContent)
  472. await snapshot.refresh()
  473. expect(snapshot.getDocContents('refs.bib')).to.equal(
  474. bomContent + insertedText
  475. )
  476. })
  477. it('loads multiple BOM files in the same project', async function () {
  478. const insert1 = '% first\n'
  479. const insert2 = '% second\n'
  480. const bomChunk = {
  481. history: {
  482. snapshot: { files: {} },
  483. changes: [
  484. {
  485. operations: [
  486. {
  487. pathname: 'refs1.bib',
  488. file: { hash: bomHash, stringLength: bomContent.length },
  489. },
  490. {
  491. pathname: 'refs2.bib',
  492. file: { hash: bomHash2, stringLength: bomContent2.length },
  493. },
  494. ],
  495. timestamp: '2025-01-01T12:00:00.000Z',
  496. },
  497. {
  498. operations: [
  499. {
  500. pathname: 'refs1.bib',
  501. textOperation: [bomContent.length, insert1],
  502. },
  503. {
  504. pathname: 'refs2.bib',
  505. textOperation: [bomContent2.length, insert2],
  506. },
  507. ],
  508. timestamp: '2025-01-01T12:01:00.000Z',
  509. },
  510. ],
  511. },
  512. startVersion: 0,
  513. }
  514. fetchMock.post(`/project/${projectId}/flush`, 200)
  515. fetchMock.getOnce(`/project/${projectId}/latest/history`, {
  516. chunk: bomChunk,
  517. })
  518. fetchMock.get(`/project/${projectId}/blob/${bomHash}`, bomContent)
  519. fetchMock.get(`/project/${projectId}/blob/${bomHash2}`, bomContent2)
  520. await snapshot.refresh()
  521. expect(snapshot.getDocContents('refs1.bib')).to.equal(
  522. bomContent + insert1
  523. )
  524. expect(snapshot.getDocContents('refs2.bib')).to.equal(
  525. bomContent2 + insert2
  526. )
  527. })
  528. it('loads a BOM file with multiple accumulated textOps', async function () {
  529. // Multiple edits accumulate in the lazy operations list before toEager
  530. // is called. All ops use BOM-inclusive baseLengths.
  531. const bomChunk = {
  532. history: {
  533. snapshot: { files: {} },
  534. changes: [
  535. {
  536. operations: [
  537. {
  538. pathname: 'refs.bib',
  539. file: { hash: bomHash, stringLength: bomContent.length },
  540. },
  541. ],
  542. timestamp: '2025-01-01T12:00:00.000Z',
  543. },
  544. {
  545. operations: [
  546. {
  547. pathname: 'refs.bib',
  548. // first edit: insert text at end
  549. textOperation: [bomContent.length, '% edit1\n'],
  550. },
  551. ],
  552. timestamp: '2025-01-01T12:01:00.000Z',
  553. },
  554. {
  555. operations: [
  556. {
  557. pathname: 'refs.bib',
  558. // second edit: insert more text at end
  559. textOperation: [
  560. bomContent.length + '% edit1\n'.length,
  561. '% edit2\n',
  562. ],
  563. },
  564. ],
  565. timestamp: '2025-01-01T12:02:00.000Z',
  566. },
  567. ],
  568. },
  569. startVersion: 0,
  570. }
  571. fetchMock.post(`/project/${projectId}/flush`, 200)
  572. fetchMock.getOnce(`/project/${projectId}/latest/history`, {
  573. chunk: bomChunk,
  574. })
  575. fetchMock.get(`/project/${projectId}/blob/${bomHash}`, bomContent)
  576. await snapshot.refresh()
  577. expect(snapshot.getDocContents('refs.bib')).to.equal(
  578. bomContent + '% edit1\n' + '% edit2\n'
  579. )
  580. })
  581. it('does not affect files without a BOM', async function () {
  582. // BOM handling is per-file; non-BOM files must not be broken.
  583. const insertedText = '% added\n'
  584. const mixedChunk = {
  585. history: {
  586. snapshot: { files: {} },
  587. changes: [
  588. {
  589. operations: [
  590. {
  591. pathname: 'bom.bib',
  592. file: { hash: bomHash, stringLength: bomContent.length },
  593. },
  594. {
  595. pathname: 'nobom.bib',
  596. file: {
  597. hash: noBomHash,
  598. stringLength: noBomContent.length,
  599. },
  600. },
  601. ],
  602. timestamp: '2025-01-01T12:00:00.000Z',
  603. },
  604. {
  605. operations: [
  606. {
  607. pathname: 'bom.bib',
  608. textOperation: [bomContent.length, insertedText],
  609. },
  610. {
  611. pathname: 'nobom.bib',
  612. textOperation: [noBomContent.length, insertedText],
  613. },
  614. ],
  615. timestamp: '2025-01-01T12:01:00.000Z',
  616. },
  617. ],
  618. },
  619. startVersion: 0,
  620. }
  621. fetchMock.post(`/project/${projectId}/flush`, 200)
  622. fetchMock.getOnce(`/project/${projectId}/latest/history`, {
  623. chunk: mixedChunk,
  624. })
  625. fetchMock.get(`/project/${projectId}/blob/${bomHash}`, bomContent)
  626. fetchMock.get(`/project/${projectId}/blob/${noBomHash}`, noBomContent)
  627. await snapshot.refresh()
  628. expect(snapshot.getDocContents('bom.bib')).to.equal(
  629. bomContent + insertedText
  630. )
  631. expect(snapshot.getDocContents('nobom.bib')).to.equal(
  632. noBomContent + insertedText
  633. )
  634. })
  635. })
  636. })