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: "",
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
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
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(`
[c2}{c2]
`)
);
expect(getContent(peerInfos.c2.editor.editable, { sortAttrs: true })).toBe(
unformat(`
[][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(`
`)
)
);
e1.shared.history.addStep();
mergePeersSteps(peerInfos);
expect(getContent(e1.editable, { sortAttrs: true })).toBe(
unformat(`
[]a
`)
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
unformat(`
[]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, "");
});
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
");
});
});
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(`
`)
)
);
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(`
`)
)
);
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(`
[]a
`);
expect(getContent(e1.editable, { sortAttrs: true })).toBe(editable);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(editable);
await animationFrame();
// After mount:
editable = unformat(`
[]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(`
[]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(`
`)
)
);
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(`
[]a
`)
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
unformat(`
[]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(`
`)
)
);
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(`
[]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(`
`)
)
);
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(`
[]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[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
obj2.embeddedState.obj["2"] = 2;
await animationFrame();
mergePeersSteps(peerInfos);
await animationFrame();
expect(getContent(e1.editable, { sortAttrs: true })).toBe(
`a[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
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[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
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[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
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[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
});
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[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
undo(e2);
await animationFrame();
mergePeersSteps(peerInfos);
await animationFrame();
expect(getContent(e1.editable, { sortAttrs: true })).toBe(
`a[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
});
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[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
});
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[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
});
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[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
undo(e1);
await animationFrame();
mergePeersSteps(peerInfos);
await animationFrame();
expect(getContent(e1.editable, { sortAttrs: true })).toBe(
`a[]
`
);
expect(getContent(e2.editable, { sortAttrs: true })).toBe(
`a[]
`
);
});
});
});