SyncManagerTests.js 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621
  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. getLatestSnapshot: 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.fileMap = {
  444. 'main.tex': {
  445. isEditable: sinon.stub().returns(true),
  446. getContent: sinon.stub().returns(null),
  447. getHash: sinon.stub().returns(null),
  448. load: sinon.stub().resolves(this.loadedSnapshotDoc),
  449. },
  450. '1.png': {
  451. isEditable: sinon.stub().returns(false),
  452. data: { hash: this.persistedFile._hash },
  453. },
  454. }
  455. this.UpdateTranslator._convertPathname
  456. .withArgs('main.tex')
  457. .returns('main.tex')
  458. this.UpdateTranslator._convertPathname
  459. .withArgs('/main.tex')
  460. .returns('main.tex')
  461. this.UpdateTranslator._convertPathname
  462. .withArgs('another.tex')
  463. .returns('another.tex')
  464. this.UpdateTranslator._convertPathname.withArgs('1.png').returns('1.png')
  465. this.UpdateTranslator._convertPathname.withArgs('2.png').returns('2.png')
  466. this.SnapshotManager.promises.getLatestSnapshot.resolves(this.fileMap)
  467. })
  468. it('returns updates if no sync updates are queued', async function () {
  469. const updates = ['some', 'mock', 'updates']
  470. const expandedUpdates = await this.SyncManager.promises.expandSyncUpdates(
  471. this.projectId,
  472. this.historyId,
  473. updates,
  474. this.extendLock
  475. )
  476. expect(expandedUpdates).to.equal(updates)
  477. expect(this.SnapshotManager.promises.getLatestSnapshot).to.not.have.been
  478. .called
  479. expect(this.extendLock).to.not.have.been.called
  480. })
  481. describe('expanding project structure sync updates', function () {
  482. it('queues nothing for expected docs and files', async function () {
  483. const updates = [
  484. resyncProjectStructureUpdate(
  485. [this.persistedDoc],
  486. [this.persistedFile]
  487. ),
  488. ]
  489. const expandedUpdates =
  490. await this.SyncManager.promises.expandSyncUpdates(
  491. this.projectId,
  492. this.historyId,
  493. updates,
  494. this.extendLock
  495. )
  496. expect(expandedUpdates).to.deep.equal([])
  497. expect(this.extendLock).to.have.been.called
  498. })
  499. it('queues file removes for unexpected files', async function () {
  500. const updates = [resyncProjectStructureUpdate([this.persistedDoc], [])]
  501. const expandedUpdates =
  502. await this.SyncManager.promises.expandSyncUpdates(
  503. this.projectId,
  504. this.historyId,
  505. updates,
  506. this.extendLock
  507. )
  508. expect(expandedUpdates).to.deep.equal([
  509. {
  510. pathname: this.persistedFile.path,
  511. new_pathname: '',
  512. meta: {
  513. resync: true,
  514. ts: TIMESTAMP,
  515. origin: { kind: 'history-resync' },
  516. },
  517. },
  518. ])
  519. expect(this.extendLock).to.have.been.called
  520. })
  521. it('queues doc removes for unexpected docs', async function () {
  522. const updates = [resyncProjectStructureUpdate([], [this.persistedFile])]
  523. const expandedUpdates =
  524. await this.SyncManager.promises.expandSyncUpdates(
  525. this.projectId,
  526. this.historyId,
  527. updates,
  528. this.extendLock
  529. )
  530. expect(expandedUpdates).to.deep.equal([
  531. {
  532. pathname: this.persistedDoc.path,
  533. new_pathname: '',
  534. meta: {
  535. resync: true,
  536. ts: TIMESTAMP,
  537. origin: { kind: 'history-resync' },
  538. },
  539. },
  540. ])
  541. expect(this.extendLock).to.have.been.called
  542. })
  543. it('queues file additions for missing files', async function () {
  544. const newFile = {
  545. path: '2.png',
  546. file: {},
  547. url: 'filestore/2.png',
  548. }
  549. const updates = [
  550. resyncProjectStructureUpdate(
  551. [this.persistedDoc],
  552. [this.persistedFile, newFile]
  553. ),
  554. ]
  555. const expandedUpdates =
  556. await this.SyncManager.promises.expandSyncUpdates(
  557. this.projectId,
  558. this.historyId,
  559. updates,
  560. this.extendLock
  561. )
  562. expect(expandedUpdates).to.deep.equal([
  563. {
  564. pathname: newFile.path,
  565. file: newFile.file,
  566. url: newFile.url,
  567. meta: {
  568. resync: true,
  569. ts: TIMESTAMP,
  570. origin: { kind: 'history-resync' },
  571. },
  572. },
  573. ])
  574. expect(this.extendLock).to.have.been.called
  575. })
  576. it('queues blank doc additions for missing docs', async function () {
  577. const newDoc = {
  578. path: 'another.tex',
  579. doc: new ObjectId().toString(),
  580. }
  581. const updates = [
  582. resyncProjectStructureUpdate(
  583. [this.persistedDoc, newDoc],
  584. [this.persistedFile]
  585. ),
  586. ]
  587. const expandedUpdates =
  588. await this.SyncManager.promises.expandSyncUpdates(
  589. this.projectId,
  590. this.historyId,
  591. updates,
  592. this.extendLock
  593. )
  594. expect(expandedUpdates).to.deep.equal([
  595. {
  596. pathname: newDoc.path,
  597. doc: newDoc.doc,
  598. docLines: '',
  599. meta: {
  600. resync: true,
  601. ts: TIMESTAMP,
  602. origin: { kind: 'history-resync' },
  603. },
  604. },
  605. ])
  606. expect(this.extendLock).to.have.been.called
  607. })
  608. it('removes and re-adds files if whether they are binary differs', async function () {
  609. const fileWichWasADoc = {
  610. path: this.persistedDoc.path,
  611. url: 'filestore/2.png',
  612. _hash: 'other-hash',
  613. }
  614. const updates = [
  615. resyncProjectStructureUpdate(
  616. [],
  617. [fileWichWasADoc, this.persistedFile]
  618. ),
  619. ]
  620. const expandedUpdates =
  621. await this.SyncManager.promises.expandSyncUpdates(
  622. this.projectId,
  623. this.historyId,
  624. updates,
  625. this.extendLock
  626. )
  627. expect(expandedUpdates).to.deep.equal([
  628. {
  629. pathname: fileWichWasADoc.path,
  630. new_pathname: '',
  631. meta: {
  632. resync: true,
  633. ts: TIMESTAMP,
  634. origin: { kind: 'history-resync' },
  635. },
  636. },
  637. {
  638. pathname: fileWichWasADoc.path,
  639. file: fileWichWasADoc.file,
  640. url: fileWichWasADoc.url,
  641. meta: {
  642. resync: true,
  643. ts: TIMESTAMP,
  644. origin: { kind: 'history-resync' },
  645. },
  646. },
  647. ])
  648. expect(this.extendLock).to.have.been.called
  649. })
  650. it("does not remove and re-add files if the expected file doesn't have a hash", async function () {
  651. const fileWichWasADoc = {
  652. path: this.persistedDoc.path,
  653. url: 'filestore/2.png',
  654. }
  655. const updates = [
  656. resyncProjectStructureUpdate(
  657. [],
  658. [fileWichWasADoc, this.persistedFile]
  659. ),
  660. ]
  661. const expandedUpdates =
  662. await this.SyncManager.promises.expandSyncUpdates(
  663. this.projectId,
  664. this.historyId,
  665. updates,
  666. this.extendLock
  667. )
  668. expect(expandedUpdates).to.deep.equal([])
  669. expect(this.extendLock).to.have.been.called
  670. })
  671. it('does not remove and re-add editable files if there is a binary file with same hash', async function () {
  672. const binaryFile = {
  673. file: Object().toString(),
  674. // The paths in the resyncProjectStructureUpdate must have a leading slash ('/')
  675. // The other unit tests in this file are incorrectly missing the leading slash.
  676. // The leading slash is present in web where the paths are created with
  677. // ProjectEntityHandler.getAllEntitiesFromProject in ProjectEntityUpdateHandler.resyncProjectHistory.
  678. path: '/' + this.persistedDoc.path,
  679. url: 'filestore/12345',
  680. _hash: 'abcdef',
  681. }
  682. this.fileMap['main.tex'].data = { hash: 'abcdef' }
  683. const updates = [
  684. resyncProjectStructureUpdate([], [binaryFile, this.persistedFile]),
  685. ]
  686. const expandedUpdates =
  687. await this.SyncManager.promises.expandSyncUpdates(
  688. this.projectId,
  689. this.historyId,
  690. updates,
  691. this.extendLock
  692. )
  693. expect(expandedUpdates).to.deep.equal([])
  694. expect(this.extendLock).to.have.been.called
  695. })
  696. it('removes and re-adds binary files if they do not have same hash', async function () {
  697. const persistedFileWithNewContent = {
  698. _hash: 'anotherhashvalue',
  699. hello: 'world',
  700. path: '1.png',
  701. url: 'filestore-new-url',
  702. }
  703. const updates = [
  704. resyncProjectStructureUpdate(
  705. [this.persistedDoc],
  706. [persistedFileWithNewContent]
  707. ),
  708. ]
  709. const expandedUpdates =
  710. await this.SyncManager.promises.expandSyncUpdates(
  711. this.projectId,
  712. this.historyId,
  713. updates,
  714. this.extendLock
  715. )
  716. expect(expandedUpdates).to.deep.equal([
  717. {
  718. pathname: persistedFileWithNewContent.path,
  719. new_pathname: '',
  720. meta: {
  721. resync: true,
  722. ts: TIMESTAMP,
  723. origin: { kind: 'history-resync' },
  724. },
  725. },
  726. {
  727. pathname: persistedFileWithNewContent.path,
  728. file: persistedFileWithNewContent.file,
  729. url: persistedFileWithNewContent.url,
  730. meta: {
  731. resync: true,
  732. ts: TIMESTAMP,
  733. origin: { kind: 'history-resync' },
  734. },
  735. },
  736. ])
  737. expect(this.extendLock).to.have.been.called
  738. })
  739. it('preserves other updates', async function () {
  740. const update = { mock: 'update' }
  741. const updates = [
  742. update,
  743. resyncProjectStructureUpdate(
  744. [this.persistedDoc],
  745. [this.persistedFile]
  746. ),
  747. ]
  748. const expandedUpdates =
  749. await this.SyncManager.promises.expandSyncUpdates(
  750. this.projectId,
  751. this.historyId,
  752. updates,
  753. this.extendLock
  754. )
  755. expect(expandedUpdates).to.deep.equal([update])
  756. expect(this.extendLock).to.have.been.called
  757. })
  758. })
  759. describe('expanding doc contents sync updates', function () {
  760. it('returns errors from diffAsShareJsOps', async function () {
  761. const diffError = new Error('test')
  762. this.UpdateCompressor.diffAsShareJsOps.throws(diffError)
  763. const updates = [
  764. resyncProjectStructureUpdate(
  765. [this.persistedDoc],
  766. [this.persistedFile]
  767. ),
  768. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent),
  769. ]
  770. await expect(
  771. this.SyncManager.promises.expandSyncUpdates(
  772. this.projectId,
  773. this.historyId,
  774. updates,
  775. this.extendLock
  776. )
  777. ).to.be.rejectedWith(diffError)
  778. expect(this.extendLock).to.have.been.called
  779. })
  780. it('handles an update for a file that is missing from the snapshot', async function () {
  781. const updates = [docContentSyncUpdate('not-in-snapshot.txt', 'test')]
  782. await expect(
  783. this.SyncManager.promises.expandSyncUpdates(
  784. this.projectId,
  785. this.historyId,
  786. updates,
  787. this.extendLock
  788. )
  789. ).to.be.rejectedWith('unrecognised file: not in snapshot')
  790. })
  791. it('queues nothing for in docs whose contents is in sync', async function () {
  792. const updates = [
  793. resyncProjectStructureUpdate(
  794. [this.persistedDoc],
  795. [this.persistedFile]
  796. ),
  797. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent),
  798. ]
  799. this.UpdateCompressor.diffAsShareJsOps.returns([])
  800. const expandedUpdates =
  801. await this.SyncManager.promises.expandSyncUpdates(
  802. this.projectId,
  803. this.historyId,
  804. updates,
  805. this.extendLock
  806. )
  807. expect(expandedUpdates).to.deep.equal([])
  808. expect(this.extendLock).to.have.been.called
  809. })
  810. it('queues text updates for docs whose contents is out of sync', async function () {
  811. const updates = [
  812. resyncProjectStructureUpdate(
  813. [this.persistedDoc],
  814. [this.persistedFile]
  815. ),
  816. docContentSyncUpdate(
  817. this.persistedDoc,
  818. 'the fox jumps over the lazy dog'
  819. ),
  820. ]
  821. this.UpdateCompressor.diffAsShareJsOps.returns([
  822. { d: 'quick brown ', p: 4 },
  823. ])
  824. const expandedUpdates =
  825. await this.SyncManager.promises.expandSyncUpdates(
  826. this.projectId,
  827. this.historyId,
  828. updates,
  829. this.extendLock
  830. )
  831. expect(expandedUpdates).to.deep.equal([
  832. {
  833. doc: this.persistedDoc.doc,
  834. op: [{ d: 'quick brown ', p: 4 }],
  835. meta: {
  836. pathname: this.persistedDoc.path,
  837. doc_length: this.persistedDocContent.length,
  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('queues text updates for docs created by project structure sync', async function () {
  847. this.UpdateCompressor.diffAsShareJsOps.returns([{ i: 'a', p: 0 }])
  848. const newDoc = {
  849. path: 'another.tex',
  850. doc: new ObjectId().toString(),
  851. }
  852. const newDocContent = 'a'
  853. const updates = [
  854. resyncProjectStructureUpdate(
  855. [this.persistedDoc, newDoc],
  856. [this.persistedFile]
  857. ),
  858. docContentSyncUpdate(newDoc, newDocContent),
  859. ]
  860. const expandedUpdates =
  861. await this.SyncManager.promises.expandSyncUpdates(
  862. this.projectId,
  863. this.historyId,
  864. updates,
  865. this.extendLock
  866. )
  867. expect(expandedUpdates).to.deep.equal([
  868. {
  869. pathname: newDoc.path,
  870. doc: newDoc.doc,
  871. docLines: '',
  872. meta: {
  873. resync: true,
  874. ts: TIMESTAMP,
  875. origin: { kind: 'history-resync' },
  876. },
  877. },
  878. {
  879. doc: newDoc.doc,
  880. op: [{ i: 'a', p: 0 }],
  881. meta: {
  882. pathname: newDoc.path,
  883. doc_length: 0,
  884. resync: true,
  885. ts: TIMESTAMP,
  886. origin: { kind: 'history-resync' },
  887. },
  888. },
  889. ])
  890. expect(this.extendLock).to.have.been.called
  891. })
  892. it('skips text updates for docs when hashes match', async function () {
  893. this.fileMap['main.tex'].getHash.returns('special-hash')
  894. this.HashManager._getBlobHashFromString.returns('special-hash')
  895. const updates = [
  896. resyncProjectStructureUpdate(
  897. [this.persistedDoc],
  898. [this.persistedFile]
  899. ),
  900. docContentSyncUpdate(this.persistedDoc, 'hello'),
  901. ]
  902. const expandedUpdates =
  903. await this.SyncManager.promises.expandSyncUpdates(
  904. this.projectId,
  905. this.historyId,
  906. updates,
  907. this.extendLock
  908. )
  909. expect(expandedUpdates).to.deep.equal([])
  910. expect(this.extendLock).to.have.been.called
  911. })
  912. it('computes text updates for docs when hashes differ', async function () {
  913. this.fileMap['main.tex'].getHash.returns('first-hash')
  914. this.HashManager._getBlobHashFromString.returns('second-hash')
  915. this.UpdateCompressor.diffAsShareJsOps.returns([
  916. { i: 'test diff', p: 0 },
  917. ])
  918. const updates = [
  919. resyncProjectStructureUpdate(
  920. [this.persistedDoc],
  921. [this.persistedFile]
  922. ),
  923. docContentSyncUpdate(this.persistedDoc, 'hello'),
  924. ]
  925. const expandedUpdates =
  926. await this.SyncManager.promises.expandSyncUpdates(
  927. this.projectId,
  928. this.historyId,
  929. updates,
  930. this.extendLock
  931. )
  932. expect(expandedUpdates).to.deep.equal([
  933. {
  934. doc: this.persistedDoc.doc,
  935. op: [{ i: 'test diff', p: 0 }],
  936. meta: {
  937. pathname: this.persistedDoc.path,
  938. doc_length: this.persistedDocContent.length,
  939. resync: true,
  940. ts: TIMESTAMP,
  941. origin: { kind: 'history-resync' },
  942. },
  943. },
  944. ])
  945. expect(this.extendLock).to.have.been.called
  946. })
  947. describe('for docs whose contents is out of sync', function () {
  948. beforeEach(async function () {
  949. const updates = [
  950. resyncProjectStructureUpdate(
  951. [this.persistedDoc],
  952. [this.persistedFile]
  953. ),
  954. docContentSyncUpdate(this.persistedDoc, 'the quick brown fox'),
  955. ]
  956. this.UpdateCompressor.diffAsShareJsOps.returns([
  957. { d: ' jumps over the lazy dog', p: 19 },
  958. ])
  959. this.expandedUpdates =
  960. await this.SyncManager.promises.expandSyncUpdates(
  961. this.projectId,
  962. this.historyId,
  963. updates,
  964. this.extendLock
  965. )
  966. })
  967. it('loads content from the history service when needed', function () {
  968. expect(this.expandedUpdates).to.deep.equal([
  969. {
  970. doc: this.persistedDoc.doc,
  971. op: [{ d: ' jumps over the lazy dog', p: 19 }],
  972. meta: {
  973. pathname: this.persistedDoc.path,
  974. doc_length: this.persistedDocContent.length,
  975. resync: true,
  976. ts: TIMESTAMP,
  977. origin: { kind: 'history-resync' },
  978. },
  979. },
  980. ])
  981. expect(this.extendLock).to.have.been.called
  982. })
  983. })
  984. })
  985. describe('syncing comments', function () {
  986. beforeEach(function () {
  987. this.loadedSnapshotDoc
  988. .getComments()
  989. .add(new Comment('comment1', [new Range(4, 5)]))
  990. this.loadedSnapshotDoc
  991. .getComments()
  992. .add(new Comment('comment2', [new Range(10, 5)], true))
  993. this.comments = [
  994. makeComment('comment1', 4, 'quick'),
  995. makeComment('comment2', 10, 'brown'),
  996. ]
  997. this.resolvedCommentIds = ['comment2']
  998. })
  999. it('does nothing if comments have not changed', async function () {
  1000. const updates = [
  1001. docContentSyncUpdate(
  1002. this.persistedDoc,
  1003. this.persistedDocContent,
  1004. {
  1005. comments: this.comments,
  1006. },
  1007. this.resolvedCommentIds
  1008. ),
  1009. ]
  1010. const expandedUpdates =
  1011. await this.SyncManager.promises.expandSyncUpdates(
  1012. this.projectId,
  1013. this.historyId,
  1014. updates,
  1015. this.extendLock
  1016. )
  1017. expect(expandedUpdates).to.deep.equal([])
  1018. })
  1019. it('adds missing comments', async function () {
  1020. this.comments.push(makeComment('comment3', 20, 'jumps'))
  1021. const updates = [
  1022. docContentSyncUpdate(
  1023. this.persistedDoc,
  1024. this.persistedDocContent,
  1025. {
  1026. comments: this.comments,
  1027. },
  1028. this.resolvedCommentIds
  1029. ),
  1030. ]
  1031. const expandedUpdates =
  1032. await this.SyncManager.promises.expandSyncUpdates(
  1033. this.projectId,
  1034. this.historyId,
  1035. updates,
  1036. this.extendLock
  1037. )
  1038. expect(expandedUpdates).to.deep.equal([
  1039. {
  1040. doc: this.persistedDoc.doc,
  1041. op: [
  1042. {
  1043. c: 'jumps',
  1044. p: 20,
  1045. t: 'comment3',
  1046. resolved: false,
  1047. },
  1048. ],
  1049. meta: {
  1050. origin: {
  1051. kind: 'history-resync',
  1052. },
  1053. pathname: this.persistedDoc.path,
  1054. resync: true,
  1055. ts: TIMESTAMP,
  1056. doc_length: this.persistedDocContent.length,
  1057. },
  1058. },
  1059. ])
  1060. })
  1061. it('deletes extra comments', async function () {
  1062. this.comments.splice(0, 1)
  1063. const updates = [
  1064. docContentSyncUpdate(
  1065. this.persistedDoc,
  1066. this.persistedDocContent,
  1067. {
  1068. comments: this.comments,
  1069. },
  1070. this.resolvedCommentIds
  1071. ),
  1072. ]
  1073. const expandedUpdates =
  1074. await this.SyncManager.promises.expandSyncUpdates(
  1075. this.projectId,
  1076. this.historyId,
  1077. updates,
  1078. this.extendLock
  1079. )
  1080. expect(expandedUpdates).to.deep.equal([
  1081. {
  1082. pathname: this.persistedDoc.path,
  1083. deleteComment: 'comment1',
  1084. meta: {
  1085. origin: {
  1086. kind: 'history-resync',
  1087. },
  1088. resync: true,
  1089. ts: TIMESTAMP,
  1090. },
  1091. },
  1092. ])
  1093. })
  1094. it('updates comments when ranges differ', async function () {
  1095. this.comments[1] = makeComment('comment2', 16, 'fox')
  1096. const updates = [
  1097. docContentSyncUpdate(
  1098. this.persistedDoc,
  1099. this.persistedDocContent,
  1100. {
  1101. comments: this.comments,
  1102. },
  1103. this.resolvedCommentIds
  1104. ),
  1105. ]
  1106. const expandedUpdates =
  1107. await this.SyncManager.promises.expandSyncUpdates(
  1108. this.projectId,
  1109. this.historyId,
  1110. updates,
  1111. this.extendLock
  1112. )
  1113. expect(expandedUpdates).to.deep.equal([
  1114. {
  1115. doc: 'doc-id',
  1116. op: [
  1117. {
  1118. c: 'fox',
  1119. p: 16,
  1120. t: 'comment2',
  1121. resolved: true,
  1122. },
  1123. ],
  1124. meta: {
  1125. origin: {
  1126. kind: 'history-resync',
  1127. },
  1128. resync: true,
  1129. ts: TIMESTAMP,
  1130. pathname: this.persistedDoc.path,
  1131. doc_length: this.persistedDocContent.length,
  1132. },
  1133. },
  1134. ])
  1135. })
  1136. it('sets the resolved state when it differs', async function () {
  1137. this.resolvedCommentIds = ['comment1']
  1138. const updates = [
  1139. docContentSyncUpdate(
  1140. this.persistedDoc,
  1141. this.persistedDocContent,
  1142. {
  1143. comments: this.comments,
  1144. },
  1145. this.resolvedCommentIds
  1146. ),
  1147. ]
  1148. const expandedUpdates =
  1149. await this.SyncManager.promises.expandSyncUpdates(
  1150. this.projectId,
  1151. this.historyId,
  1152. updates,
  1153. this.extendLock
  1154. )
  1155. expect(expandedUpdates).to.have.deep.members([
  1156. {
  1157. pathname: this.persistedDoc.path,
  1158. commentId: 'comment1',
  1159. resolved: true,
  1160. meta: {
  1161. origin: {
  1162. kind: 'history-resync',
  1163. },
  1164. resync: true,
  1165. ts: TIMESTAMP,
  1166. },
  1167. },
  1168. {
  1169. pathname: this.persistedDoc.path,
  1170. commentId: 'comment2',
  1171. resolved: false,
  1172. meta: {
  1173. origin: {
  1174. kind: 'history-resync',
  1175. },
  1176. resync: true,
  1177. ts: TIMESTAMP,
  1178. },
  1179. },
  1180. ])
  1181. })
  1182. it('treats zero length comments as detached comments', async function () {
  1183. this.loadedSnapshotDoc.getComments().add(new Comment('comment1', []))
  1184. this.comments = [
  1185. makeComment('comment1', 16, ''),
  1186. makeComment('comment2', 10, 'brown'),
  1187. ]
  1188. this.resolvedCommentIds = ['comment2']
  1189. const updates = [
  1190. docContentSyncUpdate(
  1191. this.persistedDoc,
  1192. this.persistedDocContent,
  1193. {
  1194. comments: this.comments,
  1195. },
  1196. this.resolvedCommentIds
  1197. ),
  1198. ]
  1199. const expandedUpdates =
  1200. await this.SyncManager.promises.expandSyncUpdates(
  1201. this.projectId,
  1202. this.historyId,
  1203. updates,
  1204. this.extendLock
  1205. )
  1206. expect(expandedUpdates).to.deep.equal([])
  1207. })
  1208. it('adjusts comment positions when the underlying text has changed', async function () {
  1209. const updates = [
  1210. docContentSyncUpdate(
  1211. this.persistedDoc,
  1212. 'quick brown fox',
  1213. {
  1214. comments: [
  1215. makeComment('comment1', 0, 'quick'),
  1216. makeComment('comment2', 12, 'fox'),
  1217. ],
  1218. },
  1219. this.resolvedCommentIds
  1220. ),
  1221. ]
  1222. this.UpdateCompressor.diffAsShareJsOps.returns([
  1223. { d: 'the ', p: 0 },
  1224. { d: ' jumps over the lazy dog', p: 15 },
  1225. ])
  1226. const expandedUpdates =
  1227. await this.SyncManager.promises.expandSyncUpdates(
  1228. this.projectId,
  1229. this.historyId,
  1230. updates,
  1231. this.extendLock
  1232. )
  1233. expect(expandedUpdates).to.deep.equal([
  1234. {
  1235. doc: this.persistedDoc.doc,
  1236. op: [
  1237. { d: 'the ', p: 0 },
  1238. { d: ' jumps over the lazy dog', p: 15 },
  1239. ],
  1240. meta: {
  1241. pathname: this.persistedDoc.path,
  1242. doc_length: this.persistedDocContent.length,
  1243. resync: true,
  1244. ts: TIMESTAMP,
  1245. origin: { kind: 'history-resync' },
  1246. },
  1247. },
  1248. {
  1249. doc: this.persistedDoc.doc,
  1250. op: [
  1251. {
  1252. c: 'fox',
  1253. p: 12,
  1254. t: 'comment2',
  1255. resolved: true,
  1256. },
  1257. ],
  1258. meta: {
  1259. origin: {
  1260. kind: 'history-resync',
  1261. },
  1262. pathname: this.persistedDoc.path,
  1263. resync: true,
  1264. ts: TIMESTAMP,
  1265. doc_length: 'quick brown fox'.length,
  1266. },
  1267. },
  1268. ])
  1269. })
  1270. })
  1271. describe('syncing tracked changes', function () {
  1272. beforeEach(function () {
  1273. this.loadedSnapshotDoc.getTrackedChanges().add(
  1274. new TrackedChange(new Range(4, 6), {
  1275. type: 'delete',
  1276. userId: USER_ID,
  1277. ts: new Date(TIMESTAMP),
  1278. })
  1279. )
  1280. this.loadedSnapshotDoc.getTrackedChanges().add(
  1281. new TrackedChange(new Range(10, 6), {
  1282. type: 'insert',
  1283. userId: USER_ID,
  1284. ts: new Date(TIMESTAMP),
  1285. })
  1286. )
  1287. this.loadedSnapshotDoc.getTrackedChanges().add(
  1288. new TrackedChange(new Range(20, 6), {
  1289. type: 'delete',
  1290. userId: USER_ID,
  1291. ts: new Date(TIMESTAMP),
  1292. })
  1293. )
  1294. this.loadedSnapshotDoc.getTrackedChanges().add(
  1295. new TrackedChange(new Range(40, 3), {
  1296. type: 'insert',
  1297. userId: USER_ID,
  1298. ts: new Date(TIMESTAMP),
  1299. })
  1300. )
  1301. this.changes = [
  1302. makeTrackedChange('td1', { p: 4, d: 'quick ' }),
  1303. makeTrackedChange('ti1', { p: 4, hpos: 10, i: 'brown ' }),
  1304. makeTrackedChange('td2', { p: 14, hpos: 20, d: 'jumps ' }),
  1305. makeTrackedChange('ti2', { p: 28, hpos: 40, i: 'dog' }),
  1306. ]
  1307. })
  1308. it('does nothing if tracked changes have not changed', async function () {
  1309. const updates = [
  1310. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
  1311. changes: this.changes,
  1312. }),
  1313. ]
  1314. const expandedUpdates =
  1315. await this.SyncManager.promises.expandSyncUpdates(
  1316. this.projectId,
  1317. this.historyId,
  1318. updates,
  1319. this.extendLock
  1320. )
  1321. expect(expandedUpdates).to.deep.equal([])
  1322. })
  1323. it('adds new tracked changes', async function () {
  1324. this.changes.splice(
  1325. 3,
  1326. 0,
  1327. makeTrackedChange('td3', { p: 29, hpos: 35, d: 'lazy ' })
  1328. )
  1329. const updates = [
  1330. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
  1331. changes: this.changes,
  1332. }),
  1333. ]
  1334. const expandedUpdates =
  1335. await this.SyncManager.promises.expandSyncUpdates(
  1336. this.projectId,
  1337. this.historyId,
  1338. updates,
  1339. this.extendLock
  1340. )
  1341. expect(expandedUpdates).to.deep.equal([
  1342. {
  1343. doc: this.persistedDoc.doc,
  1344. op: [
  1345. {
  1346. p: 35,
  1347. r: 'lazy ',
  1348. tracking: {
  1349. type: 'delete',
  1350. userId: USER_ID,
  1351. ts: TIMESTAMP,
  1352. },
  1353. },
  1354. ],
  1355. meta: {
  1356. origin: {
  1357. kind: 'history-resync',
  1358. },
  1359. pathname: this.persistedDoc.path,
  1360. resync: true,
  1361. ts: TIMESTAMP,
  1362. doc_length: this.persistedDocContent.length,
  1363. },
  1364. },
  1365. ])
  1366. })
  1367. it('removes extra tracked changes', async function () {
  1368. this.changes.splice(0, 1)
  1369. const updates = [
  1370. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
  1371. changes: this.changes,
  1372. }),
  1373. ]
  1374. const expandedUpdates =
  1375. await this.SyncManager.promises.expandSyncUpdates(
  1376. this.projectId,
  1377. this.historyId,
  1378. updates,
  1379. this.extendLock
  1380. )
  1381. expect(expandedUpdates).to.deep.equal([
  1382. {
  1383. doc: this.persistedDoc.doc,
  1384. op: [
  1385. {
  1386. p: 4,
  1387. r: 'quick ',
  1388. tracking: { type: 'none' },
  1389. },
  1390. ],
  1391. meta: {
  1392. origin: {
  1393. kind: 'history-resync',
  1394. },
  1395. pathname: this.persistedDoc.path,
  1396. resync: true,
  1397. ts: TIMESTAMP,
  1398. doc_length: this.persistedDocContent.length,
  1399. },
  1400. },
  1401. ])
  1402. })
  1403. it('handles overlapping ranges', async function () {
  1404. this.changes = [
  1405. makeTrackedChange('ti1', { p: 0, i: 'the quic' }),
  1406. makeTrackedChange('td1', { p: 8, d: 'k br' }),
  1407. makeTrackedChange('ti2', { p: 14, hpos: 23, i: 'ps over' }),
  1408. ]
  1409. const updates = [
  1410. docContentSyncUpdate(this.persistedDoc, this.persistedDocContent, {
  1411. changes: this.changes,
  1412. }),
  1413. ]
  1414. const expandedUpdates =
  1415. await this.SyncManager.promises.expandSyncUpdates(
  1416. this.projectId,
  1417. this.historyId,
  1418. updates,
  1419. this.extendLock
  1420. )
  1421. // Before: the [quick ][brown ] fox [jumps ]over the lazy dog
  1422. // After: [the quic][k br]own fox jum[ps over] the lazy dog
  1423. expect(expandedUpdates).to.deep.equal([
  1424. {
  1425. doc: this.persistedDoc.doc,
  1426. op: [
  1427. {
  1428. p: 0,
  1429. r: 'the quic',
  1430. tracking: { type: 'insert', userId: USER_ID, ts: TIMESTAMP },
  1431. },
  1432. {
  1433. p: 10,
  1434. r: 'br',
  1435. tracking: { type: 'delete', userId: USER_ID, ts: TIMESTAMP },
  1436. },
  1437. {
  1438. p: 12,
  1439. r: 'own ',
  1440. tracking: { type: 'none' },
  1441. },
  1442. {
  1443. p: 20,
  1444. r: 'jum',
  1445. tracking: { type: 'none' },
  1446. },
  1447. {
  1448. p: 23,
  1449. r: 'ps over',
  1450. tracking: { type: 'insert', userId: USER_ID, ts: TIMESTAMP },
  1451. },
  1452. {
  1453. p: 40,
  1454. r: 'dog',
  1455. tracking: { type: 'none' },
  1456. },
  1457. ],
  1458. meta: {
  1459. origin: { kind: 'history-resync' },
  1460. pathname: this.persistedDoc.path,
  1461. resync: true,
  1462. ts: TIMESTAMP,
  1463. doc_length: this.persistedDocContent.length,
  1464. },
  1465. },
  1466. ])
  1467. })
  1468. it('adjusts tracked change positions when the underlying text has changed', async function () {
  1469. const updates = [
  1470. docContentSyncUpdate(
  1471. this.persistedDoc,
  1472. 'every fox jumps over the lazy dog',
  1473. {
  1474. changes: [
  1475. makeTrackedChange('ti1', { p: 5, i: ' ' }), // the space after every is still a tracked insert
  1476. makeTrackedChange('td2', { p: 10, d: 'jumps ' }),
  1477. makeTrackedChange('ti2', { p: 24, hpos: 30, i: 'dog' }),
  1478. ],
  1479. },
  1480. this.resolvedCommentIds
  1481. ),
  1482. ]
  1483. this.UpdateCompressor.diffAsShareJsOps.returns([
  1484. { d: 'the quick brown', p: 0 },
  1485. { i: 'every', p: 0 },
  1486. ])
  1487. const expandedUpdates =
  1488. await this.SyncManager.promises.expandSyncUpdates(
  1489. this.projectId,
  1490. this.historyId,
  1491. updates,
  1492. this.extendLock
  1493. )
  1494. expect(expandedUpdates).to.deep.equal([
  1495. {
  1496. doc: this.persistedDoc.doc,
  1497. op: [
  1498. { d: 'the quick brown', p: 0 },
  1499. { i: 'every', p: 0 },
  1500. ],
  1501. meta: {
  1502. pathname: this.persistedDoc.path,
  1503. doc_length: this.persistedDocContent.length,
  1504. resync: true,
  1505. ts: TIMESTAMP,
  1506. origin: { kind: 'history-resync' },
  1507. },
  1508. },
  1509. ])
  1510. })
  1511. })
  1512. })
  1513. })