diff --git a/rollup.config.js b/rollup.config.js index 7fce1eea..ac14611f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -11,6 +11,9 @@ const customModules = new Set([ 'y-dom', 'y-prosemirror' ]) +/** + * @type {Set} + */ const customLibModules = new Set([ // 'funlib', // 'y-protocols' diff --git a/tests/helper.js b/tests/helper.js deleted file mode 100644 index d8235d86..00000000 --- a/tests/helper.js +++ /dev/null @@ -1,413 +0,0 @@ - -import * as Y from '../src/index.js' -import { ItemJSON } from '../src/structs/ItemJSON.js' -import { ItemString } from '../src/structs/ItemString.js' -import { defragmentItemContent } from '../src/utils/defragmentItemContent.js' -import Quill from 'quill' -import { GC } from '../src/structs/GC.js' -import * as random from 'lib0/prng.js' -import * as syncProtocol from 'y-protocols/sync.js' -import * as encoding from 'lib0/encoding.js' -import * as decoding from 'lib0/decoding.js' -import { createMutex } from 'lib0/mutex.js' -import { QuillBinding } from 'y-quill' -import { DomBinding } from 'y-dom' - -export * from '../src/index.js' - -/** - * @param {TestYInstance} y - * @param {Y.Transaction} transaction - */ -const afterTransaction = (y, transaction) => { - y.mMux(() => { - if (transaction.encodedStructsLen > 0) { - const encoder = encoding.createEncoder() - syncProtocol.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs) - broadcastMessage(y, encoding.toBuffer(encoder)) - } - }) -} - -export class TestYInstance extends Y.Y { - /** - * @param {TestConnector} testConnector - */ - constructor (testConnector, clientID) { - super() - this.userID = clientID // overwriting clientID - /** - * @type {TestConnector} - */ - this.tc = testConnector - /** - * @type {Map>} - */ - this.receiving = new Map() - /** - * Message mutex - * @type {Function} - */ - this.mMux = createMutex() - testConnector.allConns.add(this) - // set up observe on local model - this.on('afterTransaction', afterTransaction) - this.connect() - } - /** - * Disconnect from TestConnector. - */ - disconnect () { - this.receiving = new Map() - this.tc.onlineConns.delete(this) - } - /** - * Append yourself to the list of known Y instances in testconnector. - * Also initiate sync with all clients. - */ - connect () { - if (!this.tc.onlineConns.has(this)) { - this.tc.onlineConns.add(this) - const encoder = encoding.createEncoder() - syncProtocol.writeSyncStep1(encoder, this) - // publish SyncStep1 - broadcastMessage(this, encoding.toBuffer(encoder)) - this.tc.onlineConns.forEach(remoteYInstance => { - if (remoteYInstance !== this) { - // remote instance sends instance to this instance - const encoder = encoding.createEncoder() - syncProtocol.writeSyncStep1(encoder, remoteYInstance) - this._receive(encoding.toBuffer(encoder), remoteYInstance) - } - }) - } - } - /** - * Receive a message from another client. This message is only appended to the list of receiving messages. - * TestConnector decides when this client actually reads this message. - * - * @param {ArrayBuffer} message - * @param {TestYInstance} remoteClient - */ - _receive (message, remoteClient) { - let messages = this.receiving.get(remoteClient) - if (messages === undefined) { - messages = [] - this.receiving.set(remoteClient, messages) - } - messages.push(message) - } -} - -/** - * Keeps track of TestYInstances. - * - * The TestYInstances add/remove themselves from the list of connections maintained in this object. - * I think it makes sense. Deal with it. - */ -export class TestConnector { - constructor (prng) { - /** - * @type {Set} - */ - this.allConns = new Set() - /** - * @type {Set} - */ - this.onlineConns = new Set() - /** - * @type {random.PRNG} - */ - this.prng = prng - } - /** - * Create a new Y instance and add it to the list of connections - * @param {number} clientID - */ - createY (clientID) { - return new TestYInstance(this, clientID) - } - /** - * Choose random connection and flush a random message from a random sender. - * - * If this function was unable to flush a message, because there are no more messages to flush, it returns false. true otherwise. - * @return {boolean} - */ - flushRandomMessage () { - const prng = this.prng - const conns = Array.from(this.onlineConns).filter(conn => conn.receiving.size > 0) - if (conns.length > 0) { - const receiver = random.oneOf(prng, conns) - const [sender, messages] = random.oneOf(prng, Array.from(receiver.receiving)) - const m = messages.shift() - if (messages.length === 0) { - receiver.receiving.delete(sender) - } - const encoder = encoding.createEncoder() - receiver.mMux(() => { - // console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver)) - // do not publish data created when this function is executed (could be ss2 or update message) - syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver) - }) - if (encoding.length(encoder) > 0) { - // send reply message - sender._receive(encoding.toBuffer(encoder), receiver) - } - return true - } - return false - } - /** - * @return {boolean} True iff this function actually flushed something - */ - flushAllMessages () { - let didSomething = false - while (this.flushRandomMessage()) { - didSomething = true - } - return didSomething - } - reconnectAll () { - this.allConns.forEach(conn => conn.connect()) - } - disconnectAll () { - this.allConns.forEach(conn => conn.disconnect()) - } - syncAll () { - this.reconnectAll() - this.flushAllMessages() - } - /** - * @return {boolean} Whether it was possible to disconnect a randon connection. - */ - disconnectRandom () { - if (this.onlineConns.size === 0) { - return false - } - random.oneOf(this.prng, Array.from(this.onlineConns)).disconnect() - return true - } - /** - * @return {boolean} Whether it was possible to reconnect a random connection. - */ - reconnectRandom () { - const reconnectable = [] - this.allConns.forEach(conn => { - if (!this.onlineConns.has(conn)) { - reconnectable.push(conn) - } - }) - if (reconnectable.length === 0) { - return false - } - random.oneOf(this.prng, reconnectable).connect() - return true - } -} - -/** - * @param {TestYInstance} y // publish message created by `y` to all other online clients - * @param {ArrayBuffer} m - */ -const broadcastMessage = (y, m) => { - if (y.tc.onlineConns.has(y)) { - y.tc.onlineConns.forEach(remoteYInstance => { - if (remoteYInstance !== y) { - remoteYInstance._receive(m, y) - } - }) - } -} - -/** - * Convert DS to a proper DeleteSet of Map. - * - * @param {Y.Y} y - * @return {Object>} - */ -const getDeleteSet = y => { - /** - * @type {Object} - */ - var ds = {} - y.ds.iterate(null, null, n => { - var user = n._id.user - var counter = n._id.clock - var len = n.len - var gc = n.gc - var dv = ds[user] - if (dv === void 0) { - dv = [] - ds[user] = dv - } - dv.push([counter, len, gc]) - }) - return ds -} - -/** - * 1. reconnect and flush all - * 2. user 0 gc - * 3. get type content - * 4. disconnect & reconnect all (so gc is propagated) - * 5. compare os, ds, ss - * - * @param {any} t - * @param {Array} users - */ -export const compareUsers = (t, users) => { - users.forEach(u => u.connect()) - do { - users.forEach(u => { - // flush dom changes - u.domBinding._beforeTransactionHandler(null, null, false) - }) - } while (users[0].tc.flushAllMessages()) - - var userArrayValues = users.map(u => u.define('array', Y.Array).toJSON().map(val => JSON.stringify(val))) - var userMapValues = users.map(u => u.define('map', Y.Map).toJSON()) - var userXmlValues = users.map(u => u.define('xml', Y.XmlElement).toString()) - var userTextValues = users.map(u => u.define('text', Y.Text).toDelta()) - var userQuillValues = users.map(u => { - u.quill.update('yjs') // get latest changes - return u.quill.getContents().ops - }) - - var data = users.map(u => { - defragmentItemContent(u) - var data = {} - let ops = [] - u.os.iterate(null, null, op => { - let json - if (op.constructor === GC) { - json = { - type: 'GC', - id: op._id, - length: op._length, - content: null - } - } else { - json = { - id: op._id, - left: op._left === null ? null : op._left._lastId, - right: op._right === null ? null : op._right._id, - length: op._length, - deleted: op._deleted, - parent: op._parent._id, - content: null - } - } - if (op instanceof ItemJSON || op instanceof ItemString) { - json.content = op._content - } - ops.push(json) - }) - data.os = ops - data.ds = getDeleteSet(u) - const ss = {} - u.ss.state.forEach((clock, user) => { - ss[user] = clock - }) - data.ss = ss - return data - }) - for (var i = 0; i < data.length - 1; i++) { - t.group(() => { - t.compare(userArrayValues[i].length, users[i].get('array').length, 'array length correctly computed') - t.compare(userArrayValues[i], userArrayValues[i + 1], 'array types') - t.compare(userMapValues[i], userMapValues[i + 1], 'map types') - t.compare(userXmlValues[i], userXmlValues[i + 1], 'xml types') - t.compare(userTextValues[i].map(a => a.insert).join('').length, users[i].get('text').length, 'text length correctly computed') - t.compare(userTextValues[i], userTextValues[i + 1], 'text types') - t.compare(userQuillValues[i], userQuillValues[i + 1], 'quill delta content') - t.compare(data[i].os, data[i + 1].os, 'os') - t.compare(data[i].ds, data[i + 1].ds, 'ds') - t.compare(data[i].ss, data[i + 1].ss, 'ss') - }, `Compare user${i} with user${i + 1}`) - } - users.forEach(user => { - if (user._missingStructs.size !== 0) { - t.fail('missing structs should mes empty!') - } - }) - users.map(u => u.destroy()) -} - -/** - * @param {string} nodeName - * @param {Map} attrs - * @return {null|Map} - */ -const filter = (nodeName, attrs) => { - if (nodeName === 'HIDDEN') { - return null - } - attrs.delete('hidden') - return attrs -} - -/** - * @param {any} t - * @param {any} opts - * @return {any} - */ -export const initArrays = (t, opts) => { - var result = { - users: [] - } - var prng = opts.prng || random.createPRNG(t.getSeed()) - const testConnector = new TestConnector(prng) - result.testConnector = testConnector - for (let i = 0; i < opts.users; i++) { - let y = testConnector.createY(i) - result.users.push(y) - result['array' + i] = y.define('array', Y.Array) - result['map' + i] = y.define('map', Y.Map) - const yxml = y.define('xml', Y.XmlElement) - result['xml' + i] = yxml - const dom = document.createElement('my-dom') - const domBinding = new DomBinding(yxml, dom, { filter }) - result['domBinding' + i] = domBinding - result['dom' + i] = dom - const textType = y.define('text', Y.Text) - result['text' + i] = textType - const quill = new Quill(document.createElement('div')) - result['quillBinding' + i] = new QuillBinding(textType, quill) - result['quill' + i] = quill - y.quill = quill // put quill on the y object (so we can use it later) - y.dom = dom - y.domBinding = domBinding - } - testConnector.syncAll() - return result -} - -export const applyRandomTests = (t, mods, iterations) => { - const prng = random.createPRNG(t.getSeed()) - const result = initArrays(t, { users: 5, prng }) - const { testConnector, users } = result - for (var i = 0; i < iterations; i++) { - if (random.int32(prng, 0, 100) <= 2) { - // 2% chance to disconnect/reconnect a random user - if (random.bool(prng)) { - testConnector.disconnectRandom() - } else { - testConnector.reconnectRandom() - } - } else if (random.int32(prng, 0, 100) <= 1) { - // 1% chance to flush all & garbagecollect - // TODO: We do not gc all users as this does not work yet - // await garbageCollectUsers(t, users) - testConnector.flushAllMessages() - // await users[0].db.emptyGarbageCollector() // TODO: reintroduce GC tests! - } else if (random.int32(prng, 0, 100) <= 50) { - // 50% chance to flush a random message - testConnector.flushRandomMessage() - } - let user = random.oneOf(prng, users) - var test = random.oneOf(prng, mods) - test(t, user, prng) - } - compareUsers(t, users) - return result -} diff --git a/tests/index.html b/tests/index.html deleted file mode 100644 index cf894761..00000000 --- a/tests/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/tests/index.js b/tests/index.js index a215a975..bb332e3f 100644 --- a/tests/index.js +++ b/tests/index.js @@ -6,8 +6,9 @@ import * as deleteStore from './DeleteStore.tests.js' import * as array from './y-array.tests.js' import * as map from './y-map.tests.js' import * as text from './y-text.tests.js' +import * as xml from './y-xml.tests.js' if (isBrowser) { log.createVConsole(document.body) } -runTests({ deleteStore, map, array, text }) +runTests({ deleteStore, map, array, text, xml }) diff --git a/tests/y-xml.tests.js b/tests/y-xml.tests.js index f7e0a06b..d3ea9d28 100644 --- a/tests/y-xml.tests.js +++ b/tests/y-xml.tests.js @@ -1,20 +1,20 @@ -import { initArrays, compareUsers } from './helper.js' -import { test } from 'cutest' +import { init, compare } from './testHelper.js' import * as Y from '../src/index.js' +import * as t from 'lib0/testing.js' -test('set property', async function xml0 (t) { - var { testConnector, users, xml0, xml1 } = await initArrays(t, { users: 2 }) +export const testSetProperty = tc => { + const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 }) xml0.setAttribute('height', '10') t.assert(xml0.getAttribute('height') === '10', 'Simple set+get works') testConnector.flushAllMessages() t.assert(xml1.getAttribute('height') === '10', 'Simple set+get works (remote)') - await compareUsers(t, users) -}) + compare(users) +} -test('events', async function xml1 (t) { - var { testConnector, users, xml0, xml1 } = await initArrays(t, { users: 2 }) - var event = { attributesChanged: new Set() } - var remoteEvent = { attributesChanged: new Set() } +export const testEvents = tc => { + const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 }) + let event = { attributesChanged: new Set() } + let remoteEvent = { attributesChanged: new Set() } xml0.observe(e => { delete e._content delete e.nodes @@ -45,96 +45,11 @@ test('events', async function xml1 (t) { t.assert(event.childListChanged, 'YXmlEvent.childListChanged on deleted element') testConnector.flushAllMessages() t.assert(remoteEvent.childListChanged, 'YXmlEvent.childListChanged on deleted element (remote)') - await compareUsers(t, users) -}) + compare(users) +} -test('attribute modifications (y -> dom)', async function xml2 (t) { - var { users, xml0, dom0 } = await initArrays(t, { users: 3 }) - xml0.setAttribute('height', '100px') - t.assert(dom0.getAttribute('height') === '100px', 'setAttribute') - xml0.removeAttribute('height') - t.assert(dom0.getAttribute('height') == null, 'removeAttribute') - xml0.setAttribute('class', 'stuffy stuff') - t.assert(dom0.getAttribute('class') === 'stuffy stuff', 'set class attribute') - await compareUsers(t, users) -}) - -test('element insert (y -> dom)', async function xml5 (t) { - var { users, xml0, dom0 } = await initArrays(t, { users: 3 }) - xml0.insert(0, [new Y.XmlText('some text')]) - xml0.insert(1, [new Y.XmlElement('p')]) - t.assert(dom0.childNodes[0].textContent === 'some text', 'Retrieve Text node') - t.assert(dom0.childNodes[1].nodeName === 'P', 'Retrieve Element node') - await compareUsers(t, users) -}) - -test('y on insert, then delete (y -> dom)', async function xml7 (t) { - var { users, xml0, dom0 } = await initArrays(t, { users: 3 }) - xml0.insert(0, [new Y.XmlElement('p')]) - t.assert(dom0.childNodes[0].nodeName === 'P', 'Get inserted element from dom') - xml0.delete(0, 1) - t.assert(dom0.childNodes.length === 0, '#childNodes is empty after delete') - await compareUsers(t, users) -}) - -test('delete consecutive (1) (Text)', async function xml8 (t) { - var { users, xml0, dom0 } = await initArrays(t, { users: 3 }) - xml0.insert(0, [new Y.XmlText('1'), new Y.XmlText('2'), new Y.XmlText('3')]) - xml0.delete(1, 2) - t.assert(xml0.length === 1, 'check length (y)') - t.assert(dom0.childNodes.length === 1, 'check length (dom)') - t.assert(dom0.childNodes[0].textContent === '1', 'check content') - await compareUsers(t, users) -}) - -test('delete consecutive (2) (Text)', async function xml9 (t) { - var { users, xml0, dom0 } = await initArrays(t, { users: 3 }) - xml0.insert(0, [new Y.XmlText('1'), new Y.XmlText('2'), new Y.XmlText('3')]) - xml0.delete(0, 1) - xml0.delete(1, 1) - t.assert(xml0.length === 1, 'check length (y)') - t.assert(dom0.childNodes.length === 1, 'check length (dom)') - t.assert(dom0.childNodes[0].textContent === '2', 'check content') - await compareUsers(t, users) -}) - -test('delete consecutive (1) (Element)', async function xml10 (t) { - var { users, xml0, dom0 } = await initArrays(t, { users: 3 }) - xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')]) - xml0.delete(1, 2) - t.assert(xml0.length === 1, 'check length (y)') - t.assert(dom0.childNodes.length === 1, 'check length (dom)') - t.assert(dom0.childNodes[0].nodeName === 'A', 'check content') - await compareUsers(t, users) -}) - -test('delete consecutive (2) (Element)', async function xml11 (t) { - var { users, xml0, dom0 } = await initArrays(t, { users: 3 }) - xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')]) - xml0.delete(0, 1) - xml0.delete(1, 1) - t.assert(xml0.length === 1, 'check length (y)') - t.assert(dom0.childNodes.length === 1, 'check length (dom)') - t.assert(dom0.childNodes[0].nodeName === 'B', 'check content') - await compareUsers(t, users) -}) - -test('Receive a bunch of elements (with disconnect)', async function xml12 (t) { - var { testConnector, users, xml0, xml1, dom0, dom1 } = await initArrays(t, { users: 3 }) - users[1].disconnect() - xml0.insert(0, [new Y.XmlElement('A'), new Y.XmlElement('B'), new Y.XmlElement('C')]) - xml0.insert(0, [new Y.XmlElement('X'), new Y.XmlElement('Y'), new Y.XmlElement('Z')]) - await users[1].connect() - testConnector.flushAllMessages() - t.assert(xml0.length === 6, 'check length (y)') - t.assert(xml1.length === 6, 'check length (y) (reconnected user)') - t.assert(dom0.childNodes.length === 6, 'check length (dom)') - t.assert(dom1.childNodes.length === 6, 'check length (dom) (reconnected user)') - await compareUsers(t, users) -}) - -test('treeWalker', async function xml17 (t) { - var { users, xml0 } = await initArrays(t, { users: 3 }) +export const testTreewalker = tc => { + const { users, xml0 } = init(tc, { users: 3 }) let paragraph1 = new Y.XmlElement('p') let paragraph2 = new Y.XmlElement('p') let text1 = new Y.XmlText('init') @@ -146,5 +61,5 @@ test('treeWalker', async function xml17 (t) { t.assert(allParagraphs[0] === paragraph1, 'querySelectorAll found paragraph1') t.assert(allParagraphs[1] === paragraph2, 'querySelectorAll found paragraph2') t.assert(xml0.querySelector('p') === paragraph1, 'querySelector found paragraph1') - await compareUsers(t, users) -}) + compare(users) +}