Initial commit: Web packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit cd458d4b85
791 changed files with 410049 additions and 0 deletions

View file

@ -0,0 +1,11 @@
odoo.define('web_editor.ajax.loader', function (require) {
'use strict';
const loaderFunctions = require('web_editor.loader');
loaderFunctions.createWysiwyg = (parent, options) => {
const Wysiwyg = odoo.__DEBUG__.services['web_editor.wysiwyg'];
return new Wysiwyg(parent, options.wysiwygOptions);
};
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,559 @@
/** @odoo-module **/
import { click, editInput, getFixture, makeDeferred, mockSendBeacon, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
import { FormController } from '@web/views/form/form_controller';
import { HtmlField } from "@web_editor/js/backend/html_field";
import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog";
import { parseHTML, setSelection } from "@web_editor/js/editor/odoo-editor/src/utils/utils";
import { onRendered } from "@odoo/owl";
import { wysiwygData } from "web_editor.test_utils";
import { insertText } from '@web_editor/js/editor/odoo-editor/test/utils'
// Legacy
import legacyEnv from 'web.commonEnv';
async function iframeReady(iframe) {
const iframeLoadPromise = makeDeferred();
iframe.addEventListener("load", function () {
iframeLoadPromise.resolve();
});
if (!iframe.contentDocument.body) {
await iframeLoadPromise;
}
await nextTick(); // ensure document is loaded
}
QUnit.module("WebEditor.HtmlField", ({ beforeEach }) => {
let serverData;
let target;
beforeEach(() => {
serverData = {
models: {
partner: {
fields: {
txt: { string: "txt", type: "html", trim: true },
},
records: [],
},
},
};
target = getFixture();
setupViewRegistries();
// Explicitly removed by web_editor, we need to add it back
registry.category("fields").add("html", HtmlField, { force: true });
});
QUnit.module("Form view interactions with the HtmlField");
QUnit.test("A new MediaDialog after switching record in a Form view should have the correct resId", async (assert) => {
serverData.models.partner.records = [
{id: 1, txt: "<p>first</p>"},
{id: 2, txt: "<p>second</p>"},
];
let wysiwyg, mediaDialog;
const wysiwygPromise = makeDeferred();
const mediaDialogPromise = makeDeferred();
patchWithCleanup(HtmlField.prototype, {
async startWysiwyg() {
await this._super(...arguments);
wysiwyg = this.wysiwyg;
wysiwygPromise.resolve();
}
});
patchWithCleanup(MediaDialog.prototype, {
setup() {
mediaDialog = this;
mediaDialogPromise.resolve();
this.size = 'xl';
this.contentClass = 'o_select_media_dialog';
this.title = "TEST";
this.tabs = [];
this.state = {};
// no call to super to avoid services dependencies
// this test only cares about the props given to the dialog
}
});
await makeView({
type: "form",
resId: 1,
resIds: [1, 2],
resModel: "partner",
serverData,
arch: `
<form>
<field name="txt" widget="html"/>
</form>`,
});
await wysiwygPromise;
assert.containsOnce(target, ".odoo-editor-editable p:contains(first)");
// click on the pager to switch to the next record
await click(target.querySelector(".o_pager_next"));
assert.containsOnce(target, ".odoo-editor-editable p:contains(second)");
const paragraph = target.querySelector(".odoo-editor-editable p");
setSelection(paragraph, 0, paragraph, 0);
wysiwyg.openMediaDialog();
await mediaDialogPromise;
assert.equal(mediaDialog.props.resId, 2);
});
QUnit.test("discard html field changes in form", async (assert) => {
serverData.models.partner.records = [{ id: 1, txt: "<p>first</p>" }];
let wysiwyg;
const wysiwygPromise = makeDeferred();
patchWithCleanup(HtmlField.prototype, {
async startWysiwyg() {
await this._super(...arguments);
wysiwyg = this.wysiwyg;
wysiwygPromise.resolve();
},
});
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<field name="txt" widget="html" options="{'style-inline' : true}"/>
</form>`,
});
await wysiwygPromise;
const editor = wysiwyg.odooEditor;
const editable = editor.editable;
editor.testMode = true;
assert.strictEqual(editable.innerHTML, `<p>first</p>`);
const paragraph = editable.querySelector("p");
await setSelection(paragraph, 0);
await insertText(editor, "a");
assert.strictEqual(editable.innerHTML, `<p>afirst</p>`);
// For blur event here to call _onWysiwygBlur function in html_field
await editable.dispatchEvent(new Event("blur", { bubbles: true, cancelable: true }));
// Wait for the updates to be saved , if we don't wait the update of the value will
// be done after the call for discardChanges since it uses some async functions.
await new Promise((r) => setTimeout(r, 100));
const discardButton = target.querySelector(".o_form_button_cancel");
assert.ok(discardButton);
await click(discardButton);
assert.strictEqual(editable.innerHTML, `<p>first</p>`);
});
QUnit.module('Sandboxed Preview');
QUnit.test("complex html is automatically in sandboxed preview mode", async (assert) => {
serverData.models.partner.records = [{
id: 1,
txt: `
<!DOCTYPE HTML>
<html xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="format-detection" content="telephone=no"/>
<style type="text/css">
body {
color: blue;
}
</style>
</head>
<body>
Hello
</body>
</html>
`,
}];
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<field name="txt" widget="html"/>
</form>`,
});
assert.containsOnce(target, '.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
});
QUnit.test("readonly sandboxed preview", async (assert) => {
serverData.models.partner.records = [{
id: 1,
txt: `
<!DOCTYPE HTML>
<html xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="format-detection" content="telephone=no"/>
<style type="text/css">
body {
color: blue;
}
</style>
</head>
<body>
Hello
</body>
</html>`,
}];
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form string="Partner">
<field name="txt" widget="html" readonly="1" options="{'sandboxedPreview': true}"/>
</form>`,
});
const readonlyIframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
assert.ok(readonlyIframe);
await iframeReady(readonlyIframe);
assert.strictEqual(readonlyIframe.contentDocument.body.innerText, 'Hello');
assert.strictEqual(readonlyIframe.contentWindow.getComputedStyle(readonlyIframe.contentDocument.body).color, 'rgb(0, 0, 255)');
assert.containsN(target, '#codeview-btn-group > button', 0, 'Codeview toggle should not be possible in readonly mode.');
});
QUnit.test("sandboxed preview display and editing", async (assert) => {
let codeViewState = false;
const togglePromises = [makeDeferred(), makeDeferred()];
let togglePromiseId = 0;
const writePromise = makeDeferred();
patchWithCleanup(HtmlField.prototype, {
setup: function () {
this._super(...arguments);
onRendered(() => {
if (codeViewState !== this.state.showCodeView) {
togglePromises[togglePromiseId].resolve();
}
codeViewState = this.state.showCodeView;
});
},
});
const htmlDocumentTextTemplate = (text, color) => `
<html>
<head>
<style>
body {
color: ${color};
}
</style>
</head>
<body>
${text}
</body>
</html>
`;
serverData.models.partner.records = [{
id: 1,
txt: htmlDocumentTextTemplate('Hello', 'red'),
}];
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<sheet>
<notebook>
<page string="Body" name="body">
<field name="txt" widget="html" options="{'sandboxedPreview': true}"/>
</page>
</notebook>
</sheet>
</form>`,
mockRPC(route, args) {
if (args.method === "write" && args.model === 'partner') {
assert.equal(args.args[1].txt, htmlDocumentTextTemplate('Hi', 'blue'));
writePromise.resolve();
}
}
});
// check original displayed content
let iframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
assert.ok(iframe, 'Should use a sanboxed iframe');
await iframeReady(iframe);
assert.strictEqual(iframe.contentDocument.body.textContent.trim(), 'Hello');
assert.strictEqual(iframe.contentDocument.head.querySelector('style').textContent.trim().replace(/\s/g, ''),
'body{color:red;}', 'Head nodes should remain unaltered in the head');
assert.equal(iframe.contentWindow.getComputedStyle(iframe.contentDocument.body).color, 'rgb(255, 0, 0)');
// check button is there
assert.containsOnce(target, '#codeview-btn-group > button');
// edit in xml editor
await click(target, '#codeview-btn-group > button');
await togglePromises[togglePromiseId];
togglePromiseId++;
assert.containsOnce(target, '.o_field_html[name="txt"] textarea');
await editInput(target, '.o_field_html[name="txt"] textarea', htmlDocumentTextTemplate('Hi', 'blue'));
await click(target, '#codeview-btn-group > button');
await togglePromises[togglePromiseId];
// check dispayed content after edit
iframe = target.querySelector('.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
await iframeReady(iframe);
assert.strictEqual(iframe.contentDocument.body.textContent.trim(), 'Hi');
assert.strictEqual(iframe.contentDocument.head.querySelector('style').textContent.trim().replace(/\s/g, ''),
'body{color:blue;}', 'Head nodes should remain unaltered in the head');
assert.equal(iframe.contentWindow.getComputedStyle(iframe.contentDocument.body).color, 'rgb(0, 0, 255)',
'Style should be applied inside the iframe.');
const saveButton = target.querySelector('.o_form_button_save');
assert.ok(saveButton);
await click(saveButton);
await writePromise;
});
QUnit.test("sanboxed preview mode not automatically enabled for regular values", async (assert) => {
serverData.models.partner.records = [{
id: 1,
txt: `
<body>
<p>Hello</p>
</body>
`,
}];
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<field name="txt" widget="html"/>
</form>`,
});
assert.containsN(target, '.o_field_html[name="txt"] iframe[sandbox]', 0);
assert.containsN(target, '.o_field_html[name="txt"] textarea', 0);
});
QUnit.test("sandboxed preview option applies even for simple text", async (assert) => {
serverData.models.partner.records = [{
id: 1,
txt: `
Hello
`,
}];
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<field name="txt" widget="html" options="{'sandboxedPreview': true}"/>
</form>`,
});
assert.containsOnce(target, '.o_field_html[name="txt"] iframe[sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"]');
});
QUnit.module('Readonly mode');
QUnit.test("Links should open on a new tab", async (assert) => {
assert.expect(6);
serverData.models.partner.records = [{
id: 1,
txt: `
<body>
<a href="/contactus">Relative link</a>
<a href="${location.origin}/contactus">Internal link</a>
<a href="https://google.com">External link</a>
</body>`,
}];
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<field name="txt" widget="html" readonly="1"/>
</form>`,
});
for (const link of target.querySelectorAll('a')) {
assert.strictEqual(link.getAttribute('target'), '_blank');
assert.strictEqual(link.getAttribute('rel'), 'noreferrer');
}
});
QUnit.module('Save scenarios');
QUnit.test("Ensure that urgentSave works even with modified image to save", async (assert) => {
assert.expect(5);
let sendBeaconDef;
mockSendBeacon((route, blob) => {
blob.text().then((r) => {
const { params } = JSON.parse(r);
const { args, model } = params;
if (route === '/web/dataset/call_kw/partner/write' && model === 'partner') {
if (writeCount === 0) {
// Save normal value without image.
assert.equal(args[1].txt, `<p class="test_target"><br></p>`);
} else if (writeCount === 1) {
// Save image with unfinished modification changes.
assert.equal(args[1].txt, imageContainerHTML);
} else if (writeCount === 2) {
// Save the modified image.
assert.equal(args[1].txt, getImageContainerHTML(newImageSrc, false));
} else {
// Fail the test if too many write are called.
assert.ok(writeCount === 2, "Write should only be called 3 times during this test");
}
writeCount += 1;
}
sendBeaconDef.resolve();
});
return true;
});
let formController;
// Patch to get the controller instance.
patchWithCleanup(FormController.prototype, {
setup() {
this._super(...arguments);
formController = this;
}
});
// Patch to get a promise to get the htmlField component instance when
// the wysiwyg is instancied.
const htmlFieldPromise = makeDeferred();
patchWithCleanup(HtmlField.prototype, {
async startWysiwyg() {
await this._super(...arguments);
await nextTick();
htmlFieldPromise.resolve(this);
}
});
// Add a partner record and ir.attachments model and record.
serverData.models.partner.records.push({
id: 1,
txt: "<p class='test_target'><br></p>",
});
serverData.models["ir.attachment"] = wysiwygData({})["ir.attachment"];
const imageRecord = serverData.models["ir.attachment"].records[0];
// Method to get the html of a cropped image.
// Use `data-src` instead of `src` when the SRC is an URL that would
// make a call to the server.
const getImageContainerHTML = (src, isModified) => {
return `
<p>
<img
class="img img-fluid o_we_custom_image o_we_image_cropped${isModified ? ' o_modified_image_to_save' : ''}"
data-original-id="${imageRecord.id}"
data-original-src="${imageRecord.image_src}"
data-mimetype="image/png"
data-width="50"
data-height="50"
data-scale-x="1"
data-scale-y="1"
data-aspect-ratio="0/0"
${src.startsWith("/web") ? 'data-src="' : 'src="'}${src}"
>
<br>
</p>
`.replace(/(?:\s|(?:\r\n))+/g, ' ')
.replace(/\s?(<|>)\s?/g, '$1');
};
// Promise to resolve when we want the response of the modify_image RPC.
const modifyImagePromise = makeDeferred();
let writeCount = 0;
let modifyImageCount = 0;
// Valid base64 encoded image in its transitory modified state.
const imageContainerHTML = getImageContainerHTML(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII",
true
);
// New src URL to assign to the image when the modification is
// "registered".
const newImageSrc = "/web/image/1234/cropped_transparent.png";
const mockRPC = async function (route, args) {
if (
route === '/web/dataset/call_kw/partner/write' &&
args.model === 'partner'
) {
assert.ok(false, "write should only be called through sendBeacon");
} else if (
route === `/web_editor/modify_image/${imageRecord.id}`
) {
if (modifyImageCount === 0) {
assert.equal(args.res_model, 'partner');
assert.equal(args.res_id, 1);
await modifyImagePromise;
return newImageSrc;
} else {
// Fail the test if too many modify_image are called.
assert.ok(modifyImageCount === 0, "The image should only have been modified once during this test");
}
modifyImageCount += 1;
}
};
// Add the ajax service (legacy), because wysiwyg RPCs use it.
patchWithCleanup(legacyEnv, {
services: {
...legacyEnv.services,
ajax: {
rpc: mockRPC,
},
}
});
await makeView({
type: "form",
resId: 1,
resModel: "partner",
serverData,
arch: `
<form>
<field name="txt" widget="html"/>
</form>`,
mockRPC: mockRPC,
});
// Let the htmlField be mounted and recover the Component instance.
const htmlField = await htmlFieldPromise;
const editor = htmlField.wysiwyg.odooEditor;
// Simulate an urgent save without any image in the content.
sendBeaconDef = makeDeferred();
await formController.beforeUnload();
await sendBeaconDef;
// Replace the empty paragraph with a paragrah containing an unsaved
// modified image
const imageContainerElement = parseHTML(imageContainerHTML).firstChild;
let paragraph = editor.editable.querySelector(".test_target");
editor.editable.replaceChild(imageContainerElement, paragraph);
editor.historyStep();
// Simulate an urgent save before the end of the RPC roundtrip for the
// image.
sendBeaconDef = makeDeferred();
await formController.beforeUnload();
await sendBeaconDef;
// Resolve the image modification (simulate end of RPC roundtrip).
modifyImagePromise.resolve();
await modifyImagePromise;
await nextTick();
// Simulate the last urgent save, with the modified image.
sendBeaconDef = makeDeferred();
await formController.beforeUnload();
await sendBeaconDef;
});
});

View file

@ -0,0 +1,92 @@
/** @odoo-module **/
import {getAffineApproximation, getProjective, transform} from '@web_editor/js/editor/perspective_utils';
const epsilon = 100 * Number.EPSILON;
function midpoint([x0, y0], [x1, y1], weight = 0.5) {
return [weight * (x0 + y0), (1.0 - weight) * (x1 + y1)];
}
function pointEqual(assert, a, b) {
assert.pushResult({
result: Math.abs(a[0] - b[0]) < epsilon && Math.abs(a[1] - b[1]) < epsilon,
expected: `(${a[0]}, ${a[1]})`,
actual: `(${b[0]}, ${b[1]})`,
});
}
function notPointEqual(assert, a, b) {
assert.pushResult({
result: Math.abs(a[0] - b[0]) > epsilon || Math.abs(a[1] - b[1]) > epsilon,
expected: `(${a[0]}, ${a[1]})`,
actual: `different from (${b[0]}, ${b[1]})`,
});
}
QUnit.module('Perspective Utils', {
}, function () {
QUnit.test("Should correctly transform 2D points using a projective transformation", async function (assert) {
assert.expect(3);
const translation = [[0, 0, 3], [0, 0, 5], [0, 0, 1]];
pointEqual(assert, transform(translation, [0, 0]), [3, 5]);
const scale = [[2, 0, 0], [0, 0.5, 0], [0, 0, 1]];
pointEqual(assert, transform(scale, [1, 1]), [2, 0.5]);
const perspective = [[1, 0, 0], [0, 1, 0], [1, 1, 1]];
pointEqual(assert, transform(perspective, [4, 5]), [0.4, 0.5]);
});
QUnit.test("Should find an affine approximation of a projective transformation", async function (assert) {
assert.expect(4);
const a = [0, 0];
const b = [1, 0];
const c = [0, 1];
const projective = [[1, 2, 3], [2, 4, 5], [2, 2, 1]];
const affine = getAffineApproximation(projective, [a, b, c]);
pointEqual(assert, transform(projective, a), transform(affine, a));
pointEqual(assert, transform(projective, b), transform(affine, b));
pointEqual(assert, transform(projective, c), transform(affine, c));
notPointEqual(assert, (projective, [1, 1]), transform(affine, [1, 1]));
});
QUnit.test("Should identically transform common edge points of two affine approximations", async function (assert) {
assert.expect(3);
const a = [0, 0];
const b = [1, 0];
const c = [1, 1];
const d = [0, 1];
const projective = [[1, 2, 3], [2, 4, 5], [2, 2, 1]];
const affine1 = getAffineApproximation(projective, [a, b, d]);
const affine2 = getAffineApproximation(projective, [b, c, d]);
pointEqual(assert, transform(affine1, midpoint(b, d, 0.2)), transform(affine2, midpoint(b, d, 0.2)));
pointEqual(assert, transform(affine1, midpoint(b, d, 0.5)), transform(affine2, midpoint(b, d, 0.5)));
pointEqual(assert, transform(affine1, midpoint(b, d, 0.8)), transform(affine2, midpoint(b, d, 0.8)));
});
QUnit.test("Should find a projective transformation for a given quadrilateral", async function (assert) {
assert.expect(4);
const width = 2;
const height = 3;
const a = [0.1, 0.3];
const b = [1.9, 0.1];
const c = [1.7, 2.9];
const d = [0.1, 2.8];
const projective = getProjective(width, height, [a, b, c, d]);
pointEqual(assert, a, transform(projective, [0, 0]));
pointEqual(assert, b, transform(projective, [width, 0]));
pointEqual(assert, c, transform(projective, [width, height]));
pointEqual(assert, d, transform(projective, [0, height]));
});
});

View file

@ -0,0 +1,899 @@
odoo.define('web_editor.test_utils', function (require) {
"use strict";
var ajax = require('web.ajax');
var MockServer = require('web.MockServer');
var testUtils = require('web.test_utils');
var OdooEditorLib = require('@web_editor/js/editor/odoo-editor/src/OdooEditor');
var Widget = require('web.Widget');
var Wysiwyg = require('web_editor.wysiwyg');
var options = require('web_editor.snippets.options');
const { TABLE_ATTRIBUTES, TABLE_STYLES } = require('@web_editor/js/backend/convert_inline');
const COLOR_PICKER_TEMPLATE = `
<colorpicker>
<div class="o_colorpicker_section" data-name="theme" data-display="Theme Colors" data-icon-class="fa fa-flask">
<button data-color="o-color-1"/>
<button data-color="o-color-2"/>
<button data-color="o-color-3"/>
<button data-color="o-color-4"/>
<button data-color="o-color-5"/>
</div>
<div class="o_colorpicker_section" data-name="transparent_grayscale" data-display="Transparent Colors" data-icon-class="fa fa-eye-slash">
<button class="o_btn_transparent"/>
<button data-color="black-25"/>
<button data-color="black-50"/>
<button data-color="black-75"/>
<button data-color="white-25"/>
<button data-color="white-50"/>
<button data-color="white-75"/>
</div>
<div class="o_colorpicker_section" data-name="common" data-display="Common Colors" data-icon-class="fa fa-paint-brush">
<button data-color="black"></button>
<button data-color="900"></button>
<button data-color="800"></button>
<button data-color="700" class="d-none"></button>
<button data-color="600"></button>
<button data-color="500" class="d-none"></button>
<button data-color="400"></button>
<button data-color="300" class="d-none"></button>
<button data-color="200"></button>
<button data-color="100"></button>
<button data-color="white"></button>
</div>
</colorpicker>
`;
const SNIPPETS_TEMPLATE = `
<h2 id="snippets_menu">Add blocks</h2>
<div id="o_scroll">
<div id="snippet_structure" class="o_panel">
<div class="o_panel_header">First Panel</div>
<div class="o_panel_body">
<div name="Separator" data-oe-type="snippet" data-oe-thumbnail="/web_editor/static/src/img/snippets_thumbs/s_hr.svg">
<div class="s_hr pt32 pb32">
<hr class="s_hr_1px s_hr_solid w-100 mx-auto"/>
</div>
</div>
<div name="Content" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">
<section name="Content+Options" class="test_option_all pt32 pb32" data-oe-type="snippet" data-oe-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png">
<div class="container">
<div class="row">
<div class="col-lg-10 offset-lg-1 pt32 pb32">
<h2>Title</h2>
<p class="lead o_default_snippet_text">Content</p>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
<div id="snippet_options" class="d-none">
<div data-js="many2one" data-selector="[data-oe-many2one-model]:not([data-oe-readonly])" data-no-check="true"/>
<div data-js="content"
data-selector=".s_hr, .test_option_all"
data-drop-in=".note-editable"
data-drop-near="p, h1, h2, h3, blockquote, .s_hr"/>
<div data-js="sizing_y" data-selector=".s_hr, .test_option_all"/>
<div data-selector=".test_option_all">
<we-colorpicker string="Background Color" data-select-style="true" data-css-property="background-color" data-color-prefix="bg-"/>
</div>
<div data-js="BackgroundImage" data-selector=".test_option_all">
<we-button data-choose-image="true" data-no-preview="true">
<i class="fa fa-picture-o"/> Background Image
</we-button>
</div>
<div data-js="option_test" data-selector=".s_hr">
<we-select string="Alignment">
<we-button data-select-class="align-items-start">Top</we-button>
<we-button data-select-class="align-items-center">Middle</we-button>
<we-button data-select-class="align-items-end">Bottom</we-button>
<we-button data-select-class="align-items-stretch">Equal height</we-button>
</we-select>
</div>
</div>`;
MockServer.include({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
* @returns {Promise}
*/
async _performRpc(route, args) {
if (args.model === "ir.ui.view" && args.method === 'render_public_asset') {
if (args.args[0] === "web_editor.colorpicker") {
return COLOR_PICKER_TEMPLATE;
}
if (args.args[0] === "web_editor.snippets") {
return SNIPPETS_TEMPLATE;
}
}
return this._super(...arguments);
},
});
/**
* Options with animation and edition for test.
*/
options.registry.option_test = options.Class.extend({
cleanForSave: function () {
this.$target.addClass('cleanForSave');
},
onBuilt: function () {
this.$target.addClass('built');
},
onBlur: function () {
this.$target.removeClass('focus');
},
onClone: function () {
this.$target.addClass('clone');
this.$target.removeClass('focus');
},
onFocus: function () {
this.$target.addClass('focus');
},
onMove: function () {
this.$target.addClass('move');
},
onRemove: function () {
this.$target.closest('.note-editable').addClass('snippet_has_removed');
},
});
/**
* Constructor WysiwygTest why editable and unbreakable node used in test.
*/
var WysiwygTest = Wysiwyg.extend({
_parentToDestroyForTest: null,
/**
* Override 'destroy' of discuss so that it calls 'destroy' on the parent.
*
* @override
*/
destroy: function () {
unpatch();
this._super();
this.$target.remove();
this._parentToDestroyForTest.destroy();
},
});
function patch() {
testUtils.mock.patch(ajax, {
loadAsset: function (xmlId) {
if (xmlId === 'template.assets') {
return Promise.resolve({
cssLibs: [],
cssContents: ['body {background-color: red;}']
});
}
if (xmlId === 'template.assets_all_style') {
return Promise.resolve({
cssLibs: $('link[href]:not([type="image/x-icon"])').map(function () {
return $(this).attr('href');
}).get(),
cssContents: ['body {background-color: red;}']
});
}
throw 'Wrong template';
},
});
}
function unpatch() {
testUtils.mock.unpatch(ajax);
}
/**
* @param {object} data
* @returns {object}
*/
function wysiwygData(data) {
return _.defaults({}, data, {
'ir.ui.view': {
fields: {
display_name: {
string: "Displayed name",
type: "char",
},
},
records: [],
render_template(args) {
if (args[0] === 'web_editor.colorpicker') {
return COLOR_PICKER_TEMPLATE;
}
if (args[0] === 'web_editor.snippets') {
return SNIPPETS_TEMPLATE;
}
},
},
'ir.attachment': {
fields: {
display_name: {
string: "display_name",
type: 'char',
},
description: {
string: "description",
type: 'char',
},
mimetype: {
string: "mimetype",
type: 'char',
},
checksum: {
string: "checksum",
type: 'char',
},
url: {
string: "url",
type: 'char',
},
type: {
string: "type",
type: 'char',
},
res_id: {
string: "res_id",
type: 'integer',
},
res_model: {
string: "res_model",
type: 'char',
},
public: {
string: "public",
type: 'boolean',
},
access_token: {
string: "access_token",
type: 'char',
},
image_src: {
string: "image_src",
type: 'char',
},
image_width: {
string: "image_width",
type: 'integer',
},
image_height: {
string: "image_height",
type: 'integer',
},
original_id: {
string: "original_id",
type: 'many2one',
relation: 'ir.attachment',
},
},
records: [{
id: 1,
name: 'image',
description: '',
mimetype: 'image/png',
checksum: false,
url: '/web/image/123/transparent.png',
type: 'url',
res_id: 0,
res_model: false,
public: true,
access_token: false,
image_src: '/web/image/123/transparent.png',
image_width: 256,
image_height: 256,
}],
generate_access_token: function () {
return;
},
},
});
}
/**
* Create the wysiwyg instance for test (contains patch, usefull ir.ui.view, snippets).
*
* @param {object} params
*/
async function createWysiwyg(params) {
patch();
params.data = wysiwygData(params.data);
var parent = new Widget();
await testUtils.mock.addMockEnvironment(parent, params);
var wysiwygOptions = _.extend({}, params.wysiwygOptions, {
recordInfo: {
context: {},
res_model: 'module.test',
res_id: 1,
},
useOnlyTestUnbreakable: params.useOnlyTestUnbreakable,
});
var wysiwyg = new WysiwygTest(parent, wysiwygOptions);
wysiwyg._parentToDestroyForTest = parent;
var $textarea = $('<textarea/>');
if (wysiwygOptions.value) {
$textarea.val(wysiwygOptions.value);
}
var selector = params.debug ? 'body' : '#qunit-fixture';
$textarea.prependTo($(selector));
if (params.debug) {
$('body').addClass('debug');
}
return wysiwyg.attachTo($textarea).then(function () {
if (wysiwygOptions.snippets) {
var defSnippets = testUtils.makeTestPromise();
testUtils.mock.intercept(wysiwyg, "snippets_loaded", function () {
defSnippets.resolve(wysiwyg);
});
return defSnippets;
}
return wysiwyg;
});
}
/**
* Char codes.
*/
var keyboardMap = {
"8": "BACKSPACE",
"9": "TAB",
"13": "ENTER",
"16": "SHIFT",
"17": "CONTROL",
"18": "ALT",
"19": "PAUSE",
"20": "CAPS_LOCK",
"27": "ESCAPE",
"32": "SPACE",
"33": "PAGE_UP",
"34": "PAGE_DOWN",
"35": "END",
"36": "HOME",
"37": "LEFT",
"38": "UP",
"39": "RIGHT",
"40": "DOWN",
"45": "INSERT",
"46": "DELETE",
"91": "OS_KEY", // 'left command': Windows Key (Windows) or Command Key (Mac)
"93": "CONTEXT_MENU", // 'right command'
};
_.each(_.range(40, 127), function (keyCode) {
if (!keyboardMap[keyCode]) {
keyboardMap[keyCode] = String.fromCharCode(keyCode);
}
});
/**
* Perform a series of tests (`keyboardTests`) for using keyboard inputs.
*
* @see wysiwyg_keyboard_tests.js
* @see wysiwyg_tests.js
*
* @param {jQuery} $editable
* @param {object} assert
* @param {object[]} keyboardTests
* @param {string} keyboardTests.name
* @param {string} keyboardTests.content
* @param {object[]} keyboardTests.steps
* @param {string} keyboardTests.steps.start
* @param {string} [keyboardTests.steps.end] default: steps.start
* @param {string} keyboardTests.steps.key
* @param {object} keyboardTests.test
* @param {string} [keyboardTests.test.content]
* @param {string} [keyboardTests.test.start]
* @param {string} [keyboardTests.test.end] default: steps.start
* @param {function($editable, assert)} [keyboardTests.test.check]
* @param {Number} addTests
*/
var testKeyboard = function ($editable, assert, keyboardTests, addTests) {
var tests = _.compact(_.pluck(keyboardTests, 'test'));
var testNumber = _.compact(_.pluck(tests, 'start')).length +
_.compact(_.pluck(tests, 'content')).length +
_.compact(_.pluck(tests, 'check')).length +
(addTests | 0);
assert.expect(testNumber);
function keydown(target, keypress) {
var $target = $(target.tagName ? target : target.parentNode);
if (!keypress.keyCode) {
keypress.keyCode = +_.findKey(keyboardMap, function (key) {
return key === keypress.key;
});
} else {
keypress.key = keyboardMap[keypress.keyCode] || String.fromCharCode(keypress.keyCode);
}
var event = $.Event("keydown", keypress);
$target.trigger(event);
if (!event.isDefaultPrevented()) {
if (keypress.key.length === 1) {
textInput($target[0], keypress.key);
} else {
console.warn('Native "' + keypress.key + '" is not supported in test');
}
}
$target.trigger($.Event("keyup", keypress));
return $target;
}
function _select(selector) {
// eg: ".class:contents()[0]->1" selects the first contents of the 'class' class, with an offset of 1
var reDOMSelection = /^(.+?)(:contents(\(\)\[|\()([0-9]+)[\]|\)])?(->([0-9]+))?$/;
var sel = selector.match(reDOMSelection);
var $node = $editable.find(sel[1]);
var point = {
node: sel[3] ? $node.contents()[+sel[4]] : $node[0],
offset: sel[5] ? +sel[6] : 0,
};
if (!point.node || point.offset > (point.node.tagName ? point.node.childNodes : point.node.textContent).length) {
assert.notOk("Node not found: '" + selector + "' " + (point.node ? "(container: '" + (point.node.outerHTML || point.node.textContent) + "')" : ""));
}
return point;
}
function selectText(start, end) {
start = _select(start);
var target = start.node;
$(target.tagName ? target : target.parentNode).trigger("mousedown");
if (end) {
end = _select(end);
Wysiwyg.setRange(start.node, start.offset, end.node, end.offset);
} else {
Wysiwyg.setRange(start.node, start.offset);
}
target = end ? end.node : start.node;
$(target.tagName ? target : target.parentNode).trigger('mouseup');
}
function nextPoint(point) {
var node, offset;
if (OdooEditorLib.nodeSize(point.node) === point.offset) {
node = point.node.parentNode;
offset = OdooEditorLib.childNodeIndex(point.node) + 1;
} else if (point.node.hasChildNodes()) {
node = point.node.childNodes[point.offset];
offset = 0;
} else {
node = point.node;
offset = point.offset + 1;
}
return {
node: node,
offset: offset
};
}
function endOfAreaBetweenTwoNodes(point) {
// move the position because some browser make the caret on the end of the previous area after normalize
if (
!point.node.tagName &&
point.offset === point.node.textContent.length &&
!/\S|\u00A0/.test(point.node.textContent)
) {
point = nextPoint(nextPoint(point));
while (point.node.tagName && point.node.textContent.length) {
point = nextPoint(point);
}
}
return point;
}
var defPollTest = Promise.resolve();
function pollTest(test) {
var def = Promise.resolve();
$editable.data('wysiwyg').setValue(test.content);
function poll(step) {
var def = testUtils.makeTestPromise();
if (step.start) {
selectText(step.start, step.end);
if (!Wysiwyg.getRange()) {
throw 'Wrong range! \n' +
'Test: ' + test.name + '\n' +
'Selection: ' + step.start + '" to "' + step.end + '"\n' +
'DOM: ' + $editable.html();
}
}
setTimeout(function () {
if (step.keyCode || step.key) {
var target = Wysiwyg.getRange().ec;
if (window.location.search.indexOf('notrycatch') !== -1) {
keydown(target, {
key: step.key,
keyCode: step.keyCode,
ctrlKey: !!step.ctrlKey,
shiftKey: !!step.shiftKey,
altKey: !!step.altKey,
metaKey: !!step.metaKey,
});
} else {
try {
keydown(target, {
key: step.key,
keyCode: step.keyCode,
ctrlKey: !!step.ctrlKey,
shiftKey: !!step.shiftKey,
altKey: !!step.altKey,
metaKey: !!step.metaKey,
});
} catch (e) {
assert.notOk(e.name + '\n\n' + e.stack, test.name);
}
}
}
setTimeout(function () {
if (step.keyCode || step.key) {
var $target = $(target.tagName ? target : target.parentNode);
$target.trigger($.Event('keyup', {
key: step.key,
keyCode: step.keyCode,
ctrlKey: !!step.ctrlKey,
shiftKey: !!step.shiftKey,
altKey: !!step.altKey,
metaKey: !!step.metaKey,
}));
}
setTimeout(def.resolve.bind(def));
});
});
return def;
}
while (test.steps.length) {
def = def.then(poll.bind(null, test.steps.shift()));
}
return def.then(function () {
if (!test.test) {
return;
}
if (test.test.check) {
test.test.check($editable, assert);
}
// test content
if (test.test.content) {
var value = $editable.data('wysiwyg').getValue({
keepPopover: true,
});
var allInvisible = /\u200B/g;
value = value.replace(allInvisible, '&#8203;');
var result = test.test.content.replace(allInvisible, '&#8203;');
assert.strictEqual(value, result, test.name);
if (test.test.start && value !== result) {
assert.notOk("Wrong DOM (see previous assert)", test.name + " (carret position)");
return;
}
}
$editable[0].normalize();
// test carret position
if (test.test.start) {
var start = _select(test.test.start);
var range = Wysiwyg.getRange();
if ((range.sc !== range.ec || range.so !== range.eo) && !test.test.end) {
assert.ok(false, test.name + ": the carret is not colapsed and the 'end' selector in test is missing");
return;
}
var end = test.test.end ? _select(test.test.end) : start;
if (start.node && end.node) {
range = Wysiwyg.getRange();
var startPoint = endOfAreaBetweenTwoNodes({
node: range.sc,
offset: range.so,
});
var endPoint = endOfAreaBetweenTwoNodes({
node: range.ec,
offset: range.eo,
});
var sameDOM = (startPoint.node.outerHTML || startPoint.node.textContent) === (start.node.outerHTML || start.node.textContent);
var stringify = function (obj) {
if (!sameDOM) {
delete obj.sameDOMsameNode;
}
return JSON.stringify(obj, null, 2)
.replace(/"([^"\s-]+)":/g, "\$1:")
.replace(/([^\\])"/g, "\$1'")
.replace(/\\"/g, '"');
};
assert.deepEqual(stringify({
startNode: startPoint.node.outerHTML || startPoint.node.textContent,
startOffset: startPoint.offset,
endPoint: endPoint.node.outerHTML || endPoint.node.textContent,
endOffset: endPoint.offset,
sameDOMsameNode: sameDOM && startPoint.node === start.node,
}),
stringify({
startNode: start.node.outerHTML || start.node.textContent,
startOffset: start.offset,
endPoint: end.node.outerHTML || end.node.textContent,
endOffset: end.offset,
sameDOMsameNode: true,
}),
test.name + " (carret position)");
}
}
});
}
while (keyboardTests.length) {
defPollTest = defPollTest.then(pollTest.bind(null, keyboardTests.shift()));
}
return defPollTest;
};
/**
* Select a node in the dom with is offset.
*
* @param {String} startSelector
* @param {String} endSelector
* @param {jQuery} $editable
* @returns {Object} {sc, so, ec, eo}
*/
var select = (function () {
var __select = function (selector, $editable) {
var sel = selector.match(/^(.+?)(:contents\(\)\[([0-9]+)\]|:contents\(([0-9]+)\))?(->([0-9]+))?$/);
var $node = $editable.find(sel[1]);
return {
node: sel[2] ? $node.contents()[sel[3] ? +sel[3] : +sel[4]] : $node[0],
offset: sel[5] ? +sel[6] : 0,
};
};
return function (startSelector, endSelector, $editable) {
var start = __select(startSelector, $editable);
var end = endSelector ? __select(endSelector, $editable) : start;
return {
sc: start.node,
so: start.offset,
ec: end.node,
eo: end.offset,
};
};
})();
/**
* Trigger a keydown event.
*
* @param {String or Number} key (name or code)
* @param {jQuery} $editable
* @param {Object} [options]
* @param {Boolean} [options.firstDeselect] (default: false) true to deselect before pressing
*/
var keydown = function (key, $editable, options) {
var keyPress = {};
if (typeof key === 'string') {
keyPress.key = key;
keyPress.keyCode = +_.findKey(keyboardMap, function (k) {
return k === key;
});
} else {
keyPress.key = keyboardMap[key] || String.fromCharCode(key);
keyPress.keyCode = key;
}
var range = Wysiwyg.getRange();
if (!range) {
console.error("Editor have not any range");
return;
}
if (options && options.firstDeselect) {
range.sc = range.ec;
range.so = range.eo;
Wysiwyg.setRange(range.sc, range.so, range.ec, range.eo);
}
var target = range.ec;
var $target = $(target.tagName ? target : target.parentNode);
var event = $.Event("keydown", keyPress);
$target.trigger(event);
if (!event.isDefaultPrevented()) {
if (keyPress.key.length === 1) {
textInput($target[0], keyPress.key);
} else {
console.warn('Native "' + keyPress.key + '" is not supported in test');
}
}
};
var textInput = function (target, char) {
var ev = new CustomEvent('textInput', {
bubbles: true,
cancelBubble: false,
cancelable: true,
composed: true,
data: char,
defaultPrevented: false,
detail: 0,
eventPhase: 3,
isTrusted: true,
returnValue: true,
sourceCapabilities: null,
type: "textInput",
which: 0,
});
ev.data = char;
target.dispatchEvent(ev);
if (!ev.defaultPrevented) {
document.execCommand("insertText", 0, ev.data);
}
};
//--------------------------------------------------------------------------
// Convert Inline
//--------------------------------------------------------------------------
const tableAttributesString = Object.keys(TABLE_ATTRIBUTES).map(key => `${key}="${TABLE_ATTRIBUTES[key]}"`).join(' ');
const tableStylesString = Object.keys(TABLE_STYLES).map(key => `${key}: ${TABLE_STYLES[key]};`).join(' ');
/**
* Take a matrix representing a grid and return an HTML string of the Bootstrap
* grid. The matrix is an array of rows, with each row being an array of cells.
* Each cell can be represented either by a 0 < number < 13 (col-#) or a falsy
* value (col). Each cell has its coordinates `(row index, column index)` as
* text content.
* Eg: [ // <div class="container">
* [ // <div class="row">
* 1, // <div class="col-1">(0, 0)</div>
* 11, // <div class="col-11">(0, 1)</div>
* ], // </div>
* [ // <div class="row">
* false, // <div class="col">(1, 0)</div>
* ], // </div>
* ] // </div>
*
* @param {Array<Array<Number|null>>} matrix
* @returns {string}
*/
function getGridHtml(matrix) {
return (
`<div class="container">` +
matrix.map((row, iRow) => (
`<div class="row">` +
row.map((col, iCol) => (
`<div class="${col ? 'col-' + col : 'col'}">(${iRow}, ${iCol})</div>`
)).join('') +
`</div>`
)).join('') +
`</div>`
);
}
function getTdHtml(colspan, text, containerWidth) {
return (
`<td colspan="${colspan}"${
containerWidth ? ' ' + `style="max-width: ${Math.round(containerWidth*colspan/12*100)/100}px;"`
: ''}>` +
text +
`</td>`
);
}
/**
* Take a matrix representing a table and return an HTML string of the table.
* The matrix is an array of rows, with each row being an array of cells. Each
* cell is represented by a tuple of numbers [colspan, width (in percent)]. A
* cell can have a string as third value to represent its text content. The
* default text content of each cell is its coordinates `(row index, column
* index)`. If the cell has a number as third value, it will be used as the
* max-width of the cell (in pixels).
* Eg: [ // <table> (note: extra attrs and styles apply)
* [ // <tr>
* [1, 8], // <td colspan="1" width="8%">(0, 0)</td>
* [11, 92] // <td colspan="11" width="92%">(0, 1)</td>
* ], // </tr>
* [ // <tr>
* [2, 17, 'A'], // <td colspan="2" width="17%">A</td>
* [10, 83], // <td colspan="10" width="83%">(1, 1)</td>
* ], // </tr>
* ] // </table>
*
* @param {Array<Array<Array<[Number, Number, string?, number?]>>>} matrix
* @param {Number} [containerWidth]
* @returns {string}
*/
function getTableHtml(matrix, containerWidth) {
return (
`<table ${tableAttributesString} style="width: 100% !important; ${tableStylesString}">` +
matrix.map((row, iRow) => (
`<tr>` +
row.map((col, iCol) => (
getTdHtml(col[0], typeof col[2] === 'string' ? col[2] : `(${iRow}, ${iCol})`, containerWidth)
)).join('') +
`</tr>`
)).join('') +
`</table>`
);
}
/**
* Take a number of rows and a number of columns (or number of columns per
* individual row) and return an HTML string of the corresponding grid. Every
* column is a regular Bootstrap "col" (no col-#).
* Eg: [2, 3] <=> getGridHtml([[false, false, false], [false, false, false]])
* Eg: [2, [2, 1]] <=> getGridHtml([[false, false], [false]])
*
* @see getGridHtml
* @param {Number} nRows
* @param {Number|Number[]} nCols
* @returns {string}
*/
function getRegularGridHtml(nRows, nCols) {
const matrix = new Array(nRows).fill().map((_, iRow) => (
new Array(Array.isArray(nCols) ? nCols[iRow] : nCols).fill()
));
return getGridHtml(matrix);
};
/**
* Take a number of rows, a number of columns (or number of columns per
* individual row), a colspan (or colspan per individual row) and a width (or
* width per individual row, in percent), and return an HTML string of the
* corresponding table. Every cell in a row has the same colspan/width.
* Eg: [2, 2, 6, 50] <=> getTableHtml([[[6, 50], [6, 50]], [[6, 50], [6, 50]]])
* Eg: [2, [2, 1], [6, 12], [50, 100]] <=> getTableHtml([[[6, 50], [6, 50]], [[12, 100]]])
*
* @see getTableHtml
* @param {Number} nRows
* @param {Number|Number[]} nCols
* @param {Number|Number[]} colspan
* @param {Number|Number[]} width
* @param {Number} containerWidth
* @returns {string}
*/
function getRegularTableHtml(nRows, nCols, colspan, width, containerWidth) {
const matrix = new Array(nRows).fill().map((_, iRow) => (
new Array(Array.isArray(nCols) ? nCols[iRow] : nCols).fill().map(() => ([
Array.isArray(colspan) ? colspan[iRow] : colspan,
Array.isArray(width) ? width[iRow] : width,
])))
);
return getTableHtml(matrix, containerWidth);
}
/**
* Take an HTML string and returns that string stripped from any HTML comments.
* By default, also removes the mso-hide class which is only there for outlook
* to hide elements when we use mso conditional comments.
*
* @param {string} html
* @param {boolean} [removeMsoHide=true]
* @returns {string}
*/
function removeComments(html, removeMsoHide=true) {
const cleanHtml = html.replace(/<!--(.*?)-->/g, '');
if (removeMsoHide) {
return cleanHtml.replaceAll(' class="mso-hide"', '').replace(/\s*mso-hide/g, '').replace(/mso-hide\s*/g, '');
} else {
return cleanHtml;
}
}
return {
wysiwygData: wysiwygData,
createWysiwyg: createWysiwyg,
testKeyboard: testKeyboard,
select: select,
keydown: keydown,
patch: patch,
unpatch: unpatch,
getGridHtml: getGridHtml,
getTableHtml: getTableHtml,
getRegularGridHtml: getRegularGridHtml,
getRegularTableHtml: getRegularTableHtml,
getTdHtml: getTdHtml,
removeComments: removeComments,
};
});

View file

@ -0,0 +1,882 @@
/** @odoo-module **/
import { patch, unpatch } from "@web/core/utils/patch";
import {
parseTextualSelection,
setTestSelection,
renderTextualSelection,
patchEditorIframe,
} from '@web_editor/js/editor/odoo-editor/test/utils';
import { stripHistoryIds } from '@web_editor/js/backend/html_field';
import Wysiwyg from 'web_editor.wysiwyg';
import { Mutex } from '@web/core/utils/concurrency';
function makeSpy() {
const spy = function() {
spy.callCount++;
return this._super.apply(this, arguments);
};
spy.callCount = 0;
return spy;
}
class PeerTest {
constructor(infos) {
this.peerId = infos.peerId;
this.wysiwyg = infos.wysiwyg;
this.iframe = infos.iframe;
this.document = this.iframe.contentWindow.document;
this.wrapper = infos.wrapper;
this.pool = infos.pool;
this.peers = infos.pool.peers;
this._connections = new Set();
this.onlineMutex = new Mutex();
this.isOnline = true;
}
async startEditor() {
this._started = this.wysiwyg.appendTo(this.wrapper);
await this._started;
if (this.initialParsedSelection) {
await setTestSelection(this.initialParsedSelection, this.document);
this.wysiwyg.odooEditor._recordHistorySelection();
} else {
document.getSelection().removeAllRanges();
}
clearInterval(this.wysiwyg._collaborationInterval);
return this._started;
}
async destroyEditor() {
for (const peer of this._connections) {
peer._connections.delete(this);
}
this.wysiwyg.destroy();
}
async focus() {
await this.started;
return this.wysiwyg._joinPeerToPeer();
}
async openDataChannel(peer) {
this._connections.add(peer);
peer._connections.add(this);
const ptpFrom = this.wysiwyg.ptp;
const ptpTo = peer.wysiwyg.ptp;
ptpFrom.clientsInfos[peer.peerId] = {};
ptpTo.clientsInfos[this.peerId] = {};
// Simulate the rtc_data_channel_open on both peers.
await this.wysiwyg.ptp.notifySelf('rtc_data_channel_open', {
connectionClientId: peer.peerId,
});
await peer.wysiwyg.ptp.notifySelf('rtc_data_channel_open', {
connectionClientId: this.peerId,
});
}
async removeDataChannel(peer) {
this._connections.delete(peer);
peer._connections.delete(this);
const ptpFrom = this.wysiwyg.ptp;
const ptpTo = peer.wysiwyg.ptp;
delete ptpFrom.clientsInfos[peer.peerId];
delete ptpTo.clientsInfos[this.peerId];
this.onlineMutex = new Mutex();
this._onlineResolver = undefined;
}
async getValue() {
this.wysiwyg.odooEditor.observerUnactive('PeerTest.getValue');
renderTextualSelection(this.wysiwyg.odooEditor);
const html = this.wysiwyg.$editable[0].innerHTML;
const selection = parseTextualSelection(this.wysiwyg.$editable[0]);
if (selection) {
await setTestSelection(selection, this.document);
}
this.wysiwyg.odooEditor.observerActive('PeerTest.getValue');
return stripHistoryIds(html);
}
writeToServer() {
this.pool.lastRecordSaved = this.wysiwyg.getValue();
const lastId = this.wysiwyg._getLastHistoryStepId(this.pool.lastRecordSaved);
for (const peer of Object.values(this.peers)) {
if (peer === this || !peer._started) continue;
peer.onlineMutex.exec(() => {
return peer.wysiwyg._onServerLastIdUpdate(String(lastId));
});
}
}
async setOnline() {
this.isOnline = true;
this._onlineResolver && this._onlineResolver();
return this.onlineMutex.getUnlockedDef();
}
setOffline() {
this.isOnline = false;
if (this._onlineResolver) return;
this.onlineMutex.exec(async () => {
await new Promise((resolve) => {
this._onlineResolver = () => {
this._onlineResolver = null;
resolve();
}
});
});
}
}
const initialValue = '<p data-last-history-steps="1">a[]</p>';
class PeerPool {
constructor(peers) {
this.peers = {};
}
}
async function createPeers(peers) {
const pool = new PeerPool();
let lastGeneratedId = 0;
for (const peerId of peers) {
const peerWysiwygWrapper = document.createElement('div');
peerWysiwygWrapper.classList.add('peer_wysiwyg_wrapper');
peerWysiwygWrapper.classList.add('client_' + peerId);
const iframe = document.createElement('iframe');
if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) {
// Firefox reset the page without this hack.
// With this hack, chrome does not render content.
iframe.setAttribute('src', ' javascript:void(0);');
}
document.querySelector('#qunit-fixture').append(iframe);
patchEditorIframe(iframe);
iframe.contentDocument.body.append(peerWysiwygWrapper);
iframe.contentWindow.$ = $;
const fakeWysiwygParent = {
_trigger_up: () => {},
};
const wysiwyg = new Wysiwyg(fakeWysiwygParent, {
value: initialValue,
collaborative: true,
collaborationChannel: {
collaborationFieldName: "fake_field",
collaborationModelName: "fake.model",
collaborationResId: 1
},
document: iframe.contentWindow.document,
});
patch(wysiwyg, 'web_editor', {
_generateClientId() {
return peerId;
},
// Hacky hook as we know this method is called after setting the value in the wysiwyg start and before sending the value to odooEditor.
_getLastHistoryStepId() {
pool.peers[peerId].initialParsedSelection = parseTextualSelection(wysiwyg.$editable[0]);
return this._super(...arguments);
},
call: () => {},
getSession: () => ({notification_type: true}),
_rpc(params) {
if (params.route === '/web_editor/get_ice_servers') {
return [];
} else if (params.route === '/web_editor/bus_broadcast') {
const currentPeer = pool.peers[peerId];
for (const peer of currentPeer._connections) {
peer.wysiwyg.ptp.handleNotification(structuredClone(params.params.bus_data));
}
} else if (params.model === "res.users" && params.method === "search_read") {
return [{ name: "admin" }];
}
},
_getNewPtp() {
const ptp = this._super(...arguments);
ptp.options.onRequest.get_client_avatar = () => '';
patch(ptp, "web_editor_peer_to_peer", {
removeClient(peerId) {
this.notifySelf('ptp_remove', peerId);
delete this.clientsInfos[peerId];
},
notifyAllClients(...args) {
// This is not needed because the opening of the
// dataChannel is done through `openDataChannel` and we
// do not want to simulate the events that thrigger the
// openning of the dataChannel.
if (args[0] === 'ptp_join') {
return;
}
this._super(...args);
},
_getPtpClients() {
return pool.peers[peerId]._connections.map((peer) => {
return { id: peer.peerId }
});
},
async _channelNotify(peerId, transportPayload) {
if (!pool.peers[peerId].isOnline) return;
pool.peers[peerId].wysiwyg.ptp.handleNotification(structuredClone(transportPayload));
},
_createClient() {
throw new Error('Should not be called.');
},
_addIceCandidate() {
throw new Error('Should not be called.');
},
_recoverConnection() {
throw new Error('Should not be called.');
},
_killPotentialZombie() {
throw new Error('Should not be called.');
},
});
return ptp;
},
_getCurrentRecord() {
return {
id: 1,
fake_field: pool.lastRecordSaved,
}
},
_getCollaborationClientAvatarUrl() {
return '';
},
async startEdition() {
await this._super(...arguments);
patch(this.odooEditor, 'odooEditor', {
_generateId() {
// Ensure the id are deterministically gererated for
// when we need to sort by them. (eg. in the
// callaboration sorting of steps)
lastGeneratedId++;
return lastGeneratedId.toString();
},
});
}
});
pool.peers[peerId] = new PeerTest({
peerId,
wysiwyg,
iframe,
wrapper: peerWysiwygWrapper,
pool,
});
}
return pool;
}
function removePeers(peers) {
for (const peer of Object.values(peers)) {
peer.wysiwyg.destroy();
peer.wrapper.remove();
}
}
QUnit.module('web_editor', {
before() {
patch(Wysiwyg, 'web_editor', {
activeCollaborationChannelNames: {
has: () => false,
add: () => {},
delete: () => {},
}
});
},
after() {
unpatch(Wysiwyg, "web_editor");
}
}, () => {
QUnit.module('Collaboration', {}, () => {
/**
* Detect stale when <already focused | not already focused>
*/
QUnit.module('Focus', {}, () => {
QUnit.test('Focused client should not receive step if no data channel is open', async (assert) => {
assert.expect(3);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the document changed');
assert.equal(await peers.p2.getValue(), `<p>a[]</p>`, 'p2 should not have the document changed');
assert.equal(await peers.p3.getValue(), `<p>a[]</p>`, 'p3 should not have the document changed');
removePeers(peers);
});
QUnit.test('Focused client should receive step while unfocused should not (if the datachannel is open before the step)', async (assert) => {
assert.expect(3);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(await peers.p2.getValue(), `<p>[]ab</p>`, 'p2 should have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>a[]</p>`, 'p3 should not have the document changed');
removePeers(peers);
});
QUnit.test('Focused client should receive step while unfocused should not (if the datachannel is open after the step)', async (assert) => {
assert.expect(3);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
await peers.p1.openDataChannel(peers.p2);
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(await peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>a[]</p>`, 'p3 should not have the document changed because it has not focused');
removePeers(peers);
});
});
QUnit.module('Stale detection & recovery', {}, () => {
QUnit.module('detect stale while unfocused', async () => {
QUnit.test('should do nothing until focus', async (assert) => {
assert.expect(10);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
await peers.p1.writeToServer();
assert.equal(peers.p1.wysiwyg._isDocumentStale, false, 'p1 should not have a stale document');
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(peers.p2.wysiwyg._isDocumentStale, false, 'p2 should not have a stale document');
assert.equal(await peers.p2.getValue(), `<p>[]ab</p>`, 'p2 should have the same document as p1');
assert.equal(peers.p3.wysiwyg._isDocumentStale, true, 'p3 should have a stale document');
assert.equal(await peers.p3.getValue(), `<p>a[]</p>`, 'p3 should not have the same document as p1');
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p3);
// This timeout is necessary for the selection to be set
await new Promise(resolve => setTimeout(resolve));
assert.equal(peers.p3.wysiwyg._isDocumentStale, false, 'p3 should not have a stale document');
assert.equal(await peers.p3.getValue(), `<p>[]ab</p>`, 'p3 should have the same document as p1');
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'c');
assert.equal(await peers.p1.getValue(), `<p>abc[]</p>`, 'p1 should have the same document as p3');
assert.equal(await peers.p3.getValue(), `<p>[]abc</p>`, 'p3 should have the same document as p1');
removePeers(peers);
});
});
QUnit.module('detect stale while focused', async () => {
QUnit.module('recover from missing steps', async () => {
QUnit.test('should recover from missing steps', async (assert) => {
assert.expect(18);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
const p3Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
};
patch(peers.p3.wysiwyg, 'test', p3Spies);
assert.equal(peers.p1.wysiwyg._historyShareId, peers.p2.wysiwyg._historyShareId, 'p1 and p2 should have the same _historyShareId');
assert.equal(peers.p1.wysiwyg._historyShareId, peers.p3.wysiwyg._historyShareId, 'p1 and p3 should have the same _historyShareId');
assert.equal(await peers.p1.getValue(), `<p>a[]</p>`, 'p1 should have the same document as p2');
assert.equal(await peers.p2.getValue(), `<p>[]a</p>`, 'p2 should have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should have the same document as p1');
await peers.p3.setOffline();
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(await peers.p2.getValue(), `<p>[]ab</p>`, 'p2 should have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
await peers.p1.writeToServer();
assert.equal(peers.p1.wysiwyg._isDocumentStale, false, 'p1 should not have a stale document');
assert.equal(peers.p2.wysiwyg._isDocumentStale, false, 'p2 should not have a stale document');
assert.equal(peers.p3.wysiwyg._isDocumentStale, false, 'p3 should not have a stale document');
await peers.p3.setOnline();
unpatch(peers.p3.wysiwyg, 'test');
assert.equal(p3Spies._recoverFromStaleDocument.callCount, 1, 'p3 _recoverFromStaleDocument should have been called once');
assert.equal(p3Spies._processMissingSteps.callCount, 1, 'p3 _processMissingSteps should have been called once');
assert.equal(p3Spies._applySnapshot.callCount, 0, 'p3 _applySnapshot should not have been called');
assert.equal(p3Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p3 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 should have the same document as p2');
assert.equal(await peers.p2.getValue(), `<p>[]ab</p>`, 'p2 should have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]ab</p>`, 'p3 should have the same document as p1');
removePeers(peers);
});
});
QUnit.module('recover from snapshot', async () => {
QUnit.test('should wait for all peer to recover from snapshot', async (assert) => {
assert.expect(19);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
peers.p2.setOffline();
peers.p3.setOffline();
const p2Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
};
patch(peers.p2.wysiwyg, 'test', p2Spies);
const p3Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
_onRecoveryClientTimeout: makeSpy(),
};
patch(peers.p3.wysiwyg, 'test', p3Spies);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
await peers.p1.writeToServer();
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(await peers.p2.getValue(), `<p>[]a</p>`, 'p2 should not have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
peers.p1.destroyEditor();
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
await peers.p2.setOnline();
assert.equal(await peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 1, 'p2 _recoverFromStaleDocument should have been called once');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p2 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
await peers.p3.setOnline();
assert.equal(await peers.p3.getValue(), `[]<p>ab</p>`, 'p3 should have the same document as p1');
assert.equal(p3Spies._recoverFromStaleDocument.callCount, 1, 'p3 _recoverFromStaleDocument should have been called once');
assert.equal(p3Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p3 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p3Spies._processMissingSteps.callCount, 1, 'p3 _processMissingSteps should have been called once');
assert.equal(p3Spies._applySnapshot.callCount, 1, 'p3 _applySnapshot should have been called once');
assert.equal(p3Spies._onRecoveryClientTimeout.callCount, 0, 'p3 _onRecoveryClientTimeout should not have been called');
unpatch(peers.p2.wysiwyg, 'test');
unpatch(peers.p3.wysiwyg, 'test');
removePeers(peers);
});
QUnit.test('should recover from snapshot after PTP_MAX_RECOVERY_TIME if some peer do not respond', async (assert) => {
assert.expect(19);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
peers.p2.setOffline();
peers.p3.setOffline();
const p2Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
};
patch(peers.p2.wysiwyg, 'test', p2Spies);
const p3Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
_onRecoveryClientTimeout: makeSpy(),
};
patch(peers.p3.wysiwyg, 'test', p3Spies);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
await peers.p1.writeToServer();
peers.p1.setOffline();
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(await peers.p2.getValue(), `<p>[]a</p>`, 'p2 should not have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
await peers.p2.setOnline();
assert.equal(await peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 1, 'p2 _recoverFromStaleDocument should have been called once');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p2 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
await peers.p3.setOnline();
assert.equal(await peers.p3.getValue(), `[]<p>ab</p>`, 'p3 should have the same document as p1');
assert.equal(p3Spies._recoverFromStaleDocument.callCount, 1, 'p3 _recoverFromStaleDocument should have been called once');
assert.equal(p3Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p3 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p3Spies._processMissingSteps.callCount, 1, 'p3 _processMissingSteps should have been called once');
assert.equal(p3Spies._applySnapshot.callCount, 1, 'p3 _applySnapshot should have been called once');
assert.equal(p3Spies._onRecoveryClientTimeout.callCount, 1, 'p3 _onRecoveryClientTimeout should have been called once');
unpatch(peers.p2.wysiwyg, 'test');
unpatch(peers.p3.wysiwyg, 'test');
removePeers(peers);
});
});
QUnit.module('recover from server', async () => {
QUnit.test('should recover from server if no snapshot have been processed', async (assert) => {
assert.expect(16);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p3.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
peers.p2.setOffline();
peers.p3.setOffline();
const p2Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
_onRecoveryClientTimeout: makeSpy(),
_resetFromClient: makeSpy(),
};
patch(peers.p2.wysiwyg, 'test', p2Spies);
const p3Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
_onRecoveryClientTimeout: makeSpy(),
_resetFromClient: makeSpy(),
};
patch(peers.p3.wysiwyg, 'test', p3Spies);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
await peers.p1.writeToServer();
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(await peers.p2.getValue(), `<p>[]a</p>`, 'p2 should not have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
peers.p1.destroyEditor();
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._onRecoveryClientTimeout.callCount, 0, 'p2 _onRecoveryClientTimeout should not have been called');
assert.equal(p2Spies._resetFromClient.callCount, 0, 'p2 _resetFromClient should not have been called');
// Because we do not wait for the end of the
// p2.setOnline promise, p3 will not be able to reset
// from p2 wich allow us to test that p3 reset from the
// server as a fallback.
peers.p2.setOnline();
await peers.p3.setOnline();
assert.equal(await peers.p3.getValue(), `[]<p>ab</p>`, 'p3 should have the same document as p1');
assert.equal(p3Spies._recoverFromStaleDocument.callCount, 1, 'p3 _recoverFromStaleDocument should have been called once');
assert.equal(p3Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p3 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p3Spies._processMissingSteps.callCount, 0, 'p3 _processMissingSteps should not have been called');
assert.equal(p3Spies._applySnapshot.callCount, 1, 'p3 _applySnapshot should have been called once');
assert.equal(p3Spies._onRecoveryClientTimeout.callCount, 0, 'p3 _onRecoveryClientTimeout should not have been called');
assert.equal(p3Spies._resetFromClient.callCount, 1, 'p3 _resetFromClient should have been called once');
unpatch(peers.p2.wysiwyg, 'test');
unpatch(peers.p3.wysiwyg, 'test');
removePeers(peers);
});
QUnit.test('should recover from server if there is no peer connected', async (assert) => {
assert.expect(14);
const pool = await createPeers(['p1', 'p2']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
peers.p2.setOffline();
const p2Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
_onRecoveryClientTimeout: makeSpy(),
_resetFromClient: makeSpy(),
};
patch(peers.p2.wysiwyg, 'test', p2Spies);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
await peers.p1.writeToServer();
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(await peers.p2.getValue(), `[]<p>a</p>`, 'p2 should not have the same document as p1');
peers.p1.destroyEditor();
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._resetFromClient.callCount, 0, 'p2 _resetFromClient should not have been called');
await peers.p2.setOnline();
assert.equal(await peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 1, 'p2 _recoverFromStaleDocument should have been called once');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p2 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._onRecoveryClientTimeout.callCount, 0, 'p2 _onRecoveryClientTimeout should not have been called');
assert.equal(p2Spies._resetFromClient.callCount, 0, 'p2 _resetFromClient should not have been called');
unpatch(peers.p2.wysiwyg, 'test');
removePeers(peers);
});
QUnit.test('should recover from server if there is no response after PTP_MAX_RECOVERY_TIME', async (assert) => {
assert.expect(16);
const pool = await createPeers(['p1', 'p2', 'p3']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p3.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.openDataChannel(peers.p3);
await peers.p2.openDataChannel(peers.p3);
peers.p2.setOffline();
peers.p3.setOffline();
const p2Spies = {
_recoverFromStaleDocument: makeSpy(),
_resetFromServerAndResyncWithClients: makeSpy(),
_processMissingSteps: makeSpy(),
_applySnapshot: makeSpy(),
_onRecoveryClientTimeout: makeSpy(),
_resetFromClient: makeSpy(),
};
patch(peers.p2.wysiwyg, 'test', p2Spies);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
await peers.p1.writeToServer();
peers.p1.setOffline();
assert.equal(await peers.p1.getValue(), `<p>ab[]</p>`, 'p1 have inserted char b');
assert.equal(await peers.p2.getValue(), `<p>[]a</p>`, 'p2 should not have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 0, 'p2 _recoverFromStaleDocument should not have been called');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 0, 'p2 _resetFromServerAndResyncWithClients should not have been called');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._resetFromClient.callCount, 0, 'p2 _resetFromClient should not have been called');
await peers.p2.setOnline();
assert.equal(await peers.p2.getValue(), `[]<p>ab</p>`, 'p2 should have the same document as p1');
assert.equal(await peers.p3.getValue(), `<p>[]a</p>`, 'p3 should not have the same document as p1');
assert.equal(p2Spies._recoverFromStaleDocument.callCount, 1, 'p2 _recoverFromStaleDocument should have been called once');
assert.equal(p2Spies._resetFromServerAndResyncWithClients.callCount, 1, 'p2 _resetFromServerAndResyncWithClients should have been called once');
assert.equal(p2Spies._processMissingSteps.callCount, 0, 'p2 _processMissingSteps should not have been called');
assert.equal(p2Spies._applySnapshot.callCount, 0, 'p2 _applySnapshot should not have been called');
assert.equal(p2Spies._onRecoveryClientTimeout.callCount, 1, 'p2 _onRecoveryClientTimeout should have been called once');
// p1 and p3 are considered offline but not
// disconnected. It means that p2 will try to recover
// from p1 and p3 even if they are currently
// unavailable. This test is usefull to check that the
// code path to _resetFromClient is properly taken.
assert.equal(p2Spies._resetFromClient.callCount, 2, 'p2 _resetFromClient should have been called twice');
unpatch(peers.p2.wysiwyg, 'test');
removePeers(peers);
});
});
});
});
QUnit.module('Disconnect & reconnect', {}, () => {
QUnit.test('should sync history when disconnecting and reconnecting to internet', async (assert) => {
assert.expect(2);
const pool = await createPeers(['p1', 'p2']);
const peers = pool.peers;
await peers.p1.startEditor();
await peers.p2.startEditor();
await peers.p1.focus();
await peers.p2.focus();
await peers.p1.openDataChannel(peers.p2);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'b');
await peers.p1.setOffline();
peers.p1.removeDataChannel(peers.p2);
const setSelection = peer => {
const selection = peer.document.getSelection();
const pElement = peer.wysiwyg.odooEditor.editable.querySelector('p')
const range = new Range();
range.setStart(pElement, 1);
range.setEnd(pElement, 1);
selection.removeAllRanges();
selection.addRange(range);
}
const addP = (peer, content) => {
const p = document.createElement('p');
p.textContent = content;
peer.wysiwyg.odooEditor.editable.append(p);
peer.wysiwyg.odooEditor.historyStep();
}
setSelection(peers.p1);
await peers.p1.wysiwyg.odooEditor.execCommand('insert', 'c');
addP(peers.p1, 'd');
setSelection(peers.p2);
await peers.p2.wysiwyg.odooEditor.execCommand('insert', 'e');
addP(peers.p2, 'f');
peers.p1.setOnline();
peers.p2.setOnline();
// todo: p1PromiseForMissingStep and p2PromiseForMissingStep
// should be removed when the fix of undetected missing step
// will be merged. (task-3208277)
const p1PromiseForMissingStep = new Promise((resolve) => {
patch(peers.p2.wysiwyg, 'missingSteps', {
async _processMissingSteps() {
const _super = this._super;
// Wait for the p2PromiseForMissingStep to resolve
// to avoid undetected missing step.
await p2PromiseForMissingStep;
_super(...arguments);
resolve();
}
})
});
const p2PromiseForMissingStep = new Promise((resolve) => {
patch(peers.p1.wysiwyg, 'missingSteps', {
async _processMissingSteps() {
this._super(...arguments);
resolve();
}
})
});
await peers.p1.openDataChannel(peers.p2);
await p1PromiseForMissingStep;
assert.equal(await peers.p1.getValue(), `<p>ac[]eb</p><p>d</p><p>f</p>`, 'p1 should have the value merged with p2');
assert.equal(await peers.p2.getValue(), `<p>ace[]b</p><p>d</p><p>f</p>`, 'p2 should have the value merged with p1');
removePeers(peers);
});
});
});
});

