import { collaborativeObject, Counter, EmbeddedWrapper, EmbeddedWrapperMixin, embedding, offsetCounter, savedCounter, SavedCounter, } from "@html_editor/../tests/_helpers/embedded_component"; import { EmbeddedComponentPlugin } from "@html_editor/others/embedded_component_plugin"; import { getEditableDescendants, StateChangeManager, } from "@html_editor/others/embedded_component_utils"; import { parseHTML } from "@html_editor/utils/html"; import { beforeEach, describe, expect, test } from "@odoo/hoot"; import { click, manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom"; import { animationFrame } from "@odoo/hoot-mock"; import { onMounted, onWillDestroy, xml } from "@odoo/owl"; import { patchWithCleanup } from "@web/../tests/web_test_helpers"; import { applyConcurrentActions, mergePeersSteps, renderTextualSelection, setupMultiEditor, testMultiEditor, validateContent, validateSameHistory, } from "./_helpers/collaboration"; import { dispatchClean } from "./_helpers/dispatch"; import { unformat } from "./_helpers/format"; import { getContent } from "./_helpers/selection"; import { addStep, deleteBackward, deleteForward, redo, undo } from "./_helpers/user_actions"; import { execCommand } from "./_helpers/userCommands"; /** * @param {Editor} editor * @param {string} value */ function insert(editor, value) { editor.shared.dom.insert(value); editor.shared.history.addStep(); } describe("Conflict resolution", () => { test("all peer steps should be on the same order", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2", "c3"], contentBefore: "

a[c1}{c1]e[c2}{c2]i[c3}{c3]

", }); applyConcurrentActions(peerInfos, { c1: (editor) => { insert(editor, "b"); insert(editor, "c"); insert(editor, "d"); }, c2: (editor) => { insert(editor, "f"); insert(editor, "g"); insert(editor, "h"); }, c3: (editor) => { insert(editor, "j"); insert(editor, "k"); insert(editor, "l"); }, }); mergePeersSteps(peerInfos); validateSameHistory(peerInfos); renderTextualSelection(peerInfos); validateContent( peerInfos, "

abcd[c1}{c1]efgh[c2}{c2]ijkl[c3}{c3]

" ); }); test("should 2 peer insertText in 2 different paragraph", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

ab[c1}{c1]

cd[c2}{c2]

", }); applyConcurrentActions(peerInfos, { c1: (editor) => { insert(editor, "e"); }, c2: (editor) => { insert(editor, "f"); }, }); mergePeersSteps(peerInfos); validateSameHistory(peerInfos); renderTextualSelection(peerInfos); validateContent(peerInfos, "

abe[c1}{c1]

cdf[c2}{c2]

"); }); test("should 2 peer insertText twice in 2 different paragraph", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

ab[c1}{c1]

cd[c2}{c2]

", afterCreate: (peerInfos) => { applyConcurrentActions(peerInfos, { c1: (editor) => { insert(editor, "e"); insert(editor, "f"); }, c2: (editor) => { insert(editor, "g"); insert(editor, "h"); }, }); mergePeersSteps(peerInfos); validateSameHistory(peerInfos); }, contentAfter: "

abef[c1}{c1]

cdgh[c2}{c2]

", }); }); test("should insertText with peer 1 and deleteBackward with peer 2", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

ab[c1}{c1][c2}{c2]c

", afterCreate: (peerInfos) => { applyConcurrentActions(peerInfos, { c1: (editor) => { insert(editor, "d"); }, c2: (editor) => { deleteBackward(editor); }, }); mergePeersSteps(peerInfos); validateSameHistory(peerInfos); }, contentAfter: "

a[c2}{c2]d[c1}{c1]cc

", }); }); test("should insertText twice with peer 1 and deleteBackward twice with peer 2", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

ab[c1}{c1][c2}{c2]c

", afterCreate: (peerInfos) => { applyConcurrentActions(peerInfos, { c1: (editor) => { insert(editor, "d"); insert(editor, "e"); }, c2: (editor) => { deleteBackward(editor); deleteBackward(editor); }, }); mergePeersSteps(peerInfos); validateSameHistory(peerInfos); }, contentAfter: "

de[c1}{c1]c[c2}{c2]c

", }); }); }); test("should not revert the step of another peer", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a[c1}{c1]b[c2}{c2]

", afterCreate: (peerInfos) => { applyConcurrentActions(peerInfos, { c1: (editor) => { insert(editor, "c"); }, c2: (editor) => { insert(editor, "d"); }, }); mergePeersSteps(peerInfos); undo(peerInfos.c1.editor); undo(peerInfos.c2.editor); expect(peerInfos.c1.editor.editable).toHaveInnerHTML("

abd

