import * as t from 'lib0/testing' import { init, compare } from './testHelper.js' // eslint-disable-line import * as Y from '../src/index.js' import { readClientsStructRefs, readDeleteSet, UpdateDecoderV2, UpdateEncoderV2, writeDeleteSet } from '../src/internals.js' import * as encoding from 'lib0/encoding' import * as decoding from 'lib0/decoding' import * as object from 'lib0/object' /** * @typedef {Object} Enc * @property {function(Array):Uint8Array} Enc.mergeUpdates * @property {function(Y.Doc):Uint8Array} Enc.encodeStateAsUpdate * @property {function(Y.Doc, Uint8Array):void} Enc.applyUpdate * @property {function(Uint8Array):void} Enc.logUpdate * @property {function(Uint8Array):{from:Map,to:Map}} Enc.parseUpdateMeta * @property {function(Y.Doc):Uint8Array} Enc.encodeStateVector * @property {function(Uint8Array):Uint8Array} Enc.encodeStateVectorFromUpdate * @property {'update'|'updateV2'} Enc.updateEventName * @property {string} Enc.description * @property {function(Uint8Array, Uint8Array):Uint8Array} Enc.diffUpdate */ /** * @type {Enc} */ const encV1 = { mergeUpdates: Y.mergeUpdates, encodeStateAsUpdate: Y.encodeStateAsUpdate, applyUpdate: Y.applyUpdate, logUpdate: Y.logUpdate, parseUpdateMeta: Y.parseUpdateMeta, encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdate, encodeStateVector: Y.encodeStateVector, updateEventName: 'update', description: 'V1', diffUpdate: Y.diffUpdate } /** * @type {Enc} */ const encV2 = { mergeUpdates: Y.mergeUpdatesV2, encodeStateAsUpdate: Y.encodeStateAsUpdateV2, applyUpdate: Y.applyUpdateV2, logUpdate: Y.logUpdateV2, parseUpdateMeta: Y.parseUpdateMetaV2, encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2, encodeStateVector: Y.encodeStateVector, updateEventName: 'updateV2', description: 'V2', diffUpdate: Y.diffUpdateV2 } /** * @type {Enc} */ const encDoc = { mergeUpdates: (updates) => { const ydoc = new Y.Doc({ gc: false }) updates.forEach(update => { Y.applyUpdateV2(ydoc, update) }) return Y.encodeStateAsUpdateV2(ydoc) }, encodeStateAsUpdate: Y.encodeStateAsUpdateV2, applyUpdate: Y.applyUpdateV2, logUpdate: Y.logUpdateV2, parseUpdateMeta: Y.parseUpdateMetaV2, encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2, encodeStateVector: Y.encodeStateVector, updateEventName: 'updateV2', description: 'Merge via Y.Doc', /** * @param {Uint8Array} update * @param {Uint8Array} sv */ diffUpdate: (update, sv) => { const ydoc = new Y.Doc({ gc: false }) Y.applyUpdateV2(ydoc, update) return Y.encodeStateAsUpdateV2(ydoc, sv) } } const encoders = [encV1, encV2, encDoc] /** * @param {Array} users * @param {Enc} enc */ const fromUpdates = (users, enc) => { const updates = users.map(user => enc.encodeStateAsUpdate(user) ) const ydoc = new Y.Doc() enc.applyUpdate(ydoc, enc.mergeUpdates(updates)) return ydoc } /** * @param {t.TestCase} tc */ export const testMergeUpdates = tc => { const { users, array0, array1 } = init(tc, { users: 3 }) array0.insert(0, [1]) array1.insert(0, [2]) compare(users) encoders.forEach(enc => { const merged = fromUpdates(users, enc) t.compareArrays(array0.toArray(), merged.getArray('array').toArray()) }) } /** * @param {t.TestCase} tc */ export const testKeyEncoding = tc => { const { users, text0, text1 } = init(tc, { users: 2 }) text0.insert(0, 'a', { italic: true }) text0.insert(0, 'b') text0.insert(0, 'c', { italic: true }) const update = Y.encodeStateAsUpdateV2(users[0]) Y.applyUpdateV2(users[1], update) t.compare(text1.toDelta(), [{ insert: 'c', attributes: { italic: true } }, { insert: 'b' }, { insert: 'a', attributes: { italic: true } }]) compare(users) } /** * @param {Y.Doc} ydoc * @param {Array} updates - expecting at least 4 updates * @param {Enc} enc * @param {boolean} hasDeletes */ const checkUpdateCases = (ydoc, updates, enc, hasDeletes) => { const cases = [] // Case 1: Simple case, simply merge everything cases.push(enc.mergeUpdates(updates)) // Case 2: Overlapping updates cases.push(enc.mergeUpdates([ enc.mergeUpdates(updates.slice(2)), enc.mergeUpdates(updates.slice(0, 2)) ])) // Case 3: Overlapping updates cases.push(enc.mergeUpdates([ enc.mergeUpdates(updates.slice(2)), enc.mergeUpdates(updates.slice(1, 3)), updates[0] ])) // Case 4: Separated updates (containing skips) cases.push(enc.mergeUpdates([ enc.mergeUpdates([updates[0], updates[2]]), enc.mergeUpdates([updates[1], updates[3]]), enc.mergeUpdates(updates.slice(4)) ])) // Case 5: overlapping with many duplicates cases.push(enc.mergeUpdates(cases)) // const targetState = enc.encodeStateAsUpdate(ydoc) // t.info('Target State: ') // enc.logUpdate(targetState) cases.forEach((mergedUpdates) => { // t.info('State Case $' + i + ':') // enc.logUpdate(updates) const merged = new Y.Doc({ gc: false }) enc.applyUpdate(merged, mergedUpdates) t.compareArrays(merged.getArray().toArray(), ydoc.getArray().toArray()) t.compare(enc.encodeStateVector(merged), enc.encodeStateVectorFromUpdate(mergedUpdates)) if (enc.updateEventName !== 'update') { // @todo should this also work on legacy updates? for (let j = 1; j < updates.length; j++) { const partMerged = enc.mergeUpdates(updates.slice(j)) const partMeta = enc.parseUpdateMeta(partMerged) const targetSV = Y.encodeStateVectorFromUpdateV2(Y.mergeUpdatesV2(updates.slice(0, j))) const diffed = enc.diffUpdate(mergedUpdates, targetSV) const diffedMeta = enc.parseUpdateMeta(diffed) t.compare(partMeta, diffedMeta) { // We can'd do the following // - t.compare(diffed, mergedDeletes) // because diffed contains the set of all deletes. // So we add all deletes from `diffed` to `partDeletes` and compare then const decoder = decoding.createDecoder(diffed) const updateDecoder = new UpdateDecoderV2(decoder) readClientsStructRefs(updateDecoder, new Y.Doc()) const ds = readDeleteSet(updateDecoder) const updateEncoder = new UpdateEncoderV2() encoding.writeVarUint(updateEncoder.restEncoder, 0) // 0 structs writeDeleteSet(updateEncoder, ds) const deletesUpdate = updateEncoder.toUint8Array() const mergedDeletes = Y.mergeUpdatesV2([deletesUpdate, partMerged]) if (!hasDeletes || enc !== encDoc) { // deletes will almost definitely lead to different encoders because of the mergeStruct feature that is present in encDoc t.compare(diffed, mergedDeletes) } } } } const meta = enc.parseUpdateMeta(mergedUpdates) meta.from.forEach((clock, client) => t.assert(clock === 0)) meta.to.forEach((clock, client) => { const structs = /** @type {Array} */ (merged.store.clients.get(client)) const lastStruct = structs[structs.length - 1] t.assert(lastStruct.id.clock + lastStruct.length === clock) }) }) } /** * @param {t.TestCase} _tc */ export const testMergeUpdates1 = _tc => { encoders.forEach((enc) => { t.info(`Using encoder: ${enc.description}`) const ydoc = new Y.Doc({ gc: false }) const updates = /** @type {Array} */ ([]) ydoc.on(enc.updateEventName, update => { updates.push(update) }) const array = ydoc.getArray() array.insert(0, [1]) array.insert(0, [2]) array.insert(0, [3]) array.insert(0, [4]) checkUpdateCases(ydoc, updates, enc, false) }) } /** * @param {t.TestCase} tc */ export const testMergeUpdates2 = tc => { encoders.forEach((enc, i) => { t.info(`Using encoder: ${enc.description}`) const ydoc = new Y.Doc({ gc: false }) const updates = /** @type {Array} */ ([]) ydoc.on(enc.updateEventName, update => { updates.push(update) }) const array = ydoc.getArray() array.insert(0, [1, 2]) array.delete(1, 1) array.insert(0, [3, 4]) array.delete(1, 2) checkUpdateCases(ydoc, updates, enc, true) }) } /** * @param {t.TestCase} tc */ export const testMergePendingUpdates = tc => { const yDoc = new Y.Doc() /** * @type {Array} */ const serverUpdates = [] yDoc.on('update', (update, origin, c) => { serverUpdates.splice(serverUpdates.length, 0, update) }) const yText = yDoc.getText('textBlock') yText.applyDelta([{ insert: 'r' }]) yText.applyDelta([{ insert: 'o' }]) yText.applyDelta([{ insert: 'n' }]) yText.applyDelta([{ insert: 'e' }]) yText.applyDelta([{ insert: 'n' }]) const yDoc1 = new Y.Doc() Y.applyUpdate(yDoc1, serverUpdates[0]) const update1 = Y.encodeStateAsUpdate(yDoc1) const yDoc2 = new Y.Doc() Y.applyUpdate(yDoc2, update1) Y.applyUpdate(yDoc2, serverUpdates[1]) const update2 = Y.encodeStateAsUpdate(yDoc2) const yDoc3 = new Y.Doc() Y.applyUpdate(yDoc3, update2) Y.applyUpdate(yDoc3, serverUpdates[3]) const update3 = Y.encodeStateAsUpdate(yDoc3) const yDoc4 = new Y.Doc() Y.applyUpdate(yDoc4, update3) Y.applyUpdate(yDoc4, serverUpdates[2]) const update4 = Y.encodeStateAsUpdate(yDoc4) const yDoc5 = new Y.Doc() Y.applyUpdate(yDoc5, update4) Y.applyUpdate(yDoc5, serverUpdates[4]) // @ts-ignore const _update5 = Y.encodeStateAsUpdate(yDoc5) // eslint-disable-line const yText5 = yDoc5.getText('textBlock') t.compareStrings(yText5.toString(), 'nenor') } /** * @param {t.TestCase} _tc */ export const testObfuscateUpdates = _tc => { const ydoc = new Y.Doc() const ytext = ydoc.getText('text') const ymap = ydoc.getMap('map') const yarray = ydoc.getArray('array') // test ytext ytext.applyDelta([{ insert: 'text', attributes: { bold: true } }, { insert: { href: 'supersecreturl' } }]) // test ymap ymap.set('key', 'secret1') ymap.set('key', 'secret2') // test yarray with subtype & subdoc const subtype = new Y.XmlElement('secretnodename') const subdoc = new Y.Doc({ guid: 'secret' }) subtype.setAttribute('attr', 'val') yarray.insert(0, ['teststring', 42, subtype, subdoc]) // obfuscate the content and put it into a new document const obfuscatedUpdate = Y.obfuscateUpdate(Y.encodeStateAsUpdate(ydoc)) const odoc = new Y.Doc() Y.applyUpdate(odoc, obfuscatedUpdate) const otext = odoc.getText('text') const omap = odoc.getMap('map') const oarray = odoc.getArray('array') // test ytext const delta = otext.toDelta() t.assert(delta.length === 2) t.assert(delta[0].insert !== 'text' && delta[0].insert.length === 4) t.assert(object.length(delta[0].attributes) === 1) t.assert(!object.hasProperty(delta[0].attributes, 'bold')) t.assert(object.length(delta[1]) === 1) t.assert(object.hasProperty(delta[1], 'insert')) // test ymap t.assert(omap.size === 1) t.assert(!omap.has('key')) // test yarray with subtype & subdoc const result = oarray.toArray() t.assert(result.length === 4) t.assert(result[0] !== 'teststring') t.assert(result[1] !== 42) const osubtype = /** @type {Y.XmlElement} */ (result[2]) const osubdoc = result[3] // test subtype t.assert(osubtype.nodeName !== subtype.nodeName) t.assert(object.length(osubtype.getAttributes()) === 1) t.assert(osubtype.getAttribute('attr') === undefined) // test subdoc t.assert(osubdoc.guid !== subdoc.guid) }