SyncManagerTests.js 44 KB

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