", { message: "error with peer c1", }); expect(peerInfos.c2.editor.editable).toHaveInnerHTML("

acb

", { message: "error with peer c2", }); }, }); }); describe("collaborative makeSavePoint", () => { test("After a savePoint, local steps should be discarded in collaboration and external steps should not", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

[c1}{c1]

[c2}{c2]

`, }); const savepoint = peerInfos.c1.editor.shared.history.makeSavePoint(); insert(peerInfos.c2.editor, "a"); mergePeersSteps(peerInfos); insert(peerInfos.c1.editor, "z"); mergePeersSteps(peerInfos); insert(peerInfos.c2.editor, "b"); mergePeersSteps(peerInfos); savepoint(); mergePeersSteps(peerInfos); dispatchClean(peerInfos.c1.editor); dispatchClean(peerInfos.c2.editor); renderTextualSelection(peerInfos); expect(peerInfos.c1.editor.editable).toHaveInnerHTML( `

[c1}{c1]

ab[c2}{c2]

` ); expect(peerInfos.c2.editor.editable).toHaveInnerHTML( `

[c1}{c1]

ab[c2}{c2]

` ); }); test("Ensure splitElement steps reversibility in the context of makeSavePoint", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1]

b[c2}{c2]

`, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const savepoint = e2.shared.history.makeSavePoint(); await manuallyDispatchProgrammaticEvent(e1.editable, "beforeinput", { inputType: "insertParagraph", }); mergePeersSteps(peerInfos); insert(e1, "z"); mergePeersSteps(peerInfos); savepoint(); mergePeersSteps(peerInfos); expect(getContent(e1.editable)).toBe("

a

z[]

b

"); expect(getContent(e2.editable)).toBe("

a

z

b[]

"); }); }); describe("history addExternalStep", () => { test("should revert and re-apply local mutations that are not part of a finished step", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

i[c1}{c1][c2}{c2]

`, }); peerInfos.c1.editor.shared.dom.insert("b"); insert(peerInfos.c2.editor, "a"); mergePeersSteps(peerInfos); peerInfos.c1.editor.shared.history.addStep(); mergePeersSteps(peerInfos); dispatchClean(peerInfos.c1.editor); dispatchClean(peerInfos.c2.editor); // TODO @phoenix c1 editable should be `

iab[]

`, but its selection // was not adjusted properly when receiving the external step expect(getContent(peerInfos.c1.editor.editable)).toBe(`

ia[]b

`); expect(getContent(peerInfos.c2.editor.editable)).toBe(`

ia[]b

`); }); }); test("should reset from snapshot", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a[c1}{c1]

", afterCreate: (peerInfos) => { insert(peerInfos.c1.editor, "b"); peerInfos.c1.collaborationPlugin.makeSnapshot(); // Insure the snapshot is considered to be older than 30 seconds. peerInfos.c1.collaborationPlugin.snapshots[0].time = 1; const { steps } = peerInfos.c1.collaborationPlugin.getSnapshotSteps(); peerInfos.c2.collaborationPlugin.resetFromSteps(steps); expect(peerInfos.c2.historyPlugin.steps.map((x) => x.id)).toEqual([ "fake_concurrent_id_1", ]); expect(peerInfos.c2.historyPlugin.steps[0].mutations.map((x) => x.id)).toEqual([ "fake_id_4", ]); }, contentAfter: "

ab[c1}{c1]

", }); }); describe("steps whith no parent in history", () => { test("should be able to retreive steps when disconnected from peers that has send step", async () => { await testMultiEditor({ peerIds: ["c1", "c2", "c3"], contentBefore: "

a[c1}{c1]b[c2}{c2]c[c3}{c3]

", afterCreate: (peerInfos) => { insert(peerInfos.c1.editor, "d"); peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); insert(peerInfos.c2.editor, "e"); peerInfos.c1.collaborationPlugin.onExternalHistorySteps([ peerInfos.c2.historyPlugin.steps[2], ]); peerInfos.c3.collaborationPlugin.onExternalHistorySteps([ peerInfos.c2.historyPlugin.steps[2], ]); // receive step 1 after step 2 peerInfos.c3.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); validateSameHistory(peerInfos); }, contentAfter: "

ad[c1}{c1]be[c2}{c2]c[c3}{c3]

", }); }); test("should receive steps where parent was not received", async () => { await testMultiEditor({ peerIds: ["c1", "c2", "c3"], contentBefore: "

a[c1}{c1]b[c2}{c2]

", afterCreate: (peerInfos) => { insert(peerInfos.c1.editor, "c"); peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); // Peer 3 connect firt to peer 1 that made a snapshot. peerInfos.c1.collaborationPlugin.makeSnapshot(); // Fake the time of the snapshot so it is considered to be // older than 30 seconds. peerInfos.c1.collaborationPlugin.snapshots[0].time = 1; const { steps } = peerInfos.c1.collaborationPlugin.getSnapshotSteps(); peerInfos.c3.collaborationPlugin.resetFromSteps(steps); // In the meantime peer 2 send the step to peer 1 insert(peerInfos.c2.editor, "d"); insert(peerInfos.c2.editor, "e"); peerInfos.c1.collaborationPlugin.onExternalHistorySteps([ peerInfos.c2.historyPlugin.steps[2], ]); peerInfos.c1.collaborationPlugin.onExternalHistorySteps([ peerInfos.c2.historyPlugin.steps[3], ]); // Now peer 2 is connected to peer 3 and peer 2 make a new step. insert(peerInfos.c2.editor, "f"); peerInfos.c1.collaborationPlugin.onExternalHistorySteps([ peerInfos.c2.historyPlugin.steps[4], ]); peerInfos.c3.collaborationPlugin.onExternalHistorySteps([ peerInfos.c2.historyPlugin.steps[4], ]); }, contentAfter: "

ac[c1}{c1]bdef[c2}{c2]

", }); }); }); describe("sanitize", () => { beforeEach(() => patchWithCleanup(console, { log: expect.step })); const LOG_XSS = /* js */ `window.top.console.log("xss")`; test("should sanitize when adding a node", async () => { patchWithCleanup(console, { log: expect.step, }); await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a

", afterCreate: (peerInfos) => { const script = document.createElement("script"); script.innerHTML = LOG_XSS; peerInfos.c1.editor.editable.append(script); addStep(peerInfos.c1.editor); expect(peerInfos.c1.historyPlugin.steps[1]).not.toBe(undefined); peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); expect(peerInfos.c2.editor.editable).toHaveInnerHTML("

a

"); }, }); expect.verifySteps(["xss"]); }); test("should sanitize when adding a script as descendant", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a[c1}{c1][c2}{c2]

", afterCreate: (peerInfos) => { const document = peerInfos.c1.editor.document; const i = document.createElement("i"); i.innerHTML = 'b'; peerInfos.c1.editor.editable.append(i); addStep(peerInfos.c1.editor); peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); }, afterCursorInserted: (peerInfos) => { expect(peerInfos.c2.editor.editable).toHaveInnerHTML( "

a[c1}{c1][c2}{c2]

b" ); }, }); }); test("should sanitize when changing an attribute", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a

", afterCreate: (peerInfos) => { const img = peerInfos.c1.editor.editable.childNodes[0].childNodes[1]; img.setAttribute("class", "b"); img.setAttribute("onerror", LOG_XSS); addStep(peerInfos.c1.editor); peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); expect(peerInfos.c1.editor.editable).toHaveInnerHTML( `

a

` ); expect(peerInfos.c2.editor.editable).toHaveInnerHTML('

a

'); }, }); }); test("should sanitize when undo is adding a script node", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a

", afterCreate: (peerInfos) => { const script = document.createElement("script"); script.innerHTML = LOG_XSS; peerInfos.c1.editor.editable.append(script); addStep(peerInfos.c1.editor); script.remove(); addStep(peerInfos.c1.editor); peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); // Change the peer in order to be undone from peer 2 peerInfos.c1.historyPlugin.steps[2].peerId = "c2"; peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[2], ]); execCommand(peerInfos.c2.editor, "historyUndo"); expect(peerInfos.c2.editor.editable).toHaveInnerHTML("

a

"); }, }); expect.verifySteps(["xss"]); }); test("should sanitize when undo is adding a descendant script node", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a

", afterCreate: (peerInfos) => { const div = document.createElement("div"); div.innerHTML = `b`; peerInfos.c1.editor.editable.append(div); addStep(peerInfos.c1.editor); div.remove(); addStep(peerInfos.c1.editor); peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); // Change the peer in order to be undone from peer 2 peerInfos.c1.historyPlugin.steps[2].peerId = "c2"; peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[2], ]); execCommand(peerInfos.c2.editor, "historyUndo"); expect(peerInfos.c2.editor.editable).toHaveInnerHTML("

a

b
"); }, }); }); test("should sanitize when undo is changing an attribute", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

a

", afterCreate: (peerInfos) => { const img = peerInfos.c1.editor.editable.childNodes[0].childNodes[1]; img.setAttribute("class", "b"); img.setAttribute("onerror", LOG_XSS); addStep(peerInfos.c1.editor); img.setAttribute("class", ""); img.setAttribute("onerror", ""); addStep(peerInfos.c1.editor); peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[1], ]); // Change the peer in order to be undone from peer 2 peerInfos.c1.historyPlugin.steps[2].peerId = "c2"; peerInfos.c2.collaborationPlugin.onExternalHistorySteps([ peerInfos.c1.historyPlugin.steps[2], ]); execCommand(peerInfos.c2.editor, "historyUndo"); expect(peerInfos.c2.editor.editable).toHaveInnerHTML('

a

'); }, }); }); test("should not sanitize contenteditable attribute (check DOMPurify DEFAULT_ALLOWED_ATTR)", async () => { await testMultiEditor({ peerIds: ["c1"], contentBefore: '
[c1}{c1]
', afterCreate: (peerInfos) => { const editor = peerInfos.c1.editor; const target = editor.editable.querySelector(".remove-me"); target.classList.remove("remove-me"); addStep(editor); execCommand(editor, "historyUndo"); execCommand(editor, "historyRedo"); }, contentAfter: '
[c1}{c1]
', }); }); test("should not sanitize the content of an element recursively when sanitizing an attribute", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

base

", afterCreate: (peerInfos) => { const editor1 = peerInfos.c1.editor; const editor2 = peerInfos.c2.editor; const content1 = editor1.editable.querySelector(".content"); const content2 = editor2.editable.querySelector(".content"); content2.append( ...parseHTML( editor2.document, "

mysecretcode

" ).children ); editor2.editable.append( ...parseHTML(editor2.document, "

sanitycheckc2

").children ); addStep(editor2); content1.setAttribute("onclick", "javascript:badStuff?.()"); content1.setAttribute("data-info", "43"); editor1.editable.prepend( ...parseHTML(editor1.document, "

sanitycheckc1

").children ); addStep(editor1); mergePeersSteps(peerInfos); // peer 1: // did not receive the secret code doing secret stuff from peer 2 because // it was protected // still has its own onclick attribute doing bad stuff, because he wrote it // himself expect(peerInfos.c1.editor.editable).toHaveInnerHTML( unformat(`

sanitycheckc1

base

sanitycheckc2

`) ); // peer 2: // did not receive the onclick attribute doing bad stuff from peer 1 (was // sanitized) // received the `data-info="43"` from peer 1, and doing so did not sanitize // the custom script doing secret stuff expect(peerInfos.c2.editor.editable).toHaveInnerHTML( unformat(`

sanitycheckc1

base

mysecretcode

sanitycheckc2

`) ); }, }); }); }); describe("selection", () => { test("should rectify a selection offset after an external step", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1][c2}{c2]

`, }); const e1 = peerInfos.c1.editor; e1.shared.dom.insert(parseHTML(e1.document, `a`)); e1.shared.history.addStep(); mergePeersSteps(peerInfos); const e2 = peerInfos.c2.editor; expect(getContent(e1.editable)).toBe(`

aa[]

`); expect(getContent(e2.editable)).toBe(`

a[]a

`); const p = e2.editable.querySelector("p"); e2.shared.selection.setSelection({ anchorNode: p, anchorOffset: 2, focusNode: p, focusOffset: 2, }); deleteBackward(e2); mergePeersSteps(peerInfos); expect(getContent(e1.editable)).toBe("

a[]

"); expect(getContent(e2.editable)).toBe("

a[]

"); }); }); describe("data-oe-protected", () => { test("should not share protected mutations and share unprotected ones", async () => { await testMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

[c1}{c1][c2}{c2]

", afterCreate: (peerInfos) => { peerInfos.c1.editor.editable.prepend( ...parseHTML( peerInfos.c1.editor.document, unformat(`



`) ).children ); addStep(peerInfos.c1.editor); const pTrue = peerInfos.c1.editor.editable.querySelector("#true"); peerInfos.c1.editor.shared.selection.setSelection({ anchorNode: pTrue, anchorOffset: 0, }); pTrue.prepend(peerInfos.c1.editor.document.createTextNode("a")); addStep(peerInfos.c1.editor); const pFalse = peerInfos.c1.editor.editable.querySelector("#false"); peerInfos.c1.editor.shared.selection.setSelection({ anchorNode: pFalse, anchorOffset: 0, }); insert(peerInfos.c1.editor, "a"); peerInfos.c2.collaborationPlugin.onExternalHistorySteps( peerInfos.c1.historyPlugin.steps ); validateSameHistory(peerInfos); }, afterCursorInserted: async (peerInfos) => { await animationFrame(); expect(getContent(peerInfos.c1.editor.editable, { sortAttrs: true })).toBe( unformat(`

a

a[][c1}{c1]

[c2}{c2]

`) ); expect(getContent(peerInfos.c2.editor.editable, { sortAttrs: true })).toBe( unformat(`


a[c1}{c1]

[][c2}{c2]

`) ); }, }); }); test("should properly apply `contenteditable` attribute on received protected nodes", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

[c1}{c1][c2}{c2]a

`, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert( parseHTML( e1.document, unformat(`

d

`) ) ); e1.shared.history.addStep(); mergePeersSteps(peerInfos); expect(getContent(e1.editable, { sortAttrs: true })).toBe( unformat(`

d

[]a

`) ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( unformat(`

d

[]a

`) ); }); }); describe("serialize/unserialize", () => { test("Should add a new node that contain an existing node", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

x

", }); applyConcurrentActions(peerInfos, { c1: (editor) => { const divA = editor.document.createElement("div"); divA.textContent = "a"; editor.editable.append(divA); const p = editor.editable.querySelector("p"); divA.append(p); editor.shared.history.addStep(); }, }); mergePeersSteps(peerInfos); validateSameHistory(peerInfos); validateContent(peerInfos, "
a

x

"); }); test("Should add a new node that contain another node created in the same mutation stack", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

x

", }); applyConcurrentActions(peerInfos, { c1: (editor) => { const divA = editor.document.createElement("div"); divA.textContent = "a"; editor.editable.append(divA); const divB = editor.document.createElement("div"); divB.textContent = "b"; editor.editable.append(divB); divB.append(divA); editor.shared.history.addStep(); }, }); mergePeersSteps(peerInfos); validateSameHistory(peerInfos); validateContent(peerInfos, "

x

b
a
"); }); }); describe("Collaboration with embedded components", () => { test("should send an empty embedded element", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: "

[c1}{c1][c2}{c2]

", Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [embedding("counter", Counter)], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert( parseHTML( e1.document, unformat(`

secret

`) ) ); addStep(e1); peerInfos.c2.collaborationPlugin.onExternalHistorySteps(peerInfos.c1.historyPlugin.steps); validateSameHistory(peerInfos); dispatchClean(e2); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

[]

` ); await animationFrame(); dispatchClean(e1); dispatchClean(e2); expect(getContent(e1.editable, { sortAttrs: true })).toBe( unformat(`
Counter:0

[]

`) ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( unformat(`
Counter:0

[]

`) ); }); test("components are mounted and destroyed during addExternalStep", async () => { let index = 1; patchWithCleanup(Counter.prototype, { setup() { super.setup(); this.index = index++; onMounted(() => { expect.step(`${this.index} mounted`); }); onWillDestroy(() => { expect.step(`${this.index} destroyed`); }); }, }); const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1][c2}{c2]

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [embedding("counter", Counter)], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert(parseHTML(e1.document, ``)); e1.shared.history.addStep(); mergePeersSteps(peerInfos); await animationFrame(); expect.verifySteps(["1 mounted", "2 mounted"]); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

aCounter:0[]

` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]Counter:0

` ); await click(e2.editable.querySelector(".counter")); await animationFrame(); // e1 counter was not clicked, no change expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

aCounter:0[]

` ); // e2 counter was incremented expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]Counter:1

` ); const p = e2.editable.querySelector("p"); e2.shared.selection.setSelection({ anchorNode: p, anchorOffset: 2, focusNode: p, focusOffset: 2, }); deleteBackward(e2); mergePeersSteps(peerInfos); expect.verifySteps(["2 destroyed", "1 destroyed"]); }); test("components are mounted and destroyed during resetFromSteps", async () => { let index = 1; patchWithCleanup(Counter.prototype, { setup() { super.setup(); this.index = index++; onMounted(() => { expect.step(`${this.index} mounted`); }); onWillDestroy(() => { expect.step(`${this.index} destroyed`); }); }, }); const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1][c2}{c2]

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [embedding("counter", Counter)], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert(parseHTML(e1.document, ``)); e1.shared.history.addStep(); await animationFrame(); e2.shared.dom.insert(parseHTML(e2.document, ``)); e2.shared.history.addStep(); await animationFrame(); e2.shared.dom.insert(parseHTML(e2.document, ``)); e2.shared.history.addStep(); await animationFrame(); expect.verifySteps(["1 mounted", "2 mounted", "3 mounted"]); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

aCounter:0[]

` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( unformat( `

a Counter:0 Counter:0 []

` ) ); const { steps } = peerInfos.c1.collaborationPlugin.getSnapshotSteps(); peerInfos.c2.collaborationPlugin.resetFromSteps(steps); const p = e2.editable.querySelector("p"); e2.shared.selection.setSelection({ anchorNode: p, anchorOffset: 0 }); expect.verifySteps(["2 destroyed", "3 destroyed"]); await animationFrame(); expect.verifySteps(["4 mounted"]); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

[]aCounter:0

` ); e1.destroy(); e2.destroy(); expect.verifySteps(["1 destroyed", "4 destroyed"]); }); test("editableDescendants for components are collaborative (during mount)", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

[c1}{c1][c2}{c2]a

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [ embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), { getEditableDescendants, }), ], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert( parseHTML( e1.document, unformat(`

deep

`) ) ); e1.shared.history.addStep(); const deep1 = e1.editable.querySelector("[data-embedded-editable='deep'] > p"); deep1.append(e1.document.createTextNode("1")); e1.shared.history.addStep(); mergePeersSteps(peerInfos); const deep2 = e2.editable.querySelector("[data-embedded-editable='deep'] > p"); deep2.append(e2.document.createTextNode("2")); e2.shared.history.addStep(); mergePeersSteps(peerInfos); // Before mount: let editable = unformat(`

