base
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"; import { wrapInlinesInBlocks } from "@html_editor/utils/dom"; /** * @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: "
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: "[c1}{c1]
[c2}{c2]
[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("wrapInlinesInBlocks should not create impossible mutations in a collaborative step", async () => { const peerInfos = await setupMultiEditor({ peerIds: ["c1", "c2"], contentBefore: `myNode[]
myNode[]
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]
", 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[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
a
a
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
a
a
base
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]
a
a[][c1}{c1]
[c2}{c2]
a[c1}{c1]
[][c2}{c2]
[c1}{c1][c2}{c2]a
`, }); const e1 = peerInfos.c1.editor; const e2 = peerInfos.c2.editor; e1.shared.dom.insert( parseHTML( e1.document, unformat(`d
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, "x
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
[c1}{c1][c2}{c2]
secret
[]
[]
[]
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
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
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
[]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
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[]
a[]
a[]
a[]
a[]
a[]
a[]
a[]
a[]
a[]
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[]
a[]
a[]
a[]
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[]
a[]
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`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[]
a[]
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[]
a[]
a[]
a[]