import * as Y from '../src/index.js' import { init, compare } from './testHelper.js' import * as t from 'lib0/testing' import * as delta from 'lib0/delta' export const testCustomTypings = () => { const ydoc = new Y.Doc() const ymap = ydoc.get() /** * @type {Y.Node<{ attrs: { num: number, str: string, [k:string]: number|string } }>} */ const yxml = ymap.setAttr('yxml', new Y.Node('test')) /** * @type {number|undefined} */ const num = yxml.getAttr('num') /** * @type {string|undefined} */ const str = yxml.getAttr('str') /** * @type {object|number|string|undefined} */ const dtrn = yxml.getAttr('dtrn') const attrs = yxml.getAttrs() /** * @type {object|number|string|undefined} */ const any = attrs.shouldBeAny console.log({ num, str, dtrn, attrs, any }) } /** * @param {t.TestCase} tc */ export const testSetProperty = tc => { const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 }) xml0.setAttr('height', '10') t.assert(xml0.getAttr('height') === '10', 'Simple set+get works') testConnector.flushAllMessages() t.assert(xml1.getAttr('height') === '10', 'Simple set+get works (remote)') compare(users) } /** * @param {t.TestCase} tc */ export const testHasProperty = tc => { const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 }) xml0.setAttr('height', '10') t.assert(xml0.hasAttr('height'), 'Simple set+has works') testConnector.flushAllMessages() t.assert(xml1.hasAttr('height'), 'Simple set+has works (remote)') xml0.deleteAttr('height') t.assert(!xml0.hasAttr('height'), 'Simple set+remove+has works') testConnector.flushAllMessages() t.assert(!xml1.hasAttr('height'), 'Simple set+remove+has works (remote)') compare(users) } /** * @param {t.TestCase} _tc */ export const testYtextAttributes = _tc => { const ydoc = new Y.Doc() const ytext = ydoc.get('') ytext.observe(event => { t.assert(event.delta.attrs.test?.type === 'insert') }) ytext.setAttr('test', 42) t.compare(ytext.getAttr('test'), 42) t.compare(ytext.getAttrs(), { test: 42 }) } /** * @param {t.TestCase} _tc */ export const testSiblings = _tc => { const ydoc = new Y.Doc() const yxml = ydoc.get() const first = new Y.Node() const second = new Y.Node('p') yxml.insert(0, [first, second]) t.assert(first.parent === /** @type {Y.Node} */ (yxml)) t.assert(yxml.parent === null) } /** * @param {t.TestCase} _tc */ export const testClone = _tc => { const ydoc = new Y.Doc() const yxml = ydoc.get() const first = new Y.Node('text') const second = new Y.Node('p') const third = new Y.Node('p') yxml.push([first, second, third]) t.compareArrays(yxml.toArray(), [first, second, third]) const cloneYxml = yxml.clone() ydoc.get('copyarr').insert(0, [cloneYxml]) t.assert(cloneYxml.length === 3) t.compare(cloneYxml.toJSON(), yxml.toJSON()) } /** * @param {t.TestCase} _tc */ export const testFormattingBug = _tc => { const ydoc = new Y.Doc() const yxml = ydoc.get() const q = delta.create() .insert('A', { em: {}, strong: {} }) .insert('B', { em: {} }) .insert('C', { em: {}, strong: {} }) yxml.applyDelta(q) t.compare(yxml.toDelta(), q) } /** * @param {t.TestCase} _tc */ export const testElement = _tc => { const ydoc = new Y.Doc() const yxmlel = ydoc.get() const text1 = new Y.Node('text1') const text2 = new Y.Node('text2') yxmlel.insert(0, [text1, text2]) t.compareArrays(yxmlel.toArray(), [text1, text2]) } /** * @param {t.TestCase} _tc */ export const testFragmentAttributedContent = _tc => { const ydoc = new Y.Doc({ gc: false }) const yfragment = new Y.Node() const elem1 = Y.Node.from(delta.create().insert('hello')) const elem2 = new Y.Node() const elem3 = Y.Node.from(delta.create().insert('world')) yfragment.insert(0, [elem1, elem2]) ydoc.get().insert(0, [yfragment]) let renderer = /** @type {AbstractRenderer?} */ (null) ydoc.on('afterTransaction', tr => { // renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, []))) }) t.group('insert / delete', () => { ydoc.transact(() => { yfragment.delete(0, 1) yfragment.insert(1, [elem3]) }) const expectedContent = delta.create().insert([elem1], null, { delete: [] }).insert([elem2]).insert([elem3], null, { insert: [] }) const attributedContent = yfragment.toDelta({ renderer }) console.log(attributedContent.toJSON()) t.assert(attributedContent.equals(expectedContent)) t.compare(elem1.toDelta({ renderer }).toJSON(), delta.create().insert('hello', null, { delete: [] }).toJSON()) }) } /** * @param {t.TestCase} _tc */ export const testElementAttributedContent = _tc => { const ydoc = new Y.Doc({ gc: false }) const yelement = ydoc.get('p') const elem1 = delta.create().insert('hello').done() const elem2 = delta.create('span').done() const elem3 = delta.create().insert('world').done() yelement.insert(0, [elem1, elem2]) let renderer = /** @type {AbstractRenderer?} */ (null) ydoc.on('afterTransaction', tr => { // renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')])) renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, []))) }) t.group('insert / delete', () => { ydoc.transact(() => { yelement.delete(0, 1) yelement.insert(1, [elem3]) yelement.setAttr('key', '42') }) const expectedContent = delta.create() .insert([delta.create().insert('hello', null, { delete: [] })], null, { delete: [] }) .insert([elem2]) .insert([delta.create().insert('world', null, { insert: [] })], null, { insert: [] }) .setAttr('key', '42', { insert: [] }) const attributedContent = yelement.toDeltaDeep({ renderer }) console.log('retrieved content', attributedContent.toJSON()) t.assert(attributedContent.equals(expectedContent)) t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } }) }) } /** * @param {t.TestCase} _tc */ export const testElementAttributedContentViaDiffer = _tc => { const ydocV1 = new Y.Doc() ydocV1.get('p').insert(0, [delta.create().insert('hello'), delta.create('span')]) const ydoc = new Y.Doc() Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1)) const yelement = ydoc.get('p') const elem2 = yelement.get(1) // new Y.XmlElement('span') const elem3 = Y.Node.from(delta.create().insert('world')) ydoc.transact(() => { yelement.delete(0, 1) yelement.insert(1, [elem3]) yelement.setAttr('key', '42') }) const renderer = Y.createDiffRenderer(ydocV1, ydoc) const expectedContent = delta.create().insert([delta.create().insert('hello')], null, { delete: [] }).insert([elem2.toDeltaDeep()]).insert([delta.create().insert('world', null, { insert: [] })], null, { insert: [] }).setAttr('key', '42', { insert: [] }) const attributedContent = yelement.toDeltaDeep({ renderer }) console.log('children', attributedContent.toJSON().children) console.log('attributes', attributedContent.toJSON().attrs) t.compare(attributedContent.toJSON(), expectedContent.toJSON()) t.assert(attributedContent.equals(expectedContent)) t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } }) t.group('test getContentDeep', () => { const expectedContent = delta.create() .insert( [delta.create().insert('hello')], null, { delete: [] } ) .insert([delta.create('span')]) .insert([ delta.create().insert('world', null, { insert: [] }) ], null, { insert: [] }) .setAttr('key', '42', { insert: [] }) const attributedContent = yelement.toDeltaDeep({ renderer }) console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2)) console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2)) console.log('attributes', attributedContent.toJSON().attrs) t.assert(attributedContent.equals(expectedContent)) t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } }) t.assert(attributedContent.name === null) }) ydoc.transact(() => { elem3.insert(0, 'big') }) t.group('test getContentDeep after some more updates', () => { t.info('expecting DiffRenderer to auto update itself') const expectedContent = delta.create() .insert( [delta.create().insert('hello')], null, { delete: [] } ) .insert([delta.create('span')]) .insert([ delta.create().insert('bigworld', null, { insert: [] }) ], null, { insert: [] }) .setAttr('key', '42', { insert: [] }) const attributedContent = yelement.toDeltaDeep({ renderer }) console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2)) console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2)) console.log('attributes', attributedContent.toJSON().attrs) t.assert(attributedContent.equals(expectedContent)) t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } }) t.assert(attributedContent.name === null) }) Y.applyUpdate(ydocV1, Y.encodeStateAsUpdate(ydoc)) t.group('test getContentDeep both docs synced', () => { t.info('expecting DiffRenderer to auto update itself') const expectedContent = delta.create().insert([delta.create('span')]).insert([ delta.create().insert('bigworld') ]).setAttr('key', '42') const attributedContent = yelement.toDeltaDeep({ renderer }) console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2)) console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2)) console.log('attributes', attributedContent.toJSON().attrs) t.assert(attributedContent.equals(expectedContent)) t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42' } }) t.assert(attributedContent.name === null) }) } /** * @param {t.TestCase} _tc */ export const testRendererSimpleExample = _tc => { const ydoc = new Y.Doc() ydoc.clientID = 0 // create some initial content ydoc.get().insert(0, [delta.create().insert('hello world')]) const ydocFork = new Y.Doc() ydocFork.clientID = 1 Y.applyUpdate(ydocFork, Y.encodeStateAsUpdate(ydoc)) // modify the fork // append a span element ydocFork.get().insert(1, [new Y.Node('span')]) const ytext = ydocFork.get().get(0) // make "hello" italic ytext.format(0, 5, { italic: true }) ytext.insert(11, 'deleteme') ytext.delete(11, 8) ytext.insert(11, '!') // highlight the changes console.log(JSON.stringify(ydocFork.get().toDeltaDeep({ renderer: Y.createDiffRenderer(ydoc, ydocFork) }), null, 2)) /* => { "children": { "ops": [ { "insert": [ { "ops": [ { "insert": "hello", "attributes": { "italic": true }, "attribution": { "attributes": { "italic": [] -- the attribute "italic" was changed } } }, { "insert": " world" -- "world" remains unchanged }, { "insert": "!", "attribution": { "insert": [] -- "!" was inserted } } ] } ] }, { "insert": [ { "nodeName": "span", "children": { "ops": [] }, "attributes": {} } ], "attribution": { "insert": [] -- A tag was inserted } } ] } } */ } /** * Walk a delta tree and collect every op whose constructor matches one of the * forbidden names. Returns a list of `{ path, opType, op }` for reporting. * * @param {any} d * @param {Array} forbidden - constructor names that should never appear * @param {string} [path] * @param {Array<{path:string, opType:string, op:any}>} [acc] */ const collectForbiddenOps = (d, forbidden, path = '$', acc = []) => { if (d == null || typeof d !== 'object') return acc if (d.attrs != null) { for (const attrOp of d.attrs) { const name = attrOp?.constructor?.name if (name && forbidden.includes(name)) { acc.push({ path: `${path}.attrs[${attrOp.key}]`, opType: name, op: attrOp }) } } } if (d.children?.start != null) { let op = d.children.start let i = 0 while (op != null) { const name = op.constructor?.name if (name && forbidden.includes(name)) { acc.push({ path: `${path}.children[${i}]`, opType: name, op }) } if (op.insert && Array.isArray(op.insert)) { op.insert.forEach((/** @type {any} */ nested, /** @type {number} */ j) => collectForbiddenOps(nested, forbidden, `${path}.children[${i}].insert[${j}]`, acc)) } if (op.value != null) { collectForbiddenOps(op.value, forbidden, `${path}.children[${i}].value`, acc) } op = op.next i++ } } return acc } /** * Reproduces the y-prosemirror issue #247 contract violation at the @y/y * level: `ynode.toDeltaDeep({ renderer })` is supposed to surface soft-deleted content * as positive ops (`SetAttrOp` / `InsertOp`) carrying attribution metadata, * never as `DeleteAttrOp` / `DeleteOp`. Today, when a parent YXmlElement is * itself soft-deleted under attribution, `nodeMapGetDelta` (ynode.js:1928) * and the children traversal in `toDelta` emit raw delete ops for the * cascaded child setAttr / content items - which downstream consumers * (lib0/delta `diff`, y-prosemirror's PM mapper) cannot handle. * * Expected after fix: walking the delta returned by `parent.toDeltaDeep({ renderer })` * finds zero `DeleteAttrOp` entries in any `attrs` map and zero `DeleteOp` * entries in any `children` list, at every nesting level. * * @param {t.TestCase} _tc */ export const testToDeltaDeepEmitsNoDeleteOpsForSoftDeletedParent = _tc => { // Base doc: a fragment containing one YXmlElement that has an `id` attr // and one nested text child. const ydocV1 = new Y.Doc({ gc: false }) const parentV1 = ydocV1.get('frag') const childV1 = new Y.Node('item') childV1.setAttr('id', 'C') childV1.insert(0, [delta.create().insert('hello')]) parentV1.insert(0, [childV1]) // Forked doc: copy V1, then suggestion-delete the YXmlElement child by // removing it from the fragment. Under the diff AM this surfaces as a // soft-deleted child whose own `id` setAttr Item is cascade-tombstoned. const ydoc = new Y.Doc({ gc: false }) Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1)) const parent = ydoc.get('frag') ydoc.transact(() => { parent.delete(0, 1) }) const renderer = Y.createDiffRenderer(ydocV1, ydoc) const rendered = parent.toDeltaDeep({ renderer }) // The cascade should surface as positive ops with attribution, not as // delete ops. Find any DeleteAttrOp / DeleteOp anywhere in the tree. const offenders = collectForbiddenOps(rendered, ['DeleteAttrOp', 'DeleteOp']) if (offenders.length > 0) { console.log('Forbidden delete ops in toDeltaDeep output:') for (const o of offenders) { console.log(` ${o.opType} at ${o.path}`) } console.log('Full delta:', JSON.stringify(rendered.toJSON(), null, 2)) } t.assert( offenders.length === 0, `toDeltaDeep(renderer) emitted ${offenders.length} forbidden delete op(s) for a soft-deleted parent (issue #247 / y-prosemirror)` ) } /** * Companion to the cascade case above: an explicit `deleteAttr` under a diff * AM must also surface as a positive `SetAttrOp` carrying the prior value and * `{ delete: [] }` attribution, never as a `DeleteAttrOp`. The same contract * applies whether the attribute deletion came from a parent-cascade or from * a direct `ynode.deleteAttr(key)` call. * * @param {t.TestCase} _tc */ export const testToDeltaDeepRendersExplicitDeleteAttrAsSetAttrWithAttribution = _tc => { const ydocV1 = new Y.Doc({ gc: false }) ydocV1.get('p').setAttr('id', 'C') const ydoc = new Y.Doc({ gc: false }) Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1)) ydoc.transact(() => { ydoc.get('p').deleteAttr('id') }) const renderer = Y.createDiffRenderer(ydocV1, ydoc) const rendered = ydoc.get('p').toDeltaDeep({ renderer }) const offenders = collectForbiddenOps(rendered, ['DeleteAttrOp', 'DeleteOp']) t.assert(offenders.length === 0, 'no DeleteAttrOp / DeleteOp from explicit deleteAttr under diff AM') t.compare( rendered.toJSON().attrs, { id: { type: 'insert', value: 'C', attribution: { delete: [] } } }, 'explicit deleteAttr surfaces as SetAttrOp with prior value and delete attribution' ) }