SyncManagerTests.js 58 KB

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