deep12

[]a

`); expect(getContent(e1.editable, { sortAttrs: true })).toBe(editable); expect(getContent(e2.editable, { sortAttrs: true })).toBe(editable); await animationFrame(); // After mount: editable = unformat(`

deep12

[]a

`); expect(getContent(e1.editable, { sortAttrs: true })).toBe(editable); expect(getContent(e2.editable, { sortAttrs: true })).toBe(editable); deep1.append(e1.document.createTextNode("3")); e1.shared.history.addStep(); mergePeersSteps(peerInfos); deep2.append(e2.document.createTextNode("4")); e2.shared.history.addStep(); mergePeersSteps(peerInfos); editable = unformat(`

deep1234

[]a

`); expect(getContent(e1.editable, { sortAttrs: true })).toBe(editable); expect(getContent(e2.editable, { sortAttrs: true })).toBe(editable); }); test("editableDescendants for components are collaborative (with different template shapes)", async () => { const wrappers = []; patchWithCleanup(EmbeddedWrapper.prototype, { setup() { super.setup(); wrappers.push(this); }, }); const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

[c1}{c1][c2}{c2]a

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [ embedding("wrapper", EmbeddedWrapper, (host) => ({ host }), { getEditableDescendants, }), ], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert( parseHTML( e1.document, unformat(`

