SyncManagerTests.js 70 KB

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