SyncManagerTests.js 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176
  1. import sinon from 'sinon'
  2. import { expect } from 'chai'
  3. import mongodb from 'mongodb-legacy'
  4. import tk from 'timekeeper'
  5. import { File, Comment, TrackedChange, Range } from 'overleaf-editor-core'
  6. import { strict as esmock } from 'esmock'
  7. const { ObjectId } = mongodb
  8. const MODULE_PATH = '../../../../app/js/SyncManager.js'
  9. const TIMESTAMP = new Date().toISOString()
  10. const USER_ID = 'user-id'
  11. function resyncProjectStructureUpdate(docs, files) {
  12. return {
  13. resyncProjectStructure: { docs, files },
  14. meta: {
  15. ts: TIMESTAMP,
  16. },
  17. }
  18. }
  19. function docContentSyncUpdate(
  20. doc,
  21. content,
  22. ranges = {},
  23. resolvedCommentIds = []
  24. ) {
  25. return {
  26. path: doc.path,
  27. doc: doc.doc,
  28. resyncDocContent: {
  29. content,
  30. ranges,
  31. resolvedCommentIds,
  32. },
  33. meta: {
  34. ts: TIMESTAMP,
  35. },
  36. }
  37. }
  38. function makeComment(commentId, pos, text) {
  39. return {
  40. id: commentId,
  41. op: { p: pos, c: text, t: commentId },
  42. metadata: {
  43. user_id: USER_ID,
  44. ts: TIMESTAMP,
  45. },
  46. }
  47. }
  48. function makeTrackedChange(id, op) {
  49. return {
  50. id,
  51. op,
  52. metadata: { user_id: USER_ID, ts: TIMESTAMP },
  53. }
  54. }
  55. describe('SyncManager', function () {
  56. beforeEach(async function () {
  57. this.now = new Date()
  58. tk.freeze(this.now)
  59. this.projectId = new ObjectId().toString()
  60. this.historyId = 'mock-overleaf-id'
  61. this.syncState = { origin: { kind: 'history-resync' } }
  62. this.db = {
  63. projectHistorySyncState: {
  64. findOne: sinon.stub().resolves(this.syncState),
  65. updateOne: sinon.stub().resolves(),
  66. },
  67. projects: {
  68. updateOne: sinon.stub().resolves(),
  69. },
  70. }
  71. this.extendLock = sinon.stub().resolves()
  72. this.LockManager = {
  73. promises: {
  74. runWithLock: sinon.stub().callsFake(async (key, runner) => {
  75. await runner(this.extendLock)
  76. }),
  77. },
  78. }
  79. this.UpdateCompressor = {
  80. diffAsShareJsOps: sinon.stub().returns([]),
  81. }
  82. this.UpdateTranslator = {
  83. isTextUpdate: sinon.stub(),
  84. _convertPathname: sinon.stub(),
  85. }
  86. this.WebApiManager = {
  87. promises: {
  88. getHistoryId: sinon.stub(),
  89. requestResync: sinon.stub().resolves(),
  90. },
  91. }
  92. this.WebApiManager.promises.getHistoryId
  93. .withArgs(this.projectId)
  94. .resolves(this.historyId)
  95. this.ErrorRecorder = {
  96. promises: {
  97. record: sinon.stub().resolves(),
  98. recordSyncStart: sinon.stub().resolves(),
  99. },
  100. }
  101. this.RedisManager = {}
  102. this.SnapshotManager = {
  103. promises: {
  104. getLatestSnapshotFilesForChunk: sinon.stub(),
  105. },
  106. }
  107. this.HistoryStoreManager = {
  108. getBlobStore: sinon.stub(),
  109. _getBlobHashFromString: sinon.stub().returns('random-hash'),
  110. }
  111. this.HashManager = {
  112. _getBlobHashFromString: sinon.stub(),
  113. }
  114. this.Metrics = { inc: sinon.stub() }
  115. this.Settings = {
  116. redis: {
  117. lock: {
  118. key_schema: {
  119. projectHistoryLock({ project_id: projectId }) {
  120. return `ProjectHistoryLock:${projectId}`
  121. },
  122. },
  123. },
  124. },
  125. }
  126. this.SyncManager = await esmock(MODULE_PATH, {
  127. '../../../../app/js/LockManager.js': this.LockManager,
  128. '../../../../app/js/UpdateCompressor.js': this.UpdateCompressor,
  129. '../../../../app/js/UpdateTranslator.js': this.UpdateTranslator,
  130. '../../../../app/js/mongodb.js': { ObjectId, db: this.db },
  131. '../../../../app/js/WebApiManager.js': this.WebApiManager,
  132. '../../../../app/js/ErrorRecorder.js': this.ErrorRecorder,
  133. '../../../../app/js/RedisManager.js': this.RedisManager,
  134. '../../../../app/js/SnapshotManager.js': this.SnapshotManager,
  135. '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
  136. '../../../../app/js/HashManager.js': this.HashManager,
  137. '@overleaf/metrics': this.Metrics,
  138. '@overleaf/settings': this.Settings,
  139. })
  140. })
  141. afterEach(function () {
  142. tk.reset()
  143. })
  144. describe('startResync', function () {
  145. describe('if a sync is not in progress', function () {
  146. beforeEach(async function () {
  147. this.db.projectHistorySyncState.findOne.resolves({})
  148. await this.SyncManager.promises.startResync(this.projectId)
  149. })
  150. it('takes the project lock', function () {
  151. expect(this.LockManager.promises.runWithLock).to.have.been.calledWith(
  152. `ProjectHistoryLock:${this.projectId}`
  153. )
  154. })
  155. it('gets the sync state from mongo', function () {
  156. expect(this.db.projectHistorySyncState.findOne).to.have.been.calledWith(
  157. { project_id: new ObjectId(this.projectId) }
  158. )
  159. })
  160. it('requests a resync from web', function () {
  161. expect(
  162. this.WebApiManager.promises.requestResync
  163. ).to.have.been.calledWith(this.projectId)
  164. })
  165. it('sets the sync state in mongo and prevents it expiring', function () {
  166. expect(
  167. this.db.projectHistorySyncState.updateOne
  168. ).to.have.been.calledWith(
  169. {
  170. project_id: new ObjectId(this.projectId),
  171. },
  172. sinon.match({
  173. $set: sinon.match({
  174. resyncProjectStructure: true,
  175. resyncDocContents: [],
  176. origin: { kind: 'history-resync' },
  177. }),
  178. $currentDate: { lastUpdated: true },
  179. $inc: { resyncCount: 1 },
  180. $unset: { expiresAt: true },
  181. }),
  182. {
  183. upsert: true,
  184. }
  185. )
  186. })
  187. })
  188. describe('if project structure sync is in progress', function () {
  189. beforeEach(function () {
  190. const syncState = {
  191. resyncProjectStructure: true,
  192. resyncPendingSince: new Date(),
  193. }
  194. this.db.projectHistorySyncState.findOne.resolves(syncState)
  195. })
  196. it('returns an error if already syncing', async function () {
  197. await expect(
  198. this.SyncManager.promises.startResync(this.projectId)
  199. ).to.be.rejectedWith('sync ongoing')
  200. })
  201. })
  202. describe('if doc content sync in is progress', function () {
  203. beforeEach(async function () {
  204. const syncState = {
  205. resyncDocContents: ['/foo.tex'],
  206. resyncPendingSince: new Date(),
  207. }
  208. this.db.projectHistorySyncState.findOne.resolves(syncState)
  209. })
  210. it('returns an error if already syncing', async function () {
  211. await expect(
  212. this.SyncManager.promises.startResync(this.projectId)
  213. ).to.be.rejectedWith('sync ongoing')
  214. })
  215. })
  216. describe('if sync is stuck (no resyncPendingSince — legacy state)', function () {
  217. beforeEach(function () {
  218. const syncState = { resyncDocContents: ['/foo.tex'] }
  219. this.db.projectHistorySyncState.findOne.resolves(syncState)
  220. })
  221. it('records the stuck clear, does not delete state, and starts a new resync', async function () {
  222. await this.SyncManager.promises.startResync(this.projectId)
  223. expect(
  224. this.db.projectHistorySyncState.updateOne
  225. ).to.have.been.calledWith(
  226. { project_id: new ObjectId(this.projectId) },
  227. sinon.match({
  228. $inc: { stuckClearCount: 1 },
  229. $unset: { resyncPendingSince: 1 },
  230. })
  231. )
  232. expect(this.WebApiManager.promises.requestResync.calledOnce).to.be.true
  233. expect(
  234. this.Metrics.inc.calledWith('project_history_sync_stuck_cleared')
  235. ).to.be.true
  236. })
  237. })
  238. describe('if sync is stuck (resyncPendingSince older than timeout)', function () {
  239. beforeEach(function () {
  240. const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000)
  241. const syncState = {
  242. resyncDocContents: ['/foo.tex'],
  243. resyncPendingSince: fiveHoursAgo,
  244. }
  245. this.db.projectHistorySyncState.findOne.resolves(syncState)
  246. })
  247. it('records the stuck clear and starts a new resync', async function () {
  248. await this.SyncManager.promises.startResync(this.projectId)
  249. expect(
  250. this.db.projectHistorySyncState.updateOne
  251. ).to.have.been.calledWith(
  252. { project_id: new ObjectId(this.projectId) },
  253. sinon.match({
  254. $inc: { stuckClearCount: 1 },
  255. $unset: { resyncPendingSince: 1 },
  256. })
  257. )
  258. expect(this.WebApiManager.promises.requestResync.calledOnce).to.be.true
  259. })
  260. })
  261. describe('if sync is not stuck (resyncPendingSince within timeout)', function () {
  262. beforeEach(function () {
  263. const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000)
  264. const syncState = {
  265. resyncDocContents: ['/foo.tex'],
  266. resyncPendingSince: threeHoursAgo,
  267. }
  268. this.db.projectHistorySyncState.findOne.resolves(syncState)
  269. })
  270. it('throws sync ongoing and does not clear', async function () {
  271. await expect(
  272. this.SyncManager.promises.startResync(this.projectId)
  273. ).to.be.rejectedWith('sync ongoing')
  274. expect(
  275. this.Metrics.inc.calledWith('project_history_sync_stuck_cleared')
  276. ).to.be.false
  277. expect(
  278. this.Metrics.inc.calledWith('project_history_sync_stuck_permanent')
  279. ).to.be.false
  280. })
  281. })
  282. describe('if sync is permanently stuck (exceeded retry limit)', function () {
  283. beforeEach(function () {
  284. const syncState = {
  285. resyncDocContents: ['/foo.tex'],
  286. stuckClearCount: 5,
  287. }
  288. this.db.projectHistorySyncState.findOne.resolves(syncState)
  289. })
  290. it('throws permanently stuck, emits metric, and records the attempt', async function () {
  291. await expect(
  292. this.SyncManager.promises.startResync(this.projectId)
  293. ).to.be.rejectedWith('sync permanently stuck')
  294. expect(
  295. this.Metrics.inc.calledWith('project_history_sync_stuck_permanent')
  296. ).to.be.true
  297. expect(
  298. this.db.projectHistorySyncState.updateOne
  299. ).to.have.been.calledWith(
  300. { project_id: new ObjectId(this.projectId) },
  301. sinon.match({ $inc: { stuckClearCount: 1 } })
  302. )
  303. })
  304. it('does not attempt a resync', async function () {
  305. try {
  306. await this.SyncManager.promises.startResync(this.projectId)
  307. } catch (e) {
  308. // expected
  309. }
  310. expect(this.WebApiManager.promises.requestResync.called).to.be.false
  311. })
  312. })
  313. })
  314. describe('setResyncState', function () {
  315. describe('when the sync is starting', function () {
  316. beforeEach(function () {
  317. this.syncState = {
  318. toRaw() {
  319. return {
  320. resyncProjectStructure: true,
  321. resyncDocContents: [],
  322. origin: { kind: 'history-resync' },
  323. }
  324. },
  325. isSyncOngoing: sinon.stub().returns(true),
  326. }
  327. })
  328. it('sets the sync state in mongo and prevents it expiring', async function () {
  329. // SyncState is a private class of SyncManager
  330. // we know the interface however:
  331. await this.SyncManager.promises.setResyncState(
  332. this.projectId,
  333. this.syncState
  334. )
  335. expect(
  336. this.db.projectHistorySyncState.updateOne
  337. ).to.have.been.calledWith(
  338. { project_id: new ObjectId(this.projectId) },
  339. sinon.match({
  340. $set: sinon.match({
  341. resyncProjectStructure: true,
  342. resyncDocContents: [],
  343. origin: { kind: 'history-resync' },
  344. }),
  345. $currentDate: { lastUpdated: true },
  346. $inc: { resyncCount: 1 },
  347. $unset: { expiresAt: true },
  348. }),
  349. { upsert: true }
  350. )
  351. })
  352. })
  353. describe('when the sync is ending', function () {
  354. beforeEach(function () {
  355. this.syncState = {
  356. toRaw() {
  357. return {
  358. resyncProjectStructure: false,
  359. resyncDocContents: [],
  360. origin: { kind: 'history-resync' },
  361. }
  362. },
  363. isSyncOngoing: sinon.stub().returns(false),
  364. }
  365. })
  366. it('sets the sync state entry in mongo to expire and clears stuck fields', async function () {
  367. await this.SyncManager.promises.setResyncState(
  368. this.projectId,
  369. this.syncState
  370. )
  371. expect(
  372. this.db.projectHistorySyncState.updateOne
  373. ).to.have.been.calledWith(
  374. { project_id: new ObjectId(this.projectId) },
  375. sinon.match({
  376. $set: {
  377. resyncProjectStructure: false,
  378. resyncDocContents: [],
  379. origin: { kind: 'history-resync' },
  380. expiresAt: new Date(this.now.getTime() + 90 * 24 * 3600 * 1000),
  381. },
  382. $currentDate: { lastUpdated: true },
  383. $unset: {
  384. resyncPendingSince: 1,
  385. stuckClearCount: 1,
  386. lastStuckClearAt: 1,
  387. lastStuckDocPaths: 1,
  388. },
  389. }),
  390. { upsert: true }
  391. )
  392. })
  393. })
  394. describe('when the new sync state is null', function () {
  395. it('does not update the sync state in mongo', async function () {
  396. // SyncState is a private class of SyncManager
  397. // we know the interface however:
  398. await this.SyncManager.promises.setResyncState(this.projectId, null)
  399. expect(this.db.projectHistorySyncState.updateOne).to.not.have.been
  400. .called
  401. })
  402. })
  403. })
  404. describe('skipUpdatesDuringSync', function () {
  405. describe('if a sync is not in progress', function () {
  406. beforeEach(async function () {
  407. this.db.projectHistorySyncState.findOne.resolves({})
  408. this.updates = ['some', 'mock', 'updates']
  409. this.result = await this.SyncManager.promises.skipUpdatesDuringSync(
  410. this.projectId,
  411. this.updates
  412. )
  413. })
  414. it('returns all updates', function () {
  415. expect(this.result.updates).to.deep.equal(this.updates)
  416. })
  417. it('should not return any newSyncState', function () {
  418. expect(this.result.syncState).to.be.null
  419. })
  420. })
  421. describe('if a sync in is progress', function () {
  422. beforeEach(function () {
  423. this.renameUpdate = {
  424. pathname: 'old.tex',
  425. newPathname: 'new.tex',
  426. }
  427. this.projectStructureSyncUpdate = {
  428. resyncProjectStructure: {
  429. docs: [{ path: 'new.tex' }],
  430. files: [],
  431. },
  432. }
  433. this.textUpdate = {
  434. doc: new ObjectId(),
  435. op: [{ i: 'a', p: 4 }],
  436. meta: {
  437. pathname: 'new.tex',
  438. doc_length: 4,
  439. },
  440. }
  441. this.docContentSyncUpdate = {
  442. path: 'new.tex',
  443. resyncDocContent: {
  444. content: 'a',
  445. },
  446. }
  447. this.UpdateTranslator.isTextUpdate
  448. .withArgs(this.renameUpdate)
  449. .returns(false)
  450. this.UpdateTranslator.isTextUpdate
  451. .withArgs(this.projectStructureSyncUpdate)
  452. .returns(false)
  453. this.UpdateTranslator.isTextUpdate
  454. .withArgs(this.docContentSyncUpdate)
  455. .returns(false)
  456. this.UpdateTranslator.isTextUpdate
  457. .withArgs(this.textUpdate)
  458. .returns(true)
  459. const syncState = {
  460. resyncProjectStructure: true,
  461. resyncDocContents: [],
  462. origin: { kind: 'history-resync' },
  463. }
  464. this.db.projectHistorySyncState.findOne.resolves(syncState)
  465. })
  466. it('remove updates before a project structure sync update', async function () {
  467. const updates = [
  468. this.renameUpdate,
  469. this.textUpdate,
  470. this.projectStructureSyncUpdate,
  471. ]
  472. const { updates: filteredUpdates, syncState } =
  473. await this.SyncManager.promises.skipUpdatesDuringSync(
  474. this.projectId,
  475. updates
  476. )
  477. expect(filteredUpdates).to.deep.equal([this.projectStructureSyncUpdate])
  478. expect(syncState.toRaw()).to.deep.equal({
  479. resyncProjectStructure: false,
  480. resyncDocContents: ['new.tex'],
  481. origin: { kind: 'history-resync' },
  482. })
  483. })
  484. it('records docs to resync when resyncProjectStructureOnly=true is not set', async function () {
  485. const updates = [this.projectStructureSyncUpdate]
  486. const { updates: filteredUpdates, syncState } =
  487. await this.SyncManager.promises.skipUpdatesDuringSync(
  488. this.projectId,
  489. updates
  490. )
  491. expect(filteredUpdates).to.deep.equal([this.projectStructureSyncUpdate])
  492. expect(syncState.toRaw()).to.deep.equal({
  493. resyncProjectStructure: false,
  494. resyncDocContents: ['new.tex'],
  495. origin: { kind: 'history-resync' },
  496. })
  497. })
  498. it('records no docs to resync with resyncProjectStructureOnly=true', async function () {
  499. this.projectStructureSyncUpdate.resyncProjectStructureOnly = true
  500. const updates = [this.projectStructureSyncUpdate]
  501. const { updates: filteredUpdates, syncState } =
  502. await this.SyncManager.promises.skipUpdatesDuringSync(
  503. this.projectId,
  504. updates
  505. )
  506. expect(filteredUpdates).to.deep.equal([this.projectStructureSyncUpdate])
  507. expect(syncState.toRaw()).to.deep.equal({
  508. resyncProjectStructure: false,
  509. resyncDocContents: [],
  510. origin: { kind: 'history-resync' },
  511. })
  512. })
  513. it('allow project structure updates after project structure sync update', async function () {
  514. const updates = [this.projectStructureSyncUpdate, this.renameUpdate]
  515. const { updates: filteredUpdates, syncState } =
  516. await this.SyncManager.promises.skipUpdatesDuringSync(
  517. this.projectId,
  518. updates
  519. )
  520. expect(filteredUpdates).to.deep.equal([
  521. this.projectStructureSyncUpdate,
  522. this.renameUpdate,
  523. ])
  524. expect(syncState.toRaw()).to.deep.equal({
  525. resyncProjectStructure: false,
  526. resyncDocContents: ['new.tex'],
  527. origin: { kind: 'history-resync' },
  528. })
  529. })
  530. it('remove text updates for a doc before doc sync update', async function () {
  531. const updates = [
  532. this.projectStructureSyncUpdate,
  533. this.textUpdate,
  534. this.docContentSyncUpdate,
  535. ]
  536. const { updates: filteredUpdates, syncState } =
  537. await this.SyncManager.promises.skipUpdatesDuringSync(
  538. this.projectId,
  539. updates
  540. )
  541. expect(filteredUpdates).to.deep.equal([
  542. this.projectStructureSyncUpdate,
  543. this.docContentSyncUpdate,
  544. ])
  545. expect(syncState.toRaw()).to.deep.equal({
  546. resyncProjectStructure: false,
  547. resyncDocContents: [],
  548. origin: { kind: 'history-resync' },
  549. })
  550. })
  551. it('allow text updates for a doc after doc sync update', async function () {
  552. const updates = [
  553. this.projectStructureSyncUpdate,
  554. this.docContentSyncUpdate,
  555. this.textUpdate,
  556. ]
  557. const { updates: filteredUpdates, syncState } =
  558. await this.SyncManager.promises.skipUpdatesDuringSync(
  559. this.projectId,
  560. updates
  561. )
  562. expect(filteredUpdates).to.deep.equal([
  563. this.projectStructureSyncUpdate,
  564. this.docContentSyncUpdate,
  565. this.textUpdate,
  566. ])
  567. expect(syncState.toRaw()).to.deep.equal({
  568. resyncProjectStructure: false,
  569. resyncDocContents: [],
  570. origin: { kind: 'history-resync' },
  571. })
  572. })
  573. })
  574. })
  575. describe('expandSyncUpdates', function () {
  576. beforeEach(function () {
  577. this.persistedDoc = {
  578. doc: 'doc-id',
  579. path: 'main.tex',
  580. }
  581. this.persistedDocContent = 'the quick brown fox jumps over the lazy dog'
  582. this.persistedFile = {
  583. file: 'file-id',
  584. path: '1.png',
  585. _hash: 'abcde',
  586. }
  587. this.loadedSnapshotDoc = File.fromString(this.persistedDocContent)
  588. this.mostRecentChunk = 'fake chunk'
  589. this.fileMap = {
  590. 'main.tex': {
  591. isEditable: sinon.stub().returns(true),
  592. getContent: sinon.stub().returns(null),
  593. getHash: sinon.stub().returns(null),
  594. load: sinon.stub().resolves(this.loadedSnapshotDoc),
  595. getMetadata: sinon.stub().returns({}),
  596. },
  597. '1.png': {
  598. isEditable: sinon.stub().returns(false),
  599. data: { hash: this.persistedFile._hash },
  600. getMetadata: sinon.stub().returns({}),
  601. },
  602. }
  603. this.UpdateTranslator._convertPathname
  604. .withArgs('main.tex')
  605. .returns('main.tex')
  606. this.UpdateTranslator._convertPathname
  607. .withArgs('/main.tex')
  608. .returns('main.tex')
  609. this.UpdateTranslator._convertPathname
  610. .withArgs('another.tex')
  611. .returns('another.tex')
  612. this.UpdateTranslator._convertPathname.withArgs('1.png').returns('1.png')
  613. this.UpdateTranslator._convertPathname.withArgs('2.png').returns('2.png')
  614. this.SnapshotManager.promises.getLatestSnapshotFilesForChunk.resolves(
  615. this.fileMap
  616. )
  617. })
  618. it('returns updates if no sync updates are queued', async function () {
  619. const updates = ['some', 'mock', 'updates']
  620. const expandedUpdates = await this.SyncManager.promises.expandSyncUpdates(
  621. this.projectId,
  622. this.historyId,
  623. this.mostRecentChunk,
  624. updates,
  625. this.extendLock
  626. )
  627. expect(expandedUpdates).to.equal(updates)
  628. expect(this.SnapshotManager.promises.getLatestSnapshotFilesForChunk).to
  629. .not.have.been.called
  630. expect(this.extendLock).to.not.have.been.called
  631. })
  632. describe('expanding project structure sync updates', function () {
  633. it('queues nothing for expected docs and files', async function () {
  634. const updates = [
  635. resyncProjectStructureUpdate(
  636. [this.persistedDoc],
  637. [this.persistedFile]
  638. ),
  639. ]
  640. const expandedUpdates =
  641. await this.SyncManager.promises.expandSyncUpdates(
  642. this.projectId,
  643. this.historyId,
  644. this.mostRecentChunk,
  645. updates,
  646. this.extendLock
  647. )
  648. expect(expandedUpdates).to.deep.equal([])
  649. expect(this.extendLock).to.have.been.called
  650. })
  651. it('queues file removes for unexpected files', async function () {
  652. const updates = [resyncProjectStructureUpdate([this.persistedDoc], [])]
  653. const expandedUpdates =
  654. await this.SyncManager.promises.expandSyncUpdates(
  655. this.projectId,
  656. this.historyId,
  657. this.mostRecentChunk,
  658. updates,
  659. this.extendLock
  660. )
  661. expect(expandedUpdates).to.deep.equal([
  662. {
  663. pathname: this.persistedFile.path,
  664. new_pathname: '',
  665. meta: {
  666. resync: true,
  667. ts: TIMESTAMP,
  668. origin: { kind: 'history-resync' },
  669. },
  670. },
  671. ])
  672. expect(this.extendLock).to.have.been.called
  673. })
  674. it('queues doc removes for unexpected docs', async function () {
  675. const updates = [resyncProjectStructureUpdate([], [this.persistedFile])]
  676. const expandedUpdates =
  677. await this.SyncManager.promises.expandSyncUpdates(
  678. this.projectId,
  679. this.historyId,
  680. this.mostRecentChunk,
  681. updates,
  682. this.extendLock
  683. )
  684. expect(expandedUpdates).to.deep.equal([
  685. {
  686. pathname: this.persistedDoc.path,
  687. new_pathname: '',
  688. meta: {
  689. resync: true,
  690. ts: TIMESTAMP,
  691. origin: { kind: 'history-resync' },
  692. },
  693. },
  694. ])
  695. expect(this.extendLock).to.have.been.called
  696. })
  697. it('queues file additions for missing regular files', async function () {
  698. const newFile = {
  699. path: '2.png',
  700. file: {},
  701. url: 'filestore/2.png',
  702. _hash: 'hash-42',
  703. }
  704. const updates = [
  705. resyncProjectStructureUpdate(
  706. [this.persistedDoc],
  707. [this.persistedFile, newFile]
  708. ),
  709. ]
  710. const expandedUpdates =
  711. await this.SyncManager.promises.expandSyncUpdates(
  712. this.projectId,
  713. this.historyId,
  714. this.mostRecentChunk,
  715. updates,
  716. this.extendLock
  717. )
  718. expect(expandedUpdates).to.deep.equal([
  719. {
  720. pathname: newFile.path,
  721. file: newFile.file,
  722. url: newFile.url,
  723. hash: 'hash-42',
  724. meta: {
  725. resync: true,
  726. ts: TIMESTAMP,
  727. origin: { kind: 'history-resync' },
  728. },
  729. },
  730. ])
  731. expect(this.extendLock).to.have.been.called
  732. })
  733. it('queues file additions for missing regular files w/o url', async function () {
  734. const newFile = {
  735. path: '2.png',
  736. file: {},
  737. _hash: 'hash-42',
  738. createdBlob: true,
  739. }
  740. const updates = [
  741. resyncProjectStructureUpdate(
  742. [this.persistedDoc],
  743. [this.persistedFile, newFile]
  744. ),
  745. ]
  746. const expandedUpdates =
  747. await this.SyncManager.promises.expandSyncUpdates(
  748. this.projectId,
  749. this.historyId,
  750. this.mostRecentChunk,
  751. updates,
  752. this.extendLock
  753. )
  754. expect(expandedUpdates).to.deep.equal([
  755. {
  756. pathname: newFile.path,
  757. file: newFile.file,
  758. hash: 'hash-42',
  759. createdBlob: true,
  760. meta: {
  761. resync: true,
  762. ts: TIMESTAMP,
  763. origin: { kind: 'history-resync' },
  764. },
  765. },
  766. ])
  767. expect(this.extendLock).to.have.been.called
  768. })
  769. it('queues file additions for missing linked files', async function () {
  770. const newFile = {
  771. path: '2.png',
  772. file: {},
  773. url: 'filestore/2.png',
  774. metadata: {
  775. importedAt: '2024-07-30T09:14:45.928Z',
  776. provider: 'references-provider',
  777. },
  778. _hash: 'hash-42',
  779. }
  780. const updates = [
  781. resyncProjectStructureUpdate(
  782. [this.persistedDoc],
  783. [this.persistedFile, newFile]
  784. ),
  785. ]
  786. const expandedUpdates =
  787. await this.SyncManager.promises.expandSyncUpdates(
  788. this.projectId,
  789. this.historyId,
  790. this.mostRecentChunk,
  791. updates,
  792. this.extendLock
  793. )
  794. expect(expandedUpdates).to.deep.equal([
  795. {
  796. pathname: newFile.path,
  797. file: newFile.file,
  798. url: newFile.url,
  799. hash: 'hash-42',
  800. metadata: {
  801. importedAt: '2024-07-30T09:14:45.928Z',
  802. provider: 'references-provider',
  803. },
  804. meta: {
  805. resync: true,
  806. ts: TIMESTAMP,
  807. origin: { kind: 'history-resync' },
  808. },
  809. },
  810. ])
  811. expect(this.extendLock).to.have.been.called
  812. })
  813. it('queues blank doc additions for missing docs', async function () {
  814. const newDoc = {
  815. path: 'another.tex',
  816. doc: new ObjectId().toString(),
  817. }
  818. const updates = [
  819. resyncProjectStructureUpdate(
  820. [this.persistedDoc, newDoc],
  821. [this.persistedFile]
  822. ),
  823. ]
  824. const expandedUpdates =
  825. await this.SyncManager.promises.expandSyncUpdates(
  826. this.projectId,
  827. this.historyId,
  828. this.mostRecentChunk,
  829. updates,
  830. this.extendLock
  831. )
  832. expect(expandedUpdates).to.deep.equal([
  833. {
  834. pathname: newDoc.path,
  835. doc: newDoc.doc,
  836. docLines: '',
  837. meta: {
  838. resync: true,
  839. ts: TIMESTAMP,
  840. origin: { kind: 'history-resync' },
  841. },
  842. },
  843. ])
  844. expect(this.extendLock).to.have.been.called
  845. })
  846. it('removes and re-adds files if whether they are binary differs', async function () {
  847. const fileWichWasADoc = {
  848. path: this.persistedDoc.path,
  849. url: 'filestore/2.png',
  850. _hash: 'other-hash',
  851. }
  852. const updates = [
  853. resyncProjectStructureUpdate(
  854. [],
  855. [fileWichWasADoc, this.persistedFile]
  856. ),
  857. ]
  858. const expandedUpdates =
  859. await this.SyncManager.promises.expandSyncUpdates(
  860. this.projectId,
  861. this.historyId,
  862. this.mostRecentChunk,
  863. updates,
  864. this.extendLock
  865. )
  866. expect(expandedUpdates).to.deep.equal([
  867. {
  868. pathname: fileWichWasADoc.path,
  869. new_pathname: '',
  870. meta: {
  871. resync: true,
  872. ts: TIMESTAMP,
  873. origin: { kind: 'history-resync' },
  874. },
  875. },
  876. {
  877. pathname: fileWichWasADoc.path,
  878. file: fileWichWasADoc.file,
  879. url: fileWichWasADoc.url,
  880. hash: 'other-hash',
  881. meta: {
  882. resync: true,
  883. ts: TIMESTAMP,
  884. origin: { kind: 'history-resync' },
  885. },
  886. },
  887. ])
  888. expect(this.extendLock).to.have.been.called
  889. })
  890. it('removes and re-adds linked-files if their binary state differs', async function () {
  891. const fileWhichWasADoc = {
  892. path: this.persistedDoc.path,
  893. url: 'filestore/references.txt',
  894. _hash: 'other-hash',
  895. metadata: {
  896. importedAt: '2024-07-30T09:14:45.928Z',
  897. provider: 'references-provider',
  898. },
  899. }
  900. const updates = [
  901. resyncProjectStructureUpdate(
  902. [],
  903. [fileWhichWasADoc, this.persistedFile]
  904. ),
  905. ]
  906. const expandedUpdates =
  907. await this.SyncManager.promises.expandSyncUpdates(
  908. this.projectId,
  909. this.historyId,
  910. this.mostRecentChunk,
  911. updates,
  912. this.extendLock
  913. )
  914. expect(expandedUpdates).to.deep.equal([
  915. {
  916. pathname: fileWhichWasADoc.path,
  917. new_pathname: '',
  918. meta: {
  919. resync: true,
  920. ts: TIMESTAMP,
  921. origin: { kind: 'history-resync' },
  922. },
  923. },
  924. {
  925. pathname: fileWhichWasADoc.path,
  926. file: fileWhichWasADoc.file,
  927. url: fileWhichWasADoc.url,
  928. hash: 'other-hash',
  929. metadata: {
  930. importedAt: '2024-07-30T09:14:45.928Z',
  931. provider: 'references-provider',
  932. },
  933. meta: {
  934. resync: true,
  935. ts: TIMESTAMP,
  936. origin: { kind: 'history-resync' },
  937. },
  938. },
  939. ])
  940. expect(this.extendLock).to.have.been.called
  941. })
  942. it('add linked file data with same hash', async function () {
  943. const nowLinkedFile = {
  944. path: this.persistedFile.path,
  945. url: 'filestore/1.png',
  946. _hash: this.persistedFile._hash,
  947. metadata: {
  948. importedAt: '2024-07-30T09:14:45.928Z',
  949. provider: 'image-provider',
  950. },
  951. }
  952. const updates = [
  953. resyncProjectStructureUpdate([this.persistedDoc], [nowLinkedFile]),
  954. ]
  955. const expandedUpdates =
  956. await this.SyncManager.promises.expandSyncUpdates(
  957. this.projectId,
  958. this.historyId,
  959. this.mostRecentChunk,
  960. updates,
  961. this.extendLock
  962. )
  963. expect(expandedUpdates).to.deep.equal([
  964. {
  965. pathname: nowLinkedFile.path,
  966. metadata: {
  967. importedAt: '2024-07-30T09:14:45.928Z',
  968. provider: 'image-provider',
  969. },
  970. meta: {
  971. resync: true,
  972. ts: TIMESTAMP,
  973. origin: { kind: 'history-resync' },
  974. },
  975. },
  976. ])
  977. expect(this.extendLock).to.have.been.called
  978. })
  979. it('updates linked file data when hash remains the same', async function () {
  980. this.fileMap[this.persistedFile.path].getMetadata.returns({
  981. importedAt: '2024-07-30T09:14:45.928Z',
  982. provider: 'image-provider',
  983. })
  984. const updatedLinkedFile = {
  985. path: this.persistedFile.path,
  986. url: 'filestore/1.png',
  987. _hash: this.persistedFile._hash,
  988. metadata: {
  989. importedAt: '2024-07-31T00:00:00.000Z',
  990. provider: 'image-provider',
  991. },
  992. }
  993. const updates = [
  994. resyncProjectStructureUpdate(
  995. [this.persistedDoc],
  996. [updatedLinkedFile]
  997. ),
  998. ]
  999. const expandedUpdates =
  1000. await this.SyncManager.promises.expandSyncUpdates(
  1001. this.projectId,
  1002. this.historyId,
  1003. this.mostRecentChunk,
  1004. updates,
  1005. this.extendLock
  1006. )
  1007. expect(expandedUpdates).to.deep.equal([
  1008. {
  1009. pathname: updatedLinkedFile.path,
  1010. metadata: {
  1011. importedAt: '2024-07-31T00:00:00.000Z',
  1012. provider: 'image-provider',
  1013. },
  1014. meta: {
  1015. resync: true,
  1016. ts: TIMESTAMP,
  1017. origin: { kind: 'history-resync' },
  1018. },
  1019. },
  1020. ])
  1021. expect(this.extendLock).to.have.been.called
  1022. })
  1023. it('remove linked file data', async function () {
  1024. this.fileMap[this.persistedFile.path].getMetadata.returns({
  1025. importedAt: '2024-07-30T09:14:45.928Z',
  1026. provider: 'image-provider',
  1027. })
  1028. const noLongerLinkedFile = {
  1029. path: this.persistedFile.path,
  1030. url: 'filestore/1.png',
  1031. _hash: this.persistedFile._hash,
  1032. }
  1033. const updates = [
  1034. resyncProjectStructureUpdate(
  1035. [this.persistedDoc],
  1036. [noLongerLinkedFile]
  1037. ),
  1038. ]
  1039. const expandedUpdates =
  1040. await this.SyncManager.promises.expandSyncUpdates(
  1041. this.projectId,
  1042. this.historyId,
  1043. this.mostRecentChunk,
  1044. updates,
  1045. this.extendLock
  1046. )
  1047. expect(expandedUpdates).to.deep.equal([
  1048. {
  1049. pathname: noLongerLinkedFile.path,
  1050. metadata: {},
  1051. meta: {
  1052. resync: true,
  1053. ts: TIMESTAMP,
  1054. origin: { kind: 'history-resync' },
  1055. },
  1056. },
  1057. ])
  1058. expect(this.extendLock).to.have.been.called
  1059. })
  1060. it("does not remove and re-add files if the expected file doesn't have a hash", async function () {
  1061. const fileWichWasADoc = {
  1062. path: this.persistedDoc.path,
  1063. url: 'filestore/2.png',
  1064. }
  1065. const updates = [
  1066. resyncProjectStructureUpdate(
  1067. [],
  1068. [fileWichWasADoc, this.persistedFile]
  1069. ),
  1070. ]
  1071. const expandedUpdates =
  1072. await this.SyncManager.promises.expandSyncUpdates(
  1073. this.projectId,
  1074. this.historyId,
  1075. this.mostRecentChunk,
  1076. updates,
  1077. this.extendLock
  1078. )
  1079. expect(expandedUpdates).to.deep.equal([])
  1080. expect(this.extendLock).to.have.been.called
  1081. })
  1082. it('does not remove and re-add editable files if there is a binary file with same hash', async function () {
  1083. const binaryFile = {
  1084. file: Object().toString(),
  1085. // The paths in the resyncProjectStructureUpdate must have a leading slash ('/')
  1086. // The other unit tests in this file are incorrectly missing the leading slash.
  1087. // The leading slash is present in web where the paths are created with
  1088. // ProjectEntityHandler.getAllEntitiesFromProject in ProjectEntityUpdateHandler.resyncProjectHistory.
  1089. path: '/' + this.persistedDoc.path,
  1090. url: 'filestore/12345',
  1091. _hash: 'abcdef',
  1092. }
  1093. this.fileMap['main.tex'].data = { hash: 'abcdef' }
  1094. const updates = [
  1095. resyncProjectStructureUpdate([], [binaryFile, this.persistedFile]),
  1096. ]
  1097. const expandedUpdates =
  1098. await this.SyncManager.promises.expandSyncUpdates(
  1099. this.projectId,
  1100. this.historyId,
  1101. this.mostRecentChunk,
  1102. updates,
  1103. this.extendLock
  1104. )
  1105. expect(expandedUpdates).to.deep.equal([])
  1106. expect(this.extendLock).to.have.been.called
  1107. })
  1108. it('removes and re-adds binary files if they do not have same hash', async function () {
  1109. const persistedFileWithNewContent = {
  1110. _hash: 'anotherhashvalue',
  1111. hello: 'world',
  1112. path: '1.png',
  1113. url: 'filestore-new-url',
  1114. }
  1115. const updates = [
  1116. resyncProjectStructureUpdate(
  1117. [this.persistedDoc],
  1118. [persistedFileWithNewContent]
  1119. ),
  1120. ]
  1121. const expandedUpdates =
  1122. await this.SyncManager.promises.expandSyncUpdates(
  1123. this.projectId,
  1124. this.historyId,
  1125. this.mostRecentChunk,
  1126. updates,
  1127. this.extendLock
  1128. )
  1129. expect(expandedUpdates).to.deep.equal([
  1130. {
  1131. pathname: persistedFileWithNewContent.path,
  1132. new_pathname: '',
  1133. meta: {
  1134. resync: true,
  1135. ts: TIMESTAMP,
  1136. origin: { kind: 'history-resync' },
  1137. },
  1138. },
  1139. {
  1140. pathname: persistedFileWithNewContent.path,
  1141. file: persistedFileWithNewContent.file,
  1142. url: persistedFileWithNewContent.url,
  1143. hash: 'anotherhashvalue',
  1144. meta: {
  1145. resync: true,
  1146. ts: TIMESTAMP,
  1147. origin: { kind: 'history-resync' },
  1148. },
  1149. },
  1150. ])
  1151. expect(this.extendLock).to.have.been.called
  1152. })
  1153. it('removes and re-adds binary files w/o url if they do not have same hash', async function () {
  1154. const persistedFileWithNewContent = {
  1155. _hash: 'anotherhashvalue',
  1156. hello: 'world',
  1157. path: '1.png',
  1158. createdBlob: true,
  1159. }
  1160. const updates = [
  1161. resyncProjectStructureUpdate(
  1162. [this.persistedDoc],
  1163. [persistedFileWithNewContent]
  1164. ),
  1165. ]
  1166. const expandedUpdates =
  1167. await this.SyncManager.promises.expandSyncUpdates(
  1168. this.projectId,
  1169. this.historyId,
  1170. this.mostRecentChunk,
  1171. updates,
  1172. this.extendLock
  1173. )
  1174. expect(expandedUpdates).to.deep.equal([
  1175. {
  1176. pathname: persistedFileWithNewContent.path,
  1177. new_pathname: '',
  1178. meta: {
  1179. resync: true,
  1180. ts: TIMESTAMP,
  1181. origin: { kind: 'history-resync' },
  1182. },
  1183. },
  1184. {
  1185. pathname: persistedFileWithNewContent.path,
  1186. file: persistedFileWithNewContent.file,
  1187. hash: 'anotherhashvalue',
  1188. createdBlob: true,
  1189. meta: {
  1190. resync: true,
  1191. ts: TIMESTAMP,
  1192. origin: { kind: 'history-resync' },
  1193. },
  1194. },
  1195. ])
  1196. expect(this.extendLock).to.have.been.called
  1197. })
  1198. it('preserves other updates', async function () {
  1199. const update = { mock: 'update' }
  1200. const updates = [
  1201. update,
  1202. resyncProjectStructureUpdate(
  1203. [this.persistedDoc],
  1204. [this.persistedFile]
  1205. ),
  1206. ]
  1207. const expandedUpdates =
  1208. await this.SyncManager.promises.expandSyncUpdates(
  1209. this.projectId,
  1210. this.historyId,
  1211. this.mostRecentChunk,
  1212. updates,
  1213. this.extendLock
  1214. )
  1215. expect(expandedUpdates).to.deep.equal([update])
  1216. expect(this.extendLock).to.have.been.called
  1217. })
  1218. })
  1219. describe('expanding doc contents sync updates', function () {
  1220. it('returns errors from diffAsShareJsOps', async function () {
  1221. const diffError = new Error('test')
  1222. this.UpdateCompressor.diffAsShareJsOps.throws(diffError)
  1223. const updates = [
  1224. resyncProjectStructureUpdate(
  1225. [this.persistedDoc],
  1226. [this.persistedFile]
  1227. ),
  1228. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent),
  1229. ]
  1230. await expect(
  1231. this.SyncManager.promises.expandSyncUpdates(
  1232. this.projectId,
  1233. this.historyId,
  1234. this.mostRecentChunk,
  1235. updates,
  1236. this.extendLock
  1237. )
  1238. ).to.be.rejectedWith(diffError)
  1239. expect(this.extendLock).to.have.been.called
  1240. })
  1241. it('handles an update for a file that is missing from the snapshot', async function () {
  1242. const updates = [docContentSyncUpdate('not-in-snapshot.txt', 'test')]
  1243. await expect(
  1244. this.SyncManager.promises.expandSyncUpdates(
  1245. this.projectId,
  1246. this.historyId,
  1247. this.mostRecentChunk,
  1248. updates,
  1249. this.extendLock
  1250. )
  1251. ).to.be.rejectedWith('unrecognised file: not in snapshot')
  1252. })
  1253. it('queues nothing for in docs whose contents is in sync', async function () {
  1254. const updates = [
  1255. resyncProjectStructureUpdate(
  1256. [this.persistedDoc],
  1257. [this.persistedFile]
  1258. ),
  1259. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent),
  1260. ]
  1261. this.UpdateCompressor.diffAsShareJsOps.returns([])
  1262. const expandedUpdates =
  1263. await this.SyncManager.promises.expandSyncUpdates(
  1264. this.projectId,
  1265. this.historyId,
  1266. this.mostRecentChunk,
  1267. updates,
  1268. this.extendLock
  1269. )
  1270. expect(expandedUpdates).to.deep.equal([])
  1271. expect(this.extendLock).to.have.been.called
  1272. })
  1273. it('queues text updates for docs whose contents is out of sync', async function () {
  1274. const updates = [
  1275. resyncProjectStructureUpdate(
  1276. [this.persistedDoc],
  1277. [this.persistedFile]
  1278. ),
  1279. docContentSyncUpdate(
  1280. this.persistedDoc,
  1281. 'the fox jumps over the lazy dog'
  1282. ),
  1283. ]
  1284. this.UpdateCompressor.diffAsShareJsOps.returns([
  1285. { d: 'quick brown ', p: 4 },
  1286. ])
  1287. const expandedUpdates =
  1288. await this.SyncManager.promises.expandSyncUpdates(
  1289. this.projectId,
  1290. this.historyId,
  1291. this.mostRecentChunk,
  1292. updates,
  1293. this.extendLock
  1294. )
  1295. expect(expandedUpdates).to.deep.equal([
  1296. {
  1297. doc: this.persistedDoc.doc,
  1298. op: [{ d: 'quick brown ', p: 4 }],
  1299. meta: {
  1300. pathname: this.persistedDoc.path,
  1301. doc_length: this.persistedDocContent.length,
  1302. resync: true,
  1303. ts: TIMESTAMP,
  1304. origin: { kind: 'history-resync' },
  1305. },
  1306. },
  1307. ])
  1308. expect(this.extendLock).to.have.been.called
  1309. })
  1310. it('queues text updates for docs created by project structure sync', async function () {
  1311. this.UpdateCompressor.diffAsShareJsOps.returns([{ i: 'a', p: 0 }])
  1312. const newDoc = {
  1313. path: 'another.tex',
  1314. doc: new ObjectId().toString(),
  1315. }
  1316. const newDocContent = 'a'
  1317. const updates = [
  1318. resyncProjectStructureUpdate(
  1319. [this.persistedDoc, newDoc],
  1320. [this.persistedFile]
  1321. ),
  1322. docContentSyncUpdate(newDoc, newDocContent),
  1323. ]
  1324. const expandedUpdates =
  1325. await this.SyncManager.promises.expandSyncUpdates(
  1326. this.projectId,
  1327. this.historyId,
  1328. this.mostRecentChunk,
  1329. updates,
  1330. this.extendLock
  1331. )
  1332. expect(expandedUpdates).to.deep.equal([
  1333. {
  1334. pathname: newDoc.path,
  1335. doc: newDoc.doc,
  1336. docLines: '',
  1337. meta: {
  1338. resync: true,
  1339. ts: TIMESTAMP,
  1340. origin: { kind: 'history-resync' },
  1341. },
  1342. },
  1343. {
  1344. doc: newDoc.doc,
  1345. op: [{ i: 'a', p: 0 }],
  1346. meta: {
  1347. pathname: newDoc.path,
  1348. doc_length: 0,
  1349. resync: true,
  1350. ts: TIMESTAMP,
  1351. origin: { kind: 'history-resync' },
  1352. },
  1353. },
  1354. ])
  1355. expect(this.extendLock).to.have.been.called
  1356. })
  1357. it('skips text updates for docs when hashes match', async function () {
  1358. this.fileMap['main.tex'].getHash.returns('special-hash')
  1359. this.HashManager._getBlobHashFromString.returns('special-hash')
  1360. const updates = [
  1361. resyncProjectStructureUpdate(
  1362. [this.persistedDoc],
  1363. [this.persistedFile]
  1364. ),
  1365. docContentSyncUpdate(this.persistedDoc, 'hello'),
  1366. ]
  1367. const expandedUpdates =
  1368. await this.SyncManager.promises.expandSyncUpdates(
  1369. this.projectId,
  1370. this.historyId,
  1371. this.mostRecentChunk,
  1372. updates,
  1373. this.extendLock
  1374. )
  1375. expect(expandedUpdates).to.deep.equal([])
  1376. expect(this.extendLock).to.have.been.called
  1377. })
  1378. it('computes text updates for docs when hashes differ', async function () {
  1379. this.fileMap['main.tex'].getHash.returns('first-hash')
  1380. this.HashManager._getBlobHashFromString.returns('second-hash')
  1381. this.UpdateCompressor.diffAsShareJsOps.returns([
  1382. { i: 'test diff', p: 0 },
  1383. ])
  1384. const updates = [
  1385. resyncProjectStructureUpdate(
  1386. [this.persistedDoc],
  1387. [this.persistedFile]
  1388. ),
  1389. docContentSyncUpdate(this.persistedDoc, 'hello'),
  1390. ]
  1391. const expandedUpdates =
  1392. await this.SyncManager.promises.expandSyncUpdates(
  1393. this.projectId,
  1394. this.historyId,
  1395. this.mostRecentChunk,
  1396. updates,
  1397. this.extendLock
  1398. )
  1399. expect(expandedUpdates).to.deep.equal([
  1400. {
  1401. doc: this.persistedDoc.doc,
  1402. op: [{ i: 'test diff', p: 0 }],
  1403. meta: {
  1404. pathname: this.persistedDoc.path,
  1405. doc_length: this.persistedDocContent.length,
  1406. resync: true,
  1407. ts: TIMESTAMP,
  1408. origin: { kind: 'history-resync' },
  1409. },
  1410. },
  1411. ])
  1412. expect(this.extendLock).to.have.been.called
  1413. })
  1414. describe('for docs whose contents is out of sync', function () {
  1415. beforeEach(async function () {
  1416. const updates = [
  1417. resyncProjectStructureUpdate(
  1418. [this.persistedDoc],
  1419. [this.persistedFile]
  1420. ),
  1421. docContentSyncUpdate(this.persistedDoc, 'the quick brown fox'),
  1422. ]
  1423. this.UpdateCompressor.diffAsShareJsOps.returns([
  1424. { d: ' jumps over the lazy dog', p: 19 },
  1425. ])
  1426. this.expandedUpdates =
  1427. await this.SyncManager.promises.expandSyncUpdates(
  1428. this.projectId,
  1429. this.historyId,
  1430. this.mostRecentChunk,
  1431. updates,
  1432. this.extendLock
  1433. )
  1434. })
  1435. it('loads content from the history service when needed', function () {
  1436. expect(this.expandedUpdates).to.deep.equal([
  1437. {
  1438. doc: this.persistedDoc.doc,
  1439. op: [{ d: ' jumps over the lazy dog', p: 19 }],
  1440. meta: {
  1441. pathname: this.persistedDoc.path,
  1442. doc_length: this.persistedDocContent.length,
  1443. resync: true,
  1444. ts: TIMESTAMP,
  1445. origin: { kind: 'history-resync' },
  1446. },
  1447. },
  1448. ])
  1449. expect(this.extendLock).to.have.been.called
  1450. })
  1451. })
  1452. })
  1453. describe('syncing comments', function () {
  1454. beforeEach(function () {
  1455. this.loadedSnapshotDoc
  1456. .getComments()
  1457. .add(new Comment('comment1', [new Range(4, 5)]))
  1458. this.loadedSnapshotDoc
  1459. .getComments()
  1460. .add(new Comment('comment2', [new Range(10, 5)], true))
  1461. this.comments = [
  1462. makeComment('comment1', 4, 'quick'),
  1463. makeComment('comment2', 10, 'brown'),
  1464. ]
  1465. this.resolvedCommentIds = ['comment2']
  1466. })
  1467. it('does nothing if comments have not changed', async function () {
  1468. const updates = [
  1469. docContentSyncUpdate(
  1470. this.persistedDoc,
  1471. this.persistedDocContent,
  1472. {
  1473. comments: this.comments,
  1474. },
  1475. this.resolvedCommentIds
  1476. ),
  1477. ]
  1478. const expandedUpdates =
  1479. await this.SyncManager.promises.expandSyncUpdates(
  1480. this.projectId,
  1481. this.historyId,
  1482. this.mostRecentChunk,
  1483. updates,
  1484. this.extendLock
  1485. )
  1486. expect(expandedUpdates).to.deep.equal([])
  1487. })
  1488. it('adds missing comments', async function () {
  1489. this.comments.push(makeComment('comment3', 20, 'jumps'))
  1490. const updates = [
  1491. docContentSyncUpdate(
  1492. this.persistedDoc,
  1493. this.persistedDocContent,
  1494. {
  1495. comments: this.comments,
  1496. },
  1497. this.resolvedCommentIds
  1498. ),
  1499. ]
  1500. const expandedUpdates =
  1501. await this.SyncManager.promises.expandSyncUpdates(
  1502. this.projectId,
  1503. this.historyId,
  1504. this.mostRecentChunk,
  1505. updates,
  1506. this.extendLock
  1507. )
  1508. expect(expandedUpdates).to.deep.equal([
  1509. {
  1510. doc: this.persistedDoc.doc,
  1511. op: [
  1512. {
  1513. c: 'jumps',
  1514. p: 20,
  1515. t: 'comment3',
  1516. resolved: false,
  1517. },
  1518. ],
  1519. meta: {
  1520. origin: {
  1521. kind: 'history-resync',
  1522. },
  1523. pathname: this.persistedDoc.path,
  1524. resync: true,
  1525. ts: TIMESTAMP,
  1526. doc_length: this.persistedDocContent.length,
  1527. },
  1528. },
  1529. ])
  1530. })
  1531. it('deletes extra comments', async function () {
  1532. this.comments.splice(0, 1)
  1533. const updates = [
  1534. docContentSyncUpdate(
  1535. this.persistedDoc,
  1536. this.persistedDocContent,
  1537. {
  1538. comments: this.comments,
  1539. },
  1540. this.resolvedCommentIds
  1541. ),
  1542. ]
  1543. const expandedUpdates =
  1544. await this.SyncManager.promises.expandSyncUpdates(
  1545. this.projectId,
  1546. this.historyId,
  1547. this.mostRecentChunk,
  1548. updates,
  1549. this.extendLock
  1550. )
  1551. expect(expandedUpdates).to.deep.equal([
  1552. {
  1553. pathname: this.persistedDoc.path,
  1554. deleteComment: 'comment1',
  1555. meta: {
  1556. origin: {
  1557. kind: 'history-resync',
  1558. },
  1559. resync: true,
  1560. ts: TIMESTAMP,
  1561. },
  1562. },
  1563. ])
  1564. })
  1565. it('updates comments when ranges differ', async function () {
  1566. this.comments[1] = makeComment('comment2', 16, 'fox')
  1567. const updates = [
  1568. docContentSyncUpdate(
  1569. this.persistedDoc,
  1570. this.persistedDocContent,
  1571. {
  1572. comments: this.comments,
  1573. },
  1574. this.resolvedCommentIds
  1575. ),
  1576. ]
  1577. const expandedUpdates =
  1578. await this.SyncManager.promises.expandSyncUpdates(
  1579. this.projectId,
  1580. this.historyId,
  1581. this.mostRecentChunk,
  1582. updates,
  1583. this.extendLock
  1584. )
  1585. expect(expandedUpdates).to.deep.equal([
  1586. {
  1587. doc: 'doc-id',
  1588. op: [
  1589. {
  1590. c: 'fox',
  1591. p: 16,
  1592. t: 'comment2',
  1593. resolved: true,
  1594. },
  1595. ],
  1596. meta: {
  1597. origin: {
  1598. kind: 'history-resync',
  1599. },
  1600. resync: true,
  1601. ts: TIMESTAMP,
  1602. pathname: this.persistedDoc.path,
  1603. doc_length: this.persistedDocContent.length,
  1604. },
  1605. },
  1606. ])
  1607. })
  1608. it('sets the resolved state when it differs', async function () {
  1609. this.resolvedCommentIds = ['comment1']
  1610. const updates = [
  1611. docContentSyncUpdate(
  1612. this.persistedDoc,
  1613. this.persistedDocContent,
  1614. {
  1615. comments: this.comments,
  1616. },
  1617. this.resolvedCommentIds
  1618. ),
  1619. ]
  1620. const expandedUpdates =
  1621. await this.SyncManager.promises.expandSyncUpdates(
  1622. this.projectId,
  1623. this.historyId,
  1624. this.mostRecentChunk,
  1625. updates,
  1626. this.extendLock
  1627. )
  1628. expect(expandedUpdates).to.have.deep.members([
  1629. {
  1630. pathname: this.persistedDoc.path,
  1631. commentId: 'comment1',
  1632. resolved: true,
  1633. meta: {
  1634. origin: {
  1635. kind: 'history-resync',
  1636. },
  1637. resync: true,
  1638. ts: TIMESTAMP,
  1639. },
  1640. },
  1641. {
  1642. pathname: this.persistedDoc.path,
  1643. commentId: 'comment2',
  1644. resolved: false,
  1645. meta: {
  1646. origin: {
  1647. kind: 'history-resync',
  1648. },
  1649. resync: true,
  1650. ts: TIMESTAMP,
  1651. },
  1652. },
  1653. ])
  1654. })
  1655. it('treats zero length comments as detached comments', async function () {
  1656. this.loadedSnapshotDoc.getComments().add(new Comment('comment1', []))
  1657. this.comments = [
  1658. makeComment('comment1', 16, ''),
  1659. makeComment('comment2', 10, 'brown'),
  1660. ]
  1661. this.resolvedCommentIds = ['comment2']
  1662. const updates = [
  1663. docContentSyncUpdate(
  1664. this.persistedDoc,
  1665. this.persistedDocContent,
  1666. {
  1667. comments: this.comments,
  1668. },
  1669. this.resolvedCommentIds
  1670. ),
  1671. ]
  1672. const expandedUpdates =
  1673. await this.SyncManager.promises.expandSyncUpdates(
  1674. this.projectId,
  1675. this.historyId,
  1676. this.mostRecentChunk,
  1677. updates,
  1678. this.extendLock
  1679. )
  1680. expect(expandedUpdates).to.deep.equal([])
  1681. })
  1682. it('adjusts comment positions when the underlying text has changed', async function () {
  1683. const updates = [
  1684. docContentSyncUpdate(
  1685. this.persistedDoc,
  1686. 'quick brown fox',
  1687. {
  1688. comments: [
  1689. makeComment('comment1', 0, 'quick'),
  1690. makeComment('comment2', 12, 'fox'),
  1691. ],
  1692. },
  1693. this.resolvedCommentIds
  1694. ),
  1695. ]
  1696. this.UpdateCompressor.diffAsShareJsOps.returns([
  1697. { d: 'the ', p: 0 },
  1698. { d: ' jumps over the lazy dog', p: 15 },
  1699. ])
  1700. const expandedUpdates =
  1701. await this.SyncManager.promises.expandSyncUpdates(
  1702. this.projectId,
  1703. this.historyId,
  1704. this.mostRecentChunk,
  1705. updates,
  1706. this.extendLock
  1707. )
  1708. expect(expandedUpdates).to.deep.equal([
  1709. {
  1710. doc: this.persistedDoc.doc,
  1711. op: [
  1712. { d: 'the ', p: 0 },
  1713. { d: ' jumps over the lazy dog', p: 15 },
  1714. ],
  1715. meta: {
  1716. pathname: this.persistedDoc.path,
  1717. doc_length: this.persistedDocContent.length,
  1718. resync: true,
  1719. ts: TIMESTAMP,
  1720. origin: { kind: 'history-resync' },
  1721. },
  1722. },
  1723. {
  1724. doc: this.persistedDoc.doc,
  1725. op: [
  1726. {
  1727. c: 'fox',
  1728. p: 12,
  1729. t: 'comment2',
  1730. resolved: true,
  1731. },
  1732. ],
  1733. meta: {
  1734. origin: {
  1735. kind: 'history-resync',
  1736. },
  1737. pathname: this.persistedDoc.path,
  1738. resync: true,
  1739. ts: TIMESTAMP,
  1740. doc_length: 'quick brown fox'.length,
  1741. },
  1742. },
  1743. ])
  1744. })
  1745. })
  1746. describe('syncing tracked changes', function () {
  1747. beforeEach(function () {
  1748. this.loadedSnapshotDoc.getTrackedChanges().add(
  1749. new TrackedChange(new Range(4, 6), {
  1750. type: 'delete',
  1751. userId: USER_ID,
  1752. ts: new Date(TIMESTAMP),
  1753. })
  1754. )
  1755. this.loadedSnapshotDoc.getTrackedChanges().add(
  1756. new TrackedChange(new Range(10, 6), {
  1757. type: 'insert',
  1758. userId: USER_ID,
  1759. ts: new Date(TIMESTAMP),
  1760. })
  1761. )
  1762. this.loadedSnapshotDoc.getTrackedChanges().add(
  1763. new TrackedChange(new Range(20, 6), {
  1764. type: 'delete',
  1765. userId: USER_ID,
  1766. ts: new Date(TIMESTAMP),
  1767. })
  1768. )
  1769. this.loadedSnapshotDoc.getTrackedChanges().add(
  1770. new TrackedChange(new Range(40, 3), {
  1771. type: 'insert',
  1772. userId: USER_ID,
  1773. ts: new Date(TIMESTAMP),
  1774. })
  1775. )
  1776. this.changes = [
  1777. makeTrackedChange('td1', { p: 4, d: 'quick ' }),
  1778. makeTrackedChange('ti1', { p: 4, hpos: 10, i: 'brown ' }),
  1779. makeTrackedChange('td2', { p: 14, hpos: 20, d: 'jumps ' }),
  1780. makeTrackedChange('ti2', { p: 28, hpos: 40, i: 'dog' }),
  1781. ]
  1782. })
  1783. it('does nothing if tracked changes have not changed', async function () {
  1784. const updates = [
  1785. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
  1786. changes: this.changes,
  1787. }),
  1788. ]
  1789. const expandedUpdates =
  1790. await this.SyncManager.promises.expandSyncUpdates(
  1791. this.projectId,
  1792. this.historyId,
  1793. this.mostRecentChunk,
  1794. updates,
  1795. this.extendLock
  1796. )
  1797. expect(expandedUpdates).to.deep.equal([])
  1798. })
  1799. it('adds new tracked changes', async function () {
  1800. this.changes.splice(
  1801. 3,
  1802. 0,
  1803. makeTrackedChange('td3', { p: 29, hpos: 35, d: 'lazy ' })
  1804. )
  1805. const updates = [
  1806. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
  1807. changes: this.changes,
  1808. }),
  1809. ]
  1810. const expandedUpdates =
  1811. await this.SyncManager.promises.expandSyncUpdates(
  1812. this.projectId,
  1813. this.historyId,
  1814. this.mostRecentChunk,
  1815. updates,
  1816. this.extendLock
  1817. )
  1818. expect(expandedUpdates).to.deep.equal([
  1819. {
  1820. doc: this.persistedDoc.doc,
  1821. op: [
  1822. {
  1823. p: 35,
  1824. r: 'lazy ',
  1825. tracking: {
  1826. type: 'delete',
  1827. userId: USER_ID,
  1828. ts: TIMESTAMP,
  1829. },
  1830. },
  1831. ],
  1832. meta: {
  1833. origin: {
  1834. kind: 'history-resync',
  1835. },
  1836. pathname: this.persistedDoc.path,
  1837. resync: true,
  1838. ts: TIMESTAMP,
  1839. doc_length: this.persistedDocContent.length,
  1840. },
  1841. },
  1842. ])
  1843. })
  1844. it('removes extra tracked changes', async function () {
  1845. this.changes.splice(0, 1)
  1846. const updates = [
  1847. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
  1848. changes: this.changes,
  1849. }),
  1850. ]
  1851. const expandedUpdates =
  1852. await this.SyncManager.promises.expandSyncUpdates(
  1853. this.projectId,
  1854. this.historyId,
  1855. this.mostRecentChunk,
  1856. updates,
  1857. this.extendLock
  1858. )
  1859. expect(expandedUpdates).to.deep.equal([
  1860. {
  1861. doc: this.persistedDoc.doc,
  1862. op: [
  1863. {
  1864. p: 4,
  1865. r: 'quick ',
  1866. tracking: { type: 'none' },
  1867. },
  1868. ],
  1869. meta: {
  1870. origin: {
  1871. kind: 'history-resync',
  1872. },
  1873. pathname: this.persistedDoc.path,
  1874. resync: true,
  1875. ts: TIMESTAMP,
  1876. doc_length: this.persistedDocContent.length,
  1877. },
  1878. },
  1879. ])
  1880. })
  1881. it('handles overlapping ranges', async function () {
  1882. this.changes = [
  1883. makeTrackedChange('ti1', { p: 0, i: 'the quic' }),
  1884. makeTrackedChange('td1', { p: 8, d: 'k br' }),
  1885. makeTrackedChange('ti2', { p: 14, hpos: 23, i: 'ps over' }),
  1886. ]
  1887. const updates = [
  1888. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
  1889. changes: this.changes,
  1890. }),
  1891. ]
  1892. const expandedUpdates =
  1893. await this.SyncManager.promises.expandSyncUpdates(
  1894. this.projectId,
  1895. this.historyId,
  1896. this.mostRecentChunk,
  1897. updates,
  1898. this.extendLock
  1899. )
  1900. // Before: the [quick ][brown ] fox [jumps ]over the lazy dog
  1901. // After: [the quic][k br]own fox jum[ps over] the lazy dog
  1902. expect(expandedUpdates).to.deep.equal([
  1903. {
  1904. doc: this.persistedDoc.doc,
  1905. op: [
  1906. {
  1907. p: 0,
  1908. r: 'the quic',
  1909. tracking: { type: 'insert', userId: USER_ID, ts: TIMESTAMP },
  1910. },
  1911. {
  1912. p: 10,
  1913. r: 'br',
  1914. tracking: { type: 'delete', userId: USER_ID, ts: TIMESTAMP },
  1915. },
  1916. {
  1917. p: 12,
  1918. r: 'own ',
  1919. tracking: { type: 'none' },
  1920. },
  1921. {
  1922. p: 20,
  1923. r: 'jum',
  1924. tracking: { type: 'none' },
  1925. },
  1926. {
  1927. p: 23,
  1928. r: 'ps over',
  1929. tracking: { type: 'insert', userId: USER_ID, ts: TIMESTAMP },
  1930. },
  1931. {
  1932. p: 40,
  1933. r: 'dog',
  1934. tracking: { type: 'none' },
  1935. },
  1936. ],
  1937. meta: {
  1938. origin: { kind: 'history-resync' },
  1939. pathname: this.persistedDoc.path,
  1940. resync: true,
  1941. ts: TIMESTAMP,
  1942. doc_length: this.persistedDocContent.length,
  1943. },
  1944. },
  1945. ])
  1946. })
  1947. it('adjusts tracked change positions when the underlying text has changed', async function () {
  1948. const updates = [
  1949. docContentSyncUpdate(
  1950. this.persistedDoc,
  1951. 'every fox jumps over the lazy dog',
  1952. {
  1953. changes: [
  1954. makeTrackedChange('ti1', { p: 5, i: ' ' }), // the space after every is still a tracked insert
  1955. makeTrackedChange('td2', { p: 10, d: 'jumps ' }),
  1956. makeTrackedChange('ti2', { p: 24, hpos: 30, i: 'dog' }),
  1957. ],
  1958. },
  1959. this.resolvedCommentIds
  1960. ),
  1961. ]
  1962. this.UpdateCompressor.diffAsShareJsOps.returns([
  1963. { d: 'the quick brown', p: 0 },
  1964. { i: 'every', p: 0 },
  1965. ])
  1966. const expandedUpdates =
  1967. await this.SyncManager.promises.expandSyncUpdates(
  1968. this.projectId,
  1969. this.historyId,
  1970. this.mostRecentChunk,
  1971. updates,
  1972. this.extendLock
  1973. )
  1974. expect(expandedUpdates).to.deep.equal([
  1975. {
  1976. doc: this.persistedDoc.doc,
  1977. op: [
  1978. { d: 'the quick brown', p: 0 },
  1979. { i: 'every', p: 0 },
  1980. ],
  1981. meta: {
  1982. pathname: this.persistedDoc.path,
  1983. doc_length: this.persistedDocContent.length,
  1984. resync: true,
  1985. ts: TIMESTAMP,
  1986. origin: { kind: 'history-resync' },
  1987. },
  1988. },
  1989. ])
  1990. })
  1991. })
  1992. })
  1993. describe('stuck sync state reproduction', function () {
  1994. describe('when resyncDocContents has entries but no resyncDocContent update arrives', function () {
  1995. beforeEach(function () {
  1996. // Simulate the stuck state: project structure sync is done,
  1997. // but doc content sync never completed for /main.tex
  1998. const stuckSyncState = {
  1999. resyncProjectStructure: false,
  2000. resyncDocContents: ['/main.tex'],
  2001. origin: { kind: 'history-resync' },
  2002. }
  2003. this.db.projectHistorySyncState.findOne.resolves(stuckSyncState)
  2004. })
  2005. it('should skip text updates for a doc stuck in resyncDocContents', async function () {
  2006. const textUpdate = {
  2007. doc: new ObjectId(),
  2008. op: [{ i: 'hello', p: 0 }],
  2009. meta: {
  2010. pathname: '/main.tex',
  2011. doc_length: 0,
  2012. },
  2013. }
  2014. this.UpdateTranslator.isTextUpdate.returns(false)
  2015. this.UpdateTranslator.isTextUpdate.withArgs(textUpdate).returns(true)
  2016. const { updates: filteredUpdates, syncState } =
  2017. await this.SyncManager.promises.skipUpdatesDuringSync(
  2018. this.projectId,
  2019. [textUpdate]
  2020. )
  2021. // The text update is silently dropped — this is the stuck state bug
  2022. expect(filteredUpdates).to.deep.equal([])
  2023. // Sync state still shows /main.tex as syncing
  2024. expect(syncState.toRaw().resyncDocContents).to.deep.equal(['/main.tex'])
  2025. // Metric is emitted for the skipped update
  2026. expect(
  2027. this.Metrics.inc.calledWith('project_history_sync_update_skipped')
  2028. ).to.be.true
  2029. })
  2030. })
  2031. })
  2032. })