deep

`) ) ); e1.shared.history.addStep(); // ensure wrappers[0] is for c1 await animationFrame(); mergePeersSteps(peerInfos); // ensure wrappers[1] is for c2 await animationFrame(); const deep1 = e1.editable.querySelector("[data-embedded-editable='deep'] > p"); const deep2 = e2.editable.querySelector("[data-embedded-editable='deep'] > p"); // change state for c1 wrappers[0].state.switch = true; deep1.append(e1.document.createTextNode("1")); e1.shared.history.addStep(); // wait for patch for c1 await animationFrame(); mergePeersSteps(peerInfos); deep2.append(e2.document.createTextNode("2")); e2.shared.history.addStep(); mergePeersSteps(peerInfos); expect(getContent(e1.editable, { sortAttrs: true })).toBe( unformat(`

deep12

[]a

`) ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( unformat(`

deep12

[]a

`) ); }); test("editableDescendants for components are collaborative (after delete + undo)", async () => { const SimpleEmbeddedWrapper = EmbeddedWrapperMixin("deep"); const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

[c1}{c1][c2}{c2]a

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [ embedding("wrapper", SimpleEmbeddedWrapper, (host) => ({ host }), { getEditableDescendants, }), ], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert( parseHTML( e1.document, unformat(`

deep

