vanilla 19.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:49:46 +02:00
parent 991d2234ca
commit d1963a3c3a
3066 changed files with 1651266 additions and 922560 deletions

View file

@ -0,0 +1,100 @@
import { getFixture, after } from "@odoo/hoot";
import { clearRegistry, makeMockEnv, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { registry } from "@web/core/registry";
let activeInteractions = null;
const elementRegistry = registry.category("public.interactions");
const content = elementRegistry.content;
export function setupInteractionWhiteList(interactions) {
if (arguments.length > 1) {
throw new Error("Multiple white-listed interactions should be listed in an array.");
}
if (typeof interactions === "string") {
interactions = [interactions];
}
activeInteractions = interactions;
}
setupInteractionWhiteList.getWhiteList = () => activeInteractions;
export async function startInteraction(I, html, options) {
clearRegistry(elementRegistry);
for (const Interaction of Array.isArray(I) ? I : [I]) {
elementRegistry.add(Interaction.name, Interaction);
}
return startInteractions(html, options);
}
export async function startInteractions(
html,
options = { waitForStart: true, editMode: false, translateMode: false }
) {
if (odoo.loader.modules.has("@mail/../tests/mail_test_helpers")) {
const { defineMailModels } = odoo.loader.modules.get("@mail/../tests/mail_test_helpers");
defineMailModels();
}
const fixture = getFixture();
if (!html.includes("wrapwrap")) {
html = `<div id="wrapwrap">${html}</div>`;
}
fixture.innerHTML = html;
if (options.translateMode) {
fixture.closest("html").dataset.edit_translations = "1";
}
if (activeInteractions) {
clearRegistry(elementRegistry);
if (!options.editMode) {
for (const name of activeInteractions) {
if (name in content) {
elementRegistry.add(name, content[name][1]);
} else {
throw new Error(`White-listed Interaction does not exist: ${name}.`);
}
}
}
}
const env = await makeMockEnv();
const core = env.services["public.interactions"];
if (options.waitForStart) {
await core.isReady;
}
after(() => {
delete fixture.closest("html").dataset.edit_translations;
core.stopInteractions();
});
return {
core,
};
}
export function mockSendRequests() {
const requests = [];
patchWithCleanup(HTMLFormElement.prototype, {
submit: function () {
requests.push({
url: this.getAttribute("action"),
method: this.getAttribute("method"),
});
},
});
return requests;
}
export function isElementInViewport(el) {
const rect = el.getBoundingClientRect();
const width = window.innerWidth || document.documentElement.clientWidth;
const height = window.innerHeight || document.documentElement.clientHeight;
return (
Math.round(rect.top) >= 0 &&
Math.round(rect.left) >= 0 &&
Math.round(rect.right) <= width &&
Math.round(rect.bottom) <= height
);
}
export function isElementVerticallyInViewportOf(el, scrollEl) {
const rect = el.getBoundingClientRect();
return rect.top <= scrollEl.clientHeight && rect.bottom >= 0;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.testRenderAt">
<div class="rendered">Test</div>
</t>
<t t-name="web.TestSubInteraction1">
<div class="sub1" t-att-data-which="first">Sub 1</div>
<div class="sub2" t-att-data-which="second">Sub 2</div>
</t>
</templates>

View file

@ -0,0 +1,381 @@
import { describe, expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { makeMockEnv } from "@web/../tests/web_test_helpers";
import { Interaction } from "@web/public/interaction";
import { startInteraction } from "./helpers";
describe.current.tags("interaction_dev");
test("properly fallback to body when we have no match for wrapwrap", async () => {
const env = await makeMockEnv();
expect(env.services["public.interactions"].el).toBe(document.querySelector("body"));
});
test("wait for translation before starting interactions", async () => {
class Test extends Interaction {
static selector = ".test";
setup() {
expect("localization" in this.services).toBe(true);
}
}
await startInteraction(Test, `<div class="test"></div>`);
});
test("starting interactions twice should only actually do it once", async () => {
let n = 0;
class Test extends Interaction {
static selector = ".test";
setup() {
n++;
}
}
const { core } = await startInteraction(Test, `<div class="test"></div>`);
expect(n).toBe(1);
core.startInteractions();
await animationFrame();
expect(n).toBe(1);
});
test("start interactions even if there is a crash", async () => {
class Boom extends Interaction {
static selector = ".test";
setup() {
expect.step("start boom");
throw new Error("boom");
}
destroy() {
expect.step("destroy boom");
}
}
class NotBoom extends Interaction {
static selector = ".test";
setup() {
expect.step("start notboom");
}
destroy() {
expect.step("destroy notboom");
}
}
const { core } = await startInteraction([Boom, NotBoom], `<div class="test"></div>`, {
waitForStart: false,
});
await expect(core.isReady).rejects.toThrow("boom");
expect.verifySteps(["start boom", "start notboom"]);
core.stopInteractions();
expect.verifySteps(["destroy notboom"]);
});
test("start interactions even if there is a crash when evaluating selector", async () => {
class Boom extends Interaction {
static selector = "div:invalid(coucou)";
setup() {
expect.step("start boom");
}
destroy() {
expect.step("destroy boom");
}
}
class NotBoom extends Interaction {
static selector = ".test";
setup() {
expect.step("start notboom");
}
}
const { core } = await startInteraction([Boom, NotBoom], `<div class="test"></div>`, {
waitForStart: false,
});
await expect(core.isReady).rejects.toThrow(
"Could not start interaction Boom (invalid selector: 'div:invalid(coucou)')"
);
expect.verifySteps(["start notboom"]);
});
test("start interactions even if there is a crash when evaluating selectorHas", async () => {
class Boom extends Interaction {
static selector = ".test";
static selectorHas = "div:invalid(coucou)";
setup() {
expect.step("start boom");
}
destroy() {
expect.step("destroy boom");
}
}
class NotBoom extends Interaction {
static selector = ".test";
setup() {
expect.step("start notboom");
}
}
const { core } = await startInteraction(
[Boom, NotBoom],
`<div class="test"><div></div></div>`,
{
waitForStart: false,
}
);
await expect(core.isReady).rejects.toThrow(
"Could not start interaction Boom (invalid selector: '.test' or selectorHas: 'div:invalid(coucou)')"
);
expect.verifySteps(["start notboom"]);
});
test("start interactions with selectorHas", async () => {
class Test extends Interaction {
static selector = ".test";
static selectorHas = ".inner";
start() {
expect.step("start");
}
}
const { core } = await startInteraction(
Test,
`
<div class="test"><div class="inner"></div></div>
<div class="test"><div class="not-inner"></div></div>
`
);
expect(core.interactions).toHaveLength(1);
expect.verifySteps(["start"]);
expect(core.interactions[0].interaction.el).toBe(queryOne(".test:has(.inner)"));
});
test("start interactions even if there is a crash when evaluating selectorNotHas", async () => {
class Boom extends Interaction {
static selector = ".test";
static selectorNotHas = "div:invalid(coucou)";
setup() {
expect.step("start boom");
}
destroy() {
expect.step("destroy boom");
}
}
class NotBoom extends Interaction {
static selector = ".test";
setup() {
expect.step("start notboom");
}
}
const { core } = await startInteraction(
[Boom, NotBoom],
`<div class="test"><div></div></div>`,
{
waitForStart: false,
}
);
await expect(core.isReady).rejects.toThrow(
"Could not start interaction Boom (invalid selector: '.test' or selectorNotHas: 'div:invalid(coucou)')"
);
expect.verifySteps(["start notboom"]);
});
test("start interactions with selectorNotHas", async () => {
class Test extends Interaction {
static selector = ".test";
static selectorNotHas = ".inner";
start() {
expect.step("start");
}
}
const { core } = await startInteraction(
Test,
`
<div class="test"><div class="inner"></div></div>
<div class="test"><div class="not-inner"></div></div>
`
);
expect(core.interactions).toHaveLength(1);
expect.verifySteps(["start"]);
expect(core.interactions[0].interaction.el).toBe(queryOne(".test:not(:has(.inner))"));
});
test("recover from error as much as possible when applying dynamiccontent", async () => {
let a = "a";
let b = "b";
let c = "c";
let interaction = null;
class Test extends Interaction {
static selector = ".test";
dynamicContent = {
_root: {
"t-att-a": () => a,
"t-att-b": () => {
if (b === "boom") {
throw new Error("boom");
}
return b;
},
"t-att-c": () => c,
},
};
setup() {
interaction = this;
}
}
await startInteraction(Test, `<div class="test"></div>`);
expect(".test").toHaveOuterHTML(`<div class="test" a="a" b="b" c="c"></div>`);
a = "aa";
b = "boom";
c = "cc";
expect(() => interaction.updateContent()).toThrow(
"An error occured while updating dynamic attribute 'b' (in interaction 'Test')"
);
expect(".test").toHaveOuterHTML(`<div class="test" a="aa" b="b" c="cc"></div>`);
});
test("interactions are stopped in reverse order", async () => {
let n = 1;
class Test extends Interaction {
static selector = ".test";
setup() {
this.n = n++;
expect.step(`setup ${this.n}`);
}
destroy() {
expect.step(`destroy ${this.n}`);
}
}
const { core } = await startInteraction(
Test,
`<div class="test"></div><div class="test"></div>`
);
expect.verifySteps(["setup 1", "setup 2"]);
core.stopInteractions();
expect.verifySteps(["destroy 2", "destroy 1"]);
});
test("can mount a component", async () => {
class Test extends Component {
static selector = ".test";
static template = xml`owl component`;
static props = {};
}
const { core } = await startInteraction(Test, `<div class="test"></div>`);
expect(".test").toHaveInnerHTML(
`<owl-root contenteditable="false" data-oe-protected="true" style="display: contents;">owl component</owl-root>`
);
core.stopInteractions();
expect(".test").toHaveOuterHTML(`<div class="test"></div>`);
});
test("can start interaction in specific el", async () => {
let n = 0;
class Test extends Interaction {
static selector = ".test";
dynamicContent = {
_root: { "t-att-a": () => "b" },
};
setup() {
n++;
}
}
const { core } = await startInteraction(Test, `<p></p>`);
expect(n).toBe(0);
const p = queryOne("p");
p.innerHTML = `<div class="test">hello</div>`;
core.startInteractions(queryOne(".test"));
await animationFrame();
expect(n).toBe(1);
expect(p).toHaveInnerHTML(`<div class="test" a="b">hello</div>`);
});
test("can start and stop interaction in specific el", async () => {
let n = 0;
class Test extends Interaction {
static selector = ".test";
start() {
n++;
this.el.dataset.start = "true";
}
destroy() {
n--;
delete this.el.dataset.start;
}
}
const { core } = await startInteraction(
Test,
`
<p>
<span class="a test"></span>
<span class="b"></span>
</p>`
);
expect(n).toBe(1);
const p = queryOne("p");
expect(p).toHaveInnerHTML(
`<span class="a test" data-start="true"></span> <span class="b"></span>`
);
const b = queryOne("p .b");
b.classList.add("test");
await core.startInteractions(b);
expect(n).toBe(2);
expect(p).toHaveInnerHTML(
`<span class="a test" data-start="true"></span> <span class="b test" data-start="true"></span>`
);
core.stopInteractions(b);
expect(n).toBe(1);
expect(p).toHaveInnerHTML(
`<span class="a test" data-start="true"></span> <span class="b test"></span>`
);
});
test("does not start interaction in el if not attached", async () => {
let n = 0;
class Test extends Interaction {
static selector = ".test";
start() {
n++;
}
destroy() {
n--;
}
}
const { core } = await startInteraction(Test, `<p><span class="test"></span></p>`);
expect(n).toBe(1);
const span = queryOne("span.test");
core.stopInteractions(span);
expect(n).toBe(0);
span.remove();
await core.startInteractions(span);
expect(n).toBe(0);
});

View file

@ -0,0 +1,24 @@
import { setupInteractionWhiteList, startInteractions } from "@web/../tests/public/helpers";
import { describe, expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
setupInteractionWhiteList("public.login");
describe.current.tags("interaction_dev");
test("add and remove loading effect", async () => {
const { core } = await startInteractions(`
<div class="oe_login_form">
<button type="submit">log in</button>
</div>`);
expect(core.interactions).toHaveLength(1);
// Not using manuallyDispatchProgrammaticEvent to keep a minimalist test. We
// don't need to send a proper "submit" event with FormData, method, action,
// etc. for this test.
const ev = new Event("submit");
queryOne(".oe_login_form").dispatchEvent(ev);
expect("button").toHaveClass(["o_btn_loading", "disabled"]);
ev.preventDefault();
expect("button").not.toHaveClass(["o_btn_loading", "disabled"]);
});

View file

@ -0,0 +1,69 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { setupInteractionWhiteList, startInteractions } from "./helpers";
import { registry } from "@web/core/registry";
setupInteractionWhiteList("public_components");
const publicComponentRegistry = registry.category("public_components");
test(`render Public Component`, async () => {
class MyPublicComp extends Component {
static template = xml`<div class="my_public_comp" t-esc="value"/>`;
static props = ["*"];
setup() {
const { info } = this.props;
this.value = typeof info === "object" ? JSON.stringify(info) : info;
expect.step(`MyPublicComp: ${this.value} - ${typeof info}`);
}
}
publicComponentRegistry.add("my_public_comp", MyPublicComp);
const html = `
<div>
<owl-component name="my_public_comp" props='{"info": "blibli"}'></owl-component>
<owl-component name="my_public_comp" props='{"info": 3}'></owl-component>
<owl-component name="my_public_comp" props='{"info": {"test": "plop"}}'></owl-component>
</div>
`;
await startInteractions(html);
// interaction is now ready, but components not mounted yet
expect(`.my_public_comp`).toHaveCount(0);
expect.verifySteps([
"MyPublicComp: blibli - string",
"MyPublicComp: 3 - number",
'MyPublicComp: {"test":"plop"} - object',
]);
await animationFrame();
// components are now mounted
expect(`.my_public_comp`).toHaveCount(3);
expect(queryAllTexts`.my_public_comp`).toEqual(["blibli", "3", `{"test":"plop"}`]);
});
test(`content of owl-component tag is cleared`, async () => {
class MyPublicComp extends Component {
static template = xml`<div>component</div>`;
static props = ["*"];
}
publicComponentRegistry.add("my_public_comp", MyPublicComp);
const html = `
<div>
<owl-component name="my_public_comp">some content</owl-component>
</div>
`;
await startInteractions(html);
await animationFrame();
expect(`.my_public_comp`).toHaveCount(0);
expect("owl-component").toHaveOuterHTML(`
<owl-component name="my_public_comp">
<owl-root contenteditable="false" data-oe-protected="true" style="display: contents;">
<div> component </div>
</owl-root>
</owl-component>`);
});

View file

@ -0,0 +1,171 @@
import { describe, expect, test } from "@odoo/hoot";
import { PairSet, patchDynamicContent } from "@web/public/utils";
describe.current.tags("headless");
describe("PairSet", () => {
test("can add and delete pairs", () => {
const pairSet = new PairSet();
const a = {};
const b = {};
expect(pairSet.has(a, b)).toBe(false);
pairSet.add(a, b);
expect(pairSet.has(a, b)).toBe(true);
pairSet.delete(a, b);
expect(pairSet.has(a, b)).toBe(false);
});
test("can add and delete pairs with the same first element", () => {
const pairSet = new PairSet();
const a = {};
const b = {};
const c = {};
expect(pairSet.has(a, b)).toBe(false);
expect(pairSet.has(a, c)).toBe(false);
pairSet.add(a, b);
expect(pairSet.has(a, b)).toBe(true);
expect(pairSet.has(a, c)).toBe(false);
pairSet.add(a, c);
expect(pairSet.has(a, b)).toBe(true);
expect(pairSet.has(a, c)).toBe(true);
pairSet.delete(a, c);
expect(pairSet.has(a, b)).toBe(true);
expect(pairSet.has(a, c)).toBe(false);
pairSet.delete(a, b);
expect(pairSet.has(a, b)).toBe(false);
expect(pairSet.has(a, c)).toBe(false);
});
test("do not duplicated pairs", () => {
const pairSet = new PairSet();
const a = {};
const b = {};
expect(pairSet.map.size).toBe(0);
pairSet.add(a, b);
expect(pairSet.map.size).toBe(1);
pairSet.add(a, b);
expect(pairSet.map.size).toBe(1);
});
});
describe("patch dynamic content", () => {
test("patch applies new values", () => {
const parent = {
somewhere: {
"t-att-doNotTouch": 123,
},
};
const patch = {
somewhere: {
"t-att-class": () => ({
abc: true,
}),
"t-att-xyz": "123",
},
elsewhere: {
"t-att-class": () => ({
xyz: true,
}),
"t-att-abc": "123",
},
};
patchDynamicContent(parent, patch);
expect(Object.keys(parent)).toEqual(["somewhere", "elsewhere"]);
expect(Object.keys(parent.somewhere)).toEqual([
"t-att-doNotTouch",
"t-att-class",
"t-att-xyz",
]);
expect(Object.keys(parent.elsewhere)).toEqual(["t-att-class", "t-att-abc"]);
});
test("patch removes undefined values", () => {
const parent = {
somewhere: {
"t-att-doNotTouch": 123,
"t-att-removeMe": "abc",
},
};
const patch = {
somewhere: {
"t-att-removeMe": undefined,
},
};
patchDynamicContent(parent, patch);
expect(parent).toEqual({
somewhere: {
"t-att-doNotTouch": 123,
},
});
});
test("patch combines function outputs", () => {
const parent = {
somewhere: {
"t-att-style": () => ({
doNotTouch: true,
changeMe: 10,
doubleMe: 100,
}),
},
};
const patch = {
somewhere: {
"t-att-style": (el, old) => ({
changeMe: 50,
doubleMe: old.doubleMe * 2,
addMe: 1000,
}),
},
};
patchDynamicContent(parent, patch);
expect(parent.somewhere["t-att-style"]()).toEqual({
doNotTouch: true,
changeMe: 50,
doubleMe: 200,
addMe: 1000,
});
});
test("patch t-on-... provides access to super", () => {
const parent = {
somewhere: {
"t-on-click": () => {
expect.step("base");
},
},
};
const patch = {
somewhere: {
"t-on-click": (el, oldFn) => {
oldFn();
expect.step("patch");
},
},
};
patchDynamicContent(parent, patch);
parent.somewhere["t-on-click"]();
expect.verifySteps(["base", "patch"]);
});
test("patch t-on-... does not require knowledge about there being a super", () => {
const parent = {
// No t-on-click here.
};
const patch = {
somewhere: {
"t-on-click": (el, oldFn) => {
oldFn();
expect.step("patch");
},
},
};
patchDynamicContent(parent, patch);
parent.somewhere["t-on-click"]();
expect.verifySteps(["patch"]);
});
});