Files
Malik Glossop 863e33af78 Merge pull request #35982 from overleaf/mg-history-ot-comments
HistoryOT - Fix comments being dropped and crashing on detached ranges

GitOrigin-RevId: 0906a55a51a6fda9ac828efcbe72989573161261
2026-09-17 12:03:46 +00:00

341 lines
11 KiB
JavaScript

const sinon = require('sinon')
const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const {
StringFileData,
AddCommentOperation,
Range,
TextOperation,
} = require('overleaf-editor-core')
const MODULE_PATH = '../../../../app/js/HistoryOTUpdateManager.js'
describe('HistoryOTUpdateManager', function () {
beforeEach(function () {
this.project_id = 'project-id-123'
this.doc_id = 'document-id-123'
this.pathname = '/a/b/c.tex'
this.Metrics = { inc: sinon.stub() }
this.Profiler = class Profiler {}
this.Profiler.prototype.log = sinon.stub().returns({ end: sinon.stub() })
this.Profiler.prototype.end = sinon.stub()
this.DocumentManager = {
promises: { getDoc: sinon.stub() },
}
this.RedisManager = {
promises: {
updateDocument: sinon.stub().resolves(),
getPreviousDocOps: sinon.stub().resolves([]),
recordProjectNotificationTimestamp: sinon.stub().resolves(),
},
}
this.ProjectHistoryRedisManager = {
promises: { queueOps: sinon.stub().resolves(1) },
}
this.HistoryManager = { recordAndFlushHistoryOps: sinon.stub() }
this.RealTimeRedisManager = { sendData: sinon.stub() }
this.HistoryOTUpdateManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'./Profiler': this.Profiler,
'./DocumentManager': this.DocumentManager,
'./RedisManager': this.RedisManager,
'./Metrics': this.Metrics,
'./ProjectHistoryRedisManager': this.ProjectHistoryRedisManager,
'./HistoryManager': this.HistoryManager,
'./RealTimeRedisManager': this.RealTimeRedisManager,
},
})
})
const savedFile = redisManager => {
// updateDocument(projectId, docId, file.toRaw(), version, ...)
const raw = redisManager.promises.updateDocument.firstCall.args[2]
return StringFileData.fromRaw(raw)
}
describe('tryApplyUpdate with a composed multi-op update', function () {
it('applies every op in the array, not just the first', async function () {
// Doc after a cut: " world" with comment "c1" detached (empty ranges)
const doc = new StringFileData('hello world')
new AddCommentOperation('c1', [new Range(0, 5)]).apply(doc)
const cut = new TextOperation()
cut.remove(5)
cut.retain(6)
doc.edit(cut)
expect(doc.getComments().getComment('c1').isEmpty()).to.be.true
this.DocumentManager.promises.getDoc.resolves({
lines: doc.toRaw(),
version: 1,
pathname: this.pathname,
type: 'history-ot',
})
// Paste "hello" at the end, then re-home the comment onto it.
const paste = new TextOperation()
paste.retain(6)
paste.insert('hello')
const readd = new AddCommentOperation('c1', [new Range(6, 5)])
const update = {
doc: this.doc_id,
op: [paste.toJSON(), readd.toJSON()],
v: 1,
meta: {},
}
await this.HistoryOTUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
update
)
const file = savedFile(this.RedisManager)
expect(file.getContent()).to.equal(' worldhello')
const comment = file.getComments().getComment('c1')
expect(comment.ranges).to.have.length(1)
expect(comment.ranges[0].pos).to.equal(6)
expect(comment.ranges[0].length).to.equal(5)
})
})
describe('tryApplyUpdate with a concurrent update to transform against', function () {
it('transforms and applies every op when behind the latest version', async function () {
// Server is at v2: a previous op inserted "ZZ" at the end.
const doc = new StringFileData('hello worldZZ')
this.DocumentManager.promises.getDoc.resolves({
lines: doc.toRaw(),
version: 2,
pathname: this.pathname,
type: 'history-ot',
})
const previous = new TextOperation()
previous.retain(11)
previous.insert('ZZ')
this.RedisManager.promises.getPreviousDocOps.resolves([
{ doc: this.doc_id, op: [previous.toJSON()], v: 1, meta: {} },
])
// Our update (based on v1) inserts "X" at the start and comments it.
const insert = new TextOperation()
insert.insert('X')
insert.retain(11)
const addComment = new AddCommentOperation('c1', [new Range(0, 1)])
const update = {
doc: this.doc_id,
op: [insert.toJSON(), addComment.toJSON()],
v: 1,
meta: {},
}
await this.HistoryOTUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
update
)
const file = savedFile(this.RedisManager)
expect(file.getContent()).to.equal('Xhello worldZZ')
const comment = file.getComments().getComment('c1')
expect(comment.ranges).to.have.length(1)
expect(comment.ranges[0].pos).to.equal(0)
expect(comment.ranges[0].length).to.equal(1)
})
it('rebases an op to a no-op when a concurrent update already applied it', async function () {
// Server at v2: a concurrent client already added the same comment.
const doc = new StringFileData('hello world')
new AddCommentOperation('c1', [new Range(0, 5)]).apply(doc)
this.DocumentManager.promises.getDoc.resolves({
lines: doc.toRaw(),
version: 2,
pathname: this.pathname,
type: 'history-ot',
})
const concurrent = new AddCommentOperation('c1', [new Range(0, 5)])
this.RedisManager.promises.getPreviousDocOps.resolves([
{ doc: this.doc_id, op: [concurrent.toJSON()], v: 1, meta: {} },
])
// Our update (based on v1) adds the identical comment.
const addComment = new AddCommentOperation('c1', [new Range(0, 5)])
const update = {
doc: this.doc_id,
op: [addComment.toJSON()],
v: 1,
meta: {},
}
await this.HistoryOTUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
update
)
expect(update.op).to.deep.equal([{ noOp: true }])
const file = savedFile(this.RedisManager)
expect(file.getContent()).to.equal('hello world')
const comment = file.getComments().getComment('c1')
expect(comment.ranges).to.have.length(1)
expect(comment.ranges[0].pos).to.equal(0)
expect(comment.ranges[0].length).to.equal(5)
})
it('threads the concurrent op through the client op-list', async function () {
// Server advanced to v2 by inserting "Z" at position 6 of "hello world".
const doc = new StringFileData('hello Zworld')
this.DocumentManager.promises.getDoc.resolves({
lines: doc.toRaw(),
version: 2,
pathname: this.pathname,
type: 'history-ot',
})
const previous = new TextOperation()
previous.retain(6)
previous.insert('Z')
previous.retain(5)
this.RedisManager.promises.getPreviousDocOps.resolves([
{ doc: this.doc_id, op: [previous.toJSON()], v: 1, meta: {} },
])
// Our update (v1): insert "AAA" at the start, then comment "hello" (now at
// [3, 8)). The "Z" insert must be threaded past our "AAA" (to pos 9, after
// the comment) so it leaves the comment alone; un-threaded (pos 6) it would
// wrongly extend the comment.
const insert = new TextOperation()
insert.insert('AAA')
insert.retain(11)
const addComment = new AddCommentOperation('c1', [new Range(3, 5)])
const update = {
doc: this.doc_id,
op: [insert.toJSON(), addComment.toJSON()],
v: 1,
meta: {},
}
await this.HistoryOTUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
update
)
const file = savedFile(this.RedisManager)
expect(file.getContent()).to.equal('AAAhello Zworld')
const comment = file.getComments().getComment('c1')
expect(comment.ranges).to.have.length(1)
expect(comment.ranges[0].pos).to.equal(3)
expect(comment.ranges[0].length).to.equal(5)
})
it('rebases a [text, comment, text] update against a [text, text, comment] concurrent update', async function () {
// Concurrent update (theirs), v1 -> v2 on "hello world": append "1",
// append "2", then comment "12". Edits stay at the end.
const doc = new StringFileData('hello world')
const t0 = new TextOperation()
t0.retain(11)
t0.insert('1')
doc.edit(t0) // "hello world1"
const t1 = new TextOperation()
t1.retain(12)
t1.insert('2')
doc.edit(t1) // "hello world12"
const theirComment = new AddCommentOperation('sc', [new Range(11, 2)])
theirComment.apply(doc) // comments "12"
this.DocumentManager.promises.getDoc.resolves({
lines: doc.toRaw(),
version: 2,
pathname: this.pathname,
type: 'history-ot',
})
this.RedisManager.promises.getPreviousDocOps.resolves([
{
doc: this.doc_id,
op: [t0.toJSON(), t1.toJSON(), theirComment.toJSON()],
v: 1,
meta: {},
},
])
// Our update (ours), based on v1: prepend "A", comment "hello", prepend
// "B". Edits stay at the start, so nothing collides with theirs.
const o0 = new TextOperation()
o0.insert('A')
o0.retain(11) // "Ahello world"
const ourComment = new AddCommentOperation('c1', [new Range(1, 5)]) // "hello"
const o2 = new TextOperation()
o2.insert('B')
o2.retain(12) // "BAhello world"
const update = {
doc: this.doc_id,
op: [o0.toJSON(), ourComment.toJSON(), o2.toJSON()],
v: 1,
meta: {},
}
await this.HistoryOTUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
update
)
const file = savedFile(this.RedisManager)
const content = file.getContent()
expect(content).to.equal('BAhello world12')
const comment = file.getComments().getComment('c1')
expect(comment.ranges).to.have.length(1)
expect(comment.ranges[0].pos).to.equal(2)
expect(comment.ranges[0].length).to.equal(5)
expect(content.slice(2, 7)).to.equal('hello')
})
})
describe('tryApplyUpdate with a duplicate update', function () {
it('does not apply or persist when dupIfSource matches', async function () {
const doc = new StringFileData('hello world')
this.DocumentManager.promises.getDoc.resolves({
lines: doc.toRaw(),
version: 2,
pathname: this.pathname,
type: 'history-ot',
})
const previous = new TextOperation()
previous.retain(11)
previous.insert('!')
this.RedisManager.promises.getPreviousDocOps.resolves([
{
doc: this.doc_id,
op: [previous.toJSON()],
v: 1,
meta: { source: 'source-1' },
},
])
const addComment = new AddCommentOperation('c1', [new Range(0, 5)])
const update = {
doc: this.doc_id,
op: [addComment.toJSON()],
v: 1,
meta: {},
dupIfSource: ['source-1'],
}
await this.HistoryOTUpdateManager.applyUpdate(
this.project_id,
this.doc_id,
update
)
expect(update.dup).to.be.true
expect(this.RedisManager.promises.updateDocument.called).to.be.false
expect(this.RealTimeRedisManager.sendData.called).to.be.true
})
})
})