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,624 @@
/** @odoo-module */
import { describe, expect, makeExpect, test } from "@odoo/hoot";
import { check, manuallyDispatchProgrammaticEvent, tick, waitFor } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { mountForTest, parseUrl } from "../local_helpers";
import { Test } from "../../core/test";
import { makeLabel } from "../../hoot_utils";
describe(parseUrl(import.meta.url), () => {
test("makeExpect passing, without a test", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
expect(() => customExpect(true).toBe(true)).toThrow(
"cannot call `expect()` outside of a test"
);
hooks.before();
customExpect({ key: true }).toEqual({ key: true });
customExpect("oui").toBe("oui");
const results = hooks.after();
expect(results.pass).toBe(true);
expect(results.events).toHaveLength(2);
});
test("makeExpect failing, without a test", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
customExpect({ key: true }).toEqual({ key: true });
customExpect("oui").toBe("non");
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(2);
});
test("makeExpect with a test", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
const customTest = new Test(null, "test", {});
customTest.setRunFn(() => {
customExpect({ key: true }).toEqual({ key: true });
customExpect("oui").toBe("non");
});
hooks.before(customTest);
await customTest.run();
const results = hooks.after();
expect(customTest.lastResults).toBe(results);
// Result is expected to have the same shape, no need for other assertions
});
test("makeExpect with a test flagged with TODO", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
const customTest = new Test(null, "test", { todo: true });
customTest.setRunFn(() => {
customExpect(1).toBe(1);
});
hooks.before(customTest);
await customTest.run();
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events[0].pass).toBe(true);
});
test("makeExpect with no assertions & query events", async () => {
await mountForTest(/* xml */ `<div>ABC</div>`);
const [, hooks] = makeExpect({ headless: true });
hooks.before();
await waitFor("div:contains(ABC)");
const results = hooks.after();
expect(results.pass).toBe(true);
expect(results.events).toHaveLength(1);
expect(results.events[0].label).toBe("waitFor");
});
test("makeExpect with no assertions & no query events", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
expect(() => customExpect.assertions(0)).toThrow(
"expected assertions count should be more than 1"
);
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(1);
expect(results.events[0].message).toEqual([
"expected at least",
["1", "integer"],
"assertion or query event, but none were run",
]);
});
test("makeExpect with unconsumed matchers", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
expect(() => customExpect(true, true)).toThrow("`expect()` only accepts a single argument");
customExpect(1).toBe(1);
customExpect(true);
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(2);
expect(results.events[1].message.join(" ")).toBe(
"called once without calling any matchers"
);
});
test("makeExpect with unverified steps", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
customExpect.step("oui");
customExpect.verifySteps(["oui"]);
customExpect.step("non");
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(2); // 1 'verifySteps' + 1 'unverified steps'
expect(results.events.at(-1).message).toEqual(["unverified steps"]);
});
test("makeExpect retains current values", () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
const object = { a: 1 };
customExpect(object).toEqual({ b: 2 });
object.b = 2;
const testResult = hooks.after();
const [assertion] = testResult.events;
expect(assertion.pass).toBe(false);
expect(assertion.failedDetails[1][1]).toEqual({ a: 1 });
expect(object).toEqual({ a: 1, b: 2 });
});
test("'expect' results contain the correct informations", async () => {
await mountForTest(/* xml */ `
<label style="color: #f00">
Checkbox
<input class="cb" type="checkbox" />
</label>
<input type="text" value="abc" />
`);
await check("input[type=checkbox]");
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
const matchers = [
// Standard
["toBe", 1, 1],
["toBeCloseTo", 1, 1],
["toBeEmpty", []],
["toBeGreaterThan", 1, 0],
["toBeInstanceOf", {}, Object],
["toBeLessThan", 0, 1],
["toBeOfType", 1, "integer"],
["toBeWithin", 1, 0, 2],
["toEqual", [], []],
["toHaveLength", [], 0],
["toInclude", [1], 1],
["toMatch", "a", "a"],
["toMatchObject", { a: 1, b: { l: [1, 2] } }, { b: { l: [1, 2] } }],
[
"toThrow",
() => {
throw new Error("");
},
],
// DOM
["toBeChecked", ".cb"],
["toBeDisplayed", ".cb"],
["toBeEnabled", ".cb"],
["toBeFocused", ".cb"],
["toBeVisible", ".cb"],
["toHaveAttribute", ".cb", "type", "checkbox"],
["toHaveClass", ".cb", "cb"],
["toHaveCount", ".cb", 1],
["toHaveInnerHTML", ".cb", ""],
["toHaveOuterHTML", ".cb", `<input class="cb" type="checkbox" />`],
["toHaveProperty", ".cb", "checked", true],
["toHaveRect", "label", { x: 0 }],
["toHaveStyle", "label", { color: "rgb(255, 0, 0)" }],
["toHaveText", "label", "Checkbox"],
["toHaveValue", "input[type=text]", "abc"],
];
for (const [name, ...args] of matchers) {
customExpect(args.shift())[name](...args);
}
const testResult = hooks.after();
expect(testResult.pass).toBe(true);
expect(testResult.events).toHaveLength(matchers.length);
expect(testResult.events.map(({ label }) => label)).toEqual(matchers.map(([name]) => name));
});
test("assertions are prevented after an error", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
await customExpect(Promise.resolve(1)).resolves.toBe(1);
hooks.error(new Error("boom"));
customExpect(2).toBe(2);
customExpect(Promise.resolve(3)).resolves.toBe(3);
await tick();
const results = hooks.after();
expect(results.pass).toBe(false);
expect(results.events).toHaveLength(3); // toBe + error + unverified errors
});
describe("standard matchers", () => {
test("toBe", () => {
// Boolean
expect(true).toBe(true);
expect(true).not.toBe(false);
// Floats
expect(1.1).toBe(1.1);
expect(0.1 + 0.2).not.toBe(0.3); // floating point errors
// Integers
expect(+0).toBe(-0);
expect(1 + 2).toBe(3);
expect(1).not.toBe(-1);
expect(NaN).toBe(NaN);
// Strings
expect("abc").toBe("abc");
expect(new String("abc")).not.toBe(new String("abc"));
// Other primitives
expect(undefined).toBe(undefined);
expect(undefined).not.toBe(null);
// Symbols
const symbol = Symbol("symbol");
expect(symbol).toBe(symbol);
expect(symbol).not.toBe(Symbol("symbol"));
expect(Symbol.for("symbol")).toBe(Symbol.for("symbol"));
// Objects
const object = { x: 1 };
expect(object).toBe(object);
expect([]).not.toBe([]);
expect(object).not.toBe({ x: 1 });
// Dates
const date = new Date(0);
expect(date).toBe(date);
expect(new Date(0)).not.toBe(new Date(0));
// Nodes
expect(new Image()).not.toBe(new Image());
expect(document.createElement("div")).not.toBe(document.createElement("div"));
});
test("toBeCloseTo", () => {
expect(0.2 + 0.1).toBeCloseTo(0.3);
expect(0.2 + 0.1).toBeCloseTo(0.3, { margin: Number.EPSILON });
expect(0.2 + 0.1).not.toBeCloseTo(0.3, { margin: 1e-17 });
expect(3.51).toBeCloseTo(3);
expect(3.51).toBeCloseTo(3.52, { margin: 2e-2 });
expect(3.502).not.toBeCloseTo(3.503, { margin: 1e-3 });
expect(3).toBeCloseTo(4 - 1e-15);
expect(3 + 1e-15).toBeCloseTo(4);
expect(3).not.toBeCloseTo(4);
});
test("toEqual", () => {
// Boolean
expect(true).toEqual(true);
expect(true).not.toEqual(false);
// Floats
expect(1.1).toEqual(1.1);
expect(0.1 + 0.2).not.toEqual(0.3); // floating point errors
// Integers
expect(+0).toEqual(-0);
expect(1 + 2).toEqual(3);
expect(1).not.toEqual(-1);
expect(NaN).toEqual(NaN);
// Strings
expect("abc").toEqual("abc");
expect(new String("abc")).toEqual(new String("abc"));
// Other primitives
expect(undefined).toEqual(undefined);
expect(undefined).not.toEqual(null);
// Symbols
const symbol = Symbol("symbol");
expect(symbol).toEqual(symbol);
expect(symbol).not.toEqual(Symbol("symbol"));
expect(Symbol.for("symbol")).toEqual(Symbol.for("symbol"));
// Objects
const object = { x: 1 };
expect(object).toEqual(object);
expect([]).toEqual([]);
expect(object).toEqual({ x: 1 });
// Iterables
expect(new Set([1, 4, 6])).toEqual(new Set([1, 4, 6]));
expect(new Set([1, 4, 6])).not.toEqual([1, 4, 6]);
expect(new Map([[{}, "abc"]])).toEqual(new Map([[{}, "abc"]]));
// Dates
const date = new Date(0);
expect(date).toEqual(date);
expect(new Date(0)).toEqual(new Date(0));
// Nodes
expect(new Image()).toEqual(new Image());
expect(document.createElement("div")).toEqual(document.createElement("div"));
expect(document.createElement("div")).not.toEqual(document.createElement("span"));
});
test("toMatch", () => {
class Exception extends Error {}
expect("aaaa").toMatch(/^a{4}$/);
expect("aaaa").toMatch("aa");
expect("aaaa").not.toMatch("aaaaa");
// Matcher from a class
expect(new Exception("oui")).toMatch(Error);
expect(new Exception("oui")).toMatch(Exception);
expect(new Exception("oui")).toMatch(new Error("oui"));
});
test("toMatchObject", () => {
expect({
bath: true,
bedrooms: 4,
kitchen: {
amenities: ["oven", "stove", "washer"],
area: 20,
wallColor: "white",
},
}).toMatchObject({
bath: true,
kitchen: {
amenities: ["oven", "stove", "washer"],
wallColor: "white",
},
});
expect([{ tralalero: "tralala" }, { foo: 1 }]).toMatchObject([
{ tralalero: "tralala" },
{ foo: 1 },
]);
expect([{ tralalero: "tralala" }, { foo: 1, lirili: "larila" }]).toMatchObject([
{ tralalero: "tralala" },
{ foo: 1 },
]);
});
test("toThrow", async () => {
const asyncBoom = async () => {
throw new Error("rejection");
};
const boom = () => {
throw new Error("error");
};
expect(boom).toThrow();
expect(boom).toThrow("error");
expect(boom).toThrow(new Error("error"));
await expect(asyncBoom()).rejects.toThrow();
await expect(asyncBoom()).rejects.toThrow("rejection");
await expect(asyncBoom()).rejects.toThrow(new Error("rejection"));
});
test("verifyErrors", async () => {
expect.assertions(1);
expect.errors(3);
const boom = (msg) => {
throw new Error(msg);
};
// Timeout
setTimeout(() => boom("timeout"));
// Promise
queueMicrotask(() => boom("promise"));
// Event
manuallyDispatchProgrammaticEvent(window, "error", { message: "event" });
await tick();
expect.verifyErrors(["event", "promise", "timeout"]);
});
test("verifySteps", () => {
expect.assertions(4);
expect.verifySteps([]);
expect.step("abc");
expect.step("def");
expect.verifySteps(["abc", "def"]);
expect.step({ property: "foo" });
expect.step("ghi");
expect.verifySteps([{ property: "foo" }, "ghi"]);
expect.verifySteps([]);
});
});
describe("DOM matchers", () => {
test("toBeChecked", async () => {
await mountForTest(/* xml */ `
<input type="checkbox" />
<input type="checkbox" checked="" />
`);
expect("input:first").not.toBeChecked();
expect("input:last").toBeChecked();
});
test("toHaveAttribute", async () => {
await mountForTest(/* xml */ `
<input type="number" disabled="" />
`);
expect("input").toHaveAttribute("disabled");
expect("input").not.toHaveAttribute("readonly");
expect("input").toHaveAttribute("type", "number");
});
test("toHaveCount", async () => {
await mountForTest(/* xml */ `
<ul>
<li>milk</li>
<li>eggs</li>
<li>milk</li>
</ul>
`);
expect("iframe").toHaveCount(0);
expect("iframe").not.toHaveCount();
expect("ul").toHaveCount(1);
expect("ul").toHaveCount();
expect("li").toHaveCount(3);
expect("li").toHaveCount();
expect("li:contains(milk)").toHaveCount(2);
});
test("toHaveProperty", async () => {
await mountForTest(/* xml */ `
<input type="search" readonly="" />
`);
expect("input").toHaveProperty("type", "search");
expect("input").not.toHaveProperty("readonly");
expect("input").toHaveProperty("readOnly", true);
});
test("toHaveText", async () => {
class TextComponent extends Component {
static props = {};
static template = xml`
<div class="with">With<t t-esc="nbsp" />nbsp</div>
<div class="without">Without nbsp</div>
`;
nbsp = "\u00a0";
}
await mountForTest(TextComponent);
expect(".with").toHaveText("With nbsp");
expect(".with").toHaveText("With\u00a0nbsp", { raw: true });
expect(".with").not.toHaveText("With\u00a0nbsp");
expect(".without").toHaveText("Without nbsp");
expect(".without").not.toHaveText("Without\u00a0nbsp");
expect(".without").not.toHaveText("Without\u00a0nbsp", { raw: true });
});
test("toHaveInnerHTML", async () => {
await mountForTest(/* xml */ `
<div class="parent">
<p>
abc<strong>def</strong>ghi
<br />
<input type="text" />
</p>
</div>
`);
expect(".parent").toHaveInnerHTML(/* xml */ `
<p>abc<strong>def</strong>ghi<br><input type="text"></p>
`);
});
test("toHaveOuterHTML", async () => {
await mountForTest(/* xml */ `
<div class="parent">
<p>
abc<strong>def</strong>ghi
<br />
<input type="text" />
</p>
</div>
`);
expect(".parent").toHaveOuterHTML(/* xml */ `
<div class="parent">
<p>abc<strong>def</strong>ghi<br><input type="text"></p>
</div>
`);
});
test("toHaveStyle", async () => {
const documentFontSize = parseFloat(
getComputedStyle(document.documentElement).fontSize
);
await mountForTest(/* xml */ `
<div class="div" style="width: 3rem; height: 26px" />
`);
expect(".div").toHaveStyle({ width: `${3 * documentFontSize}px`, height: 26 });
expect(".div").toHaveStyle({ display: "block" });
expect(".div").toHaveStyle("border-top");
expect(".div").not.toHaveStyle({ height: 50 });
expect(".div").toHaveStyle("height: 26px ; width : 3rem", { inline: true });
expect(".div").not.toHaveStyle({ display: "block" }, { inline: true });
expect(".div").not.toHaveStyle("border-top", { inline: true });
});
test("no elements found messages", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
await mountForTest(/* xml */ `
<div />
`);
const SELECTOR = "#brrbrrpatapim";
const DOM_MATCHERS = [
["toBeChecked"],
["toBeDisplayed"],
["toBeEnabled"],
["toBeFocused"],
["toBeVisible"],
["toHaveAttribute", "attr"],
["toHaveClass", "cls"],
["toHaveInnerHTML", "<html></html>"],
["toHaveOuterHTML", "<html></html>"],
["toHaveProperty", "prop"],
["toHaveRect", {}],
["toHaveStyle", {}],
["toHaveText", "abc"],
["toHaveValue", "value"],
];
for (const [matcher, arg] of DOM_MATCHERS) {
customExpect(SELECTOR)[matcher](arg);
}
const results = hooks.after();
const assertions = results.getEvents("assertion");
for (let i = 0; i < DOM_MATCHERS.length; i++) {
const { label, message } = assertions[i];
expect.step(label);
expect(message).toEqual([
"expected at least",
makeLabel(1),
"element and got",
makeLabel(0),
"elements matching",
makeLabel(SELECTOR),
]);
}
expect.verifySteps(DOM_MATCHERS.map(([matcher]) => matcher));
});
});
});

