Avoid duplicating a math-closing dollar sign (#11227)

GitOrigin-RevId: ef2ef77e26df59d1af3df6dc664e284d3c70102d
This commit is contained in:
Alf Eaton
2023-01-16 08:41:42 +00:00
committed by Copybot
parent 377a8aed60
commit ee85d948e2
268 changed files with 57782 additions and 0 deletions
@@ -0,0 +1,37 @@
'use strict'
const { expect } = require('chai')
const core = require('..')
const Change = core.Change
const File = core.File
const Operation = core.Operation
describe('Change', function () {
describe('findBlobHashes', function () {
it('finds blob hashes from operations', function () {
const blobHashes = new Set()
const change = Change.fromRaw({
operations: [],
timestamp: '2015-03-05T12:03:53.035Z',
authors: [null],
})
change.findBlobHashes(blobHashes)
expect(blobHashes.size).to.equal(0)
// AddFile with content doesn't have a hash.
change.pushOperation(Operation.addFile('a.txt', File.fromString('a')))
change.findBlobHashes(blobHashes)
expect(blobHashes.size).to.equal(0)
// AddFile with hash should give us a hash.
change.pushOperation(
Operation.addFile('b.txt', File.fromHash(File.EMPTY_FILE_HASH))
)
change.findBlobHashes(blobHashes)
expect(blobHashes.size).to.equal(1)
expect(blobHashes.has(File.EMPTY_FILE_HASH)).to.be.true
})
})
})
@@ -0,0 +1,80 @@
'use strict'
const { expect } = require('chai')
const ot = require('..')
const File = ot.File
const Operation = ot.Operation
const TextOperation = ot.TextOperation
describe('EditFileOperation', function () {
function edit(pathname, textOperationJsonObject) {
return Operation.editFile(
pathname,
TextOperation.fromJSON(textOperationJsonObject)
)
}
describe('canBeComposedWith', function () {
it('on the same file', function () {
const editFileOperation1 = edit('foo.tex', ['x'])
const editFileOperation2 = edit('foo.tex', [1, 'y'])
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
.true
})
it('on different files', function () {
const editFileOperation1 = edit('foo.tex', ['x'])
const editFileOperation2 = edit('bar.tex', ['y'])
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
.false
})
it('with a different type of opperation', function () {
const editFileOperation1 = edit('foo.tex', ['x'])
const editFileOperation2 = Operation.addFile(
'bar.tex',
File.fromString('')
)
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
.false
})
it('with incompatible lengths', function () {
const editFileOperation1 = edit('foo.tex', ['x'])
const editFileOperation2 = edit('foo.tex', [2, 'y'])
expect(editFileOperation1.canBeComposedWith(editFileOperation2)).to.be
.false
})
})
describe('canBeComposedWithForUndo', function () {
it('can', function () {
const editFileOperation1 = edit('foo.tex', ['x'])
const editFileOperation2 = edit('foo.tex', [1, 'y'])
expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to
.be.true
})
it('cannot', function () {
const editFileOperation1 = edit('foo.tex', ['x'])
const editFileOperation2 = edit('foo.tex', ['y', 1, 'z'])
expect(editFileOperation1.canBeComposedWithForUndo(editFileOperation2)).to
.be.false
})
})
describe('compose', function () {
it('composes text operations', function () {
const editFileOperation1 = edit('foo.tex', ['x'])
const editFileOperation2 = edit('foo.tex', [1, 'y'])
const composedFileOperation =
editFileOperation1.compose(editFileOperation2)
const expectedComposedFileOperation = edit('foo.tex', ['xy'])
expect(composedFileOperation).to.deep.equal(expectedComposedFileOperation)
// check that the original operation wasn't modified
expect(editFileOperation1).to.deep.equal(edit('foo.tex', ['x']))
})
})
})
@@ -0,0 +1,94 @@
'use strict'
const { expect } = require('chai')
const FakeBlobStore = require('./support/fake_blob_store')
const ot = require('..')
const File = ot.File
describe('File', function () {
it('can have attached metadata', function () {
// no metadata
let file = File.fromString('foo')
expect(file.getMetadata()).to.eql({})
// metadata passed in at construction time
file = File.fromString('foo', { main: true })
expect(file.getMetadata()).to.eql({ main: true })
// metadata set at runtime
file.setMetadata({ main: false })
expect(file.getMetadata()).to.eql({ main: false })
})
describe('toRaw', function () {
it('returns non-empty metadata', function () {
const metadata = { main: true }
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
expect(file.toRaw()).to.eql({
hash: File.EMPTY_FILE_HASH,
metadata: metadata,
})
delete file.getMetadata().main
expect(file.toRaw()).to.eql({ hash: File.EMPTY_FILE_HASH })
})
it('returns a deep clone of metadata', function () {
const metadata = { externalFile: { id: 123 } }
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
const raw = file.toRaw()
const fileMetadata = file.getMetadata()
const rawMetadata = raw.metadata
expect(rawMetadata).not.to.equal(fileMetadata)
expect(rawMetadata).to.deep.equal(fileMetadata)
})
})
describe('store', function () {
it('does not return empty metadata', function () {
const file = File.fromHash(File.EMPTY_FILE_HASH)
const fakeBlobStore = new FakeBlobStore()
return file.store(fakeBlobStore).then(raw => {
expect(raw).to.eql({ hash: File.EMPTY_FILE_HASH })
})
})
it('returns non-empty metadata', function () {
const metadata = { main: true }
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
const fakeBlobStore = new FakeBlobStore()
return file.store(fakeBlobStore).then(raw => {
expect(raw).to.eql({
hash: File.EMPTY_FILE_HASH,
metadata: metadata,
})
})
})
it('returns a deep clone of metadata', function () {
const metadata = { externalFile: { id: 123 } }
const file = File.fromHash(File.EMPTY_FILE_HASH, metadata)
const fakeBlobStore = new FakeBlobStore()
return file.store(fakeBlobStore).then(raw => {
raw.metadata.externalFile.id = 456
expect(file.getMetadata().externalFile.id).to.equal(123)
})
})
})
describe('with string data', function () {
it('can be created from a string', function () {
const file = File.fromString('foo')
expect(file.getContent()).to.equal('foo')
})
})
describe('with hollow string data', function () {
it('can be cloned', function () {
const file = File.createHollow(null, 0)
expect(file.getStringLength()).to.equal(0)
const clone = file.clone()
expect(clone.getStringLength()).to.equal(0)
})
})
})
@@ -0,0 +1,202 @@
'use strict'
const { expect } = require('chai')
const BPromise = require('bluebird')
const _ = require('lodash')
const ot = require('..')
const File = ot.File
const FileMap = ot.FileMap
describe('FileMap', function () {
function makeTestFile(pathname) {
return File.fromString(pathname)
}
function makeTestFiles(pathnames) {
return _.zipObject(pathnames, _.map(pathnames, makeTestFile))
}
function makeFileMap(pathnames) {
return new FileMap(makeTestFiles(pathnames))
}
it('allows construction with a single file', function () {
makeFileMap(['a'])
})
it('allows folders to differ by case', function () {
expect(() => {
makeFileMap(['a/b', 'A/c'])
}).not.to.throw
expect(() => {
makeFileMap(['a/b/c', 'A/b/d'])
}).not.to.throw
expect(() => {
makeFileMap(['a/b/c', 'a/B/d'])
}).not.to.throw
})
it('does not allow conflicting paths on construct', function () {
expect(() => {
makeFileMap(['a', 'a/b'])
}).to.throw(FileMap.PathnameConflictError)
})
it('detects conflicting paths with characters that sort before /', function () {
const fileMap = makeFileMap(['a', 'a!'])
expect(fileMap.wouldConflict('a/b')).to.be.truthy
})
it('detects conflicting paths', function () {
const fileMap = makeFileMap(['a/b/c'])
expect(fileMap.wouldConflict('a/b/c/d')).to.be.truthy
expect(fileMap.wouldConflict('a')).to.be.truthy
expect(fileMap.wouldConflict('b')).to.be.falsy
expect(fileMap.wouldConflict('a/b')).to.be.truthy
expect(fileMap.wouldConflict('a/c')).to.be.falsy
expect(fileMap.wouldConflict('a/b/c')).to.be.falsy
expect(fileMap.wouldConflict('a/b/d')).to.be.falsy
expect(fileMap.wouldConflict('d/b/c')).to.be.falsy
})
it('allows paths that differ by case', function () {
const fileMap = makeFileMap(['a/b/c'])
expect(fileMap.wouldConflict('a/b/C')).to.be.falsy
expect(fileMap.wouldConflict('A')).to.be.falsy
expect(fileMap.wouldConflict('A/b')).to.be.falsy
expect(fileMap.wouldConflict('a/B')).to.be.falsy
expect(fileMap.wouldConflict('A/B')).to.be.falsy
})
it('does not add a file with a conflicting path', function () {
const fileMap = makeFileMap(['a/b'])
const file = makeTestFile('a/b/c')
expect(() => {
fileMap.addFile('a/b/c', file)
}).to.throw(FileMap.PathnameConflictError)
})
it('does not move a file to a conflicting path', function () {
const fileMap = makeFileMap(['a/b', 'a/c'])
expect(() => {
fileMap.moveFile('a/b', 'a')
}).to.throw(FileMap.PathnameConflictError)
})
it('errors when trying to move a non-existent file', function () {
const fileMap = makeFileMap(['a'])
expect(() => fileMap.moveFile('b', 'a')).to.throw(FileMap.FileNotFoundError)
})
it('moves a file over an empty folder', function () {
const fileMap = makeFileMap(['a/b'])
fileMap.moveFile('a/b', 'a')
expect(fileMap.countFiles()).to.equal(1)
expect(fileMap.getFile('a')).to.exist
expect(fileMap.getFile('a').getContent()).to.equal('a/b')
})
it('does not move a file over a non-empty folder', function () {
const fileMap = makeFileMap(['a/b', 'a/c'])
expect(() => {
fileMap.moveFile('a/b', 'a')
}).to.throw(FileMap.PathnameConflictError)
})
it('does not overwrite filename that differs by case on add', function () {
const fileMap = makeFileMap(['a'])
fileMap.addFile('A', makeTestFile('A'))
expect(fileMap.countFiles()).to.equal(2)
expect(fileMap.files.a).to.exist
expect(fileMap.files.A).to.exist
expect(fileMap.getFile('a')).to.exist
expect(fileMap.getFile('A').getContent()).to.equal('A')
})
it('changes case on move', function () {
const fileMap = makeFileMap(['a'])
fileMap.moveFile('a', 'A')
expect(fileMap.countFiles()).to.equal(1)
expect(fileMap.files.a).not.to.exist
expect(fileMap.files.A).to.exist
expect(fileMap.getFile('A').getContent()).to.equal('a')
})
it('does not overwrite filename that differs by case on move', function () {
const fileMap = makeFileMap(['a', 'b'])
fileMap.moveFile('a', 'B')
expect(fileMap.countFiles()).to.equal(2)
expect(fileMap.files.a).not.to.exist
expect(fileMap.files.b).to.exist
expect(fileMap.files.B).to.exist
expect(fileMap.getFile('B').getContent()).to.equal('a')
})
it('does not find pathname that differs by case', function () {
const fileMap = makeFileMap(['a'])
expect(fileMap.getFile('a')).to.exist
expect(fileMap.getFile('A')).not.to.exist
expect(fileMap.getFile('b')).not.to.exist
})
it('does not allow non-safe pathnames', function () {
expect(() => {
makeFileMap(['c*'])
}).to.throw(FileMap.BadPathnameError)
const fileMap = makeFileMap([])
expect(() => {
fileMap.addFile('c*', makeTestFile('c:'))
}).to.throw(FileMap.BadPathnameError)
fileMap.addFile('a', makeTestFile('a'))
expect(() => {
fileMap.moveFile('a', 'c*')
}).to.throw(FileMap.BadPathnameError)
expect(() => {
fileMap.addFile('hasOwnProperty', makeTestFile('hasOwnProperty'))
fileMap.addFile('anotherFile', makeTestFile('anotherFile'))
}).to.throw()
})
it('removes a file', function () {
const fileMap = makeFileMap(['a', 'b'])
fileMap.removeFile('a')
expect(fileMap.countFiles()).to.equal(1)
expect(fileMap.files.a).not.to.exist
expect(fileMap.files.b).to.exist
})
it('errors when trying to remove a non-existent file', function () {
const fileMap = makeFileMap(['a'])
expect(() => fileMap.removeFile('b')).to.throw(FileMap.FileNotFoundError)
})
it('has mapAsync', function () {
const concurrency = 1
return BPromise.map(
[
[[], {}],
[['a'], { a: 'a-a' }], // the test is to map to "content-pathname"
[['a', 'b'], { a: 'a-a', b: 'b-b' }],
],
test => {
const input = test[0]
const expectedOutput = test[1]
const fileMap = makeFileMap(input)
return fileMap
.mapAsync((file, pathname) => {
return file.getContent() + '-' + pathname
}, concurrency)
.then(result => {
expect(result).to.deep.equal(expectedOutput)
})
}
)
})
})
@@ -0,0 +1,42 @@
'use strict'
const { expect } = require('chai')
const core = require('..')
const Change = core.Change
const File = core.File
const History = core.History
const Operation = core.Operation
const Snapshot = core.Snapshot
describe('History', function () {
describe('findBlobHashes', function () {
it('finds blob hashes from snapshot and changes', function () {
const history = new History(new Snapshot(), [])
const blobHashes = new Set()
history.findBlobHashes(blobHashes)
expect(blobHashes.size).to.equal(0)
// Add a file with a hash to the snapshot.
history.getSnapshot().addFile('foo', File.fromHash(File.EMPTY_FILE_HASH))
history.findBlobHashes(blobHashes)
expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH])
// Add a file with a hash to the changes.
const testHash = 'a'.repeat(40)
const change = Change.fromRaw({
operations: [],
timestamp: '2015-03-05T12:03:53.035Z',
authors: [null],
})
change.pushOperation(Operation.addFile('bar', File.fromHash(testHash)))
history.pushChanges([change])
history.findBlobHashes(blobHashes)
expect(Array.from(blobHashes)).to.have.members([
File.EMPTY_FILE_HASH,
testHash,
])
})
})
})
@@ -0,0 +1,22 @@
'use strict'
const { expect } = require('chai')
const ot = require('..')
const HollowStringFileData = require('../lib/file_data/hollow_string_file_data')
const TextOperation = ot.TextOperation
describe('HollowStringFileData', function () {
it('validates string length when edited', function () {
const maxLength = TextOperation.MAX_STRING_LENGTH
const fileData = new HollowStringFileData(maxLength)
expect(fileData.getStringLength()).to.equal(maxLength)
expect(() => {
fileData.edit(new TextOperation().retain(maxLength).insert('x'))
}).to.throw(TextOperation.TooLongError)
expect(fileData.getStringLength()).to.equal(maxLength)
fileData.edit(new TextOperation().retain(maxLength - 1).remove(1))
expect(fileData.getStringLength()).to.equal(maxLength - 1)
})
})
@@ -0,0 +1,17 @@
'use strict'
const { expect } = require('chai')
const ot = require('..')
const Label = ot.Label
describe('Label', function () {
it('can be created by an anonymous author', function () {
const label = Label.fromRaw({
text: 'test',
authorId: null,
timestamp: '2016-01-01T00:00:00Z',
version: 123,
})
expect(label.getAuthorId()).to.be.null
})
})
@@ -0,0 +1,98 @@
'use strict'
const _ = require('lodash')
const { expect } = require('chai')
const ot = require('..')
const File = ot.File
const TextOperation = ot.TextOperation
const LazyStringFileData = require('../lib/file_data/lazy_string_file_data')
describe('LazyStringFileData', function () {
it('uses raw text operations for toRaw and fromRaw', function () {
const testHash = File.EMPTY_FILE_HASH
const fileData = new LazyStringFileData(testHash, 0)
let roundTripFileData
expect(fileData.toRaw()).to.eql({
hash: testHash,
stringLength: 0,
})
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
expect(roundTripFileData.getHash()).to.equal(testHash)
expect(roundTripFileData.getStringLength()).to.equal(0)
expect(roundTripFileData.getTextOperations()).to.have.length(0)
fileData.edit(new TextOperation().insert('a'))
expect(fileData.toRaw()).to.eql({
hash: testHash,
stringLength: 1,
textOperations: [['a']],
})
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
expect(roundTripFileData.getHash()).not.to.exist // file has changed
expect(roundTripFileData.getStringLength()).to.equal(1)
expect(roundTripFileData.getTextOperations()).to.have.length(1)
expect(roundTripFileData.getTextOperations()[0].ops).to.have.length(1)
fileData.edit(new TextOperation().retain(1).insert('b'))
expect(fileData.toRaw()).to.eql({
hash: testHash,
stringLength: 2,
textOperations: [['a'], [1, 'b']],
})
roundTripFileData = LazyStringFileData.fromRaw(fileData.toRaw())
expect(roundTripFileData.getHash()).not.to.exist // file has changed
expect(roundTripFileData.getStringLength()).to.equal(2)
expect(roundTripFileData.getTextOperations()).to.have.length(2)
expect(roundTripFileData.getTextOperations()[0].ops).to.have.length(1)
expect(roundTripFileData.getTextOperations()[1].ops).to.have.length(2)
})
it('validates operations when edited', function () {
const testHash = File.EMPTY_FILE_HASH
const fileData = new LazyStringFileData(testHash, 0)
expect(fileData.getHash()).equal(testHash)
expect(fileData.getByteLength()).to.equal(0) // approximately
expect(fileData.getStringLength()).to.equal(0)
expect(fileData.getTextOperations()).to.have.length(0)
fileData.edit(new TextOperation().insert('a'))
expect(fileData.getHash()).not.to.exist
expect(fileData.getByteLength()).to.equal(1) // approximately
expect(fileData.getStringLength()).to.equal(1)
expect(fileData.getTextOperations()).to.have.length(1)
expect(() => {
fileData.edit(new TextOperation().retain(10))
}).to.throw(TextOperation.ApplyError)
expect(fileData.getHash()).not.to.exist
expect(fileData.getByteLength()).to.equal(1) // approximately
expect(fileData.getStringLength()).to.equal(1)
expect(fileData.getTextOperations()).to.have.length(1)
})
it('validates string length when edited', function () {
const testHash = File.EMPTY_FILE_HASH
const fileData = new LazyStringFileData(testHash, 0)
expect(fileData.getHash()).equal(testHash)
expect(fileData.getByteLength()).to.equal(0) // approximately
expect(fileData.getStringLength()).to.equal(0)
expect(fileData.getTextOperations()).to.have.length(0)
const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH)
fileData.edit(new TextOperation().insert(longString))
expect(fileData.getHash()).not.to.exist
expect(fileData.getByteLength()).to.equal(longString.length) // approximate
expect(fileData.getStringLength()).to.equal(longString.length)
expect(fileData.getTextOperations()).to.have.length(1)
expect(() => {
fileData.edit(new TextOperation().retain(longString.length).insert('x'))
}).to.throw(TextOperation.TooLongError)
expect(fileData.getHash()).not.to.exist
expect(fileData.getByteLength()).to.equal(longString.length) // approximate
expect(fileData.getStringLength()).to.equal(longString.length)
expect(fileData.getTextOperations()).to.have.length(1)
})
})
@@ -0,0 +1,42 @@
'use strict'
const { expect } = require('chai')
const ot = require('..')
const File = ot.File
const MoveFileOperation = ot.MoveFileOperation
const Snapshot = ot.Snapshot
describe('MoveFileOperation', function () {
function makeEmptySnapshot() {
return new Snapshot()
}
function makeOneFileSnapshot() {
const snapshot = makeEmptySnapshot()
snapshot.addFile('foo', File.fromString('test: foo'))
return snapshot
}
function makeTwoFileSnapshot() {
const snapshot = makeOneFileSnapshot()
snapshot.addFile('bar', File.fromString('test: bar'))
return snapshot
}
it('moves a file over another', function () {
const snapshot = makeOneFileSnapshot()
const operation = new MoveFileOperation('foo', 'bar')
operation.applyTo(snapshot)
expect(snapshot.countFiles()).to.equal(1)
expect(snapshot.getFile('bar').getContent()).to.equal('test: foo')
})
it('moves a file to another pathname', function () {
const snapshot = makeTwoFileSnapshot()
const operation = new MoveFileOperation('foo', 'a')
operation.applyTo(snapshot)
expect(snapshot.countFiles()).to.equal(2)
expect(snapshot.getFile('a').getContent()).to.equal('test: foo')
expect(snapshot.getFile('bar').getContent()).to.equal('test: bar')
})
})
@@ -0,0 +1,746 @@
'use strict'
const _ = require('lodash')
const { expect } = require('chai')
const ot = require('..')
const File = ot.File
const AddFileOperation = ot.AddFileOperation
const MoveFileOperation = ot.MoveFileOperation
const EditFileOperation = ot.EditFileOperation
const NoOperation = ot.NoOperation
const Operation = ot.Operation
const TextOperation = ot.TextOperation
const Snapshot = ot.Snapshot
describe('Operation', function () {
function makeEmptySnapshot() {
return new Snapshot()
}
function makeOneFileSnapshot() {
const snapshot = makeEmptySnapshot()
snapshot.addFile('foo', File.fromString(''))
return snapshot
}
function makeTwoFileSnapshot() {
const snapshot = makeOneFileSnapshot()
snapshot.addFile('bar', File.fromString('a'))
return snapshot
}
function addFile(pathname, content) {
return new AddFileOperation(pathname, File.fromString(content))
}
function roundTripOperation(operation) {
return Operation.fromRaw(operation.toRaw())
}
function deepCopySnapshot(snapshot) {
return Snapshot.fromRaw(snapshot.toRaw())
}
function runConcurrently(operation0, operation1, snapshot) {
const operations = [
// make sure they survive serialization
roundTripOperation(operation0),
roundTripOperation(operation1),
]
const primeOperations = Operation.transform(operation0, operation1)
const originalSnapshot = snapshot || makeEmptySnapshot()
const snapshotA = deepCopySnapshot(originalSnapshot)
const snapshotB = deepCopySnapshot(originalSnapshot)
operations[0].applyTo(snapshotA)
operations[1].applyTo(snapshotB)
primeOperations[0].applyTo(snapshotB)
primeOperations[1].applyTo(snapshotA)
expect(snapshotA).to.eql(snapshotB)
return {
snapshot: snapshotA,
operations: operations,
primeOperations: primeOperations,
log() {
console.log(this)
return this
},
expectNoTransform() {
expect(this.operations).to.eql(this.primeOperations)
return this
},
swap() {
return runConcurrently(operation1, operation0, originalSnapshot)
},
expectFiles(files) {
this.expectedFiles = files
expect(this.snapshot.countFiles()).to.equal(_.size(files))
_.forOwn(files, (expectedFile, pathname) => {
if (_.isString(expectedFile)) {
expectedFile = { content: expectedFile, metadata: {} }
}
const file = this.snapshot.getFile(pathname)
expect(file.getContent()).to.equal(expectedFile.content)
expect(file.getMetadata()).to.eql(expectedFile.metadata)
})
return this
},
expectSymmetry() {
if (!this.expectedFiles) {
throw new Error('must call expectFiles before expectSymmetry')
}
this.swap().expectFiles(this.expectedFiles)
return this
},
expectPrime(index, klass) {
expect(this.primeOperations[index]).to.be.an.instanceof(klass)
return this
},
tap(fn) {
fn.call(this)
return this
},
}
}
// shorthand for creating an edit operation
function edit(pathname, textOperationJsonObject) {
return Operation.editFile(
pathname,
TextOperation.fromJSON(textOperationJsonObject)
)
}
it('transforms AddFile-AddFile with different names', function () {
runConcurrently(addFile('foo', ''), addFile('bar', 'a'))
.expectNoTransform()
.expectFiles({ bar: 'a', foo: '' })
.expectSymmetry()
})
it('transforms AddFile-AddFile with same name', function () {
// the second file 'wins'
runConcurrently(addFile('foo', ''), addFile('foo', 'a'))
.expectFiles({ foo: 'a' })
// if the first add was committed first, the second add overwrites it
.expectPrime(1, AddFileOperation)
// if the second add was committed first, the first add becomes a no-op
.expectPrime(0, NoOperation)
.swap()
.expectFiles({ foo: '' })
})
it('transforms AddFile-MoveFile with no conflict', function () {
runConcurrently(
Operation.moveFile('foo', 'baz'),
addFile('bar', 'a'),
makeOneFileSnapshot()
)
.expectNoTransform()
.expectFiles({ bar: 'a', baz: '' })
.expectSymmetry()
})
it('transforms AddFile-MoveFile with move from new file', function () {
runConcurrently(
Operation.moveFile('foo', 'baz'),
addFile('foo', 'a'),
makeOneFileSnapshot()
)
.expectFiles({ baz: 'a' })
// if the move was committed first, the add overwrites it
.expectPrime(1, AddFileOperation)
// if the add was committed first, the move appears in the history
.expectPrime(0, MoveFileOperation)
.expectSymmetry()
})
it('transforms AddFile-MoveFile with move to new file', function () {
runConcurrently(
Operation.moveFile('foo', 'baz'),
addFile('baz', 'a'),
makeOneFileSnapshot()
)
.expectFiles({ baz: 'a' })
// if the move was committed first, the add overwrites it
.expectPrime(1, AddFileOperation)
// if the add was committed first, the move becomes a delete
.expectPrime(0, MoveFileOperation)
.tap(function () {
expect(this.primeOperations[0].isRemoveFile()).to.be.true
})
.expectSymmetry()
})
it('transforms AddFile-RemoveFile with no conflict', function () {
runConcurrently(
Operation.removeFile('foo'),
addFile('bar', 'a'),
makeOneFileSnapshot()
)
.expectNoTransform()
.expectFiles({ bar: 'a' })
.expectSymmetry()
})
it('transforms AddFile-RemoveFile that removes added file', function () {
runConcurrently(
Operation.removeFile('foo'),
addFile('foo', 'a'),
makeOneFileSnapshot()
)
.expectFiles({ foo: 'a' })
// if the move was committed first, the add overwrites it
.expectPrime(1, AddFileOperation)
// if the add was committed first, the move gets dropped
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms AddFile-EditFile with no conflict', function () {
runConcurrently(
edit('foo', ['x']),
addFile('bar', 'a'),
makeOneFileSnapshot()
)
.expectNoTransform()
.expectFiles({ bar: 'a', foo: 'x' })
.expectSymmetry()
})
it('transforms AddFile-EditFile when new file is edited', function () {
runConcurrently(
edit('foo', ['x']),
addFile('foo', 'a'),
makeOneFileSnapshot()
)
.expectFiles({ foo: 'a' })
// if the edit was committed first, the add overwrites it
.expectPrime(1, AddFileOperation)
// if the add was committed first, the edit gets dropped
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms AddFile-SetFileMetadata with no conflict', function () {
const testMetadata = { baz: 1 }
runConcurrently(
addFile('bar', 'a'),
Operation.setFileMetadata('foo', testMetadata),
makeOneFileSnapshot()
)
.expectNoTransform()
.expectFiles({ foo: { content: '', metadata: testMetadata }, bar: 'a' })
.expectSymmetry()
})
it('transforms AddFile-SetFileMetadata with same name', function () {
const testMetadata = { baz: 1 }
runConcurrently(
addFile('foo', 'x'),
Operation.setFileMetadata('foo', testMetadata),
makeEmptySnapshot()
)
.expectFiles({ foo: { content: 'x', metadata: testMetadata } })
.expectSymmetry()
})
it('transforms MoveFile-MoveFile with no conflict', function () {
runConcurrently(
Operation.moveFile('foo', 'baz'),
Operation.moveFile('bar', 'bat'),
makeTwoFileSnapshot()
)
.expectFiles({ bat: 'a', baz: '' })
.expectNoTransform()
.expectSymmetry()
})
it('transforms MoveFile-MoveFile same move foo->foo, foo->foo', function () {
runConcurrently(
Operation.moveFile('foo', 'foo'),
Operation.moveFile('foo', 'foo'),
makeOneFileSnapshot()
)
.expectFiles({ foo: '' })
.expectPrime(1, NoOperation)
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms MoveFile-MoveFile no-op foo->foo, foo->bar', function () {
runConcurrently(
Operation.moveFile('foo', 'foo'),
Operation.moveFile('foo', 'bar'),
makeOneFileSnapshot()
)
.expectFiles({ bar: '' })
.expectPrime(1, MoveFileOperation)
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms MoveFile-MoveFile no-op foo->foo, bar->foo', function () {
runConcurrently(
Operation.moveFile('foo', 'foo'),
Operation.moveFile('foo', 'bar'),
makeTwoFileSnapshot()
)
.expectFiles({ bar: '' })
.expectPrime(1, MoveFileOperation)
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms MoveFile-MoveFile no-op foo->foo, bar->bar', function () {
runConcurrently(
Operation.moveFile('foo', 'foo'),
Operation.moveFile('bar', 'bar'),
makeTwoFileSnapshot()
)
.expectFiles({ bar: 'a', foo: '' })
.expectPrime(1, NoOperation)
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms MoveFile-MoveFile same move foo->bar, foo->bar', function () {
runConcurrently(
Operation.moveFile('foo', 'bar'),
Operation.moveFile('foo', 'bar'),
makeOneFileSnapshot()
)
.expectFiles({ bar: '' })
.expectPrime(1, NoOperation)
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms MoveFile-MoveFile opposite foo->bar, bar->foo', function () {
runConcurrently(
Operation.moveFile('foo', 'bar'),
Operation.moveFile('bar', 'foo'),
makeTwoFileSnapshot()
)
.expectFiles([])
.expectPrime(1, MoveFileOperation)
.expectPrime(0, MoveFileOperation)
.tap(function () {
expect(this.primeOperations[1].isRemoveFile()).to.be.true
expect(this.primeOperations[1].getPathname()).to.equal('bar')
expect(this.primeOperations[0].isRemoveFile()).to.be.true
expect(this.primeOperations[0].getPathname()).to.equal('foo')
})
.expectSymmetry()
})
it('transforms MoveFile-MoveFile no-op foo->foo, bar->baz', function () {
runConcurrently(
Operation.moveFile('foo', 'foo'),
Operation.moveFile('bar', 'baz'),
makeTwoFileSnapshot()
)
.expectFiles({ baz: 'a', foo: '' })
.expectPrime(1, MoveFileOperation)
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms MoveFile-MoveFile diverge foo->bar, foo->baz', function () {
runConcurrently(
Operation.moveFile('foo', 'bar'),
Operation.moveFile('foo', 'baz'),
makeOneFileSnapshot()
)
.expectFiles({ baz: '' })
// if foo->bar was committed first, the second move becomes bar->baz
.expectPrime(1, MoveFileOperation)
// if foo->baz was committed first, the second move becomes a no-op
.expectPrime(0, NoOperation)
.tap(function () {
expect(this.primeOperations[1].getPathname()).to.equal('bar')
expect(this.primeOperations[1].getNewPathname()).to.equal('baz')
})
.swap()
.expectFiles({ bar: '' })
})
it('transforms MoveFile-MoveFile transitive foo->baz, bar->foo', function () {
runConcurrently(
Operation.moveFile('foo', 'baz'),
Operation.moveFile('bar', 'foo'),
makeTwoFileSnapshot()
)
.expectFiles({ baz: 'a' })
.expectPrime(1, MoveFileOperation)
.expectPrime(0, MoveFileOperation)
.expectSymmetry()
})
it('transforms MoveFile-MoveFile transitive foo->bar, bar->baz', function () {
runConcurrently(
Operation.moveFile('foo', 'bar'),
Operation.moveFile('bar', 'baz'),
makeTwoFileSnapshot()
)
.expectFiles({ baz: '' })
.expectPrime(1, MoveFileOperation)
.expectPrime(0, MoveFileOperation)
.expectSymmetry()
})
it('transforms MoveFile-MoveFile converge foo->baz, bar->baz', function () {
runConcurrently(
Operation.moveFile('foo', 'baz'),
Operation.moveFile('bar', 'baz'),
makeTwoFileSnapshot()
)
.expectFiles({ baz: 'a' })
.expectPrime(1, MoveFileOperation)
.expectPrime(0, MoveFileOperation)
.tap(function () {
// if foo->baz was committed first, we just apply the move
expect(this.primeOperations[1]).to.eql(this.operations[1])
// if bar->baz was committed first, the other move becomes a remove
expect(this.primeOperations[0].isRemoveFile()).to.be.true
expect(this.primeOperations[0].getPathname()).to.equal('foo')
})
.swap()
.expectFiles({ baz: '' })
})
it('transforms MoveFile-RemoveFile no-op foo->foo, foo->', function () {
runConcurrently(
Operation.moveFile('foo', 'foo'),
Operation.removeFile('foo'),
makeOneFileSnapshot()
)
.expectFiles([])
.expectPrime(1, MoveFileOperation)
.expectPrime(0, NoOperation)
.tap(function () {
expect(this.primeOperations[1].isRemoveFile()).to.be.true
})
.expectSymmetry()
})
it('transforms MoveFile-RemoveFile same move foo->, foo->', function () {
runConcurrently(
Operation.removeFile('foo'),
Operation.removeFile('foo'),
makeOneFileSnapshot()
)
.expectFiles([])
.expectPrime(1, NoOperation)
.expectPrime(0, NoOperation)
.expectSymmetry()
})
it('transforms MoveFile-RemoveFile no conflict foo->, bar->', function () {
runConcurrently(
Operation.removeFile('foo'),
Operation.removeFile('bar'),
makeTwoFileSnapshot()
)
.expectFiles([])
.expectNoTransform()
.expectSymmetry()
})
it('transforms MoveFile-RemoveFile no conflict foo->foo, bar->', function () {
runConcurrently(
Operation.moveFile('foo', 'foo'),
Operation.removeFile('bar'),
makeTwoFileSnapshot()
)
.expectFiles({ foo: '' })
.expectPrime(1, MoveFileOperation)
.expectPrime(0, NoOperation)
.tap(function () {
expect(this.primeOperations[1].isRemoveFile()).to.be.true
})
.expectSymmetry()
})
it('transforms MoveFile-RemoveFile transitive foo->, bar->foo', function () {
runConcurrently(
Operation.removeFile('foo'),
Operation.moveFile('bar', 'foo'),
makeTwoFileSnapshot()
)
.expectFiles([])
.expectPrime(1, MoveFileOperation)
.expectPrime(0, MoveFileOperation)
.tap(function () {
expect(this.primeOperations[1].isRemoveFile()).to.be.true
expect(this.primeOperations[1].getPathname()).to.equal('bar')
expect(this.primeOperations[0].isRemoveFile()).to.be.true
expect(this.primeOperations[0].getPathname()).to.equal('foo')
})
.expectSymmetry()
})
it('transforms MoveFile-RemoveFile transitive foo->bar, bar->', function () {
runConcurrently(
Operation.moveFile('foo', 'bar'),
Operation.removeFile('bar'),
makeTwoFileSnapshot()
)
.expectFiles({})
.expectPrime(1, MoveFileOperation)
.expectPrime(0, MoveFileOperation)
.tap(function () {
expect(this.primeOperations[1].isRemoveFile()).to.be.true
expect(this.primeOperations[1].getPathname()).to.equal('bar')
expect(this.primeOperations[0].isRemoveFile()).to.be.true
expect(this.primeOperations[0].getPathname()).to.equal('foo')
})
.expectSymmetry()
})
it('transforms MoveFile-EditFile with no conflict', function () {
runConcurrently(
Operation.moveFile('bar', 'baz'),
edit('foo', ['x']),
makeTwoFileSnapshot()
)
.expectFiles({ baz: 'a', foo: 'x' })
.expectNoTransform()
.expectSymmetry()
})
it('transforms MoveFile-EditFile with edit on pathname', function () {
runConcurrently(
Operation.moveFile('foo', 'bar'),
edit('foo', ['x']),
makeOneFileSnapshot()
)
.expectFiles({ bar: 'x' })
.expectPrime(1, EditFileOperation)
.expectPrime(0, MoveFileOperation)
.tap(function () {
expect(this.primeOperations[1].getPathname()).to.equal('bar')
expect(this.primeOperations[0].getPathname()).to.equal('foo')
expect(this.primeOperations[0].getNewPathname()).to.equal('bar')
})
.expectSymmetry()
})
it('transforms MoveFile-EditFile with edit on new pathname', function () {
runConcurrently(
Operation.moveFile('bar', 'foo'),
edit('foo', ['x']),
makeTwoFileSnapshot()
)
.expectFiles({ foo: 'a' })
.expectPrime(1, NoOperation)
.tap(function () {
expect(this.primeOperations[0]).to.eql(this.operations[0])
})
.expectSymmetry()
})
it('transforms MoveFile-EditFile with no-op move', function () {
runConcurrently(
Operation.moveFile('foo', 'foo'),
edit('foo', ['x']),
makeOneFileSnapshot()
)
.expectFiles({ foo: 'x' })
.expectNoTransform()
.expectSymmetry()
})
it('transforms MoveFile-SetFileMetadata with no conflict', function () {
const testMetadata = { baz: 1 }
runConcurrently(
Operation.moveFile('foo', 'baz'),
Operation.setFileMetadata('bar', testMetadata),
makeTwoFileSnapshot()
)
.expectNoTransform()
.expectFiles({ bar: { content: 'a', metadata: testMetadata }, baz: '' })
.expectSymmetry()
})
it('transforms MoveFile-SetFileMetadata with set on pathname', function () {
const testMetadata = { baz: 1 }
runConcurrently(
Operation.moveFile('foo', 'bar'),
Operation.setFileMetadata('foo', testMetadata),
makeOneFileSnapshot()
)
.expectFiles({ bar: { content: '', metadata: testMetadata } })
.expectSymmetry()
})
it('transforms MoveFile-SetFileMetadata w/ set on new pathname', function () {
const testMetadata = { baz: 1 }
runConcurrently(
Operation.moveFile('foo', 'bar'),
Operation.setFileMetadata('bar', testMetadata),
makeTwoFileSnapshot()
)
// move wins
.expectFiles({ bar: { content: '', metadata: {} } })
.expectSymmetry()
})
it('transforms MoveFile-SetFileMetadata with no-op move', function () {
const testMetadata = { baz: 1 }
runConcurrently(
Operation.moveFile('foo', 'foo'),
Operation.setFileMetadata('foo', testMetadata),
makeOneFileSnapshot()
)
.expectFiles({ foo: { content: '', metadata: testMetadata } })
.expectSymmetry()
})
it('transforms EditFile-EditFile with no conflict', function () {
runConcurrently(
edit('foo', ['x']),
edit('bar', [1, 'x']),
makeTwoFileSnapshot()
)
.expectFiles({ bar: 'ax', foo: 'x' })
.expectNoTransform()
.expectSymmetry()
})
it('transforms EditFile-EditFile on same file', function () {
runConcurrently(
edit('foo', ['x']),
edit('foo', ['y']),
makeOneFileSnapshot()
)
.expectFiles({ foo: 'xy' })
.expectPrime(1, EditFileOperation)
.expectPrime(0, EditFileOperation)
.tap(function () {
expect(this.primeOperations[1].getTextOperation().toJSON()).to.eql([
1,
'y',
])
expect(this.primeOperations[0].getTextOperation().toJSON()).to.eql([
'x',
1,
])
})
.swap()
.expectFiles({ foo: 'yx' })
})
it('transforms EditFile-RemoveFile with no conflict', function () {
runConcurrently(
edit('foo', ['x']),
Operation.removeFile('bar'),
makeTwoFileSnapshot()
)
.expectFiles({ foo: 'x' })
.expectNoTransform()
.expectSymmetry()
})
it('transforms EditFile-RemoveFile on same file', function () {
runConcurrently(
edit('foo', ['x']),
Operation.removeFile('foo'),
makeOneFileSnapshot()
)
.expectFiles({})
.expectSymmetry()
})
it('transforms EditFile-SetFileMetadata with no conflict', function () {
const testMetadata = { baz: 1 }
runConcurrently(
edit('foo', ['x']),
Operation.setFileMetadata('bar', testMetadata),
makeTwoFileSnapshot()
)
.expectNoTransform()
.expectFiles({
foo: { content: 'x', metadata: {} },
bar: { content: 'a', metadata: testMetadata },
})
.expectSymmetry()
})
it('transforms EditFile-SetFileMetadata on same file', function () {
const testMetadata = { baz: 1 }
runConcurrently(
edit('foo', ['x']),
Operation.setFileMetadata('foo', testMetadata),
makeOneFileSnapshot()
)
.expectNoTransform()
.expectFiles({ foo: { content: 'x', metadata: testMetadata } })
.expectSymmetry()
})
it('transforms SetFileMetadata-SetFileMetadata w/ no conflict', function () {
runConcurrently(
Operation.setFileMetadata('foo', { baz: 1 }),
Operation.setFileMetadata('bar', { baz: 2 }),
makeTwoFileSnapshot()
)
.expectNoTransform()
.expectFiles({
foo: { content: '', metadata: { baz: 1 } },
bar: { content: 'a', metadata: { baz: 2 } },
})
.expectSymmetry()
})
it('transforms SetFileMetadata-SetFileMetadata on same file', function () {
runConcurrently(
Operation.setFileMetadata('foo', { baz: 1 }),
Operation.setFileMetadata('foo', { baz: 2 }),
makeOneFileSnapshot()
)
// second op wins
.expectFiles({ foo: { content: '', metadata: { baz: 2 } } })
.swap()
// first op wins
.expectFiles({ foo: { content: '', metadata: { baz: 1 } } })
})
it('transforms SetFileMetadata-RemoveFile with no conflict', function () {
const testMetadata = { baz: 1 }
runConcurrently(
Operation.setFileMetadata('foo', testMetadata),
Operation.removeFile('bar'),
makeTwoFileSnapshot()
)
.expectNoTransform()
.expectFiles({ foo: { content: '', metadata: testMetadata } })
.expectSymmetry()
})
it('transforms SetFileMetadata-RemoveFile on same file', function () {
const testMetadata = { baz: 1 }
runConcurrently(
Operation.setFileMetadata('foo', testMetadata),
Operation.removeFile('foo'),
makeOneFileSnapshot()
)
.expectFiles({})
.expectSymmetry()
})
})
@@ -0,0 +1,113 @@
'use strict'
const { expect } = require('chai')
const ot = require('..')
const safePathname = ot.safePathname
describe('safePathname', function () {
function expectClean(input, output) {
// check expected output and also idempotency
const cleanedInput = safePathname.clean(input)
expect(cleanedInput).to.equal(output)
expect(safePathname.clean(cleanedInput)).to.equal(cleanedInput)
expect(safePathname.isClean(cleanedInput)).to.be.true
}
it('cleans pathnames', function () {
// preserve valid pathnames
expectClean('llama.jpg', 'llama.jpg')
expectClean('DSC4056.JPG', 'DSC4056.JPG')
// detects unclean pathnames
expect(safePathname.isClean('rm -rf /')).to.be.falsy
// replace invalid characters with underscores
expectClean('test-s*\u0001\u0002m\u0007st\u0008.jpg', 'test-s___m_st_.jpg')
// keep slashes, normalize paths, replace ..
expectClean('./foo', 'foo')
expectClean('../foo', '__/foo')
expectClean('foo/./bar', 'foo/bar')
expectClean('foo/../bar', 'bar')
expectClean('../../tricky/foo.bar', '__/__/tricky/foo.bar')
expectClean('foo/../../tricky/foo.bar', '__/tricky/foo.bar')
expectClean('foo/bar/../../tricky/foo.bar', 'tricky/foo.bar')
expectClean('foo/bar/baz/../../tricky/foo.bar', 'foo/tricky/foo.bar')
// remove illegal chars even when there is no extension
expectClean('**foo', '__foo')
// remove windows file paths
expectClean('c:\\temp\\foo.txt', 'c:/temp/foo.txt')
// do not allow a leading slash (relative paths only)
expectClean('/foo', '_/foo')
expectClean('//foo', '_/foo')
// do not allow multiple leading slashes
expectClean('//foo', '_/foo')
// do not allow a trailing slash
expectClean('/', '_')
expectClean('foo/', 'foo')
expectClean('foo.tex/', 'foo.tex')
// do not allow multiple trailing slashes
expectClean('//', '_')
expectClean('///', '_')
expectClean('foo//', 'foo')
// file and folder names that consist of . and .. are not OK
expectClean('.', '_')
expectClean('..', '__')
// we will allow name with more dots e.g. ... and ....
expectClean('...', '...')
expectClean('....', '....')
expectClean('foo/...', 'foo/...')
expectClean('foo/....', 'foo/....')
expectClean('foo/.../bar', 'foo/.../bar')
expectClean('foo/..../bar', 'foo/..../bar')
// leading dots are OK
expectClean('._', '._')
expectClean('.gitignore', '.gitignore')
// trailing dots are not OK on Windows but we allow them
expectClean('_.', '_.')
expectClean('foo/_.', 'foo/_.')
expectClean('foo/_./bar', 'foo/_./bar')
expectClean('foo/_../bar', 'foo/_../bar')
// spaces are allowed
expectClean('a b.png', 'a b.png')
// leading and trailing spaces are not OK
expectClean(' foo', 'foo')
expectClean(' foo', 'foo')
expectClean('foo ', 'foo')
expectClean('foo ', 'foo')
// reserved file names on Windows should not be OK, but we already have
// some in the old system, so have to allow them for now
expectClean('AUX', 'AUX')
expectClean('foo/AUX', 'foo/AUX')
expectClean('AUX/foo', 'AUX/foo')
// multiple dots are OK
expectClean('a.b.png', 'a.b.png')
expectClean('a.code.tex', 'a.code.tex')
// there's no particular reason to allow multiple slashes; sometimes people
// seem to rename files to URLs (https://domain/path) in an attempt to
// upload a file, and this results in an empty directory name
expectClean('foo//bar.png', 'foo/bar.png')
expectClean('foo///bar.png', 'foo/bar.png')
// Check javascript property handling
expectClean('foo/prototype', 'foo/prototype') // OK as part of a pathname
expectClean('prototype/test.txt', 'prototype/test.txt')
expectClean('prototype', '@prototype') // not OK as whole pathname
expectClean('hasOwnProperty', '@hasOwnProperty')
expectClean('**proto**', '@__proto__')
})
})
@@ -0,0 +1,92 @@
'use strict'
const { expect } = require('chai')
const {
File,
Snapshot,
TextOperation,
Change,
EditFileOperation,
} = require('..')
describe('Snapshot', function () {
describe('findBlobHashes', function () {
it('finds blob hashes from files', function () {
const snapshot = new Snapshot()
const blobHashes = new Set()
snapshot.findBlobHashes(blobHashes)
expect(blobHashes.size).to.equal(0)
// Add a file without a hash.
snapshot.addFile('foo', File.fromString(''))
snapshot.findBlobHashes(blobHashes)
expect(blobHashes.size).to.equal(0)
// Add a file with a hash.
snapshot.addFile('bar', File.fromHash(File.EMPTY_FILE_HASH))
snapshot.findBlobHashes(blobHashes)
expect(Array.from(blobHashes)).to.have.members([File.EMPTY_FILE_HASH])
})
})
describe('editFile', function () {
let snapshot
let operation
beforeEach(function () {
snapshot = new Snapshot()
snapshot.addFile('hello.txt', File.fromString('hello'))
operation = new TextOperation()
operation.retain(5)
operation.insert(' world!')
})
it('applies text operations to the file', function () {
snapshot.editFile('hello.txt', operation)
const file = snapshot.getFile('hello.txt')
expect(file.getContent()).to.equal('hello world!')
})
it('rejects text operations for nonexistent file', function () {
expect(() => {
snapshot.editFile('does-not-exist.txt', operation)
}).to.throw(Snapshot.EditMissingFileError)
})
})
describe('applyAll', function () {
let snapshot
let change
beforeEach(function () {
snapshot = new Snapshot()
snapshot.addFile('empty.txt', File.fromString(''))
const badTextOp = new TextOperation()
badTextOp.insert('FAIL!')
const goodTextOp = new TextOperation()
goodTextOp.insert('SUCCESS!')
change = new Change(
[
new EditFileOperation('missing.txt', badTextOp),
new EditFileOperation('empty.txt', goodTextOp),
],
new Date()
)
})
it('ignores recoverable errors', function () {
snapshot.applyAll([change])
const file = snapshot.getFile('empty.txt')
expect(file.getContent()).to.equal('SUCCESS!')
})
it('stops on recoverable errors in strict mode', function () {
expect(() => {
snapshot.applyAll([change], { strict: true })
}).to.throw(Snapshot.EditMissingFileError)
const file = snapshot.getFile('empty.txt')
expect(file.getContent()).to.equal('')
})
})
})
@@ -0,0 +1,37 @@
'use strict'
const { expect } = require('chai')
const _ = require('lodash')
const ot = require('..')
const StringFileData = require('../lib/file_data/string_file_data')
const TextOperation = ot.TextOperation
describe('StringFileData', function () {
it('throws when it contains non BMP chars', function () {
const content = '𝌆𝌆𝌆'
const fileData = new StringFileData(content)
const operation = new TextOperation()
operation.insert('aa')
expect(() => {
fileData.edit(operation)
}).to.throw(TextOperation.ApplyError, /string contains non BMP characters/)
})
it('validates string length when edited', function () {
const longString = _.repeat('a', TextOperation.MAX_STRING_LENGTH)
const fileData = new StringFileData(longString)
expect(fileData.getByteLength()).to.equal(longString.length)
expect(fileData.getStringLength()).to.equal(longString.length)
expect(() => {
fileData.edit(new TextOperation().retain(longString.length).insert('x'))
}).to.throw(TextOperation.TooLongError)
expect(fileData.getByteLength()).to.equal(longString.length)
expect(fileData.getStringLength()).to.equal(longString.length)
fileData.edit(new TextOperation().retain(longString.length - 1).remove(1))
expect(fileData.getByteLength()).to.equal(longString.length - 1)
expect(fileData.getStringLength()).to.equal(longString.length - 1)
})
})
@@ -0,0 +1,35 @@
/**
* @typedef {import("../..").Blob } Blob
*/
/**
* @template T
* @typedef {import("bluebird")<T>} BPromise
*/
/**
* Fake blob store for tests
*/
class FakeBlobStore {
/**
* Get a string from the blob store
*
* @param {string} hash
* @return {BPromise<string>}
*/
getString(hash) {
throw new Error('Not implemented')
}
/**
* Store a string in the blob store
*
* @param {string} content
* @return {BPromise<Blob>}
*/
putString(content) {
throw new Error('Not implemented')
}
}
module.exports = FakeBlobStore
@@ -0,0 +1,38 @@
//
// Randomised testing helpers from OT.js:
// https://github.com/Operational-Transformation/ot.js/blob/
// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/helpers.js
//
'use strict'
function randomInt(n) {
return Math.floor(Math.random() * n)
}
function randomString(n) {
let str = ''
while (n--) {
if (Math.random() < 0.15) {
str += '\n'
} else {
const chr = randomInt(26) + 97
str += String.fromCharCode(chr)
}
}
return str
}
function randomElement(arr) {
return arr[randomInt(arr.length)]
}
function randomTest(numTrials, test) {
return function () {
while (numTrials--) test()
}
}
exports.int = randomInt
exports.string = randomString
exports.element = randomElement
exports.test = randomTest
@@ -0,0 +1,269 @@
//
// These tests are based on the OT.js tests:
// https://github.com/Operational-Transformation/ot.js/blob/
// 8873b7e28e83f9adbf6c3a28ec639c9151a838ae/test/lib/test-text-operation.js
//
'use strict'
const { expect } = require('chai')
const random = require('./support/random')
const ot = require('..')
const TextOperation = ot.TextOperation
function randomOperation(str) {
const operation = new TextOperation()
let left
while (true) {
left = str.length - operation.baseLength
if (left === 0) break
const r = Math.random()
const l = 1 + random.int(Math.min(left - 1, 20))
if (r < 0.2) {
operation.insert(random.string(l))
} else if (r < 0.4) {
operation.remove(l)
} else {
operation.retain(l)
}
}
if (Math.random() < 0.3) {
operation.insert(1 + random.string(10))
}
return operation
}
describe('TextOperation', function () {
const numTrials = 500
it('tracks base and target lengths', function () {
const o = new TextOperation()
expect(o.baseLength).to.equal(0)
expect(o.targetLength).to.equal(0)
o.retain(5)
expect(o.baseLength).to.equal(5)
expect(o.targetLength).to.equal(5)
o.insert('abc')
expect(o.baseLength).to.equal(5)
expect(o.targetLength).to.equal(8)
o.retain(2)
expect(o.baseLength).to.equal(7)
expect(o.targetLength).to.equal(10)
o.remove(2)
expect(o.baseLength).to.equal(9)
expect(o.targetLength).to.equal(10)
})
it('supports chaining', function () {
const o = new TextOperation()
.retain(5)
.retain(0)
.insert('lorem')
.insert('')
.remove('abc')
.remove(3)
.remove(0)
.remove('')
expect(o.ops.length).to.equal(3)
})
it('ignores empty operations', function () {
const o = new TextOperation()
o.retain(0)
o.insert('')
o.remove('')
expect(o.ops.length).to.equal(0)
})
it('checks for equality', function () {
const op1 = new TextOperation().remove(1).insert('lo').retain(2).retain(3)
const op2 = new TextOperation().remove(-1).insert('l').insert('o').retain(5)
expect(op1.equals(op2)).to.be.true
op1.remove(1)
op2.retain(1)
expect(op1.equals(op2)).to.be.false
})
it('merges ops', function () {
function last(arr) {
return arr[arr.length - 1]
}
const o = new TextOperation()
expect(o.ops.length).to.equal(0)
o.retain(2)
expect(o.ops.length).to.equal(1)
expect(last(o.ops)).to.equal(2)
o.retain(3)
expect(o.ops.length).to.equal(1)
expect(last(o.ops)).to.equal(5)
o.insert('abc')
expect(o.ops.length).to.equal(2)
expect(last(o.ops)).to.equal('abc')
o.insert('xyz')
expect(o.ops.length).to.equal(2)
expect(last(o.ops)).to.equal('abcxyz')
o.remove('d')
expect(o.ops.length).to.equal(3)
expect(last(o.ops)).to.equal(-1)
o.remove('d')
expect(o.ops.length).to.equal(3)
expect(last(o.ops)).to.equal(-2)
})
it('checks for no-ops', function () {
const o = new TextOperation()
expect(o.isNoop()).to.be.true
o.retain(5)
expect(o.isNoop()).to.be.true
o.retain(3)
expect(o.isNoop()).to.be.true
o.insert('lorem')
expect(o.isNoop()).to.be.false
})
it('converts to string', function () {
const o = new TextOperation()
o.retain(2)
o.insert('lorem')
o.remove('ipsum')
o.retain(5)
expect(o.toString()).to.equal(
"retain 2, insert 'lorem', remove 5, retain 5"
)
})
it('converts from JSON', function () {
const ops = [2, -1, -1, 'cde']
const o = TextOperation.fromJSON(ops)
expect(o.ops.length).to.equal(3)
expect(o.baseLength).to.equal(4)
expect(o.targetLength).to.equal(5)
function assertIncorrectAfter(fn) {
const ops2 = ops.slice(0)
fn(ops2)
expect(() => {
TextOperation.fromJSON(ops2)
}).to.throw
}
assertIncorrectAfter(ops2 => {
ops2.push({ insert: 'x' })
})
assertIncorrectAfter(ops2 => {
ops2.push(null)
})
})
it(
'applies (randomised)',
random.test(numTrials, () => {
const str = random.string(50)
const o = randomOperation(str)
expect(str.length).to.equal(o.baseLength)
expect(o.apply(str).length).to.equal(o.targetLength)
})
)
it(
'inverts (randomised)',
random.test(numTrials, () => {
const str = random.string(50)
const o = randomOperation(str)
const p = o.invert(str)
expect(o.baseLength).to.equal(p.targetLength)
expect(o.targetLength).to.equal(p.baseLength)
expect(p.apply(o.apply(str))).to.equal(str)
})
)
it(
'converts to/from JSON (randomised)',
random.test(numTrials, () => {
const doc = random.string(50)
const operation = randomOperation(doc)
const roundTripOperation = TextOperation.fromJSON(operation.toJSON())
expect(operation.equals(roundTripOperation)).to.be.true
})
)
it(
'composes (randomised)',
random.test(numTrials, () => {
// invariant: apply(str, compose(a, b)) === apply(apply(str, a), b)
const str = random.string(20)
const a = randomOperation(str)
const afterA = a.apply(str)
expect(afterA.length).to.equal(a.targetLength)
const b = randomOperation(afterA)
const afterB = b.apply(afterA)
expect(afterB.length).to.equal(b.targetLength)
const ab = a.compose(b)
expect(ab.targetLength).to.equal(b.targetLength)
const afterAB = ab.apply(str)
expect(afterAB).to.equal(afterB)
})
)
it(
'transforms (randomised)',
random.test(numTrials, () => {
// invariant: compose(a, b') = compose(b, a')
// where (a', b') = transform(a, b)
const str = random.string(20)
const a = randomOperation(str)
const b = randomOperation(str)
const primes = TextOperation.transform(a, b)
const aPrime = primes[0]
const bPrime = primes[1]
const abPrime = a.compose(bPrime)
const baPrime = b.compose(aPrime)
const afterAbPrime = abPrime.apply(str)
const afterBaPrime = baPrime.apply(str)
expect(abPrime.equals(baPrime)).to.be.true
expect(afterAbPrime).to.equal(afterBaPrime)
})
)
it('throws when invalid operations are applied', function () {
const operation = new TextOperation().retain(1)
expect(() => {
operation.apply('')
}).to.throw(TextOperation.ApplyError)
expect(() => {
operation.apply(' ')
}).not.to.throw
})
it('throws when insert text contains non BMP chars', function () {
const operation = new TextOperation()
const str = '𝌆\n'
expect(() => {
operation.insert(str)
}).to.throw(
TextOperation.UnprocessableError,
/inserted text contains non BMP characters/
)
})
it('throws when base string contains non BMP chars', function () {
const operation = new TextOperation()
const str = '𝌆\n'
expect(() => {
operation.apply(str)
}).to.throw(
TextOperation.UnprocessableError,
/string contains non BMP characters/
)
})
it('throws at from JSON when it contains non BMP chars', function () {
const operation = ['𝌆\n']
expect(() => {
TextOperation.fromJSON(operation)
}).to.throw(
TextOperation.UnprocessableError,
/inserted text contains non BMP characters/
)
})
})