SyncManagerTests.js 54 KB

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