View file

@ -0,0 +1,118 @@
/** @odoo-module */
import { after, defineTags, describe, expect, test } from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
import { Runner } from "../../core/runner";
import { Suite } from "../../core/suite";
import { undefineTags } from "../../core/tag";
const makeTestRunner = () => {
const runner = new Runner();
after(() => undefineTags(runner.tags.keys()));
return runner;
};
describe(parseUrl(import.meta.url), () => {
test("can register suites", () => {
const runner = makeTestRunner();
runner.describe("a suite", () => {});
runner.describe("another suite", () => {});
expect(runner.suites).toHaveLength(2);
expect(runner.tests).toHaveLength(0);
for (const suite of runner.suites.values()) {
expect(suite).toMatch(Suite);
}
});
test("can register nested suites", () => {
const runner = makeTestRunner();
runner.describe(["a", "b", "c"], () => {});
expect([...runner.suites.values()].map((s) => s.name)).toEqual(["a", "b", "c"]);
});
test("can register tests", () => {
const runner = makeTestRunner();
runner.describe("suite 1", () => {
runner.test("test 1", () => {});
});
runner.describe("suite 2", () => {
runner.test("test 2", () => {});
runner.test("test 3", () => {});
});
expect(runner.suites).toHaveLength(2);
expect(runner.tests).toHaveLength(3);
});
test("should not have duplicate suites", () => {
const runner = makeTestRunner();
runner.describe(["parent", "child a"], () => {});
runner.describe(["parent", "child b"], () => {});
expect([...runner.suites.values()].map((suite) => suite.name)).toEqual([
"parent",
"child a",
"child b",
]);
});
test("can refuse standalone tests", async () => {
const runner = makeTestRunner();
expect(() =>
runner.test([], "standalone test", () => {
expect(true).toBe(false);
})
).toThrow();
});
test("can register test tags", async () => {
const runner = makeTestRunner();
runner.describe("suite", () => {
for (let i = 1; i <= 10; i++) {
// 10
runner.test.tags(`Tag-${i}`);
}
runner.test("tagged test", () => {});
});
expect(runner.tags).toHaveLength(10);
expect(runner.tests.values().next().value.tags).toHaveLength(10);
});
test("can define exclusive test tags", async () => {
expect.assertions(3);
defineTags(
{
name: "a",
exclude: ["b"],
},
{
name: "b",
exclude: ["a"],
}
);
const runner = makeTestRunner();
runner.describe("suite", () => {
runner.test.tags("a");
runner.test("first test", () => {});
runner.test.tags("b");
runner.test("second test", () => {});
runner.test.tags("a", "b");
expect(() => runner.test("third test", () => {})).toThrow(`cannot apply tag "b"`);
runner.test.tags("a", "c");
runner.test("fourth test", () => {});
});
expect(runner.tests).toHaveLength(3);
expect(runner.tags).toHaveLength(3);
});
});

View file

@ -0,0 +1,29 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
import { Suite } from "../../core/suite";
describe(parseUrl(import.meta.url), () => {
test("should have a hashed id", () => {
expect(new Suite(null, "a suite", []).id).toMatch(/^\w{8}$/);
});
test("should describe its path in its name", () => {
const a = new Suite(null, "a", []);
const b = new Suite(a, "b", []);
const c = new Suite(a, "c", []);
const d = new Suite(b, "d", []);
expect(a.parent).toBe(null);
expect(b.parent).toBe(a);
expect(c.parent).toBe(a);
expect(d.parent.parent).toBe(a);
expect(a.fullName).toBe("a");
expect(b.fullName).toBe("a/b");
expect(c.fullName).toBe("a/c");
expect(d.fullName).toBe("a/b/d");
});
});

View file

@ -0,0 +1,70 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
import { Suite } from "../../core/suite";
import { Test } from "../../core/test";
function disableHighlighting() {
if (!window.Prism) {
return () => {};
}
const { highlight } = window.Prism;
window.Prism.highlight = (text) => text;
return function restoreHighlighting() {
window.Prism.highlight = highlight;
};
}
describe(parseUrl(import.meta.url), () => {
test("should have a hashed id", () => {
expect(new Test(null, "a test", {}).id).toMatch(/^\w{8}$/);
});
test("should describe its path in its name", () => {
const a = new Suite(null, "a", {});
const b = new Suite(a, "b", {});
const t1 = new Test(null, "t1", {});
const t2 = new Test(a, "t2", {});
const t3 = new Test(b, "t3", {});
expect(t1.fullName).toBe("t1");
expect(t2.fullName).toBe("a/t2");
expect(t3.fullName).toBe("a/b/t3");
});
test("run is async and lazily formatted", () => {
const restoreHighlighting = disableHighlighting();
const testName = "some test";
const t = new Test(null, testName, {});
const runFn = () => {
// Synchronous
expect(1).toBe(1);
};
expect(t.run).toBe(null);
expect(t.runFnString).toBe("");
expect(t.formatted).toBe(false);
t.setRunFn(runFn);
expect(t.run()).toBeInstanceOf(Promise);
expect(t.runFnString).toBe(runFn.toString());
expect(t.formatted).toBe(false);
expect(String(t.code)).toBe(
`
test("${testName}", () => {
// Synchronous
expect(1).toBe(1);
});
`.trim()
);
expect(t.formatted).toBe(true);
restoreHighlighting();
});
});

View file