`) ) ); e1.shared.history.addStep(); mergePeersSteps(peerInfos); await animationFrame(); deleteBackward(e1); mergePeersSteps(peerInfos); expect(getContent(e1.editable, { sortAttrs: true })).toBe(`

[]a

`); expect(getContent(e2.editable, { sortAttrs: true })).toBe(`

[]a

`); undo(e1); const deep1 = e1.editable.querySelector("[data-embedded-editable='deep'] > p"); deep1.append(e1.document.createTextNode("1")); e1.shared.history.addStep(); mergePeersSteps(peerInfos); await animationFrame(); const deep2 = e2.editable.querySelector("[data-embedded-editable='deep'] > p"); deep2.append(e2.document.createTextNode("2")); e2.shared.history.addStep(); mergePeersSteps(peerInfos); const editable = unformat(`

deep12

[]a

`); expect(getContent(e1.editable, { sortAttrs: true })).toBe(editable); expect(getContent(e2.editable, { sortAttrs: true })).toBe(editable); }); test("editableDescendants for components are collaborative (inside a nested component)", async () => { const SimpleEmbeddedWrapper = EmbeddedWrapperMixin("deep"); const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

[c1}{c1][c2}{c2]a

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [ embedding("wrapper", SimpleEmbeddedWrapper, (host) => ({ host }), { getEditableDescendants, }), ], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert( parseHTML( e1.document, unformat(`