View file

@ -0,0 +1,83 @@
/** @odoo-module **/
import weUtils from 'web_editor.utils';
QUnit.module('web_editor', {}, function () {
QUnit.module('utils', {}, function () {
QUnit.module('Utils functions');
// Test weUtils
QUnit.test('compare CSS property values', async function (assert) {
assert.expect(86);
let $div = $(`<div>abc</div>`);
let currentProperty = 'font-size';
function compare(expected, value1, value2, property = currentProperty, $target = $div) {
// Comparisons are done in both directions if values are different.
assert.strictEqual(weUtils.areCssValuesEqual(value1, value2, property, $target), expected,
`'${value1}' should be ${expected ? 'equal to' : 'different from'} '${value2}'`
);
if (value1 !== value2) {
assert.strictEqual(weUtils.areCssValuesEqual(value2, value1, property, $target), expected,
`'${value2}' should be ${expected ? 'equal to' : 'different from'} '${value1}'`
);
}
}
compare(true, '', '');
compare(true, 'auto', 'auto');
compare(true, '', 'auto');
compare(true, '15px', '15px');
compare(true, '15.0px', '15px');
compare(true, '15 px', '15px');
compare(false, '15px', '25px');
compare(false, '15px', '');
compare(false, '15px', 'auto');
compare(true, '15px', '1.5e+1px');
compare(true, '15px', '1.5e1px');
compare(true, '15px', '150e-1px');
currentProperty = 'background-size';
compare(true, '', '');
compare(true, 'auto', 'auto');
compare(true, '', 'auto');
compare(true, '15px', '15px');
compare(true, '15.0px', '15px');
compare(false, '15px', '25px');
compare(false, '15px', '');
compare(false, '15px', 'auto');
compare(true, '', 'auto auto');
compare(true, 'auto', 'auto auto');
compare(true, 'auto auto', 'auto auto');
compare(true, '15px 15px', '15px 15px');
compare(false, '15px 25px', '15px 15px');
compare(false, '25px 15px', '15px 15px');
compare(true, 'auto 15px', 'auto 15px');
compare(true, '15px auto', '15px auto');
compare(true, '15px', '15px auto');
compare(false, 'auto 25px', 'auto 15px');
compare(false, '25px auto', '15px auto');
compare(false, '25px', '15px auto');
compare(true, '15px 15px', '1.5e+1px 1.5e+1px');
compare(true, '15px 15px', '1.5e1px 1.5e1px');
compare(true, '15px 15px', '150e-1px 150e-1px');
currentProperty = 'color';
compare(true, '', '');
compare(false, '', '#123456');
compare(true, '#123456', '#123456');
compare(false, '#123456', '#654321');
compare(true, 'rgb(255, 0, 0)', '#FF0000');
compare(false, 'rgb(255, 0, 0)', '#EE0000');
currentProperty = 'background-image';
compare(true, '', '');
compare(false, '', 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)');
compare(true, 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)', 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)');
compare(false, 'linear-gradient(0deg, rgb(10, 0, 0) 0%, rgb(1, 1, 1) 100%)', 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)');
compare(false, 'linear-gradient(10deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)', 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)');
compare(false, 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(10, 1, 1) 100%)', 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)');
compare(false, 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(9, 9, 9) 50%, rgb(1, 1, 1) 100%)', 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)');
compare(true, 'linear-gradient(0deg, #000000 0%, #010101 100%)', 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)');
compare(false, 'linear-gradient(0deg, #FF0000 0%, #010101 100%)', 'linear-gradient(0deg, rgb(0, 0, 0) 0%, rgb(1, 1, 1) 100%)');
});
});
});