@ -0,0 +1,922 @@
/** @odoo-module */
import { describe, expect, getFixture, test } from "@odoo/hoot";
import {
animationFrame,
click,
formatXml,
getActiveElement,
getFocusableElements,
getNextFocusableElement,
getPreviousFocusableElement,
isDisplayed,
isEditable,
isFocusable,
isInDOM,
isVisible,
queryAll,
queryAllRects,
queryAllTexts,
queryFirst,
queryOne,
queryRect,
waitFor,
waitForNone,
} from "@odoo/hoot-dom";
import { mockTouch } from "@odoo/hoot-mock";
import { getParentFrame } from "@web/../lib/hoot-dom/helpers/dom";
import { mountForTest, parseUrl } from "../local_helpers";
const $ = queryFirst;
const $1 = queryOne;
const $$ = queryAll;
/**
* @param {...string} queryAllSelectors
*/
const expectSelector = (...queryAllSelectors) => {
/**
* @param {string} nativeSelector
*/
const toEqualNodes = (nativeSelector, options) => {
if (typeof nativeSelector !== "string") {
throw new Error(`Invalid selector: ${nativeSelector}`);
}
let root = options?.root || getFixture();
if (typeof root === "string") {
root = getFixture().querySelector(root);
if (root.tagName === "IFRAME") {
root = root.contentDocument;
}
}
let nodes = nativeSelector ? [...root.querySelectorAll(nativeSelector)] : [];
if (Number.isInteger(options?.index)) {
nodes = [nodes.at(options.index)];
}
const selector = queryAllSelectors.join(", ");
const fnNodes = $$(selector);
expect(fnNodes).toEqual($$`${selector}`, {
message: `should return the same result from a tagged template literal`,
});
expect(fnNodes).toEqual(nodes, {
message: `should match ${nodes.length} nodes`,
});
};
return { toEqualNodes };
};
/**
* @param {Document} document
* @param {HTMLElement} [root]
* @returns {Promise<HTMLIFrameElement>}
*/
const makeIframe = (document, root) =>
new Promise((resolve) => {
const iframe = document.createElement("iframe");
iframe.addEventListener("load", () => resolve(iframe));
iframe.srcdoc = "<body></body>";
(root || document.body).appendChild(iframe);
});
const FULL_HTML_TEMPLATE = /* html */ `
<header>
<h1 class="title">Title</h1>
</header>
<main id="custom-html">
<h5 class="title">List header</h5>
<ul colspan="1" class="overflow-auto" style="max-height: 80px">
<li class="text highlighted">First item</li>
<li class="text">Second item</li>
<li class="text">Last item</li>
</ul>
<p colspan="2" class="text">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur justo
velit, tristique vitae neque a, faucibus mollis dui. Aliquam iaculis
sodales mi id posuere. Proin malesuada bibendum pellentesque. Phasellus
mattis at massa quis gravida. Morbi luctus interdum mi, quis dapibus
augue. Vivamus condimentum nunc mi, vitae suscipit turpis dictum nec.
Sed varius diam dui, eget ultricies ante dictum ac.
</p>
<div class="hidden" style="display: none;">Invisible section</div>
<svg></svg>
<form class="overflow-auto" style="max-width: 100px">
<h5 class="title">Form title</h5>
<input name="name" type="text" value="John Doe (JOD)" />
<input name="email" type="email" value="johndoe@sample.com" />
<select name="title" value="mr">
<option>Select an option</option>
<option value="mr" selected="selected">Mr.</option>
<option value="mrs">Mrs.</option>
</select>
<select name="job">
<option selected="selected">Select an option</option>
<option value="employer">Employer</option>
<option value="employee">Employee</option>
</select>
<button type="submit">Submit</button>
<button type="submit" disabled="disabled">Cancel</button>
</form>
<iframe srcdoc="&lt;p&gt;Iframe text content&lt;/p&gt;"></iframe>
</main>
<footer>
<em>Footer</em>
<button type="button">Back to top</button>
</footer>
`;
customElements.define(
"hoot-test-shadow-root",
class ShadowRoot extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
const p = document.createElement("p");
p.textContent = "Shadow content";
const input = document.createElement("input");
shadow.append(p, input);
}
}
);
describe.tags("ui");
describe(parseUrl(import.meta.url), () => {
test("formatXml", () => {
expect(formatXml("")).toBe("");
expect(formatXml("<input />")).toBe("<input/>");
expect(
formatXml(/* xml */ `
<div>
A
</div>
`)
).toBe(`<div>\n A\n</div>`);
expect(formatXml(/* xml */ `<div>A</div>`)).toBe(`<div>\n A\n</div>`);
// Inline
expect(
formatXml(
/* xml */ `
<div>
A
</div>
`,
{ keepInlineTextNodes: true }
)
).toBe(`<div>\n A\n</div>`);
expect(formatXml(/* xml */ `<div>A</div>`, { keepInlineTextNodes: true })).toBe(
`<div>A</div>`
);
});
test("getActiveElement", async () => {
await mountForTest(/* xml */ `<iframe srcdoc="&lt;input &gt;"></iframe>`);
expect(":iframe input").not.toBeFocused();
const input = $1(":iframe input");
await click(input);
expect(":iframe input").toBeFocused();
expect(getActiveElement()).toBe(input);
});
test("getActiveElement: shadow dom", async () => {
await mountForTest(/* xml */ `<hoot-test-shadow-root />`);
expect("hoot-test-shadow-root:shadow input").not.toBeFocused();
const input = $1("hoot-test-shadow-root:shadow input");
await click(input);
expect("hoot-test-shadow-root:shadow input").toBeFocused();
expect(getActiveElement()).toBe(input);
});
test("getFocusableElements", async () => {
await mountForTest(/* xml */ `
<input class="input" />
<div class="div" tabindex="0">aaa</div>
<span class="span" tabindex="-1">aaa</span>
<button class="disabled-button" disabled="disabled">Disabled button</button>
<button class="button" tabindex="1">Button</button>
`);
expect(getFocusableElements().map((el) => el.className)).toEqual([
"button",
"span",
"input",
"div",
]);
expect(getFocusableElements({ tabbable: true }).map((el) => el.className)).toEqual([
"button",
"input",
"div",
]);
});
test("getNextFocusableElement", async () => {
await mountForTest(/* xml */ `
<input class="input" />
<div class="div" tabindex="0">aaa</div>
<button class="disabled-button" disabled="disabled">Disabled button</button>
<button class="button" tabindex="1">Button</button>
`);
await click(".input");
expect(getNextFocusableElement()).toHaveClass("div");
});
test("getParentFrame", async () => {
await mountForTest(/* xml */ `
<div class="root"></div>
`);
const parent = await makeIframe(document, $1(".root"));
const child = await makeIframe(parent.contentDocument);
const content = child.contentDocument.createElement("div");
child.contentDocument.body.appendChild(content);
expect(getParentFrame(content)).toBe(child);
expect(getParentFrame(child)).toBe(parent);
expect(getParentFrame(parent)).toBe(null);
});
test("getPreviousFocusableElement", async () => {
await mountForTest(/* xml */ `
<input class="input" />
<div class="div" tabindex="0">aaa</div>
<button class="disabled-button" disabled="disabled">Disabled button</button>
<button class="button" tabindex="1">Button</button>
`);
await click(".input");
expect(getPreviousFocusableElement()).toHaveClass("button");
});
test("isEditable", async () => {
expect(isEditable(document.createElement("input"))).toBe(true);
expect(isEditable(document.createElement("textarea"))).toBe(true);
expect(isEditable(document.createElement("select"))).toBe(false);
const editableDiv = document.createElement("div");
expect(isEditable(editableDiv)).toBe(false);
editableDiv.setAttribute("contenteditable", "true");
expect(isEditable(editableDiv)).toBe(false); // not supported
});
test("isFocusable", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(isFocusable("input:first")).toBe(true);
expect(isFocusable("li:first")).toBe(false);
});
test("isInDom", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(isInDOM(document)).toBe(true);
expect(isInDOM(document.body)).toBe(true);
expect(isInDOM(document.head)).toBe(true);
expect(isInDOM(document.documentElement)).toBe(true);
const form = $1`form`;
expect(isInDOM(form)).toBe(true);
form.remove();
expect(isInDOM(form)).toBe(false);
const paragraph = $1`:iframe p`;
expect(isInDOM(paragraph)).toBe(true);
paragraph.remove();
expect(isInDOM(paragraph)).toBe(false);
});
test("isDisplayed", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(isDisplayed(document)).toBe(true);
expect(isDisplayed(document.body)).toBe(true);
expect(isDisplayed(document.head)).toBe(true);
expect(isDisplayed(document.documentElement)).toBe(true);
expect(isDisplayed("form")).toBe(true);
expect(isDisplayed(".hidden")).toBe(false);
expect(isDisplayed("body")).toBe(false); // not available from fixture
});
test("isVisible", async () => {
await mountForTest(FULL_HTML_TEMPLATE + "<hoot-test-shadow-root />");
expect(isVisible(document)).toBe(true);
expect(isVisible(document.body)).toBe(true);
expect(isVisible(document.head)).toBe(false);
expect(isVisible(document.documentElement)).toBe(true);
expect(isVisible("form")).toBe(true);
expect(isVisible("hoot-test-shadow-root:shadow input")).toBe(true);
expect(isVisible(".hidden")).toBe(false);
expect(isVisible("body")).toBe(false); // not available from fixture
});
test("matchMedia", async () => {
// Invalid syntax
expect(matchMedia("aaaa").matches).toBe(false);
expect(matchMedia("display-mode: browser").matches).toBe(false);
// Does not exist
expect(matchMedia("(a)").matches).toBe(false);
expect(matchMedia("(a: b)").matches).toBe(false);
// Defaults
expect(matchMedia("(display-mode:browser)").matches).toBe(true);
expect(matchMedia("(display-mode: standalone)").matches).toBe(false);
expect(matchMedia("not (display-mode: standalone)").matches).toBe(true);
expect(matchMedia("(prefers-color-scheme :light)").matches).toBe(true);
expect(matchMedia("(prefers-color-scheme : dark)").matches).toBe(false);
expect(matchMedia("not (prefers-color-scheme: dark)").matches).toBe(true);
expect(matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true);
expect(matchMedia("(prefers-reduced-motion: no-preference)").matches).toBe(false);
// Touch feature
expect(window.matchMedia("(pointer: coarse)").matches).toBe(false);
expect(window.ontouchstart).toBe(undefined);
mockTouch(true);
expect(window.matchMedia("(pointer: coarse)").matches).toBe(true);
expect(window.ontouchstart).not.toBe(undefined);
});
test("waitFor: already in fixture", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
waitFor(".title").then((el) => {
expect.step(el.className);
return el;
});
expect.verifySteps([]);
await animationFrame();
expect.verifySteps(["title"]);
});
test("waitFor: rejects", async () => {
await expect(waitFor("never", { timeout: 1 })).rejects.toThrow(
`expected at least 1 element after 1ms and found 0 elements: 0 matching "never"`
);
});
test("waitFor: add new element", async () => {
const el1 = document.createElement("div");
el1.className = "new-element";
const el2 = document.createElement("div");
el2.className = "new-element";
const promise = waitFor(".new-element").then((el) => {
expect.step(el.className);
return el;
});
await animationFrame();
expect.verifySteps([]);
getFixture().append(el1, el2);
await expect(promise).resolves.toBe(el1);
expect.verifySteps(["new-element"]);
});
test("waitForNone: DOM empty", async () => {
waitForNone(".title").then(() => expect.step("none"));
expect.verifySteps([]);
await animationFrame();
expect.verifySteps(["none"]);
});
test("waitForNone: rejects", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
await expect(waitForNone(".title", { timeout: 1 })).rejects.toThrow();
});
test("waitForNone: delete elements", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
waitForNone(".title").then(() => expect.step("none"));
expect(".title").toHaveCount(3);
for (const title of $$(".title")) {
expect.verifySteps([]);
title.remove();
await animationFrame();
}
expect.verifySteps(["none"]);
});
describe("query", () => {
test("native selectors", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect($$()).toEqual([]);
for (const selector of [
"main",
`.${"title"}`,
`${"ul"}${" "}${`${"li"}`}`,
".title",
"ul > li",
"form:has(.title:not(.haha)):not(.huhu) input[name='email']:enabled",
"[colspan='1']",
]) {
expectSelector(selector).toEqualNodes(selector);
}
});
test("custom pseudo-classes", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
// :first, :last, :only & :eq
expectSelector(".title:first").toEqualNodes(".title", { index: 0 });
expectSelector(".title:last").toEqualNodes(".title", { index: -1 });
expectSelector(".title:eq(-1)").toEqualNodes(".title", { index: -1 });
expectSelector("main:only").toEqualNodes("main");
expectSelector(".title:only").toEqualNodes("");
expectSelector(".title:eq(1)").toEqualNodes(".title", { index: 1 });
expectSelector(".title:eq('1')").toEqualNodes(".title", { index: 1 });
expectSelector('.title:eq("1")').toEqualNodes(".title", { index: 1 });
// :contains (text)
expectSelector("main > .text:contains(ipsum)").toEqualNodes("p");
expectSelector(".text:contains(/\\bL\\w+\\b\\sipsum/)").toEqualNodes("p");
expectSelector(".text:contains(item)").toEqualNodes("li");
// :contains (value)
expectSelector("input:value(john)").toEqualNodes("[name=name],[name=email]");
expectSelector("input:value(john doe)").toEqualNodes("[name=name]");
expectSelector("input:value('John Doe (JOD)')").toEqualNodes("[name=name]");
expectSelector(`input:value("(JOD)")`).toEqualNodes("[name=name]");
expectSelector("input:value(johndoe)").toEqualNodes("[name=email]");
expectSelector("select:value(mr)").toEqualNodes("[name=title]");
expectSelector("select:value(unknown value)").toEqualNodes("");
// :selected
expectSelector("option:selected").toEqualNodes(
"select[name=title] option[value=mr],select[name=job] option:first-child"
);
// :iframe
expectSelector("iframe p:contains(iframe text content)").toEqualNodes("");
expectSelector("div:iframe p").toEqualNodes("");
expectSelector(":iframe p:contains(iframe text content)").toEqualNodes("p", {
root: "iframe",
});
});
test("advanced use cases", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
// Comma-separated selectors
expectSelector(":has(form:contains('Form title')),p:contains(ipsum)").toEqualNodes(
"p,main"
);
// :has & :not combinations with custom pseudo-classes
expectSelector(`select:has(:contains(Employer))`).toEqualNodes("select[name=job]");
expectSelector(`select:not(:has(:contains(Employer)))`).toEqualNodes(
"select[name=title]"
);
expectSelector(
`main:first-of-type:not(:has(:contains(This text does not exist))):contains('List header') > form:has([name="name"]):contains("Form title"):nth-child(6).overflow-auto:visible select[name=job] option:selected`
).toEqualNodes("select[name=job] option:first-child");
// :contains & commas
expectSelector(`p:contains(velit,)`).toEqualNodes("p");
expectSelector(`p:contains('velit,')`).toEqualNodes("p");
expectSelector(`p:contains(", tristique")`).toEqualNodes("p");
expectSelector(`p:contains(/\\bvelit,/)`).toEqualNodes("p");
});
// Whatever, at this point I'm just copying failing selectors and creating
// fake contexts accordingly as I'm fixing them.
test("comma-separated long selector: no match", async () => {
await mountForTest(/* xml */ `
<div class="o_we_customize_panel">
<we-customizeblock-option class="snippet-option-ImageTools">
<div class="o_we_so_color_palette o_we_widget_opened">
idk
</div>
<we-select data-name="shape_img_opt">
<we-toggler></we-toggler>
</we-select>
</we-customizeblock-option>
</div>
`);
expectSelector(
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
).toEqualNodes("");
});
test("comma-separated long selector: match first", async () => {
await mountForTest(/* xml */ `
<div class="o_we_customize_panel">
<we-customizeblock-option class="snippet-option-ImageTools">
<we-select data-name="shape_img_opt">
<we-toggler></we-toggler>
</we-select>
</we-customizeblock-option>
</div>
`);
expectSelector(
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
).toEqualNodes("we-toggler");
});
test("comma-separated long selector: match second", async () => {
await mountForTest(/* xml */ `
<div class="o_we_customize_panel">
<we-customizeblock-option class="snippet-option-ImageTools">
<div title='we-select[data-name="shape_img_opt"] we-toggler'>
idk
</div>
</we-customizeblock-option>
</div>
`);
expectSelector(
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
).toEqualNodes("div[title]");
});
test("comma-separated :contains", async () => {
await mountForTest(/* xml */ `
<div class="o_menu_sections">
<a class="dropdown-item">Products</a>
</div>
<nav class="o_burger_menu_content">
<ul>
<li data-menu-xmlid="sale.menu_product_template_action">
Products
</li>
</ul>
</nav>
`);
expectSelector(
`.o_menu_sections .dropdown-item:contains('Products'), nav.o_burger_menu_content li[data-menu-xmlid='sale.menu_product_template_action']`
).toEqualNodes(".dropdown-item,li");
});
test(":contains with line return", async () => {
await mountForTest(/* xml */ `
<span>
<div>Matrix (PAV11, PAV22, PAV31)</div>
<div>PA4: PAV41</div>
</span>
`);
expectSelector(
`span:contains("Matrix (PAV11, PAV22, PAV31)\nPA4: PAV41")`
).toEqualNodes("span");
});
test(":has(...):first", async () => {
await mountForTest(/* xml */ `
<a href="/web/event/1"></a>
<a target="" href="/web/event/2">
<span>Conference for Architects TEST</span>
</a>
`);
expectSelector(
`a[href*="/event"]:contains("Conference for Architects TEST")`
).toEqualNodes("[target]");
expectSelector(
`a[href*="/event"]:contains("Conference for Architects TEST"):first`
).toEqualNodes("[target]");
});
test(":eq", async () => {
await mountForTest(/* xml */ `
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
</ul>
`);
expectSelector(`li:first:contains(a)`).toEqualNodes("li:nth-child(1)");
expectSelector(`li:contains(a):first`).toEqualNodes("li:nth-child(1)");
expectSelector(`li:first:contains(b)`).toEqualNodes("");
expectSelector(`li:contains(b):first`).toEqualNodes("li:nth-child(2)");
});
test(":empty", async () => {
await mountForTest(/* xml */ `
<input class="empty" />
<input class="value" value="value" />
`);
expectSelector(`input:empty`).toEqualNodes(".empty");
expectSelector(`input:not(:empty)`).toEqualNodes(".value");
});
test("regular :contains", async () => {
await mountForTest(/* xml */ `
<div class="website_links_click_chart">
<div class="title">
0 clicks
</div>
<div class="title">
1 clicks
</div>
<div class="title">
2 clicks
</div>
</div>
`);
expectSelector(`.website_links_click_chart .title:contains("1 clicks")`).toEqualNodes(
".title:nth-child(2)"
);
});
test("other regular :contains", async () => {
await mountForTest(/* xml */ `
<ul
class="o-autocomplete--dropdown-menu ui-widget show dropdown-menu ui-autocomplete"
style="position: fixed; top: 283.75px; left: 168.938px"
>
<li class="o-autocomplete--dropdown-item ui-menu-item block">
<a
href="#"
class="dropdown-item ui-menu-item-wrapper truncate ui-state-active"
>Account Tax Group Partner</a
>
</li>
<li
class="o-autocomplete--dropdown-item ui-menu-item block o_m2o_dropdown_option o_m2o_dropdown_option_search_more"
>
<a href="#" class="dropdown-item ui-menu-item-wrapper truncate"
>Search More...</a
>
</li>
<li
class="o-autocomplete--dropdown-item ui-menu-item block o_m2o_dropdown_option o_m2o_dropdown_option_create_edit"
>
<a href="#" class="dropdown-item ui-menu-item-wrapper truncate"
>Create and edit...</a
>
</li>
</ul>
`);
expectSelector(`.ui-menu-item a:contains("Account Tax Group Partner")`).toEqualNodes(
"ul li:first-child a"
);
});
test(":iframe", async () => {
await mountForTest(/* xml */ `
<iframe srcdoc="&lt;p&gt;Iframe text content&lt;/p&gt;"></iframe>
`);
expectSelector(`:iframe html`).toEqualNodes("html", { root: "iframe" });
expectSelector(`:iframe body`).toEqualNodes("body", { root: "iframe" });
expectSelector(`:iframe head`).toEqualNodes("head", { root: "iframe" });
});
test(":contains with brackets", async () => {
await mountForTest(/* xml */ `
<div class="o_content">
<div class="o_field_widget" name="messages">
<table class="o_list_view table table-sm table-hover table-striped o_list_view_ungrouped">
<tbody>
<tr class="o_data_row">
<td class="o_list_record_selector">
bbb
</td>
<td class="o_data_cell o_required_modifier">
<span>
[test_trigger] Mitchell Admin
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`);
expectSelector(
`.o_content:has(.o_field_widget[name=messages]):has(td:contains(/^bbb$/)):has(td:contains(/^\\[test_trigger\\] Mitchell Admin$/))`
).toEqualNodes(".o_content");
});
test(":eq in the middle of a selector", async () => {
await mountForTest(/* xml */ `
<ul>
<li class="oe_overlay o_draggable"></li>
<li class="oe_overlay o_draggable"></li>
<li class="oe_overlay o_draggable oe_active"></li>
<li class="oe_overlay o_draggable"></li>
</ul>
`);
expectSelector(`.oe_overlay.o_draggable:eq(2).oe_active`).toEqualNodes(
"li:nth-child(3)"
);
});
test("combinator +", async () => {
await mountForTest(/* xml */ `
<form class="js_attributes">
<input type="checkbox" />
<label>Steel - Test</label>
</form>
`);
expectSelector(
`form.js_attributes input:not(:checked) + label:contains(Steel - Test)`
).toEqualNodes("label");
});
test("multiple + combinators", async () => {
await mountForTest(/* xml */ `
<div class="s_cover">
<span class="o_text_highlight">
<span class="o_text_highlight_item">
<span class="o_text_highlight_path_underline" />
</span>
<br />
<span class="o_text_highlight_item">
<span class="o_text_highlight_path_underline" />
</span>
</span>
</div>
`);
expectSelector(`
.s_cover span.o_text_highlight:has(
.o_text_highlight_item
+ br
+ .o_text_highlight_item
)
`).toEqualNodes(".o_text_highlight");
});
test(":last", async () => {
await mountForTest(/* xml */ `
<div class="o_field_widget" name="messages">
<table class="o_list_view table table-sm table-hover table-striped o_list_view_ungrouped">
<tbody>
<tr class="o_data_row">
<td class="o_list_record_remove">
<button class="btn">Remove</button>
</td>
</tr>
<tr class="o_data_row">
<td class="o_list_record_remove">
<button class="btn">Remove</button>
</td>
</tr>
</tbody>
</table>
</div>
`);
expectSelector(
`.o_field_widget[name=messages] .o_data_row td.o_list_record_remove button:visible:last`
).toEqualNodes(".o_data_row:last-child button");
});
test("select :contains & :value", async () => {
await mountForTest(/* xml */ `
<select class="configurator_select form-select form-select-lg">
<option value="217" selected="">Metal</option>
<option value="218">Wood</option>
</select>
`);
expectSelector(`.configurator_select:has(option:contains(Metal))`).toEqualNodes(
"select"
);
expectSelector(`.configurator_select:has(option:value(217))`).toEqualNodes("select");
expectSelector(`.configurator_select:has(option:value(218))`).toEqualNodes("select");
expectSelector(`.configurator_select:value(217)`).toEqualNodes("select");
expectSelector(`.configurator_select:value(218)`).toEqualNodes("");
expectSelector(`.configurator_select:value(Metal)`).toEqualNodes("");
});
test("invalid selectors", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(() => $$`[colspan=1]`).toThrow(); // missing quotes
expect(() => $$`[href=/]`).toThrow(); // missing quotes
expect(
() =>
$$`_o_wblog_posts_loop:has(span:has(i.fa-calendar-o):has(a[href="/blog?search=a"])):has(span:has(i.fa-search):has(a[href^="/blog?date_begin"]))`
).toThrow(); // nested :has statements
});
test("queryAllRects", async () => {
await mountForTest(/* xml */ `
<div style="width: 40px; height: 60px;" />
<div style="width: 20px; height: 10px;" />
`);
expect(queryAllRects("div")).toEqual($$("div").map((el) => el.getBoundingClientRect()));
expect(queryAllRects("div:first")).toEqual([new DOMRect({ width: 40, height: 60 })]);
expect(queryAllRects("div:last")).toEqual([new DOMRect({ width: 20, height: 10 })]);
});
test("queryAllTexts", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect(queryAllTexts(".title")).toEqual(["Title", "List header", "Form title"]);
expect(queryAllTexts("footer")).toEqual(["FooterBack to top"]);
});
test("queryOne", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect($1(".title:first")).toBe(getFixture().querySelector("header .title"));
expect(() => $1(".title")).toThrow();
expect(() => $1(".title", { exact: 2 })).toThrow();
});
test("queryRect", async () => {
await mountForTest(/* xml */ `
<div class="container">
<div class="rect" style="width: 40px; height: 60px;" />
</div>
`);
expect(".rect").toHaveRect(".container"); // same rect as parent
expect(".rect").toHaveRect({ width: 40, height: 60 });
expect(queryRect(".rect")).toEqual($1(".rect").getBoundingClientRect());
expect(queryRect(".rect")).toEqual(new DOMRect({ width: 40, height: 60 }));
});
test("queryRect with trimPadding", async () => {
await mountForTest(/* xml */ `
<div style="width: 50px; height: 70px; padding: 5px; margin: 6px" />
`);
expect("div").toHaveRect({ width: 50, height: 70 }); // with padding
expect("div").toHaveRect({ width: 40, height: 60 }, { trimPadding: true });
});
test("not found messages", async () => {
await mountForTest(/* xml */ `
<div class="tralalero">
Tralala
</div>
`);
expect(() => $("invalid:pseudo-selector")).toThrow();
// Perform in-between valid query with custom pseudo selectors
expect($`.modal:visible:contains('Tung Tung Tung Sahur')`).toBe(null);
// queryOne error messages
expect(() => $1()).toThrow(`found 0 elements instead of 1`);
expect(() => $$([], { exact: 18 })).toThrow(`found 0 elements instead of 18`);
expect(() => $1("")).toThrow(`found 0 elements instead of 1: 0 matching ""`);
expect(() => $$(".tralalero", { exact: -20 })).toThrow(
`found 1 element instead of -20: 1 matching ".tralalero"`
);
expect(() => $1`.tralalero:contains(Tralala):visible:scrollable:first`).toThrow(
`found 0 elements instead of 1: 0 matching ".tralalero:contains(Tralala):visible:scrollable:first" (1 element with text "Tralala" > 1 visible element > 0 scrollable elements)`
);
expect(() =>
$1(".tralalero", {
contains: "Tralala",
visible: true,
scrollable: true,
first: true,
})
).toThrow(
`found 0 elements instead of 1: 1 matching ".tralalero", including 1 element with text "Tralala", including 1 visible element, including 0 scrollable elements`
);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,132 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import {
Deferred,
advanceTime,
animationFrame,
microTick,
runAllTimers,
tick,
waitUntil,
} from "@odoo/hoot-dom";
import { parseUrl } from "../local_helpers";
// timeout of 1 second to ensure all timeouts are actually mocked
describe.timeout(1_000);
describe(parseUrl(import.meta.url), () => {
test("advanceTime", async () => {
expect.assertions(8);
await advanceTime(5_000);
const timeoutId = window.setTimeout(() => expect.step("timeout"), "2000");
const intervalId = window.setInterval(() => expect.step("interval"), 3_000);
const animationHandle = window.requestAnimationFrame((delta) => {
expect(delta).toBeGreaterThan(5_000);
expect.step("animation");
});
expect(timeoutId).toBeGreaterThan(0);
expect(intervalId).toBeGreaterThan(0);
expect(animationHandle).toBeGreaterThan(0);
expect.verifySteps([]);
await advanceTime(10_000); // 10 seconds
expect.verifySteps(["animation", "timeout", "interval", "interval", "interval"]);
await advanceTime(10_000);
expect.verifySteps(["interval", "interval", "interval"]);
window.clearInterval(intervalId);
await advanceTime(10_000);
expect.verifySteps([]);
});
test("Deferred", async () => {
const def = new Deferred();
def.then(() => expect.step("resolved"));
expect.step("before");
def.resolve(14);
expect.step("after");
await expect(def).resolves.toBe(14);
expect.verifySteps(["before", "after", "resolved"]);
});
test("tick", async () => {
let count = 0;
const nextTickPromise = tick().then(() => ++count);
expect(count).toBe(0);
await expect(nextTickPromise).resolves.toBe(1);
expect(count).toBe(1);
});
test("runAllTimers", async () => {
expect.assertions(4);
window.setTimeout(() => expect.step("timeout"), 1e6);
window.requestAnimationFrame((delta) => {
expect(delta).toBeGreaterThan(1);
expect.step("animation");
});
expect.verifySteps([]);
const ms = await runAllTimers();
expect(ms).toBeCloseTo(1e6, { margin: 10 });
expect.verifySteps(["animation", "timeout"]);
});
test("waitUntil: already true", async () => {
const promise = waitUntil(() => "some value").then((value) => {
expect.step("resolved");
return value;
});
expect.verifySteps([]);
expect(promise).toBeInstanceOf(Promise);
await microTick();
expect.verifySteps(["resolved"]);
await expect(promise).resolves.toBe("some value");
});
test("waitUntil: rejects", async () => {
await expect(waitUntil(() => false, { timeout: 0 })).rejects.toThrow();
});
test("waitUntil: lazy", async () => {
let returnValue = "";
const promise = waitUntil(() => returnValue).then((v) => expect.step(v));
expect.verifySteps([]);
expect(promise).toBeInstanceOf(Promise);
await animationFrame();
await animationFrame();
expect.verifySteps([]);
returnValue = "test";
await animationFrame();
expect.verifySteps(["test"]);
});
});

View file

@ -0,0 +1,38 @@
const _owl = window.owl;
delete window.owl;
export const App = _owl.App;
export const Component = _owl.Component;
export const EventBus = _owl.EventBus;
export const OwlError = _owl.OwlError;
export const __info__ = _owl.__info__;
export const blockDom = _owl.blockDom;
export const loadFile = _owl.loadFile;
export const markRaw = _owl.markRaw;
export const markup = _owl.markup;
export const mount = _owl.mount;
export const onError = _owl.onError;
export const onMounted = _owl.onMounted;
export const onPatched = _owl.onPatched;
export const onRendered = _owl.onRendered;
export const onWillDestroy = _owl.onWillDestroy;
export const onWillPatch = _owl.onWillPatch;
export const onWillRender = _owl.onWillRender;
export const onWillStart = _owl.onWillStart;
export const onWillUnmount = _owl.onWillUnmount;
export const onWillUpdateProps = _owl.onWillUpdateProps;
export const reactive = _owl.reactive;
export const status = _owl.status;
export const toRaw = _owl.toRaw;
export const useChildSubEnv = _owl.useChildSubEnv;
export const useComponent = _owl.useComponent;
export const useEffect = _owl.useEffect;
export const useEnv = _owl.useEnv;
export const useExternalListener = _owl.useExternalListener;
export const useRef = _owl.useRef;
export const useState = _owl.useState;
export const useSubEnv = _owl.useSubEnv;
export const validate = _owl.validate;
export const validateType = _owl.validateType;
export const whenReady = _owl.whenReady;
export const xml = _owl.xml;

View file

@ -0,0 +1,354 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { isInstanceOf, isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
import {
deepEqual,
formatHumanReadable,
formatTechnical,
generateHash,
levenshtein,
lookup,
match,
parseQuery,
title,
toExplicitString,
} from "../hoot_utils";
import { mountForTest, parseUrl } from "./local_helpers";
describe(parseUrl(import.meta.url), () => {
test("deepEqual", () => {
const recursive = {};
recursive.self = recursive;
const TRUTHY_CASES = [
[true, true],
[false, false],
[null, null],
[recursive, recursive],
[new Date(0), new Date(0)],
[
{ b: 2, a: 1 },
{ a: 1, b: 2 },
],
[{ o: { a: [{ b: 1 }] } }, { o: { a: [{ b: 1 }] } }],
[Symbol.for("a"), Symbol.for("a")],
[document.createElement("div"), document.createElement("div")],
[
[1, 2, 3],
[1, 2, 3],
],
];
const FALSY_CASES = [
[true, false],
[null, undefined],
[recursive, { ...recursive, a: 1 }],
[
[1, 2, 3],
[3, 1, 2],
],
[new Date(0), new Date(1_000)],
[{ a: new Date(0) }, { a: 0 }],
[document.createElement("a"), document.createElement("div")],
[{ [Symbol("a")]: 1 }, { [Symbol("a")]: 1 }],
];
const TRUTHY_IF_UNORDERED_CASES = [
[
[1, "2", 3],
["2", 3, 1],
],
[
[1, { a: [4, 2] }, "3"],
[{ a: [2, 4] }, "3", 1],
],
[
new Set([
"abc",
new Map([
["b", 2],
["a", 1],
]),
]),
new Set([
new Map([
["a", 1],
["b", 2],
]),
"abc",
]),
],
];
expect.assertions(
TRUTHY_CASES.length + FALSY_CASES.length + TRUTHY_IF_UNORDERED_CASES.length * 2
);
for (const [a, b] of TRUTHY_CASES) {
expect(deepEqual(a, b)).toBe(true, {
message: [a, `==`, b],
});
}
for (const [a, b] of FALSY_CASES) {
expect(deepEqual(a, b)).toBe(false, {
message: [a, `!=`, b],
});
}
for (const [a, b] of TRUTHY_IF_UNORDERED_CASES) {
expect(deepEqual(a, b)).toBe(false, {
message: [a, `!=`, b],
});
expect(deepEqual(a, b, { ignoreOrder: true })).toBe(true, {
message: [a, `==`, b, `(unordered))`],
});
}
});
test("formatHumanReadable", () => {
// Strings
expect(formatHumanReadable("abc")).toBe(`"abc"`);
expect(formatHumanReadable("a".repeat(300))).toBe(`"${"a".repeat(80)}…"`);
expect(formatHumanReadable(`with "double quotes"`)).toBe(`'with "double quotes"'`);
expect(formatHumanReadable(`with "double quotes" and 'single quote'`)).toBe(
`\`with "double quotes" and 'single quote'\``
);
// Numbers
expect(formatHumanReadable(1)).toBe(`1`);
// Other primitives
expect(formatHumanReadable(true)).toBe(`true`);
expect(formatHumanReadable(null)).toBe(`null`);
// Functions & classes
expect(formatHumanReadable(async function oui() {})).toBe(`async function oui() { … }`);
expect(formatHumanReadable(class Oui {})).toBe(`class Oui { … }`);
// Iterators
expect(formatHumanReadable([1, 2, 3])).toBe(`[1, 2, 3]`);
expect(formatHumanReadable(new Set([1, 2, 3]))).toBe(`Set [1, 2, 3]`);
expect(
formatHumanReadable(
new Map([
["a", 1],
["b", 2],
])
)
).toBe(`Map [["a", 1], ["b", 2]]`);
// Objects
expect(formatHumanReadable(/ab(c)d/gi)).toBe(`/ab(c)d/gi`);
expect(formatHumanReadable(new Date("1997-01-09T12:30:00.000Z"))).toBe(
`1997-01-09T12:30:00.000Z`
);
expect(formatHumanReadable({})).toBe(`{ }`);
expect(formatHumanReadable({ a: { b: 1 } })).toBe(`{ a: { b: 1 } }`);
expect(
formatHumanReadable(
new Proxy(
{
allowed: true,
get forbidden() {
throw new Error("Cannot access!");
},
},
{}
)
)
).toBe(`{ allowed: true }`);
expect(formatHumanReadable(window)).toBe(`Window { }`);
// Nodes
expect(formatHumanReadable(document.createElement("div"))).toBe("<div>");
expect(formatHumanReadable(document.createTextNode("some text"))).toBe("#text");
expect(formatHumanReadable(document)).toBe("#document");
});
test("formatTechnical", () => {
expect(
formatTechnical({
b: 2,
[Symbol("s")]: "value",
a: true,
})
).toBe(
`{
a: true,
b: 2,
Symbol(s): "value",
}`.trim()
);
expect(formatTechnical(["a", "b"])).toBe(
`[
"a",
"b",
]`.trim()
);
class List extends Array {}
expect(formatTechnical(new List("a", "b"))).toBe(
`List [
"a",
"b",
]`.trim()
);
function toArguments() {
return arguments;
}
expect(formatTechnical(toArguments("a", "b"))).toBe(
`Arguments [
"a",
"b",
]`.trim()
);
});
test("generateHash", () => {
expect(generateHash("abc")).toHaveLength(8);
expect(generateHash("abcdef")).toHaveLength(8);
expect(generateHash("abc")).toBe(generateHash("abc"));
expect(generateHash("abc")).not.toBe(generateHash("def"));
});
test("isInstanceOf", async () => {
await mountForTest(/* xml */ `
<iframe srcdoc="" />
`);
expect(() => isInstanceOf()).toThrow(TypeError);
expect(() => isInstanceOf("a")).toThrow(TypeError);
expect(isInstanceOf(null, null)).toBe(false);
expect(isInstanceOf(undefined, undefined)).toBe(false);
expect(isInstanceOf("", String)).toBe(false);
expect(isInstanceOf(24, Number)).toBe(false);
expect(isInstanceOf(true, Boolean)).toBe(false);
class List extends Array {}
class A {}
class B extends A {}
expect(isInstanceOf([], Array)).toBe(true);
expect(isInstanceOf(new List(), Array)).toBe(true);
expect(isInstanceOf(new B(), B)).toBe(true);
expect(isInstanceOf(new B(), A)).toBe(true);
expect(isInstanceOf(new Error("error"), Error)).toBe(true);
expect(isInstanceOf(/a/, RegExp, Date)).toBe(true);
expect(isInstanceOf(new Date(), RegExp, Date)).toBe(true);
const { contentDocument, contentWindow } = queryOne("iframe");
expect(isInstanceOf(queryOne("iframe"), HTMLIFrameElement)).toBe(true);
expect(contentWindow instanceof Window).toBe(false);
expect(isInstanceOf(contentWindow, Window)).toBe(true);
expect(contentDocument.body instanceof HTMLBodyElement).toBe(false);
expect(isInstanceOf(contentDocument.body, HTMLBodyElement)).toBe(true);
});
test("isIterable", () => {
expect(isIterable([1, 2, 3])).toBe(true);
expect(isIterable(new Set([1, 2, 3]))).toBe(true);
expect(isIterable(null)).toBe(false);
expect(isIterable("abc")).toBe(false);
expect(isIterable({})).toBe(false);
});
test("levenshtein", () => {
expect(levenshtein("abc", "abc")).toBe(0);
expect(levenshtein("abc", "àbc ")).toBe(2);
expect(levenshtein("abc", "def")).toBe(3);
expect(levenshtein("abc", "adc")).toBe(1);
});
test("parseQuery & lookup", () => {
/**
* @param {string} query
* @param {string[]} itemsList
* @param {string} [property]
*/
const expectQuery = (query, itemsList, property = "key") => {
const keyedItems = itemsList.map((item) => ({ [property]: item }));
const result = lookup(parseQuery(query), keyedItems);
return {
/**
* @param {string[]} expected
*/
toEqual: (expected) =>
expect(result).toEqual(
expected.map((item) => ({ [property]: item })),
{ message: `query ${query} should match ${expected}` }
),
};
};
const list = [
"Frodo",
"Sam",
"Merry",
"Pippin",
"Frodo Sam",
"Merry Pippin",
"Frodo Sam Merry Pippin",
];
// Error handling
expect(() => parseQuery()).toThrow();
expect(() => lookup()).toThrow();
expect(() => lookup("a", [{ key: "a" }])).toThrow();
expect(() => lookup(parseQuery("a"))).toThrow();
// Empty query and/or empty lists
expectQuery("", []).toEqual([]);
expectQuery("", ["bababa", "baaab", "cccbccb"]).toEqual(["bababa", "baaab", "cccbccb"]);
expectQuery("aaa", []).toEqual([]);
// Regex
expectQuery(`/.b$/`, ["bababa", "baaab", "cccbccB"]).toEqual(["baaab"]);
expectQuery(`/.b$/i`, ["bababa", "baaab", "cccbccB"]).toEqual(["baaab", "cccbccB"]);
// Exact match
expectQuery(`"aaa"`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab"]);
expectQuery(`"sam"`, list).toEqual([]);
expectQuery(`"Sam"`, list).toEqual(["Sam", "Frodo Sam", "Frodo Sam Merry Pippin"]);
expectQuery(`"Sam" "Frodo"`, list).toEqual(["Frodo Sam", "Frodo Sam Merry Pippin"]);
expectQuery(`"Frodo Sam"`, list).toEqual(["Frodo Sam", "Frodo Sam Merry Pippin"]);
expectQuery(`"FrodoSam"`, list).toEqual([]);
expectQuery(`"Frodo Sam"`, list).toEqual([]);
expectQuery(`"Sam" -"Frodo"`, list).toEqual(["Sam"]);
// Partial (fuzzy) match
expectQuery(`aaa`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab", "bababa"]);
expectQuery(`aaa -bbb`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab"]);
expectQuery(`-aaa`, ["bababa", "baaab", "cccbccb"]).toEqual(["cccbccb"]);
expectQuery(`frosapip`, list).toEqual(["Frodo Sam Merry Pippin"]);
expectQuery(`-s fro`, list).toEqual(["Frodo"]);
expectQuery(` FR SAPI `, list).toEqual(["Frodo Sam Merry Pippin"]);
// Mixed queries
expectQuery(`"Sam" fro pip`, list).toEqual(["Frodo Sam Merry Pippin"]);
expectQuery(`fro"Sam"pip`, list).toEqual(["Frodo Sam Merry Pippin"]);
expectQuery(`-"Frodo" s`, list).toEqual(["Sam"]);
expectQuery(`"Merry" -p`, list).toEqual(["Merry"]);
expectQuery(`"rry" -s`, list).toEqual(["Merry", "Merry Pippin"]);
});
test("match", () => {
expect(match("abc", /^abcd?/)).toBe(true);
expect(match(new Error("error message"), "message")).toBe(true);
});
test("title", () => {
expect(title("abcDef")).toBe("AbcDef");
});
test("toExplicitString", () => {
expect(toExplicitString("\n")).toBe(`\\n`);
expect(toExplicitString("\t")).toBe(`\\t`);
expect(toExplicitString(" \n")).toBe(` \n`);
expect(toExplicitString("\t ")).toBe(`\t `);
expect(toExplicitString("Abc\u200BDef")).toBe(`Abc\\u200bDef`);
});
});

View file

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HOOT internal tests</title>
<!-- Source map -->
<script type="importmap">
{
"imports": {
"@odoo/hoot-dom": "/web/static/lib/hoot-dom/hoot-dom.js",
"@odoo/hoot-mock": "/web/static/lib/hoot/hoot-mock.js",
"@odoo/hoot": "/web/static/lib/hoot/hoot.js",
"@odoo/owl": "/web/static/lib/hoot/tests/hoot-owl-module.js",
"@web/../lib/hoot-dom/helpers/dom": "/web/static/lib/hoot-dom/helpers/dom.js",
"@web/../lib/hoot-dom/helpers/events": "/web/static/lib/hoot-dom/helpers/events.js",
"@web/../lib/hoot-dom/helpers/time": "/web/static/lib/hoot-dom/helpers/time.js",
"@web/../lib/hoot-dom/hoot_dom_utils": "/web/static/lib/hoot-dom/hoot_dom_utils.js",
"/web/static/lib/hoot-dom/helpers/dom": "/web/static/lib/hoot-dom/helpers/dom.js",
"/web/static/lib/hoot-dom/helpers/events": "/web/static/lib/hoot-dom/helpers/events.js",
"/web/static/lib/hoot-dom/helpers/time": "/web/static/lib/hoot-dom/helpers/time.js",
"/web/static/lib/hoot-dom/hoot_dom_utils": "/web/static/lib/hoot-dom/hoot_dom_utils.js",
"/web/static/lib/hoot-dom/hoot-dom": "/web/static/lib/hoot-dom/hoot-dom.js",
"/web/static/lib/hoot/core/cleanup": "/web/static/lib/hoot/core/cleanup.js",
"/web/static/lib/hoot/core/config": "/web/static/lib/hoot/core/config.js",
"/web/static/lib/hoot/core/expect": "/web/static/lib/hoot/core/expect.js",
"/web/static/lib/hoot/core/fixture": "/web/static/lib/hoot/core/fixture.js",
"/web/static/lib/hoot/core/job": "/web/static/lib/hoot/core/job.js",
"/web/static/lib/hoot/core/logger": "/web/static/lib/hoot/core/logger.js",
"/web/static/lib/hoot/core/runner": "/web/static/lib/hoot/core/runner.js",
"/web/static/lib/hoot/core/suite": "/web/static/lib/hoot/core/suite.js",
"/web/static/lib/hoot/core/tag": "/web/static/lib/hoot/core/tag.js",
"/web/static/lib/hoot/core/test": "/web/static/lib/hoot/core/test.js",
"/web/static/lib/hoot/core/url": "/web/static/lib/hoot/core/url.js",
"/web/static/lib/hoot/hoot_utils": "/web/static/lib/hoot/hoot_utils.js",
"/web/static/lib/hoot/hoot-mock": "/web/static/lib/hoot/hoot-mock.js",
"/web/static/lib/hoot/hoot": "/web/static/lib/hoot/hoot.js",
"/web/static/lib/hoot/lib/diff_match_patch": "/web/static/lib/hoot/lib/diff_match_patch.js",
"/web/static/lib/hoot/main_runner": "/web/static/lib/hoot/main_runner.js",
"/web/static/lib/hoot/mock/animation": "/web/static/lib/hoot/mock/animation.js",
"/web/static/lib/hoot/mock/console": "/web/static/lib/hoot/mock/console.js",
"/web/static/lib/hoot/mock/crypto": "/web/static/lib/hoot/mock/crypto.js",
"/web/static/lib/hoot/mock/date": "/web/static/lib/hoot/mock/date.js",
"/web/static/lib/hoot/mock/math": "/web/static/lib/hoot/mock/math.js",
"/web/static/lib/hoot/mock/navigator": "/web/static/lib/hoot/mock/navigator.js",
"/web/static/lib/hoot/mock/network": "/web/static/lib/hoot/mock/network.js",
"/web/static/lib/hoot/mock/notification": "/web/static/lib/hoot/mock/notification.js",
"/web/static/lib/hoot/mock/storage": "/web/static/lib/hoot/mock/storage.js",
"/web/static/lib/hoot/mock/sync_values": "/web/static/lib/hoot/mock/sync_values.js",
"/web/static/lib/hoot/mock/window": "/web/static/lib/hoot/mock/window.js",
"/web/static/lib/hoot/tests/local_helpers": "/web/static/lib/hoot/tests/local_helpers.js",
"/web/static/lib/hoot/ui/hoot_buttons": "/web/static/lib/hoot/ui/hoot_buttons.js",
"/web/static/lib/hoot/ui/hoot_colors": "/web/static/lib/hoot/ui/hoot_colors.js",
"/web/static/lib/hoot/ui/hoot_config_menu": "/web/static/lib/hoot/ui/hoot_config_menu.js",
"/web/static/lib/hoot/ui/hoot_copy_button": "/web/static/lib/hoot/ui/hoot_copy_button.js",
"/web/static/lib/hoot/ui/hoot_debug_toolbar": "/web/static/lib/hoot/ui/hoot_debug_toolbar.js",
"/web/static/lib/hoot/ui/hoot_dropdown": "/web/static/lib/hoot/ui/hoot_dropdown.js",
"/web/static/lib/hoot/ui/hoot_job_buttons": "/web/static/lib/hoot/ui/hoot_job_buttons.js",
"/web/static/lib/hoot/ui/hoot_link": "/web/static/lib/hoot/ui/hoot_link.js",
"/web/static/lib/hoot/ui/hoot_log_counters": "/web/static/lib/hoot/ui/hoot_log_counters.js",
"/web/static/lib/hoot/ui/hoot_main": "/web/static/lib/hoot/ui/hoot_main.js",
"/web/static/lib/hoot/ui/hoot_reporting": "/web/static/lib/hoot/ui/hoot_reporting.js",
"/web/static/lib/hoot/ui/hoot_search": "/web/static/lib/hoot/ui/hoot_search.js",
"/web/static/lib/hoot/ui/hoot_side_bar": "/web/static/lib/hoot/ui/hoot_side_bar.js",
"/web/static/lib/hoot/ui/hoot_status_panel": "/web/static/lib/hoot/ui/hoot_status_panel.js",
"/web/static/lib/hoot/ui/hoot_tag_button": "/web/static/lib/hoot/ui/hoot_tag_button.js",
"/web/static/lib/hoot/ui/hoot_technical_value": "/web/static/lib/hoot/ui/hoot_technical_value.js",
"/web/static/lib/hoot/ui/hoot_test_path": "/web/static/lib/hoot/ui/hoot_test_path.js",
"/web/static/lib/hoot/ui/hoot_test_result": "/web/static/lib/hoot/ui/hoot_test_result.js",
"/web/static/lib/hoot/ui/setup_hoot_ui": "/web/static/lib/hoot/ui/setup_hoot_ui.js"
}
}
</script>
<style>
html,
body {
height: 100%;
margin: 0;
position: relative;
width: 100%;
}
</style>
<!-- Test assets -->
<script src="/web/static/lib/owl/owl.js"></script>
<script src="../hoot.js" type="module" defer></script>
<link rel="stylesheet" href="/web/static/lib/hoot/ui/hoot_style.css" />
<link rel="stylesheet" href="/web/static/src/libs/fontawesome/css/font-awesome.css" />
<!-- Test suites -->
<script src="./index.js" type="module" defer></script>
</head>
<body></body>
</html>

View file

@ -0,0 +1,17 @@
import { isHootReady, start } from "@odoo/hoot";
import "./core/expect.test.js";
import "./core/runner.test.js";
import "./core/suite.test.js";
import "./core/test.test.js";
import "./hoot-dom/dom.test.js";
import "./hoot-dom/events.test.js";
import "./hoot-dom/time.test.js";
import "./hoot_utils.test.js";
import "./mock/navigator.test.js";
import "./mock/network.test.js";
import "./mock/window.test.js";
import "./ui/hoot_technical_value.test.js";
import "./ui/hoot_test_result.test.js";
isHootReady.then(start);

View file

@ -0,0 +1,45 @@
/** @odoo-module */
import { after, destroy, getFixture } from "@odoo/hoot";
import { App, Component, xml } from "@odoo/owl";
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {import("@odoo/owl").ComponentConstructor} ComponentClass
* @param {ConstructorParameters<typeof App>[1]} [config]
*/
export async function mountForTest(ComponentClass, config) {
if (typeof ComponentClass === "string") {
ComponentClass = class extends Component {
static name = "anonymous component";
static props = {};
static template = xml`${ComponentClass}`;
};
}
const app = new App(ComponentClass, {
name: "TEST",
test: true,
warnIfNoStaticProps: true,
...config,
});
const fixture = getFixture();
after(() => destroy(app));
fixture.style.backgroundColor = "#fff";
await app.mount(fixture);
if (fixture.hasIframes) {
await fixture.waitForIframes();
}
}
/**
* @param {string} url
*/
export function parseUrl(url) {
return url.replace(/^.*hoot\/tests/, "@hoot").replace(/(\.test)?\.js$/, "");
}

View file

@ -0,0 +1,74 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { mockSendBeacon, mockTouch, mockVibrate } from "@odoo/hoot-mock";
import { parseUrl } from "../local_helpers";
/**
* @param {Promise<any>} promise
*/
const ensureResolvesImmediatly = (promise) =>
Promise.race([
promise,
new Promise((resolve, reject) => reject("failed to resolve in a single micro tick")),
]);
describe(parseUrl(import.meta.url), () => {
describe("clipboard", () => {
test.tags("secure");
test("read/write calls are resolved immediatly", async () => {
navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob(["some text"], { type: "text/plain" }),
}),
]);
const items = await ensureResolvesImmediatly(navigator.clipboard.read());
expect(items).toHaveLength(1);
expect(items[0]).toBeInstanceOf(ClipboardItem);
const blob = await ensureResolvesImmediatly(items[0].getType("text/plain"));
expect(blob).toBeInstanceOf(Blob);
const value = await ensureResolvesImmediatly(blob.text());
expect(value).toBe("some text");
});
});
test("maxTouchPoints", () => {
mockTouch(false);
expect(navigator.maxTouchPoints).toBe(0);
mockTouch(true);
expect(navigator.maxTouchPoints).toBe(1);
});
test("sendBeacon", () => {
expect(() => navigator.sendBeacon("/route", new Blob([]))).toThrow(/sendBeacon/);
mockSendBeacon(expect.step);
expect.verifySteps([]);
navigator.sendBeacon("/route", new Blob([]));
expect.verifySteps(["/route"]);
});
test("vibrate", () => {
expect(() => navigator.vibrate(100)).toThrow(/vibrate/);
mockVibrate(expect.step);
expect.verifySteps([]);
navigator.vibrate(100);
expect.verifySteps([100]);
});
});

View file

@ -0,0 +1,32 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { mockFetch } from "@odoo/hoot-mock";
import { parseUrl } from "../local_helpers";
describe(parseUrl(import.meta.url), () => {
test("setup network values", async () => {
expect(document.cookie).toBe("");
document.cookie = "cids=4";
document.title = "kek";
expect(document.cookie).toBe("cids=4");
expect(document.title).toBe("kek");
});
test("values are reset between test", async () => {
expect(document.cookie).toBe("");
expect(document.title).toBe("");
});
test("fetch should not mock internal URLs", async () => {
mockFetch(expect.step);
await fetch("http://some.url");
await fetch("/odoo");
await fetch(URL.createObjectURL(new Blob([""])));
expect.verifySteps(["http://some.url", "/odoo"]);
});
});

View file

@ -0,0 +1,70 @@
/** @odoo-module */
import { after, describe, expect, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { EventBus } from "@odoo/owl";
import { mountForTest, parseUrl } from "../local_helpers";
import { watchListeners } from "@odoo/hoot-mock";
describe(parseUrl(import.meta.url), () => {
class TestBus extends EventBus {
addEventListener(type) {
expect.step(`addEventListener:${type}`);
return super.addEventListener(...arguments);
}
removeEventListener() {
throw new Error("Cannot remove event listeners");
}
}
let testBus;
test("elementFromPoint and elementsFromPoint should be mocked", async () => {
await mountForTest(/* xml */ `
<div class="oui" style="position: absolute; left: 10px; top: 10px; width: 250px; height: 250px;">
Oui
</div>
`);
expect(".oui").toHaveRect({
x: 10,
y: 10,
width: 250,
height: 250,
});
const div = queryOne(".oui");
expect(document.elementFromPoint(11, 11)).toBe(div);
expect(document.elementsFromPoint(11, 11)).toEqual([
div,
document.body,
document.documentElement,
]);
expect(document.elementFromPoint(9, 9)).toBe(document.body);
expect(document.elementsFromPoint(9, 9)).toEqual([document.body, document.documentElement]);
});
// ! WARNING: the following 2 tests need to be run sequentially to work, as they
// ! attempt to test the in-between-tests event listeners cleanup.
test("event listeners are properly removed: setup", async () => {
const callback = () => expect.step("callback");
testBus = new TestBus();
expect.verifySteps([]);
after(watchListeners());
testBus.addEventListener("some-event", callback);
testBus.trigger("some-event");
expect.verifySteps(["addEventListener:some-event", "callback"]);
});
test("event listeners are properly removed: check", async () => {
testBus.trigger("some-event");
expect.verifySteps([]);
});
});

View file

@ -0,0 +1,143 @@
/** @odoo-module */
import { after, describe, expect, test } from "@odoo/hoot";
import { animationFrame, click, Deferred } from "@odoo/hoot-dom";
import { Component, reactive, useState, xml } from "@odoo/owl";
import { mountForTest, parseUrl } from "../local_helpers";
import { logger } from "../../core/logger";
import { HootTechnicalValue } from "../../ui/hoot_technical_value";
const mountTechnicalValue = async (defaultValue) => {
const updateValue = async (value) => {
state.value = value;
await animationFrame();
};
const state = reactive({ value: defaultValue });
class TechnicalValueParent extends Component {
static components = { HootTechnicalValue };
static props = {};
static template = xml`<HootTechnicalValue value="state.value" />`;
setup() {
this.state = useState(state);
}
}
await mountForTest(TechnicalValueParent);
return updateValue;
};
describe(parseUrl(import.meta.url), () => {
test("technical value with primitive values", async () => {
const updateValue = await mountTechnicalValue("oui");
expect(".hoot-string").toHaveText(`"oui"`);
await updateValue(`"stringified"`);
expect(".hoot-string").toHaveText(`'"stringified"'`);
await updateValue(3);
expect(".hoot-integer").toHaveText(`3`);
await updateValue(undefined);
expect(".hoot-undefined").toHaveText(`undefined`);
await updateValue(null);
expect(".hoot-null").toHaveText(`null`);
});
test("technical value with objects", async () => {
const logDebug = logger.debug;
logger.debug = expect.step;
after(() => (logger.debug = logDebug));
const updateValue = await mountTechnicalValue({});
expect(".hoot-technical").toHaveText(`Object(0)`);
await updateValue([1, 2, "3"]);
expect(".hoot-technical").toHaveText(`Array(3)`);
expect.verifySteps([]);
await click(".hoot-object");
await animationFrame();
expect(".hoot-technical").toHaveText(`Array(3)[\n1\n,\n2\n,\n"3"\n,\n]`);
expect.verifySteps([[1, 2, "3"]]);
await updateValue({ a: true });
expect(".hoot-technical").toHaveText(`Object(1)`);
await click(".hoot-object");
await animationFrame();
expect(".hoot-technical").toHaveText(`Object(1){\na\n:\ntrue\n,\n}`);
await updateValue({
a: true,
sub: {
key: "oui",
},
});
expect(".hoot-technical").toHaveText(`Object(2)`);
await click(".hoot-object:first");
await animationFrame();
expect(".hoot-technical:first").toHaveText(
`Object(2){\na\n:\ntrue\n,\nsub\n:\nObject(1)\n}`
);
expect.verifySteps([]);
await click(".hoot-object:last");
await animationFrame();
expect(".hoot-technical:first").toHaveText(
`Object(2){\na\n:\ntrue\n,\nsub\n:\nObject(1){\nkey\n:\n"oui"\n,\n}\n}`
);
expect.verifySteps([{ key: "oui" }]);
});
test("technical value with special cases", async () => {
const updateValue = await mountTechnicalValue(new Date(0));
expect(".hoot-technical").toHaveText(`1970-01-01T00:00:00.000Z`);
await updateValue(/ab[c]/gi);
expect(".hoot-technical").toHaveText(`/ab[c]/gi`);
const def = new Deferred(() => {});
await updateValue(def);
expect(".hoot-technical").toHaveText(`Deferred<\npending\n>`);
def.resolve("oui");
await animationFrame();
expect(".hoot-technical").toHaveText(`Deferred<\nfulfilled\n:\n"oui"\n>`);
});
test("evaluation of unsafe value does not crash", async () => {
const logDebug = logger.debug;
logger.debug = () => expect.step("debug");
after(() => (logger.debug = logDebug));
class UnsafeString extends String {
toString() {
return this.valueOf();
}
valueOf() {
throw new Error("UNSAFE");
}
}
await mountTechnicalValue(new UnsafeString("some value"));
await click(".hoot-object");
expect(".hoot-object").toHaveText("UnsafeString(0)", {
message: "size is 0 because it couldn't be evaluated",
});
expect.verifySteps(["debug"]);
});
});

View file

@ -0,0 +1,177 @@
/** @odoo-module */
import { describe, expect, makeExpect, test } from "@odoo/hoot";
import { mountForTest, parseUrl } from "../local_helpers";
import { animationFrame, click } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { Runner } from "../../core/runner";
import { Test } from "../../core/test";
import { HootTestResult } from "../../ui/hoot_test_result";
import { makeUiState } from "../../ui/setup_hoot_ui";
/**
* @param {(mockExpect: typeof expect) => any} callback
*/
const mountTestResults = async (testFn, props) => {
const runner = new Runner();
const ui = makeUiState();
const mockTest = new Test(null, "test", {});
const [mockExpect, { after, before }] = makeExpect({});
class Parent extends Component {
static components = { HootTestResult };
static props = { test: Test, open: [Boolean, { value: "always" }] };
static template = xml`
<HootTestResult test="props.test" open="props.open">
Toggle
</HootTestResult>
`;
mockTest = mockTest;
}
before(mockTest);
testFn(mockExpect);
after(runner);
await mountForTest(Parent, {
env: { runner, ui },
props: {
test: mockTest,
open: "always",
...props,
},
});
return mockTest;
};
const CLS_PASS = "text-emerald";
const CLS_FAIL = "text-rose";
describe(parseUrl(import.meta.url), () => {
test("test results: toBe and basic interactions", async () => {
const mockTest = await mountTestResults(
(mockExpect) => {
mockExpect(true).toBe(true);
mockExpect(true).toBe(false);
},
{ open: false }
);
expect(".HootTestResult button:only").toHaveText("Toggle");
expect(".hoot-result-detail").not.toHaveCount();
expect(mockTest.lastResults.pass).toBe(false);
await click(".HootTestResult button");
await animationFrame();
expect(".hoot-result-detail").toHaveCount(1);
// First assertion: pass
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
/received value is strictly equal to true/,
{ inline: true }
);
// Second assertion: fail
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
/expected values to be strictly equal/,
{ inline: true }
);
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
});
test("test results: toEqual", async () => {
await mountTestResults((mockExpect) => {
mockExpect([1, 2, { a: true }]).toEqual([1, 2, { a: true }]);
mockExpect([1, { a: false }, 3]).toEqual([1, { a: true }, 3]);
});
expect(".hoot-result-detail").toHaveCount(1);
// First assertion: pass
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
/received value is deeply equal to \[1, 2, { a: true }\]/,
{ inline: true }
);
// Second assertion: fail
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
/expected values to be deeply equal/,
{ inline: true }
);
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
});
test("test results: toHaveCount", async () => {
await mountForTest(/* xml */ `
<span class="text" >abc</span>
<span class="text" >bcd</span>
`);
await mountTestResults((mockExpect) => {
mockExpect(".text").toHaveCount(2);
mockExpect(".text").toHaveCount(1);
});
expect(".hoot-result-detail").toHaveCount(1);
// First assertion: pass
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
/found 2 elements matching ".text"/,
{ inline: true }
);
// Second assertion: fail
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
/found 2 elements matching ".text"/,
{ inline: true }
);
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
});
test("multiple test results: toHaveText", async () => {
await mountForTest(/* xml */ `
<span class="text" >abc</span>
<span class="text" >bcd</span>
`);
await mountTestResults((mockExpect) => {
mockExpect(".text:first").toHaveText("abc");
mockExpect(".text").toHaveText("abc");
mockExpect(".text").not.toHaveText("abc");
});
expect(".hoot-result-detail").toHaveCount(1);
// First assertion: pass
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
/1 element matching ".text:first" has text "abc"/,
{ inline: true }
);
// Second assertion: fail
expect(`.hoot-result-detail > .${CLS_FAIL}:eq(0)`).toHaveText(
/expected 2 elements matching ".text" to have the given text/,
{ inline: true }
);
expect(".hoot-info:eq(0) .hoot-html").toHaveCount(2);
expect(".hoot-info:eq(0) .hoot-html").toHaveText("<span.text/>");
expect(`.hoot-info:eq(0) .${CLS_PASS}:contains(Received)`).toHaveCount(1);
expect(`.hoot-info:eq(0) .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
expect(`.hoot-info:eq(0) .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
// Third assertion: fail
expect(`.hoot-result-detail > .${CLS_FAIL}:eq(1)`).toHaveText(
/expected 2 elements matching ".text" not to have the given text/,
{ inline: true }
);
expect(".hoot-info:eq(1) .hoot-html").toHaveCount(2);
expect(".hoot-info:eq(1) .hoot-html").toHaveText("<span.text/>");
expect(`.hoot-info:eq(1) .${CLS_PASS}:contains(Received)`).toHaveCount(1);
expect(`.hoot-info:eq(1) .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
expect(`.hoot-info:eq(1) .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
});
});