shallow

deep

`) ) ); e1.shared.history.addStep(); mergePeersSteps(peerInfos); const shallow1 = e1.editable.querySelector("[data-embedded-editable='deep'] > p"); shallow1.append(e1.document.createTextNode("1")); e1.shared.history.addStep(); const deep1 = e1.editable.querySelectorAll("[data-embedded-editable='deep'] > p")[1]; deep1.append(e1.document.createTextNode("9")); e1.shared.history.addStep(); await animationFrame(); mergePeersSteps(peerInfos); const shallow2 = e2.editable.querySelector("[data-embedded-editable='deep'] > p"); shallow2.append(e2.document.createTextNode("2")); e2.shared.history.addStep(); const deep2 = e2.editable.querySelectorAll("[data-embedded-editable='deep'] > p")[1]; deep2.append(e2.document.createTextNode("8")); e2.shared.history.addStep(); mergePeersSteps(peerInfos); const editable = unformat(`

shallow12

deep98

[]a

`); expect(getContent(e1.editable, { sortAttrs: true })).toBe(editable); expect(getContent(e2.editable, { sortAttrs: true })).toBe(editable); }); describe("Embedded state", () => { beforeEach(() => { let id = 1; patchWithCleanup(StateChangeManager.prototype, { generateId: () => id++, }); }); test("A peer change to the embedded state is properly applied for every other collaborator", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `
a[c1}{c1][c2}{c2]
`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [savedCounter], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const counter1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root .node.component; const counter2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root .node.component; expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:1
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:1
` ); counter1.embeddedState.value = 3; await animationFrame(); mergePeersSteps(peerInfos); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); await animationFrame(); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); expect(counter2.embeddedState).toEqual({ value: 3, }); counter2.embeddedState.value = 5; await animationFrame(); mergePeersSteps(peerInfos); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:5
` ); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:5
` ); expect(counter1.embeddedState).toEqual({ value: 5, }); }); test("Undo and Redo can overwrite a collaborator changes to the embedded state", async () => { // Undo and Redo can be confusing with states. The idea is that a step is "owned" by // a collaborator, and the current peer can not undo it. Instead, the history allows the // peer to undo his own last step. In summary: // - undo for peer goes from the current state (which can be set by the collaborator) // to the state before his own last step. // - redo for peer goes from the current state (which can be set by the collaborator) // to the state before his own last undo. const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `
a[c1}{c1][c2}{c2]
`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [savedCounter], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const counter1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root .node.component; const counter2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root .node.component; counter2.embeddedState.value = 2; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); counter1.embeddedState.value = 3; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); // e2 last step was to go from 1 to 2. e2 can not undo step from e1 // therefore undo does 3 -> 1 undo(e2); await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:1
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:1
` ); // e1 last step was to go from 2 to 3. e1 can not undo step from e2 // therefore undo does 1 -> 2 undo(e1); await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:2
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:2
` ); // e2 last undo was to go from 3 -> 1. e2 can not redo step from e1 // therefore redo does 2 -> 3 redo(e2); await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); // e1 last undo was to go from 1 -> 2. redo does 3 -> 1. redo(e1); await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:1
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:1
` ); }); test("Restoring a savePoint from makeSavePoint maintains collaborators changes to the embedded state", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1][c2}{c2]

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [collaborativeObject], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const obj1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root.node .component; const obj2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root.node .component; expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

1_1
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

1_1
` ); obj2.embeddedState.obj["2"] = 2; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2
` ); const savepoint = e1.shared.history.makeSavePoint(); delete obj2.embeddedState.obj["1"]; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

2_2
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

2_2
` ); obj1.embeddedState.obj["3"] = 3; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); obj2.embeddedState.obj["4"] = 4; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

2_2,3_3,4_4
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

2_2,3_3,4_4
` ); savepoint(); await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); // 3, which was added after makeSavePoint, was removed from obj // for every collaborator after the savepoint restoration. // stateChangeId evolves from 4 to 9 because steps 2,3,4 were // reverted, and only steps 2 and 4 were applied again, 3 was // not re-applied since it was done by c1. The last applied state // change is the transition from {2} to {2, 4}, but the step // generated by the savePoint contains all state changes from // {2, 3, 4} to {2, 4}, and that is why it is applied correctly // for both users. expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

2_2,4_4
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

2_2,4_4
` ); }); test("New component with an embedded state received from a collaborator can have its state when it hasn't finished being mounted", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `
a[c1}{c1][c2}{c2]
`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [savedCounter], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e2.shared.dom.insert( parseHTML( e2.document, `` ) ); e2.shared.history.addStep(); await animationFrame(); const counter2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root .node.component; counter2.embeddedState.value = 3; await animationFrame(); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
aCounter:3[]
` ); insert(e1, "bc"); expect(getContent(e1.editable, { sortAttrs: true })).toBe(`
abc[]
`); mergePeersSteps(peerInfos); await animationFrame(); // TODO @phoenix: selection should be at the end of the span for e2, // but it was not correctly updated after external steps. To update // when the selection is properly handled in collaboration. expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
abc[]Counter:3
` ); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
abc[]Counter:3
` ); }); test("Late embedded state changes received from a collaborator are properly applied on a mounted component", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1][c2}{c2]

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [collaborativeObject], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const obj1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root.node .component; const obj2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root.node .component; obj1.embeddedState.obj["2"] = 2; obj1.embeddedState.obj["3"] = 4; obj2.embeddedState.obj["3"] = 3; obj2.embeddedState.obj["4"] = 4; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2,3_3,4_4
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2,3_3,4_4
` ); undo(e2); await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2
` ); }); test("Late embedded state changes received from a collaborator are properly applied on a destroyed component", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1][c2}{c2]

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [collaborativeObject], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const obj1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root.node .component; const obj2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root.node .component; obj1.embeddedState.obj["2"] = 2; obj2.embeddedState.obj["3"] = 3; await animationFrame(); deleteForward(e2); mergePeersSteps(peerInfos); await animationFrame(); undo(e2); mergePeersSteps(peerInfos); await animationFrame(); // When steps were merged, both users updated their state with // both changes, even if the component was outside of the dom. expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2,3_3
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2,3_3
` ); }); test("Collaborative state changes can be applied while a current change is still pending", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `
a[c1}{c1][c2}{c2]
`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [savedCounter], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const counter1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root .node.component; const counter2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root .node.component; counter2.embeddedState.value = 2; await animationFrame(); counter1.embeddedState.value = 3; mergePeersSteps(peerInfos); await animationFrame(); // c1 change was not yet shared with c2 since it was pending expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:2
` ); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); // share the missing step with c2 mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); }); test("A pending change applied after collaborative changes only update modified properties of that change (other properties are left untouched)", async () => { class NamedCounter extends SavedCounter { static template = xml` :`; } const namedCounter = { ...savedCounter, Component: NamedCounter, }; const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `
a[c1}{c1][c2}{c2]
`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [namedCounter], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const counter1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root .node.component; const counter2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root .node.component; counter1.embeddedState.name = "newName"; await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]newName:1
` ); counter2.embeddedState.value = 2; mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]newName:1
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]newName:2
` ); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]newName:2
` ); }); test("Collaborative state changes received late can be applied while a current change is still pending", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `
a[c1}{c1][c2}{c2]
`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [savedCounter], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const counter1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root .node.component; const counter2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root .node.component; counter2.embeddedState.value = 2; counter1.embeddedState.value = 3; await animationFrame(); counter1.embeddedState.value = 4; mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:2
` ); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:4
` ); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:4
` ); }); test("State changes are properly un-applied in the context of makeSavePoint even on a destroyed component", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1][c2}{c2]

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [collaborativeObject], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const obj1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root.node .component; const savepoint = e1.shared.history.makeSavePoint(); obj1.embeddedState.obj["2"] = 2; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); deleteForward(e2); mergePeersSteps(peerInfos); savepoint(); mergePeersSteps(peerInfos); undo(e2); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

1_1
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

1_1
` ); }); test("A change from a collaborator with the same values as the previous change done by the peer is properly applied", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `
a[c1}{c1][c2}{c2]
`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [offsetCounter], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const counter1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root .node.component; const counter2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root .node.component; counter1.embeddedState.baseValue = 3; counter2.embeddedState.baseValue = 3; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); // for the offsetCounter, baseValue is updated with the difference // between previous and next. So if both users made a change going // from 1 to 3, the resulting value should be 5. expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:5
` ); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:5
` ); undo(e1); await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `
a[]Counter:3
` ); }); test("Reverting the insertion of the first key in a collaborative object does not remove the object if it does not become empty", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `

a[c1}{c1][c2}{c2]

`, Plugins: [EmbeddedComponentPlugin], resources: { embedded_components: [collaborativeObject], }, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; const obj1 = [...peerInfos.c1.plugins.get("embeddedComponents").components][0].root.node .component; const obj2 = [...peerInfos.c2.plugins.get("embeddedComponents").components][0].root.node .component; obj1.embeddedState.obj = {}; obj1.embeddedState.obj["1"] = 1; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); obj2.embeddedState.obj["2"] = 2; await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

1_1,2_2
` ); undo(e1); await animationFrame(); mergePeersSteps(peerInfos); await animationFrame(); expect(getContent(e1.editable, { sortAttrs: true })).toBe( `

a[]

2_2
` ); expect(getContent(e2.editable, { sortAttrs: true })).toBe( `

a[]

2_2
` ); }); }); });